mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-14 22:19:32 +02:00
Compare commits
296 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 | ||
![]() |
785fe32aaa | ||
![]() |
5559ad0070 | ||
![]() |
76c65566cf | ||
![]() |
c929e8f11c | ||
![]() |
3ae9cf0420 | ||
![]() |
b736464f3f | ||
![]() |
7572aa5bc9 | ||
![]() |
cb0301fd9e | ||
![]() |
b30486317d | ||
![]() |
1c6e5902db | ||
![]() |
20fe88dd57 | ||
![]() |
aad62f61c9 | ||
![]() |
79bf4b38c6 | ||
![]() |
5eadb3ede3 | ||
![]() |
36749c398b | ||
![]() |
190b5aeeca | ||
![]() |
1122d18e18 | ||
![]() |
0fe6304328 | ||
![]() |
7d4e65976f | ||
![]() |
749bc1ef63 | ||
![]() |
36a84276a2 | ||
![]() |
b72697b819 | ||
![]() |
d9362c9b9c | ||
![]() |
b0610db406 | ||
![]() |
af16a9e727 | ||
![]() |
d898c1be4d | ||
![]() |
0282220307 | ||
![]() |
bb243b382d | ||
![]() |
fbc97a3841 | ||
![]() |
380f5ed19c | ||
![]() |
b28352fb28 | ||
![]() |
695b0dc300 | ||
![]() |
fe40139838 | ||
![]() |
44b49a4cfe | ||
![]() |
469883a674 | ||
![]() |
fa5f78cf71 | ||
![]() |
e03f536925 | ||
![]() |
a92a35cfb8 | ||
![]() |
ff334e0888 | ||
![]() |
0f9ba57fef | ||
![]() |
b4376a9ff1 | ||
![]() |
87cd4061cb | ||
![]() |
e2415f652b | ||
![]() |
9cf5eb5ec0 | ||
![]() |
023a213ba6 | ||
![]() |
23d97db016 | ||
![]() |
0fb1bbd0e2 | ||
![]() |
5d2acca122 | ||
![]() |
0cbaf927e4 | ||
![]() |
0586983602 | ||
![]() |
9dc3521d5e | ||
![]() |
a1822e2091 | ||
![]() |
22ffecbb9d | ||
![]() |
d9096eacd6 | ||
![]() |
e50912df12 | ||
![]() |
393d688247 | ||
![]() |
6e38587174 | ||
![]() |
123c6fe02a | ||
![]() |
1b7731e506 | ||
![]() |
df9f0095cc | ||
![]() |
25470edb2c | ||
![]() |
22a1fc80ad | ||
![]() |
65f0eb2a04 | ||
![]() |
82f86bf537 | ||
![]() |
639629ddfe | ||
![]() |
2b342c0d56 | ||
![]() |
3ffec72d3e | ||
![]() |
edd958fff6 | ||
![]() |
2d22d6871e | ||
![]() |
5e8f5b2c58 | ||
![]() |
d5a83722de | ||
![]() |
5d8fdebb7c | ||
![]() |
f7bd6ccb31 | ||
![]() |
e4ee0171be | ||
![]() |
53d1f0c91b | ||
![]() |
a6f35119cd | ||
![]() |
68c163d943 | ||
![]() |
bb6c5ca29e | ||
![]() |
c919e79759 | ||
![]() |
8ff9b42a79 | ||
![]() |
4280ab40c6 | ||
![]() |
db1906942a | ||
![]() |
69877a32e5 | ||
![]() |
e5a9a772f0 | ||
![]() |
2f56d418cf | ||
![]() |
a4df586a8a | ||
![]() |
d9b7996e06 | ||
![]() |
92f62d3ded | ||
![]() |
9c48085829 | ||
![]() |
77e1525402 | ||
![]() |
9df80e01de | ||
![]() |
ec34cc523f | ||
![]() |
eb0b092d17 | ||
![]() |
39e8f03345 | ||
![]() |
d43b97e0c0 | ||
![]() |
d6484ba8e9 | ||
![]() |
4c26d66177 | ||
![]() |
c51dcafa40 | ||
![]() |
262dd2b28f | ||
![]() |
01ad7f4d9e | ||
![]() |
d0d5c15345 | ||
![]() |
afb752765d | ||
![]() |
ce213775b6 | ||
![]() |
fd1bbadcf3 | ||
![]() |
83c2530df4 | ||
![]() |
39782e75e7 | ||
![]() |
4bee104b62 | ||
![]() |
f4ecffbb7f | ||
![]() |
6f52bafda8 | ||
![]() |
2deecc5c91 | ||
![]() |
54cfa13861 | ||
![]() |
ee4f99261f | ||
![]() |
d2fa0a8f5a | ||
![]() |
02a15c9460 | ||
![]() |
7a6428c037 | ||
![]() |
c6001aa7b8 | ||
![]() |
eefbefd714 | ||
![]() |
683cf529d7 | ||
![]() |
38204c87cf | ||
![]() |
96ee4746ad | ||
![]() |
d7c1afa2a5 | ||
![]() |
16ed6ef200 | ||
![]() |
98b9a9c1a0 | ||
![]() |
6775633be5 | ||
![]() |
150dfecc6f | ||
![]() |
81ae55bc1c | ||
![]() |
935189ecc2 | ||
![]() |
7997f20d89 | ||
![]() |
ae27500cde | ||
![]() |
71d853999e | ||
![]() |
70288d6865 | ||
![]() |
e83d519cab | ||
![]() |
6355d8dff1 | ||
![]() |
227cfdb063 | ||
![]() |
2d4da099c7 | ||
![]() |
a9512b2333 | ||
![]() |
47e944e6c5 | ||
![]() |
6c7ce91d53 | ||
![]() |
87020de917 | ||
![]() |
a130daa0f0 | ||
![]() |
d7c68c2818 | ||
![]() |
1daad2c86c | ||
![]() |
251def2583 | ||
![]() |
560769f068 | ||
![]() |
dc9799cc53 | ||
![]() |
41c1b9ab84 | ||
![]() |
2396c8fe99 | ||
![]() |
de328c78e2 | ||
![]() |
314e4a9b74 | ||
![]() |
ff400a79ec | ||
![]() |
f4fcb96b5e | ||
![]() |
daab772971 | ||
![]() |
64c81ea565 | ||
![]() |
1dd19e8fa2 | ||
![]() |
dd3699cdeb | ||
![]() |
f9c9d17873 | ||
![]() |
5c9f03a715 | ||
![]() |
7600fe87f9 | ||
![]() |
f756e28daf | ||
![]() |
1e10d7eb4a | ||
![]() |
ccf8e03571 |
@@ -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.12",
|
||||
"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 && npm install && mkdir -p data && python3 manage.py migrate",
|
||||
"postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.dev.txt && npm install && mkdir -p data && python3 manage.py migrate",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
|
@@ -1,34 +1,21 @@
|
||||
# Remove project files, data, tmp files, build files
|
||||
/.env
|
||||
/.idea
|
||||
/data
|
||||
/node_modules
|
||||
/tmp
|
||||
/docs
|
||||
/static
|
||||
/scripts
|
||||
/build
|
||||
/out
|
||||
/.git
|
||||
/.devcontainer
|
||||
# Ignore everything
|
||||
*
|
||||
|
||||
/.dockerignore
|
||||
/.gitignore
|
||||
/.gitattributes
|
||||
/Dockerfile
|
||||
/docker-compose.yml
|
||||
/*.sh
|
||||
/*.iml
|
||||
/*.patch
|
||||
/*.md
|
||||
/*.js
|
||||
/*.log
|
||||
/*.pid
|
||||
# Include files required for build or at runtime
|
||||
!/bookmarks
|
||||
|
||||
# Whitelist files needed in build or prod image
|
||||
!/rollup.config.js
|
||||
!/bootstrap.sh
|
||||
!/background-tasks-wrapper.sh
|
||||
!/LICENSE.txt
|
||||
!/manage.py
|
||||
!/package.json
|
||||
!/package-lock.json
|
||||
!/postcss.config.js
|
||||
!/requirements.dev.txt
|
||||
!/requirements.txt
|
||||
!/rollup.config.mjs
|
||||
!/supervisord.conf
|
||||
!/uwsgi.ini
|
||||
!/version.txt
|
||||
|
||||
# Remove development settings
|
||||
/siteroot/settings/dev.py
|
||||
# Remove dev settings
|
||||
/bookmarks/settings/dev.py
|
||||
|
89
.github/workflows/build.yaml
vendored
Normal file
89
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: build
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Read version from file
|
||||
id: get_version
|
||||
run: echo "VERSION=$(cat version.txt)" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build latest
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/default.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest
|
||||
sissbruecker/linkding:${{ env.VERSION }}
|
||||
ghcr.io/sissbruecker/linkding:latest
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}
|
||||
target: linkding
|
||||
push: true
|
||||
|
||||
- name: Build latest-alpine
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest-alpine
|
||||
sissbruecker/linkding:${{ env.VERSION }}-alpine
|
||||
ghcr.io/sissbruecker/linkding:latest-alpine
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-alpine
|
||||
target: linkding
|
||||
push: true
|
||||
|
||||
- name: Build latest-plus
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/default.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest-plus
|
||||
sissbruecker/linkding:${{ env.VERSION }}-plus
|
||||
ghcr.io/sissbruecker/linkding:latest-plus
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus
|
||||
target: linkding-plus
|
||||
push: true
|
||||
|
||||
- name: Build latest-plus-alpine
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
sissbruecker/linkding:latest-plus-alpine
|
||||
sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
|
||||
ghcr.io/sissbruecker/linkding:latest-plus-alpine
|
||||
ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
|
||||
target: linkding-plus
|
||||
push: true
|
44
.github/workflows/main.yaml
vendored
44
.github/workflows/main.yaml
vendored
@@ -1,50 +1,58 @@
|
||||
name: linkding CI
|
||||
|
||||
on: [push]
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
unit_tests:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
- name: Install Node dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: pip install -r requirements.txt
|
||||
run: |
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
mkdir data
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.tests
|
||||
e2e_tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
- name: Install Node dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
playwright install chromium
|
||||
mkdir data
|
||||
- name: Run build
|
||||
run: |
|
||||
npm run build
|
||||
python manage.py compilescss
|
||||
python manage.py collectstatic --ignore=*.scss
|
||||
python manage.py collectstatic
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||
run: python manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"
|
||||
|
7
.gitignore
vendored
7
.gitignore
vendored
@@ -183,7 +183,7 @@ typings/
|
||||
### Custom
|
||||
# Rollup compilation output
|
||||
/bookmarks/static/bundle.js*
|
||||
# SASS compilation output
|
||||
# CSS compilation output
|
||||
/bookmarks/static/theme-*.css*
|
||||
# Collected static files for deployment
|
||||
/static
|
||||
@@ -191,3 +191,8 @@ typings/
|
||||
/tmp
|
||||
# Database file
|
||||
/data
|
||||
# ublock + chromium
|
||||
/uBOLite.chromium.mv3
|
||||
/chromium-profile
|
||||
# direnv
|
||||
/.direnv
|
||||
|
451
CHANGELOG.md
451
CHANGELOG.md
@@ -1,5 +1,456 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
* Include favicons and thumbnails in REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/763
|
||||
* Add Pinkt to the Community section by @fibelatti in https://github.com/sissbruecker/linkding/pull/772
|
||||
* removed version line from docker compose yaml by @volumedata21 in https://github.com/sissbruecker/linkding/pull/800
|
||||
* Add resource linkding logo by @QYG2297248353 in https://github.com/sissbruecker/linkding/pull/788
|
||||
* Allow use of standard docker `TZ` env var by @watsonbox in https://github.com/sissbruecker/linkding/pull/765
|
||||
* Add OCI source annotation to link back to source repo by @Ramblurr in https://github.com/sissbruecker/linkding/pull/701
|
||||
* Generate fallback URLs for web archive links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/804
|
||||
* Fix overflow in settings page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/805
|
||||
* Bump django from 5.0.3 to 5.0.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/795
|
||||
* Bump certifi from 2023.11.17 to 2024.7.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/775
|
||||
* Bump djangorestframework from 3.14.0 to 3.15.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/769
|
||||
* Bump urllib3 from 2.1.0 to 2.2.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/762
|
||||
|
||||
### New Contributors
|
||||
* @fibelatti made their first contribution in https://github.com/sissbruecker/linkding/pull/772
|
||||
* @volumedata21 made their first contribution in https://github.com/sissbruecker/linkding/pull/800
|
||||
* @QYG2297248353 made their first contribution in https://github.com/sissbruecker/linkding/pull/788
|
||||
* @watsonbox made their first contribution in https://github.com/sissbruecker/linkding/pull/765
|
||||
* @Ramblurr made their first contribution in https://github.com/sissbruecker/linkding/pull/701
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.0...v1.31.1
|
||||
|
||||
---
|
||||
|
||||
## v1.31.0 (16/06/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add support for bookmark thumbnails by @vslinko in https://github.com/sissbruecker/linkding/pull/721
|
||||
* Automatically add tags to bookmarks based on URL pattern by @vslinko in https://github.com/sissbruecker/linkding/pull/736
|
||||
* Load bookmark thumbnails after import by @vslinko in https://github.com/sissbruecker/linkding/pull/724
|
||||
* Load missing thumbnails after enabling the feature by @sissbruecker in https://github.com/sissbruecker/linkding/pull/725
|
||||
* Thumbnails lazy loading by @vslinko in https://github.com/sissbruecker/linkding/pull/734
|
||||
* Add option for disabling tag grouping by @vslinko in https://github.com/sissbruecker/linkding/pull/735
|
||||
* Preview auto tags in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/737
|
||||
* Hide tooltip on mobile by @vslinko in https://github.com/sissbruecker/linkding/pull/733
|
||||
* Bump requests from 2.31.0 to 2.32.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/740
|
||||
|
||||
### New Contributors
|
||||
* @vslinko made their first contribution in https://github.com/sissbruecker/linkding/pull/721
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.30.0...v1.31.0
|
||||
|
||||
---
|
||||
|
||||
## v1.30.0 (20/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/703
|
||||
* Allow uploading custom files for bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/713
|
||||
* Add option for marking bookmarks as unread by default by @ab623 in https://github.com/sissbruecker/linkding/pull/706
|
||||
* Make blocking cookie banners more reliable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/699
|
||||
* Close bookmark details with escape by @sissbruecker in https://github.com/sissbruecker/linkding/pull/702
|
||||
* Show proper name for bookmark assets in admin by @ab623 in https://github.com/sissbruecker/linkding/pull/708
|
||||
* Bump sqlparse from 0.4.4 to 0.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/704
|
||||
|
||||
### New Contributors
|
||||
* @ab623 made their first contribution in https://github.com/sissbruecker/linkding/pull/706
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.29.0...v1.30.0
|
||||
|
||||
---
|
||||
|
||||
## v1.29.0 (14/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Remove ads and cookie banners from HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/695
|
||||
* Add button for creating missing HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/696
|
||||
* Refresh file list when there are queued snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/697
|
||||
* Bump idna from 3.6 to 3.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/694
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.28.0...v1.29.0
|
||||
|
||||
---
|
||||
|
||||
## v1.28.0 (09/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add option to disable SSL verification for OIDC by @akaSyntaax in https://github.com/sissbruecker/linkding/pull/684
|
||||
* Add full backup method by @sissbruecker in https://github.com/sissbruecker/linkding/pull/686
|
||||
* Truncate snapshot filename for long URLs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/687
|
||||
* Add option for customizing single-file timeout by @pettijohn in https://github.com/sissbruecker/linkding/pull/688
|
||||
* Add option for passing arguments to single-file command by @pettijohn in https://github.com/sissbruecker/linkding/pull/691
|
||||
* Fix typo by @tianheg in https://github.com/sissbruecker/linkding/pull/689
|
||||
|
||||
### New Contributors
|
||||
* @akaSyntaax made their first contribution in https://github.com/sissbruecker/linkding/pull/684
|
||||
* @pettijohn made their first contribution in https://github.com/sissbruecker/linkding/pull/688
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.1...v1.28.0
|
||||
|
||||
---
|
||||
|
||||
## v1.27.1 (07/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Fix HTML snapshot errors related to single-file-cli by @sissbruecker in https://github.com/sissbruecker/linkding/pull/683
|
||||
* Replace django-background-tasks with huey by @sissbruecker in https://github.com/sissbruecker/linkding/pull/657
|
||||
* Add Authelia OIDC example to docs by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/675
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.0...v1.27.1
|
||||
|
||||
---
|
||||
|
||||
## v1.27.0 (01/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Archive snapshots of websites locally by @sissbruecker in https://github.com/sissbruecker/linkding/pull/672
|
||||
* Add Railway hosting option by @tianheg in https://github.com/sissbruecker/linkding/pull/661
|
||||
* Add how to for increasing the font size by @sissbruecker in https://github.com/sissbruecker/linkding/pull/667
|
||||
|
||||
### New Contributors
|
||||
* @tianheg made their first contribution in https://github.com/sissbruecker/linkding/pull/661
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.26.0...v1.27.0
|
||||
|
||||
---
|
||||
|
||||
## v1.26.0 (30/03/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add option for showing bookmark description as separate block by @sissbruecker in https://github.com/sissbruecker/linkding/pull/663
|
||||
* Add bookmark details view by @sissbruecker in https://github.com/sissbruecker/linkding/pull/665
|
||||
* Make bookmark list actions configurable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/666
|
||||
* Bump black from 24.1.1 to 24.3.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/662
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.25.0...v1.26.0
|
||||
|
||||
---
|
||||
|
||||
## v1.25.0 (18/03/2024)
|
||||
|
||||
### What's Changed
|
||||
* Improve PWA capabilities by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/630
|
||||
* build improvements by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/649
|
||||
* Add support for oidc by @Nighmared in https://github.com/sissbruecker/linkding/pull/389
|
||||
* Add option for custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/652
|
||||
* Update backup location to safe directory by @bphenriques in https://github.com/sissbruecker/linkding/pull/653
|
||||
* Include web archive link in /api/bookmarks/ by @sissbruecker in https://github.com/sissbruecker/linkding/pull/655
|
||||
* Add RSS feeds for shared bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/656
|
||||
* Bump django from 5.0.2 to 5.0.3 by @dependabot in https://github.com/sissbruecker/linkding/pull/658
|
||||
|
||||
### New Contributors
|
||||
* @hugo-vrijswijk made their first contribution in https://github.com/sissbruecker/linkding/pull/630
|
||||
* @Nighmared made their first contribution in https://github.com/sissbruecker/linkding/pull/389
|
||||
* @bphenriques made their first contribution in https://github.com/sissbruecker/linkding/pull/653
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.2...v1.25.0
|
||||
|
||||
---
|
||||
|
||||
## v1.24.2 (16/03/2024)
|
||||
|
||||
### What's Changed
|
||||
* Fix logout button by @sissbruecker in https://github.com/sissbruecker/linkding/pull/648
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.1...v1.24.2
|
||||
|
||||
---
|
||||
|
||||
## v1.24.1 (16/03/2024)
|
||||
|
||||
### What's Changed
|
||||
* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/618
|
||||
* Persist secret key in data folder by @sissbruecker in https://github.com/sissbruecker/linkding/pull/620
|
||||
* Group ideographic characters in tag cloud by @jonathan-s in https://github.com/sissbruecker/linkding/pull/613
|
||||
* Bump django from 5.0.1 to 5.0.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/625
|
||||
* Add k8s setup to community section by @jzck in https://github.com/sissbruecker/linkding/pull/633
|
||||
* Added a new Linkding client to community section by @JGeek00 in https://github.com/sissbruecker/linkding/pull/638
|
||||
|
||||
### New Contributors
|
||||
* @jzck made their first contribution in https://github.com/sissbruecker/linkding/pull/633
|
||||
* @JGeek00 made their first contribution in https://github.com/sissbruecker/linkding/pull/638
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.0...v1.24.1
|
||||
|
||||
---
|
||||
|
||||
## v1.24.0 (27/01/2024)
|
||||
|
||||
### What's Changed
|
||||
* Support Open Graph description by @jonathan-s in https://github.com/sissbruecker/linkding/pull/602
|
||||
* Add tooltip to truncated bookmark titles by @jonathan-s in https://github.com/sissbruecker/linkding/pull/607
|
||||
* Improve bulk tag performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/612
|
||||
* Increase tag limit in tag autocomplete by @hypebeast in https://github.com/sissbruecker/linkding/pull/581
|
||||
* Add CapRover as managed hosting option by @adamshand in https://github.com/sissbruecker/linkding/pull/585
|
||||
* Bump playwright dependencies by @jonathan-s in https://github.com/sissbruecker/linkding/pull/601
|
||||
* Adjust archive.org donation link in general.html by @JnsDornbusch in https://github.com/sissbruecker/linkding/pull/603
|
||||
|
||||
### New Contributors
|
||||
* @hypebeast made their first contribution in https://github.com/sissbruecker/linkding/pull/581
|
||||
* @adamshand made their first contribution in https://github.com/sissbruecker/linkding/pull/585
|
||||
* @jonathan-s made their first contribution in https://github.com/sissbruecker/linkding/pull/601
|
||||
* @JnsDornbusch made their first contribution in https://github.com/sissbruecker/linkding/pull/603
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.23.1...v1.24.0
|
||||
|
||||
---
|
||||
|
||||
## v1.23.1 (08/12/2023)
|
||||
|
||||
### What's Changed
|
||||
* Properly encode search query param by @sissbruecker in https://github.com/sissbruecker/linkding/pull/587
|
||||
|
||||
> [!WARNING]
|
||||
> *This resolves a security vulnerability in linkding. Everyone is encouraged to upgrade to the latest version as soon as possible.*
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.23.0...v1.23.1
|
||||
|
||||
---
|
||||
|
||||
## v1.23.0 (24/11/2023)
|
||||
|
||||
### What's Changed
|
||||
* Add Alpine based Docker image (experimental) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/570
|
||||
* Add backup CLI command by @sissbruecker in https://github.com/sissbruecker/linkding/pull/571
|
||||
* Update browser extension links by @OPerepadia in https://github.com/sissbruecker/linkding/pull/574
|
||||
* Include archived bookmarks in export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/579
|
||||
|
||||
### New Contributors
|
||||
* @OPerepadia made their first contribution in https://github.com/sissbruecker/linkding/pull/574
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.3...v1.23.0
|
||||
|
||||
---
|
||||
|
||||
## v1.22.3 (04/11/2023)
|
||||
|
||||
### What's Changed
|
||||
* Fix RSS feed not handling None values by @vitormarcal in https://github.com/sissbruecker/linkding/pull/569
|
||||
* Bump django from 4.1.10 to 4.1.13 by @dependabot in https://github.com/sissbruecker/linkding/pull/567
|
||||
|
||||
### New Contributors
|
||||
* @vitormarcal made their first contribution in https://github.com/sissbruecker/linkding/pull/569
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.2...v1.22.3
|
||||
|
||||
---
|
||||
|
||||
## v1.22.2 (27/10/2023)
|
||||
|
||||
### What's Changed
|
||||
* Fix search options not opening on iOS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/549
|
||||
* Bump urllib3 from 1.26.11 to 1.26.17 by @dependabot in https://github.com/sissbruecker/linkding/pull/542
|
||||
* Add iOS shortcut to community section by @andrewdolphin in https://github.com/sissbruecker/linkding/pull/550
|
||||
* Disable editing of search preferences in user admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/555
|
||||
* Add feed2linkding to community section by @Strubbl in https://github.com/sissbruecker/linkding/pull/544
|
||||
* Sanitize RSS feed to remove control characters by @sissbruecker in https://github.com/sissbruecker/linkding/pull/565
|
||||
* Bump urllib3 from 1.26.17 to 1.26.18 by @dependabot in https://github.com/sissbruecker/linkding/pull/560
|
||||
|
||||
### New Contributors
|
||||
* @andrewdolphin made their first contribution in https://github.com/sissbruecker/linkding/pull/550
|
||||
* @Strubbl made their first contribution in https://github.com/sissbruecker/linkding/pull/544
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.1...v1.22.2
|
||||
|
||||
---
|
||||
|
||||
## v1.22.1 (06/10/2023)
|
||||
|
||||
### What's Changed
|
||||
* Fix memory leak with SQLite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/548
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.0...v1.22.1
|
||||
|
||||
---
|
||||
|
||||
## v1.22.0 (01/10/2023)
|
||||
|
||||
### What's Changed
|
||||
* Fix case-insensitive search for unicode characters in SQLite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/520
|
||||
* Add sort option to bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/522
|
||||
* Add button to show tags on smaller screens by @sissbruecker in https://github.com/sissbruecker/linkding/pull/529
|
||||
* Make code blocks in notes scrollable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/530
|
||||
* Add filter for shared state by @sissbruecker in https://github.com/sissbruecker/linkding/pull/531
|
||||
* Add support for exporting/importing bookmark notes by @sissbruecker in https://github.com/sissbruecker/linkding/pull/532
|
||||
* Add filter for unread state by @sissbruecker in https://github.com/sissbruecker/linkding/pull/535
|
||||
* Allow saving search preferences by @sissbruecker in https://github.com/sissbruecker/linkding/pull/540
|
||||
* Add user profile endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/541
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.21.0...v1.22.0
|
||||
|
||||
---
|
||||
|
||||
## v1.21.1 (26/09/2023)
|
||||
|
||||
### What's Changed
|
||||
|
84
Dockerfile
84
Dockerfile
@@ -1,84 +0,0 @@
|
||||
FROM node:18.13.0-alpine AS node-build
|
||||
WORKDIR /etc/linkding
|
||||
# install build dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install -g npm && \
|
||||
npm install
|
||||
# compile JS components
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.10.6-slim-buster AS python-base
|
||||
RUN apt-get update && apt-get -y install build-essential libpq-dev
|
||||
WORKDIR /etc/linkding
|
||||
|
||||
|
||||
FROM python-base AS python-build
|
||||
# install build dependencies
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install -U pip && pip install -Ur requirements.txt
|
||||
# run Django part of the build
|
||||
COPY --from=node-build /etc/linkding .
|
||||
RUN python manage.py compilescss && \
|
||||
python manage.py collectstatic --ignore=*.scss && \
|
||||
python manage.py compilescss --delete-files
|
||||
|
||||
|
||||
FROM python-base AS prod-deps
|
||||
COPY requirements.prod.txt ./requirements.txt
|
||||
RUN mkdir /opt/venv && \
|
||||
python -m venv --upgrade-deps --copies /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip wheel && \
|
||||
/opt/venv/bin/pip install -Ur requirements.txt
|
||||
|
||||
|
||||
FROM python-base AS compile-icu
|
||||
RUN apt-get update && apt-get -y install libicu-dev libsqlite3-dev wget unzip
|
||||
WORKDIR /etc/linkding
|
||||
|
||||
# Defines SQLite version
|
||||
# Since this is only needed for downloading the header files this probably
|
||||
# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU
|
||||
# extension do not change
|
||||
ARG SQLITE_RELEASE_YEAR=2023
|
||||
ARG SQLITE_RELEASE=3430000
|
||||
|
||||
# Compile the ICU extension needed for case-insensitive search and ordering
|
||||
# with SQLite. This does:
|
||||
# - Download SQLite amalgamation for header files
|
||||
# - Download ICU extension source file
|
||||
# - Compile ICU extension
|
||||
RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQLITE_RELEASE}.zip && \
|
||||
unzip sqlite-amalgamation-${SQLITE_RELEASE}.zip && \
|
||||
cp sqlite-amalgamation-${SQLITE_RELEASE}/sqlite3.h ./sqlite3.h && \
|
||||
cp sqlite-amalgamation-${SQLITE_RELEASE}/sqlite3ext.h ./sqlite3ext.h && \
|
||||
wget https://www.sqlite.org/src/raw/ext/icu/icu.c?name=91c021c7e3e8bbba286960810fa303295c622e323567b2e6def4ce58e4466e60 -O icu.c && \
|
||||
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||
|
||||
|
||||
FROM python:3.10.6-slim-buster as final
|
||||
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev curl
|
||||
WORKDIR /etc/linkding
|
||||
# copy prod dependencies
|
||||
COPY --from=prod-deps /opt/venv /opt/venv
|
||||
# copy output from build stage
|
||||
COPY --from=python-build /etc/linkding/static static/
|
||||
# copy compiled icu extension
|
||||
COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
|
||||
# copy application code
|
||||
COPY . .
|
||||
# Expose uwsgi server at port 9090
|
||||
EXPOSE 9090
|
||||
# Activate virtual env
|
||||
ENV VIRTUAL_ENV /opt/venv
|
||||
ENV PATH /opt/venv/bin:$PATH
|
||||
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
|
||||
RUN ["chmod", "g+w", "."]
|
||||
# Run bootstrap logic
|
||||
RUN ["chmod", "+x", "./bootstrap.sh"]
|
||||
|
||||
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
||||
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
||||
|
||||
CMD ["./bootstrap.sh"]
|
15
Makefile
Normal file
15
Makefile
Normal file
@@ -0,0 +1,15 @@
|
||||
.PHONY: serve
|
||||
|
||||
serve:
|
||||
python manage.py runserver
|
||||
|
||||
tasks:
|
||||
python manage.py run_huey
|
||||
|
||||
test:
|
||||
pytest -n auto
|
||||
|
||||
format:
|
||||
black bookmarks
|
||||
npx prettier bookmarks/frontend --write
|
||||
npx prettier bookmarks/styles --write
|
207
README.md
207
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](#acknowledgements)
|
||||
- [Development](#development)
|
||||
|
||||
## Introduction
|
||||
|
||||
linkding is a bookmark manager that you can host yourself.
|
||||
@@ -33,182 +19,49 @@ The name comes from:
|
||||
**Feature Overview:**
|
||||
- Clean UI optimized for readability
|
||||
- Organize bookmarks with tags
|
||||
- Add notes using Markdown
|
||||
- Read it later functionality
|
||||
- Share bookmarks with other users
|
||||
- Bulk editing
|
||||
- Bulk editing, Markdown notes, read it later functionality
|
||||
- Share bookmarks with other users or guests
|
||||
- Automatically provides titles, descriptions and icons of bookmarked websites
|
||||
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
|
||||
- Automatically archive websites, either as local HTML file or on Internet Archive
|
||||
- Import and export bookmarks in Netscape HTML format
|
||||
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
|
||||
- Light and dark themes
|
||||
- Installable as a Progressive Web App (PWA)
|
||||
- Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
|
||||
- SSO support via OIDC or authentication proxies
|
||||
- REST API for developing 3rd party apps
|
||||
- Admin panel for user self-service and raw data access
|
||||
- Easy setup using Docker and a SQLite database, with PostgreSQL as an option
|
||||
|
||||
|
||||
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)
|
||||
**Demo:** https://demo.linkding.link/
|
||||
|
||||
**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.
|
||||
|
||||
By default, linkding uses SQLite as a database.
|
||||
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
|
||||
|
||||
### Using Docker
|
||||
|
||||
To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
|
||||
```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, with regard to keeping everything up-to-date and secure. This can be a huge entry barrier for people who are interested in self-hosting, but lack the technical knowledge to do so. This section is intended to provide alternatives in form of managed hosting solutions. Note that these options are usually commercial offerings, that require paying a fee for the convenience of being managed by another party. The technical knowledge required to make use of individual options is going to vary, and no guarantees can be made that every option is accessible for everyone. That being said, I hope this section helps in making the application accessible to a wider audience.
|
||||
|
||||
- [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)
|
||||
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/de/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)
|
||||
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||
- [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)
|
||||
- [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)
|
||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
JetBrains provides 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.12
|
||||
- Node.js
|
||||
|
||||
### Setup
|
||||
@@ -223,7 +76,7 @@ source ~/environments/linkding/bin/activate[.csh|.fish]
|
||||
```
|
||||
Within the active environment install the application dependencies from the application folder:
|
||||
```
|
||||
pip3 install -Ur requirements.txt
|
||||
pip3 install -r requirements.txt -r requirements.dev.txt
|
||||
```
|
||||
Install frontend dependencies:
|
||||
```
|
||||
@@ -248,9 +101,23 @@ python3 manage.py runserver
|
||||
```
|
||||
The frontend is now available under http://localhost:8000
|
||||
|
||||
### Tests
|
||||
|
||||
Run all tests with pytest:
|
||||
```
|
||||
make test
|
||||
```
|
||||
|
||||
### Formatting
|
||||
|
||||
Format Python code with black, 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:
|
||||
|
||||
|
BIN
assets/header.afdesign
Normal file
BIN
assets/header.afdesign
Normal file
Binary file not shown.
BIN
assets/header.png
Normal file
BIN
assets/header.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 53 KiB |
41
assets/header.svg
Normal file
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-inset.afdesign
Normal file
BIN
assets/logo-inset.afdesign
Normal file
Binary file not shown.
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
17
assets/logo.svg
Normal file
17
assets/logo.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<?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>
|
After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper script used by supervisord to first clear task locks before starting the background task processor
|
||||
|
||||
python manage.py clean_tasks
|
||||
exec python manage.py process_tasks
|
@@ -1,93 +1,223 @@
|
||||
from background_task.admin import TaskAdmin, CompletedTaskAdmin
|
||||
from background_task.models import Task, CompletedTask
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User
|
||||
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 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, Tag, UserProfile, Toast, FeedToken
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
# Custom paginator to paginate through Huey tasks
|
||||
class TaskPaginator(Paginator):
|
||||
def __init__(self):
|
||||
super().__init__(self, 100)
|
||||
self.task_count = huey.storage.queue_size()
|
||||
|
||||
@property
|
||||
def count(self):
|
||||
return self.task_count
|
||||
|
||||
def page(self, number):
|
||||
limit = self.per_page
|
||||
offset = (number - 1) * self.per_page
|
||||
return self._get_page(
|
||||
self.enqueued_items(limit, offset),
|
||||
number,
|
||||
self,
|
||||
)
|
||||
|
||||
# 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
|
||||
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)
|
||||
]
|
||||
return [huey.deserialize_task(task) for task in serialized_tasks]
|
||||
|
||||
|
||||
# Custom view to display Huey tasks in the admin
|
||||
def background_task_view(request):
|
||||
page_number = int(request.GET.get("p", 1))
|
||||
paginator = TaskPaginator()
|
||||
page = paginator.get_page(page_number)
|
||||
page_range = paginator.get_elided_page_range(page_number, on_each_side=2, on_ends=2)
|
||||
context = {
|
||||
**linkding_admin_site.each_context(request),
|
||||
"title": "Background tasks",
|
||||
"page": page,
|
||||
"page_range": page_range,
|
||||
"tasks": page.object_list,
|
||||
}
|
||||
return render(request, "admin/background_tasks.html", context)
|
||||
|
||||
|
||||
class LinkdingAdminSite(AdminSite):
|
||||
site_header = 'linkding administration'
|
||||
site_title = 'linkding Admin'
|
||||
site_header = "linkding administration"
|
||||
site_title = "linkding Admin"
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path("tasks/", background_task_view, name="background_tasks"),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def get_app_list(self, request, app_label=None):
|
||||
app_list = super().get_app_list(request, app_label)
|
||||
app_list += [
|
||||
{
|
||||
"name": "Huey",
|
||||
"app_label": "huey_app",
|
||||
"models": [
|
||||
{
|
||||
"name": "Queued tasks",
|
||||
"object_name": "background_tasks",
|
||||
"admin_url": "/admin/tasks/",
|
||||
"view_only": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
return app_list
|
||||
|
||||
|
||||
class AdminBookmark(admin.ModelAdmin):
|
||||
list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added')
|
||||
search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name')
|
||||
list_filter = ('owner__username', 'is_archived', 'unread', 'tags',)
|
||||
ordering = ('-date_added',)
|
||||
actions = ['delete_selected_bookmarks', 'archive_selected_bookmarks', 'unarchive_selected_bookmarks', 'mark_as_read', 'mark_as_unread']
|
||||
list_display = ("resolved_title", "url", "is_archived", "owner", "date_added")
|
||||
search_fields = (
|
||||
"title",
|
||||
"description",
|
||||
"website_title",
|
||||
"website_description",
|
||||
"url",
|
||||
"tags__name",
|
||||
)
|
||||
list_filter = (
|
||||
"owner__username",
|
||||
"is_archived",
|
||||
"unread",
|
||||
"tags",
|
||||
)
|
||||
ordering = ("-date_added",)
|
||||
actions = [
|
||||
"delete_selected_bookmarks",
|
||||
"archive_selected_bookmarks",
|
||||
"unarchive_selected_bookmarks",
|
||||
"mark_as_read",
|
||||
"mark_as_unread",
|
||||
]
|
||||
|
||||
def get_actions(self, request):
|
||||
actions = super().get_actions(request)
|
||||
# Remove default delete action, which gets replaced by delete_selected_bookmarks below
|
||||
# The default action shows a confirmation page which can fail in production when selecting all bookmarks and the
|
||||
# number of objects to delete exceeds the value in DATA_UPLOAD_MAX_NUMBER_FIELDS (1000 by default)
|
||||
del actions['delete_selected']
|
||||
del actions["delete_selected"]
|
||||
return actions
|
||||
|
||||
def delete_selected_bookmarks(self, request, queryset: QuerySet):
|
||||
bookmarks_count = queryset.count()
|
||||
for bookmark in queryset:
|
||||
bookmark.delete()
|
||||
self.message_user(request, ngettext(
|
||||
'%d bookmark was successfully deleted.',
|
||||
'%d bookmarks were successfully deleted.',
|
||||
bookmarks_count,
|
||||
) % bookmarks_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d bookmark was successfully deleted.",
|
||||
"%d bookmarks were successfully deleted.",
|
||||
bookmarks_count,
|
||||
)
|
||||
% bookmarks_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
def archive_selected_bookmarks(self, request, queryset: QuerySet):
|
||||
for bookmark in queryset:
|
||||
archive_bookmark(bookmark)
|
||||
bookmarks_count = queryset.count()
|
||||
self.message_user(request, ngettext(
|
||||
'%d bookmark was successfully archived.',
|
||||
'%d bookmarks were successfully archived.',
|
||||
bookmarks_count,
|
||||
) % bookmarks_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d bookmark was successfully archived.",
|
||||
"%d bookmarks were successfully archived.",
|
||||
bookmarks_count,
|
||||
)
|
||||
% bookmarks_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
def unarchive_selected_bookmarks(self, request, queryset: QuerySet):
|
||||
for bookmark in queryset:
|
||||
unarchive_bookmark(bookmark)
|
||||
bookmarks_count = queryset.count()
|
||||
self.message_user(request, ngettext(
|
||||
'%d bookmark was successfully unarchived.',
|
||||
'%d bookmarks were successfully unarchived.',
|
||||
bookmarks_count,
|
||||
) % bookmarks_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d bookmark was successfully unarchived.",
|
||||
"%d bookmarks were successfully unarchived.",
|
||||
bookmarks_count,
|
||||
)
|
||||
% bookmarks_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
def mark_as_read(self, request, queryset: QuerySet):
|
||||
bookmarks_count = queryset.count()
|
||||
queryset.update(unread=False)
|
||||
self.message_user(request, ngettext(
|
||||
'%d bookmark marked as read.',
|
||||
'%d bookmarks marked as read.',
|
||||
bookmarks_count,
|
||||
) % bookmarks_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d bookmark marked as read.",
|
||||
"%d bookmarks marked as read.",
|
||||
bookmarks_count,
|
||||
)
|
||||
% bookmarks_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
def mark_as_unread(self, request, queryset: QuerySet):
|
||||
bookmarks_count = queryset.count()
|
||||
queryset.update(unread=True)
|
||||
self.message_user(request, ngettext(
|
||||
'%d bookmark marked as unread.',
|
||||
'%d bookmarks marked as unread.',
|
||||
bookmarks_count,
|
||||
) % bookmarks_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d bookmark marked as unread.",
|
||||
"%d bookmarks marked as unread.",
|
||||
bookmarks_count,
|
||||
)
|
||||
% bookmarks_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
|
||||
class AdminBookmarkAsset(admin.ModelAdmin):
|
||||
@admin.display(description="Display Name")
|
||||
def custom_display_name(self, obj):
|
||||
return str(obj)
|
||||
|
||||
list_display = ("custom_display_name", "date_created", "status")
|
||||
search_fields = (
|
||||
"custom_display_name",
|
||||
"file",
|
||||
)
|
||||
list_filter = ("status",)
|
||||
|
||||
|
||||
class AdminTag(admin.ModelAdmin):
|
||||
list_display = ('name', 'bookmarks_count', 'owner', 'date_added')
|
||||
search_fields = ('name', 'owner__username')
|
||||
list_filter = ('owner__username',)
|
||||
ordering = ('-date_added',)
|
||||
actions = ['delete_unused_tags']
|
||||
list_display = ("name", "bookmarks_count", "owner", "date_added")
|
||||
search_fields = ("name", "owner__username")
|
||||
list_filter = ("owner__username",)
|
||||
ordering = ("-date_added",)
|
||||
actions = ["delete_unused_tags"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
queryset = super().get_queryset(request)
|
||||
@@ -97,7 +227,7 @@ class AdminTag(admin.ModelAdmin):
|
||||
def bookmarks_count(self, obj):
|
||||
return obj.bookmarks_count
|
||||
|
||||
bookmarks_count.admin_order_field = 'bookmarks_count'
|
||||
bookmarks_count.admin_order_field = "bookmarks_count"
|
||||
|
||||
def delete_unused_tags(self, request, queryset: QuerySet):
|
||||
unused_tags = queryset.filter(bookmark__isnull=True)
|
||||
@@ -106,22 +236,32 @@ class AdminTag(admin.ModelAdmin):
|
||||
tag.delete()
|
||||
|
||||
if unused_tags_count > 0:
|
||||
self.message_user(request, ngettext(
|
||||
'%d unused tag was successfully deleted.',
|
||||
'%d unused tags were successfully deleted.',
|
||||
unused_tags_count,
|
||||
) % unused_tags_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d unused tag was successfully deleted.",
|
||||
"%d unused tags were successfully deleted.",
|
||||
unused_tags_count,
|
||||
)
|
||||
% unused_tags_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
else:
|
||||
self.message_user(request, gettext(
|
||||
'There were no unused tags in the selection',
|
||||
), messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
gettext(
|
||||
"There were no unused tags in the selection",
|
||||
),
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
|
||||
class AdminUserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
verbose_name_plural = 'Profile'
|
||||
fk_name = 'user'
|
||||
verbose_name_plural = "Profile"
|
||||
fk_name = "user"
|
||||
readonly_fields = ("search_preferences",)
|
||||
|
||||
|
||||
class AdminCustomUser(UserAdmin):
|
||||
@@ -134,23 +274,22 @@ class AdminCustomUser(UserAdmin):
|
||||
|
||||
|
||||
class AdminToast(admin.ModelAdmin):
|
||||
list_display = ('key', 'message', 'owner', 'acknowledged')
|
||||
search_fields = ('key', 'message')
|
||||
list_filter = ('owner__username',)
|
||||
list_display = ("key", "message", "owner", "acknowledged")
|
||||
search_fields = ("key", "message")
|
||||
list_filter = ("owner__username",)
|
||||
|
||||
|
||||
class AdminFeedToken(admin.ModelAdmin):
|
||||
list_display = ('key', 'user')
|
||||
search_fields = ['key']
|
||||
list_filter = ('user__username',)
|
||||
list_display = ("key", "user")
|
||||
search_fields = ["key"]
|
||||
list_filter = ("user__username",)
|
||||
|
||||
|
||||
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(User, AdminCustomUser)
|
||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||
linkding_admin_site.register(Toast, AdminToast)
|
||||
linkding_admin_site.register(FeedToken, AdminFeedToken)
|
||||
linkding_admin_site.register(Task, TaskAdmin)
|
||||
linkding_admin_site.register(CompletedTask, CompletedTaskAdmin)
|
||||
|
34
bookmarks/api/auth.py
Normal file
34
bookmarks/api/auth.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication, get_authorization_header
|
||||
|
||||
|
||||
class LinkdingTokenAuthentication(TokenAuthentication):
|
||||
"""
|
||||
Extends DRF TokenAuthentication to add support for multiple keywords
|
||||
"""
|
||||
|
||||
keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]]
|
||||
|
||||
def authenticate(self, request):
|
||||
auth = get_authorization_header(request).split()
|
||||
|
||||
if not auth or auth[0].lower() not in self.keywords:
|
||||
return None
|
||||
|
||||
if len(auth) == 1:
|
||||
msg = _("Invalid token header. No credentials provided.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
elif len(auth) > 2:
|
||||
msg = _("Invalid token header. Token string should not contain spaces.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
try:
|
||||
token = auth[1].decode()
|
||||
except UnicodeError:
|
||||
msg = _(
|
||||
"Invalid token header. Token string should not contain invalid characters."
|
||||
)
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
return self.authenticate_credentials(token)
|
@@ -1,22 +1,39 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from rest_framework import viewsets, mixins, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework.routers import SimpleRouter, DefaultRouter
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer, UserProfileSerializer
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark, website_loader
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
from bookmarks.api.serializers import (
|
||||
BookmarkSerializer,
|
||||
BookmarkAssetSerializer,
|
||||
TagSerializer,
|
||||
UserProfileSerializer,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
|
||||
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.views import access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin):
|
||||
class BookmarkViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = BookmarkSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
@@ -24,80 +41,213 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
# The shared action should still filter bookmarks so that
|
||||
# unauthenticated users only see bookmarks from users that have public
|
||||
# sharing explicitly enabled
|
||||
if self.action == 'shared':
|
||||
if self.action == "shared":
|
||||
return [AllowAny()]
|
||||
|
||||
# Otherwise use default permissions which should require authentication
|
||||
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
|
||||
if self.action == 'list':
|
||||
search = BookmarkSearch.from_request(self.request.GET)
|
||||
search = BookmarkSearch.from_request(self.request.GET)
|
||||
if self.action == "list":
|
||||
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 {'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_class()
|
||||
data = serializer(page, many=True).data
|
||||
return self.get_paginated_response(data)
|
||||
@action(methods=["get"], detail=False)
|
||||
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_class()
|
||||
data = serializer(page, many=True).data
|
||||
return self.get_paginated_response(data)
|
||||
@action(methods=["get"], detail=False)
|
||||
def shared(self, request: HttpRequest):
|
||||
return self.list(request)
|
||||
|
||||
@action(methods=['post'], detail=True)
|
||||
def archive(self, request, pk):
|
||||
@action(methods=["post"], detail=True)
|
||||
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):
|
||||
@action(methods=["post"], detail=True)
|
||||
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):
|
||||
url = request.GET.get('url')
|
||||
@action(methods=["get"], detail=False)
|
||||
def check(self, request: HttpRequest):
|
||||
url = request.GET.get("url")
|
||||
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
existing_bookmark_data = self.get_serializer(bookmark).data if bookmark else None
|
||||
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)
|
||||
else:
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
metadata = website_loader.load_website_metadata(url, ignore_cache=ignore_cache)
|
||||
|
||||
return Response({
|
||||
'bookmark': existing_bookmark_data,
|
||||
'metadata': metadata.to_dict()
|
||||
}, status=status.HTTP_200_OK)
|
||||
# Return tags that would be automatically applied to the bookmark
|
||||
profile = request.user.profile
|
||||
auto_tags = []
|
||||
if profile.auto_tagging_rules:
|
||||
try:
|
||||
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to auto-tag bookmark. url={url}",
|
||||
exc_info=e,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"bookmark": existing_bookmark_data,
|
||||
"metadata": metadata.to_dict(),
|
||||
"auto_tags": auto_tags,
|
||||
},
|
||||
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.objects.filter(owner=request.user, url=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 TagViewSet(viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin):
|
||||
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")
|
||||
)
|
||||
file_name = (
|
||||
f"{asset.display_name}.html"
|
||||
if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
|
||||
else asset.display_name
|
||||
)
|
||||
response = StreamingHttpResponse(file_stream, content_type=content_type)
|
||||
response["Content-Disposition"] = f'attachment; filename="{file_name}"'
|
||||
return response
|
||||
except FileNotFoundError:
|
||||
raise Http404("Asset file does not exist")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",
|
||||
exc_info=e,
|
||||
)
|
||||
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
def upload(self, request: HttpRequest, bookmark_id):
|
||||
if settings.LD_DISABLE_ASSET_UPLOAD:
|
||||
return Response(
|
||||
{"error": "Asset upload is disabled."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
bookmark = access.bookmark_write(request, bookmark_id)
|
||||
|
||||
upload_file = request.FILES.get("file")
|
||||
if not upload_file:
|
||||
return Response(
|
||||
{"error": "No file provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
asset = assets.upload_asset(bookmark, upload_file)
|
||||
serializer = self.get_serializer(asset)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to upload asset file. bookmark_id={bookmark_id}, file={upload_file.name}",
|
||||
exc_info=e,
|
||||
)
|
||||
return Response(
|
||||
{"error": "Failed to upload asset."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
assets.remove_asset(instance)
|
||||
|
||||
|
||||
class TagViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = TagSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -105,16 +255,28 @@ class TagViewSet(viewsets.GenericViewSet,
|
||||
return Tag.objects.all().filter(owner=user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {'user': self.request.user}
|
||||
return {"user": self.request.user}
|
||||
|
||||
|
||||
class UserViewSet(viewsets.GenericViewSet):
|
||||
@action(methods=['get'], detail=False)
|
||||
def profile(self, request):
|
||||
@action(methods=["get"], detail=False)
|
||||
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')
|
||||
# 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")
|
||||
|
||||
bookmark_asset_router = SimpleRouter()
|
||||
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
|
||||
|
@@ -1,10 +1,13 @@
|
||||
from django.db.models import prefetch_related_objects
|
||||
from django.templatetags.static import static
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ListSerializer
|
||||
|
||||
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, Tag, build_tag_string, UserProfile
|
||||
from bookmarks.services import bookmarks
|
||||
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):
|
||||
@@ -14,81 +17,154 @@ class TagListField(serializers.ListField):
|
||||
class BookmarkListSerializer(ListSerializer):
|
||||
def to_representation(self, data):
|
||||
# Prefetch nested relations to avoid n+1 queries
|
||||
prefetch_related_objects(data, 'tags')
|
||||
prefetch_related_objects(data, "tags")
|
||||
|
||||
return super().to_representation(data)
|
||||
|
||||
|
||||
class EmtpyField(serializers.ReadOnlyField):
|
||||
def to_representation(self, value):
|
||||
return None
|
||||
|
||||
|
||||
class BookmarkSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
'id',
|
||||
'url',
|
||||
'title',
|
||||
'description',
|
||||
'notes',
|
||||
'website_title',
|
||||
'website_description',
|
||||
'is_archived',
|
||||
'unread',
|
||||
'shared',
|
||||
'tag_names',
|
||||
'date_added',
|
||||
'date_modified'
|
||||
"id",
|
||||
"url",
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"is_archived",
|
||||
"unread",
|
||||
"shared",
|
||||
"tag_names",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
"website_title",
|
||||
"website_description",
|
||||
]
|
||||
read_only_fields = [
|
||||
'website_title',
|
||||
'website_description',
|
||||
'date_added',
|
||||
'date_modified'
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"tag_names",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
"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()
|
||||
|
||||
def get_favicon_url(self, obj: Bookmark):
|
||||
if not obj.favicon_file:
|
||||
return None
|
||||
request = self.context.get("request")
|
||||
favicon_file_path = static(obj.favicon_file)
|
||||
favicon_url = request.build_absolute_uri(favicon_file_path)
|
||||
return favicon_url
|
||||
|
||||
def get_preview_image_url(self, obj: Bookmark):
|
||||
if not obj.preview_image_file:
|
||||
return None
|
||||
request = self.context.get("request")
|
||||
preview_image_file_path = static(obj.preview_image_file)
|
||||
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):
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['id', 'name', 'date_added']
|
||||
read_only_fields = ['date_added']
|
||||
fields = ["id", "name", "date_added"]
|
||||
read_only_fields = ["date_added"]
|
||||
|
||||
def create(self, validated_data):
|
||||
return get_or_create_tag(validated_data['name'], self.context['user'])
|
||||
return get_or_create_tag(validated_data["name"], self.context["user"])
|
||||
|
||||
|
||||
class UserProfileSerializer(serializers.ModelSerializer):
|
||||
@@ -106,4 +182,7 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"display_url",
|
||||
"permanent_notes",
|
||||
"search_preferences",
|
||||
"version",
|
||||
]
|
||||
|
||||
version = serializers.ReadOnlyField(default=app_version)
|
||||
|
@@ -2,7 +2,7 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class BookmarksConfig(AppConfig):
|
||||
name = 'bookmarks'
|
||||
name = "bookmarks"
|
||||
|
||||
def ready(self):
|
||||
# Register signal handlers
|
||||
|
@@ -1,32 +1,21 @@
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import BookmarkSearch, Toast
|
||||
from bookmarks import utils
|
||||
from bookmarks.models import Toast
|
||||
|
||||
|
||||
def toasts(request):
|
||||
user = request.user
|
||||
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user.is_authenticated else []
|
||||
toast_messages = (
|
||||
Toast.objects.filter(owner=user, acknowledged=False)
|
||||
if user.is_authenticated
|
||||
else []
|
||||
)
|
||||
has_toasts = len(toast_messages) > 0
|
||||
|
||||
return {
|
||||
'has_toasts': has_toasts,
|
||||
'toast_messages': toast_messages,
|
||||
"has_toasts": has_toasts,
|
||||
"toast_messages": toast_messages,
|
||||
}
|
||||
|
||||
|
||||
def public_shares(request):
|
||||
# Only check for public shares for anonymous users
|
||||
if not request.user.is_authenticated:
|
||||
query_set = queries.query_shared_bookmarks(None, request.user_profile, BookmarkSearch(), True)
|
||||
has_public_shares = query_set.count() > 0
|
||||
return {
|
||||
'has_public_shares': has_public_shares,
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def app_version(request):
|
||||
return {
|
||||
'app_version': utils.app_version
|
||||
}
|
||||
return {"app_version": utils.app_version}
|
||||
|
@@ -1,67 +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='')
|
@@ -1,232 +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)
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
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[ld-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[ld-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[ld-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)
|
||||
|
||||
# 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()
|
||||
|
||||
# Get reference for bookmark list
|
||||
bookmark_list = page.locator('ul[ld-bookmark-list]')
|
||||
|
||||
# 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[ld-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)
|
||||
|
||||
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()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
expect(self.locate_bulk_edit_bar().get_by_text('All pages (70 bookmarks)')).to_be_visible()
|
@@ -1,288 +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[ld-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[ld-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[ld-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[ld-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,39 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
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()
|
@@ -1,54 +0,0 @@
|
||||
from django.contrib.staticfiles.testing import LiveServerTestCase
|
||||
from playwright.sync_api import BrowserContext, Playwright, Page
|
||||
|
||||
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 locate_bookmark(self, title: str):
|
||||
bookmark_tags = self.page.locator('li[ld-bookmark-item]')
|
||||
return bookmark_tags.filter(has_text=title)
|
||||
|
||||
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[ld-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)
|
@@ -1,31 +1,60 @@
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import QuerySet, prefetch_related_objects
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeedContext:
|
||||
feed_token: FeedToken
|
||||
request: HttpRequest
|
||||
feed_token: FeedToken | None
|
||||
query_set: QuerySet[Bookmark]
|
||||
|
||||
|
||||
def sanitize(text: str):
|
||||
if not text:
|
||||
return ""
|
||||
# remove control characters
|
||||
valid_chars = ["\n", "\r", "\t"]
|
||||
return "".join(
|
||||
ch for ch in text if ch in valid_chars or unicodedata.category(ch)[0] != "C"
|
||||
)
|
||||
|
||||
|
||||
class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||
search = BookmarkSearch(q=request.GET.get('q', ''))
|
||||
query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
|
||||
return FeedContext(feed_token, query_set)
|
||||
def get_object(self, request, feed_key: str | None):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
|
||||
search = BookmarkSearch(
|
||||
q=request.GET.get("q", ""),
|
||||
unread=request.GET.get("unread", ""),
|
||||
shared=request.GET.get("shared", ""),
|
||||
)
|
||||
query_set = self.get_query_set(feed_token, search)
|
||||
return FeedContext(request, feed_token, query_set)
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
raise NotImplementedError
|
||||
|
||||
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)
|
||||
prefetch_related_objects(data, "tags")
|
||||
return data
|
||||
|
||||
def item_title(self, item: Bookmark):
|
||||
return item.resolved_title
|
||||
return sanitize(item.resolved_title)
|
||||
|
||||
def item_description(self, item: Bookmark):
|
||||
return item.resolved_description
|
||||
return sanitize(item.resolved_description)
|
||||
|
||||
def item_link(self, item: Bookmark):
|
||||
return item.url
|
||||
@@ -33,24 +62,56 @@ class BaseBookmarksFeed(Feed):
|
||||
def item_pubdate(self, item: Bookmark):
|
||||
return item.date_added
|
||||
|
||||
def item_categories(self, item: Bookmark):
|
||||
return item.tag_names
|
||||
|
||||
|
||||
class AllBookmarksFeed(BaseBookmarksFeed):
|
||||
title = 'All bookmarks'
|
||||
description = 'All bookmarks'
|
||||
title = "All bookmarks"
|
||||
description = "All bookmarks"
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
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])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set
|
||||
return reverse("linkding:feeds.all", args=[context.feed_token.key])
|
||||
|
||||
|
||||
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||
title = 'Unread bookmarks'
|
||||
description = 'All unread bookmarks'
|
||||
title = "Unread bookmarks"
|
||||
description = "All unread bookmarks"
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
return queries.query_bookmarks(
|
||||
feed_token.user, feed_token.user.profile, search
|
||||
).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])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set.filter(unread=True)
|
||||
|
||||
class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "Shared bookmarks"
|
||||
description = "All shared bookmarks"
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
return queries.query_shared_bookmarks(
|
||||
None, feed_token.user.profile, search, False
|
||||
)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("linkding:feeds.shared", args=[context.feed_token.key])
|
||||
|
||||
|
||||
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "Public shared bookmarks"
|
||||
description = "All public shared bookmarks"
|
||||
|
||||
def get_object(self, request):
|
||||
return super().get_object(request, None)
|
||||
|
||||
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||
return queries.query_shared_bookmarks(None, UserProfile(), search, True)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("linkding:feeds.public_shared")
|
||||
|
95
bookmarks/forms.py
Normal file
95
bookmarks/forms.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from django import forms
|
||||
|
||||
from bookmarks.models import Bookmark, build_tag_string
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
# Use URLField for URL
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
tag_string = forms.CharField(required=False)
|
||||
# Do not require title and description as they may be empty
|
||||
title = forms.CharField(max_length=512, required=False)
|
||||
description = forms.CharField(required=False, widget=forms.Textarea())
|
||||
unread = forms.BooleanField(required=False)
|
||||
shared = forms.BooleanField(required=False)
|
||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||
auto_close = forms.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
"url",
|
||||
"tag_string",
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"unread",
|
||||
"shared",
|
||||
"auto_close",
|
||||
]
|
||||
|
||||
def __init__(self, request: HttpRequest, instance: Bookmark = None):
|
||||
self.request = request
|
||||
|
||||
initial = None
|
||||
if instance is None and request.method == "GET":
|
||||
initial = {
|
||||
"url": request.GET.get("url"),
|
||||
"title": request.GET.get("title"),
|
||||
"description": request.GET.get("description"),
|
||||
"notes": request.GET.get("notes"),
|
||||
"tag_string": request.GET.get("tags"),
|
||||
"auto_close": "auto_close" in request.GET,
|
||||
"unread": request.user_profile.default_mark_unread,
|
||||
}
|
||||
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)
|
||||
|
||||
@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.objects.filter(owner=self.instance.owner, url=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(" ", ",")
|
@@ -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,67 +1,29 @@
|
||||
import { registerBehavior, swap } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class BookmarkPage {
|
||||
class BookmarkItem extends Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.form = element.querySelector("form.bookmark-actions");
|
||||
this.form.addEventListener("submit", this.onFormSubmit.bind(this));
|
||||
super(element);
|
||||
|
||||
this.bookmarkList = element.querySelector(".bookmark-list-container");
|
||||
this.tagCloud = element.querySelector(".tag-cloud-container");
|
||||
}
|
||||
// Toggle notes
|
||||
this.onToggleNotes = this.onToggleNotes.bind(this);
|
||||
this.notesToggle = element.querySelector(".toggle-notes");
|
||||
if (this.notesToggle) {
|
||||
this.notesToggle.addEventListener("click", this.onToggleNotes);
|
||||
}
|
||||
|
||||
async onFormSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const url = this.form.action;
|
||||
const formData = new FormData(this.form);
|
||||
formData.append(event.submitter.name, event.submitter.value);
|
||||
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
redirect: "manual", // ignore redirect
|
||||
});
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const query = window.location.search;
|
||||
const bookmarksUrl = this.element.getAttribute("bookmarks-url");
|
||||
const tagsUrl = this.element.getAttribute("tags-url");
|
||||
Promise.all([
|
||||
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
|
||||
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
|
||||
]).then(([bookmarkListHtml, tagCloudHtml]) => {
|
||||
swap(this.bookmarkList, bookmarkListHtml);
|
||||
swap(this.tagCloud, tagCloudHtml);
|
||||
|
||||
// Dispatch list updated event
|
||||
const listElement = this.bookmarkList.querySelector(
|
||||
"ul[data-bookmarks-total]",
|
||||
);
|
||||
const bookmarksTotal =
|
||||
(listElement && listElement.dataset.bookmarksTotal) || 0;
|
||||
|
||||
this.bookmarkList.dispatchEvent(
|
||||
new CustomEvent("bookmark-list-updated", {
|
||||
bubbles: true,
|
||||
detail: { bookmarksTotal },
|
||||
}),
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-bookmark-page", BookmarkPage);
|
||||
|
||||
class BookmarkItem {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
|
||||
const notesToggle = element.querySelector(".toggle-notes");
|
||||
if (notesToggle) {
|
||||
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
|
||||
destroy() {
|
||||
if (this.notesToggle) {
|
||||
this.notesToggle.removeEventListener("click", this.onToggleNotes);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,46 +1,72 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class BulkEdit {
|
||||
class BulkEdit extends Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.active = false;
|
||||
this.actionSelect = element.querySelector("select[name='bulk_action']");
|
||||
this.tagAutoComplete = element.querySelector(".tag-autocomplete");
|
||||
this.selectAcross = element.querySelector("label.select-across");
|
||||
super(element);
|
||||
|
||||
element.addEventListener(
|
||||
"bulk-edit-toggle-active",
|
||||
this.onToggleActive.bind(this),
|
||||
);
|
||||
element.addEventListener(
|
||||
"bulk-edit-toggle-all",
|
||||
this.onToggleAll.bind(this),
|
||||
);
|
||||
element.addEventListener(
|
||||
"bulk-edit-toggle-bookmark",
|
||||
this.onToggleBookmark.bind(this),
|
||||
);
|
||||
element.addEventListener(
|
||||
"bookmark-list-updated",
|
||||
this.onListUpdated.bind(this),
|
||||
);
|
||||
this.active = element.classList.contains("active");
|
||||
|
||||
this.actionSelect.addEventListener(
|
||||
"change",
|
||||
this.onActionSelected.bind(this),
|
||||
);
|
||||
this.init = this.init.bind(this);
|
||||
this.onToggleActive = this.onToggleActive.bind(this);
|
||||
this.onToggleAll = this.onToggleAll.bind(this);
|
||||
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
||||
this.onActionSelected = this.onActionSelected.bind(this);
|
||||
|
||||
this.init();
|
||||
// Reset when bookmarks are updated
|
||||
document.addEventListener("bookmark-list-updated", this.init);
|
||||
}
|
||||
|
||||
get allCheckbox() {
|
||||
return this.element.querySelector("[ld-bulk-edit-checkbox][all] input");
|
||||
destroy() {
|
||||
this.removeListeners();
|
||||
document.removeEventListener("bookmark-list-updated", this.init);
|
||||
}
|
||||
|
||||
get bookmarkCheckboxes() {
|
||||
return [
|
||||
...this.element.querySelectorAll(
|
||||
"[ld-bulk-edit-checkbox]:not([all]) input",
|
||||
),
|
||||
];
|
||||
init() {
|
||||
// Update elements
|
||||
this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
|
||||
this.actionSelect = this.element.querySelector(
|
||||
"select[name='bulk_action']",
|
||||
);
|
||||
this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
|
||||
this.selectAcross = this.element.querySelector("label.select-across");
|
||||
this.allCheckbox = this.element.querySelector(
|
||||
".bulk-edit-checkbox.all input",
|
||||
);
|
||||
this.bookmarkCheckboxes = Array.from(
|
||||
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||
);
|
||||
|
||||
// Add listeners, ensure there are no dupes by possibly removing existing listeners
|
||||
this.removeListeners();
|
||||
this.addListeners();
|
||||
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
|
||||
// Update total number of bookmarks
|
||||
const totalHolder = this.element.querySelector("[data-bookmarks-total]");
|
||||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
}
|
||||
|
||||
addListeners() {
|
||||
this.activeToggle.addEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.addEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
}
|
||||
|
||||
removeListeners() {
|
||||
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
}
|
||||
|
||||
onToggleActive() {
|
||||
@@ -81,16 +107,6 @@ class BulkEdit {
|
||||
}
|
||||
}
|
||||
|
||||
onListUpdated(event) {
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
|
||||
// Update total number of bookmarks
|
||||
const total = event.detail.bookmarksTotal;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
}
|
||||
|
||||
updateSelectAcross(allChecked) {
|
||||
if (allChecked) {
|
||||
this.selectAcross.classList.remove("d-none");
|
||||
@@ -109,33 +125,4 @@ class BulkEdit {
|
||||
}
|
||||
}
|
||||
|
||||
class BulkEditActiveToggle {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
element.addEventListener("click", this.onClick.bind(this));
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.element.dispatchEvent(
|
||||
new CustomEvent("bulk-edit-toggle-active", { bubbles: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BulkEditCheckbox {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
element.addEventListener("change", this.onChange.bind(this));
|
||||
}
|
||||
|
||||
onChange() {
|
||||
const type = this.element.hasAttribute("all") ? "all" : "bookmark";
|
||||
this.element.dispatchEvent(
|
||||
new CustomEvent(`bulk-edit-toggle-${type}`, { bubbles: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-bulk-edit", BulkEdit);
|
||||
registerBehavior("ld-bulk-edit-active-toggle", BulkEditActiveToggle);
|
||||
registerBehavior("ld-bulk-edit-checkbox", BulkEditCheckbox);
|
||||
|
42
bookmarks/frontend/behaviors/clear-button.js
Normal file
42
bookmarks/frontend/behaviors/clear-button.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class ClearButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.field = document.getElementById(element.dataset.for);
|
||||
if (!this.field) {
|
||||
console.error(`Field with ID ${element.dataset.for} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.update = this.update.bind(this);
|
||||
this.clear = this.clear.bind(this);
|
||||
|
||||
this.element.addEventListener("click", this.clear);
|
||||
this.field.addEventListener("input", this.update);
|
||||
this.field.addEventListener("value-changed", this.update);
|
||||
this.update();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.field) {
|
||||
return;
|
||||
}
|
||||
this.element.removeEventListener("click", this.clear);
|
||||
this.field.removeEventListener("input", this.update);
|
||||
this.field.removeEventListener("value-changed", this.update);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.element.style.display = this.field.value ? "inline-flex" : "none";
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.field.value = "";
|
||||
this.field.focus();
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-clear-button", ClearButtonBehavior);
|
@@ -1,25 +1,26 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class ConfirmButtonBehavior {
|
||||
class ConfirmButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
const button = element;
|
||||
button.dataset.type = button.type;
|
||||
button.dataset.name = button.name;
|
||||
button.dataset.value = button.value;
|
||||
button.removeAttribute("type");
|
||||
button.removeAttribute("name");
|
||||
button.removeAttribute("value");
|
||||
button.addEventListener("click", this.onClick.bind(this));
|
||||
this.button = button;
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.reset();
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
Behavior.interacting = true;
|
||||
|
||||
const container = document.createElement("span");
|
||||
container.className = "confirmation";
|
||||
|
||||
const icon = this.button.getAttribute("confirm-icon");
|
||||
const icon = this.element.getAttribute("ld-confirm-icon");
|
||||
if (icon) {
|
||||
const iconElement = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
@@ -31,38 +32,46 @@ class ConfirmButtonBehavior {
|
||||
container.append(iconElement);
|
||||
}
|
||||
|
||||
const question = this.button.getAttribute("confirm-question");
|
||||
const question = this.element.getAttribute("ld-confirm-question");
|
||||
if (question) {
|
||||
const questionElement = document.createElement("span");
|
||||
questionElement.innerText = question;
|
||||
container.append(question);
|
||||
}
|
||||
|
||||
const cancelButton = document.createElement(this.button.nodeName);
|
||||
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 = "btn btn-link btn-sm mr-1";
|
||||
cancelButton.className = `${buttonClasses} mr-1`;
|
||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
const confirmButton = document.createElement(this.button.nodeName);
|
||||
confirmButton.type = this.button.dataset.type;
|
||||
confirmButton.name = this.button.dataset.name;
|
||||
confirmButton.value = this.button.dataset.value;
|
||||
const confirmButton = document.createElement(this.element.nodeName);
|
||||
confirmButton.type = this.element.type;
|
||||
confirmButton.name = this.element.name;
|
||||
confirmButton.value = this.element.value;
|
||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||
confirmButton.className = "btn btn-link btn-sm";
|
||||
confirmButton.className = buttonClasses;
|
||||
confirmButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
container.append(cancelButton, confirmButton);
|
||||
this.container = container;
|
||||
|
||||
this.button.before(container);
|
||||
this.button.classList.add("d-none");
|
||||
this.element.before(container);
|
||||
this.element.classList.add("d-none");
|
||||
}
|
||||
|
||||
reset() {
|
||||
setTimeout(() => {
|
||||
this.container.remove();
|
||||
this.button.classList.remove("d-none");
|
||||
Behavior.interacting = false;
|
||||
if (this.container) {
|
||||
this.container.remove();
|
||||
this.container = null;
|
||||
}
|
||||
this.element.classList.remove("d-none");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
24
bookmarks/frontend/behaviors/details-modal.js
Normal file
24
bookmarks/frontend/behaviors/details-modal.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils";
|
||||
import { ModalBehavior } from "./modal";
|
||||
|
||||
class DetailsModalBehavior extends ModalBehavior {
|
||||
doClose() {
|
||||
super.doClose();
|
||||
|
||||
// Navigate to close URL
|
||||
const closeUrl = this.element.dataset.closeUrl;
|
||||
Turbo.visit(closeUrl, {
|
||||
action: "replace",
|
||||
frame: "details-modal",
|
||||
});
|
||||
|
||||
// Try restore focus to view details to view details link of respective bookmark
|
||||
const bookmarkId = this.element.dataset.bookmarkId;
|
||||
setAfterPageLoadFocusTarget(
|
||||
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-details-modal", DetailsModalBehavior);
|
73
bookmarks/frontend/behaviors/dropdown.js
Normal file
73
bookmarks/frontend/behaviors/dropdown.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class DropdownBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
this.opened = false;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
this.onEscape = this.onEscape.bind(this);
|
||||
this.onFocusOut = this.onFocusOut.bind(this);
|
||||
|
||||
// Prevent opening the dropdown automatically on focus, so that it only
|
||||
// opens on click then JS is enabled
|
||||
this.element.style.setProperty("--dropdown-focus-display", "none");
|
||||
this.element.addEventListener("keydown", this.onEscape);
|
||||
this.element.addEventListener("focusout", this.onFocusOut);
|
||||
|
||||
this.toggle = element.querySelector(".dropdown-toggle");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
this.toggle.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
this.toggle.removeEventListener("click", this.onClick);
|
||||
this.element.removeEventListener("keydown", this.onEscape);
|
||||
this.element.removeEventListener("focusout", this.onFocusOut);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.opened = true;
|
||||
this.element.classList.add("active");
|
||||
this.toggle.setAttribute("aria-expanded", "true");
|
||||
document.addEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.opened = false;
|
||||
this.element.classList.remove("active");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
onOutsideClick(event) {
|
||||
if (!this.element.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
onEscape(event) {
|
||||
if (event.key === "Escape" && this.opened) {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.toggle.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onFocusOut(event) {
|
||||
if (!this.element.contains(event.relatedTarget)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-dropdown", DropdownBehavior);
|
97
bookmarks/frontend/behaviors/filter-drawer.js
Normal file
97
bookmarks/frontend/behaviors/filter-drawer.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { ModalBehavior } from "./modal";
|
||||
import { isKeyboardActive } from "./focus-utils";
|
||||
|
||||
class FilterDrawerTriggerBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "drawer", "filter-drawer");
|
||||
modal.setAttribute("ld-filter-drawer", "");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>Filters</h2>
|
||||
<button class="close" aria-label="Close dialog">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.querySelector(".modals").appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterDrawerBehavior extends ModalBehavior {
|
||||
init() {
|
||||
// Teleport content before creating focus trap, otherwise it will not detect
|
||||
// focusable content elements
|
||||
this.teleport();
|
||||
super.init();
|
||||
// Add active class to start slide-in animation
|
||||
this.element.classList.add("active");
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
// Always close on destroy to restore drawer content to original location
|
||||
// before turbo caches DOM
|
||||
this.doClose();
|
||||
}
|
||||
|
||||
mapHeading(container, from, to) {
|
||||
const headings = container.querySelectorAll(from);
|
||||
headings.forEach((heading) => {
|
||||
const newHeading = document.createElement(to);
|
||||
newHeading.textContent = heading.textContent;
|
||||
heading.replaceWith(newHeading);
|
||||
});
|
||||
}
|
||||
|
||||
teleport() {
|
||||
const content = this.element.querySelector(".content");
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
content.append(...sidePanel.children);
|
||||
this.mapHeading(content, "h2", "h3");
|
||||
}
|
||||
|
||||
teleportBack() {
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
const content = this.element.querySelector(".content");
|
||||
sidePanel.append(...content.children);
|
||||
this.mapHeading(sidePanel, "h3", "h2");
|
||||
}
|
||||
|
||||
doClose() {
|
||||
super.doClose();
|
||||
this.teleportBack();
|
||||
|
||||
// Try restore focus to drawer trigger
|
||||
const restoreFocusElement =
|
||||
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
|
||||
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
|
||||
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);
|
124
bookmarks/frontend/behaviors/focus-utils.js
Normal file
124
bookmarks/frontend/behaviors/focus-utils.js
Normal file
@@ -0,0 +1,124 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
55
bookmarks/frontend/behaviors/form.js
Normal file
55
bookmarks/frontend/behaviors/form.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class AutoSubmitBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.submit = this.submit.bind(this);
|
||||
element.addEventListener("change", this.submit);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("change", this.submit);
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.element.closest("form").requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
class UploadButton extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
this.fileInput = element.nextElementSibling;
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
this.fileInput.addEventListener("change", this.onChange);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
this.fileInput.removeEventListener("change", this.onChange);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
this.fileInput.click();
|
||||
}
|
||||
|
||||
onChange() {
|
||||
// Check if the file input has a file selected
|
||||
if (!this.fileInput.files.length) {
|
||||
return;
|
||||
}
|
||||
const form = this.fileInput.closest("form");
|
||||
form.requestSubmit(this.element);
|
||||
// remove selected file so it doesn't get submitted again
|
||||
this.fileInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||
registerBehavior("ld-upload-button", UploadButton);
|
@@ -1,8 +1,15 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class GlobalShortcuts {
|
||||
constructor() {
|
||||
document.addEventListener("keydown", this.onKeyDown.bind(this));
|
||||
class GlobalShortcuts extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
|
@@ -1,8 +1,63 @@
|
||||
const behaviorRegistry = {};
|
||||
const debug = false;
|
||||
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && !node.isConnected) {
|
||||
destroyBehaviors(node);
|
||||
}
|
||||
});
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && node.isConnected) {
|
||||
applyBehaviors(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Update behaviors on Turbo events
|
||||
// - turbo:load: initial page load, only listen once, afterward can rely on turbo:render
|
||||
// - turbo:render: after page navigation, including back/forward, and failed form submissions
|
||||
// - turbo:before-cache: before page navigation, reset DOM before caching
|
||||
document.addEventListener(
|
||||
"turbo:load",
|
||||
() => {
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
applyBehaviors(document.body);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
document.addEventListener("turbo:render", () => {
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
applyBehaviors(document.body);
|
||||
});
|
||||
|
||||
document.addEventListener("turbo:before-cache", () => {
|
||||
destroyBehaviors(document.body);
|
||||
});
|
||||
|
||||
export class Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
Behavior.interacting = false;
|
||||
|
||||
export function registerBehavior(name, behavior) {
|
||||
behaviorRegistry[name] = behavior;
|
||||
applyBehaviors(document, [name]);
|
||||
}
|
||||
|
||||
export function applyBehaviors(container, behaviorNames = null) {
|
||||
@@ -12,7 +67,14 @@ export function applyBehaviors(container, behaviorNames = null) {
|
||||
|
||||
behaviorNames.forEach((behaviorName) => {
|
||||
const behavior = behaviorRegistry[behaviorName];
|
||||
const elements = container.querySelectorAll(`[${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 || [];
|
||||
@@ -26,11 +88,34 @@ export function applyBehaviors(container, behaviorNames = null) {
|
||||
|
||||
const behaviorInstance = new behavior(element);
|
||||
element.__behaviors.push(behaviorInstance);
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[Behavior] ${behaviorInstance.constructor.name} initialized`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function swap(element, html) {
|
||||
element.innerHTML = html;
|
||||
applyBehaviors(element);
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -1,65 +1,91 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior } from "./index";
|
||||
import { FocusTrapController } from "./focus-utils";
|
||||
|
||||
class ModalBehavior {
|
||||
export class ModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
const toggle = element;
|
||||
toggle.addEventListener("click", this.onToggleClick.bind(this));
|
||||
this.toggle = toggle;
|
||||
super(element);
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.overlay = element.querySelector(".modal-overlay");
|
||||
this.closeButton = element.querySelector(".modal-header .close");
|
||||
|
||||
this.overlay.addEventListener("click", this.onClose);
|
||||
this.closeButton.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
onToggleClick() {
|
||||
const contentSelector = this.toggle.getAttribute("modal-content");
|
||||
const content = document.querySelector(contentSelector);
|
||||
if (!content) {
|
||||
destroy() {
|
||||
this.overlay.removeEventListener("click", this.onClose);
|
||||
this.closeButton.removeEventListener("click", this.onClose);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.clearInert();
|
||||
this.focusTrap.destroy();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupInert();
|
||||
this.focusTrap = new FocusTrapController(
|
||||
this.element.querySelector(".modal-container"),
|
||||
);
|
||||
}
|
||||
|
||||
setupInert() {
|
||||
// Inert all other elements on the page
|
||||
document
|
||||
.querySelectorAll("body > *:not(.modals)")
|
||||
.forEach((el) => el.setAttribute("inert", ""));
|
||||
// Lock scroll on the body
|
||||
document.body.classList.add("scroll-lock");
|
||||
}
|
||||
|
||||
clearInert() {
|
||||
// Clear inert attribute from all elements to allow focus outside the modal again
|
||||
document
|
||||
.querySelectorAll("body > *")
|
||||
.forEach((el) => el.removeAttribute("inert"));
|
||||
// Remove scroll lock from the body
|
||||
document.body.classList.remove("scroll-lock");
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create modal
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "active");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
<div class="modal-container">
|
||||
<div class="modal-header d-flex justify-between align-center">
|
||||
<div class="modal-title h5">Tags</div>
|
||||
<button class="btn btn-link close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Teleport content element
|
||||
const contentOwner = content.parentElement;
|
||||
const contentContainer = modal.querySelector(".content");
|
||||
contentContainer.append(content);
|
||||
this.content = content;
|
||||
this.contentOwner = contentOwner;
|
||||
|
||||
// Register close handlers
|
||||
const modalOverlay = modal.querySelector(".modal-overlay");
|
||||
const closeButton = modal.querySelector(".btn.close");
|
||||
modalOverlay.addEventListener("click", this.onClose.bind(this));
|
||||
closeButton.addEventListener("click", this.onClose.bind(this));
|
||||
|
||||
document.body.append(modal);
|
||||
this.modal = modal;
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
// Teleport content back
|
||||
this.contentOwner.append(this.content);
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.doClose();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Remove modal
|
||||
this.modal.remove();
|
||||
doClose() {
|
||||
this.element.remove();
|
||||
this.clearInert();
|
||||
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-modal", ModalBehavior);
|
||||
|
41
bookmarks/frontend/behaviors/search-autocomplete.js
Normal file
41
bookmarks/frontend/behaviors/search-autocomplete.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte";
|
||||
|
||||
class SearchAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
const input = element.querySelector("input");
|
||||
if (!input) {
|
||||
console.warn("SearchAutocomplete: input element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
|
||||
new SearchAutoCompleteComponent({
|
||||
target: container,
|
||||
props: {
|
||||
name: "q",
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
value: input.value,
|
||||
linkTarget: input.dataset.linkTarget,
|
||||
mode: input.dataset.mode,
|
||||
search: {
|
||||
user: input.dataset.user,
|
||||
shared: input.dataset.shared,
|
||||
unread: input.dataset.unread,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.input = input;
|
||||
this.autocomplete = container.firstElementChild;
|
||||
input.replaceWith(this.autocomplete);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.autocomplete.replaceWith(this.input);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-search-autocomplete", SearchAutocomplete);
|
@@ -1,26 +1,35 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||
import { ApiClient } from "../api";
|
||||
|
||||
class TagAutocomplete {
|
||||
class TagAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
const wrapper = document.createElement("div");
|
||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
||||
const apiClient = new ApiClient(apiBaseUrl);
|
||||
super(element);
|
||||
const input = element.querySelector("input");
|
||||
if (!input) {
|
||||
console.warn("TagAutocomplete: input element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
|
||||
new TagAutoCompleteComponent({
|
||||
target: wrapper,
|
||||
target: container,
|
||||
props: {
|
||||
id: element.id,
|
||||
name: element.name,
|
||||
value: element.value,
|
||||
placeholder: element.getAttribute("placeholder") || "",
|
||||
apiClient: apiClient,
|
||||
variant: element.getAttribute("variant"),
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
variant: input.getAttribute("variant"),
|
||||
},
|
||||
});
|
||||
|
||||
element.replaceWith(wrapper.firstElementChild);
|
||||
this.input = input;
|
||||
this.autocomplete = container.firstElementChild;
|
||||
input.replaceWith(this.autocomplete);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.autocomplete.replaceWith(this.input);
|
||||
}
|
||||
}
|
||||
|
||||
|
35
bookmarks/frontend/cache.js
Normal file
35
bookmarks/frontend/cache.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { api } from "./api.js";
|
||||
|
||||
class Cache {
|
||||
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 Cache(api);
|
@@ -1,5 +1,7 @@
|
||||
<script>
|
||||
import {SearchHistory} from "./SearchHistory";
|
||||
import {api} from "../api";
|
||||
import {cache} from "../cache";
|
||||
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
|
||||
|
||||
const searchHistory = new SearchHistory()
|
||||
@@ -7,9 +9,7 @@
|
||||
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';
|
||||
|
||||
@@ -88,17 +88,18 @@
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
const tags = await cache.getTags();
|
||||
let tagSuggestions = []
|
||||
const currentWord = getCurrentWord(input)
|
||||
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
||||
const searchTag = currentWord.substring(1, currentWord.length)
|
||||
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
||||
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
||||
.slice(0, 5)
|
||||
.map(tagName => ({
|
||||
.map(tag => ({
|
||||
type: 'tag',
|
||||
index: nextIndex(),
|
||||
label: `#${tagName}`,
|
||||
tagName: tagName
|
||||
label: `#${tag.name}`,
|
||||
tagName: tag.name
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -119,9 +120,9 @@
|
||||
...search,
|
||||
q: value
|
||||
}
|
||||
const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
||||
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
||||
const fullLabel = bookmark.title || bookmark.url
|
||||
const label = clampText(fullLabel, 60)
|
||||
return {
|
||||
type: 'bookmark',
|
||||
@@ -258,4 +259,4 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
@@ -1,14 +1,13 @@
|
||||
<script>
|
||||
import {cache} from "../cache";
|
||||
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;
|
||||
@@ -17,18 +16,6 @@
|
||||
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: 1000, 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;
|
||||
}
|
||||
@@ -38,9 +25,10 @@
|
||||
close();
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
async function handleInput(e) {
|
||||
input = e.target;
|
||||
|
||||
const tags = await cache.getTags();
|
||||
const word = getCurrentWord(input);
|
||||
|
||||
suggestions = word
|
||||
@@ -150,19 +138,31 @@
|
||||
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: 1.4rem;
|
||||
min-height: 1.4rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .menu .menu-item {
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,15 +1,17 @@
|
||||
import TagAutoComplete from "./components/TagAutocomplete.svelte";
|
||||
import SearchAutoComplete from "./components/SearchAutoComplete.svelte";
|
||||
import { ApiClient } from "./api";
|
||||
import "@hotwired/turbo";
|
||||
import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/clear-button";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/modal";
|
||||
import "./behaviors/details-modal";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/filter-drawer";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/search-autocomplete";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
|
||||
export default {
|
||||
ApiClient,
|
||||
TagAutoComplete,
|
||||
SearchAutoComplete,
|
||||
};
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
export { api } from "./api";
|
||||
export { cache } from "./cache";
|
||||
|
31
bookmarks/management/commands/backup.py
Normal file
31
bookmarks/management/commands/backup.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Creates a backup of the linkding database"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("destination", type=str, help="Backup file destination")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
destination = options["destination"]
|
||||
|
||||
def progress(status, remaining, total):
|
||||
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)
|
||||
with backup_db:
|
||||
source_db.backup(backup_db, pages=50, progress=progress)
|
||||
backup_db.close()
|
||||
source_db.close()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Backup created at {destination}"))
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"This backup method is deprecated and may be removed in the future. Please use the full_backup command instead, which creates backup zip file with all contents of the data folder."
|
||||
)
|
||||
)
|
@@ -1,15 +0,0 @@
|
||||
from background_task.models import Task, CompletedTask
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Remove task locks and clear completed task history"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Remove task locks
|
||||
# If the background task processor exited while executing tasks, these tasks would still be marked as locked,
|
||||
# even though no process is working on them, and would prevent the task processor from picking the next task in
|
||||
# the queue
|
||||
Task.objects.all().update(locked_by=None, locked_at=None)
|
||||
# Clear task history to prevent them from bloating the DB
|
||||
CompletedTask.objects.all().delete()
|
@@ -12,18 +12,20 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
User = get_user_model()
|
||||
superuser_name = os.getenv('LD_SUPERUSER_NAME', None)
|
||||
superuser_password = os.getenv('LD_SUPERUSER_PASSWORD', None)
|
||||
superuser_name = os.getenv("LD_SUPERUSER_NAME", None)
|
||||
superuser_password = os.getenv("LD_SUPERUSER_PASSWORD", None)
|
||||
|
||||
# Skip if option is undefined
|
||||
if not superuser_name:
|
||||
logger.info('Skip creating initial superuser, LD_SUPERUSER_NAME option is not defined')
|
||||
logger.info(
|
||||
"Skip creating initial superuser, LD_SUPERUSER_NAME option is not defined"
|
||||
)
|
||||
return
|
||||
|
||||
# Skip if user already exists
|
||||
user_exists = User.objects.filter(username=superuser_name).exists()
|
||||
if user_exists:
|
||||
logger.info('Skip creating initial superuser, user already exists')
|
||||
logger.info("Skip creating initial superuser, user already exists")
|
||||
return
|
||||
|
||||
user = User(username=superuser_name, is_superuser=True, is_staff=True)
|
||||
@@ -34,4 +36,4 @@ class Command(BaseCommand):
|
||||
user.set_unusable_password()
|
||||
|
||||
user.save()
|
||||
logger.info('Created initial superuser')
|
||||
logger.info("Created initial superuser")
|
||||
|
@@ -14,11 +14,11 @@ class Command(BaseCommand):
|
||||
if not settings.USE_SQLITE:
|
||||
return
|
||||
|
||||
connection = connections['default']
|
||||
connection = connections["default"]
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("PRAGMA journal_mode")
|
||||
current_mode = cursor.fetchone()[0]
|
||||
logger.info(f'Current journal mode: {current_mode}')
|
||||
if current_mode != 'wal':
|
||||
logger.info(f"Current journal mode: {current_mode}")
|
||||
if current_mode != "wal":
|
||||
cursor.execute("PRAGMA journal_mode=wal;")
|
||||
logger.info('Switched to WAL journal mode')
|
||||
logger.info("Switched to WAL journal mode")
|
||||
|
@@ -6,13 +6,15 @@ class Command(BaseCommand):
|
||||
help = "Creates an admin user non-interactively if it doesn't exist"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--username', help="Admin's username")
|
||||
parser.add_argument('--email', help="Admin's email")
|
||||
parser.add_argument('--password', help="Admin's password")
|
||||
parser.add_argument("--username", help="Admin's username")
|
||||
parser.add_argument("--email", help="Admin's email")
|
||||
parser.add_argument("--password", help="Admin's password")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
User = get_user_model()
|
||||
if not User.objects.filter(username=options['username']).exists():
|
||||
User.objects.create_superuser(username=options['username'],
|
||||
email=options['email'],
|
||||
password=options['password'])
|
||||
if not User.objects.filter(username=options["username"]).exists():
|
||||
User.objects.create_superuser(
|
||||
username=options["username"],
|
||||
email=options["email"],
|
||||
password=options["password"],
|
||||
)
|
||||
|
75
bookmarks/management/commands/full_backup.py
Normal file
75
bookmarks/management/commands/full_backup.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Creates a backup of the linkding data folder"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("backup_file", type=str, help="Backup zip file destination")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
backup_file = options["backup_file"]
|
||||
with zipfile.ZipFile(backup_file, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
# Backup the database
|
||||
self.stdout.write("Create database backup...")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
backup_db_file = os.path.join(temp_dir, "db.sqlite3")
|
||||
self.backup_database(backup_db_file)
|
||||
zip_file.write(backup_db_file, "db.sqlite3")
|
||||
|
||||
# Backup the assets folder
|
||||
if not os.path.exists(os.path.join("data", "assets")):
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No assets folder found. Skipping...")
|
||||
)
|
||||
else:
|
||||
self.stdout.write("Backup bookmark assets...")
|
||||
assets_folder = os.path.join("data", "assets")
|
||||
for root, _, files in os.walk(assets_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
zip_file.write(file_path, os.path.join("assets", file))
|
||||
|
||||
# Backup the favicons folder
|
||||
if not os.path.exists(os.path.join("data", "favicons")):
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No favicons folder found. Skipping...")
|
||||
)
|
||||
else:
|
||||
self.stdout.write("Backup bookmark favicons...")
|
||||
favicons_folder = os.path.join("data", "favicons")
|
||||
for root, _, files in os.walk(favicons_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
zip_file.write(file_path, os.path.join("favicons", file))
|
||||
|
||||
# Backup the previews folder
|
||||
if not os.path.exists(os.path.join("data", "previews")):
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No previews folder found. Skipping...")
|
||||
)
|
||||
else:
|
||||
self.stdout.write("Backup bookmark previews...")
|
||||
previews_folder = os.path.join("data", "previews")
|
||||
for root, _, files in os.walk(previews_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
zip_file.write(file_path, os.path.join("previews", file))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Backup created at {backup_file}"))
|
||||
|
||||
def backup_database(self, backup_db_file):
|
||||
def progress(status, remaining, total):
|
||||
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)
|
||||
with backup_db:
|
||||
source_db.backup(backup_db, pages=50, progress=progress)
|
||||
backup_db.close()
|
||||
source_db.close()
|
24
bookmarks/management/commands/generate_secret_key.py
Normal file
24
bookmarks/management/commands/generate_secret_key.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate secret key file if it does not exist"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
secret_key_file = os.path.join("data", "secretkey.txt")
|
||||
|
||||
if os.path.exists(secret_key_file):
|
||||
logger.info(f"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")
|
@@ -5,15 +5,17 @@ from bookmarks.services.importer import import_netscape_html
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Import Netscape HTML bookmark file'
|
||||
help = "Import Netscape HTML bookmark file"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('file', type=str, help='Path to file')
|
||||
parser.add_argument('user', type=str, help='Name of the user for which to import')
|
||||
parser.add_argument("file", type=str, help="Path to file")
|
||||
parser.add_argument(
|
||||
"user", type=str, help="Name of the user for which to import"
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
filepath = kwargs['file']
|
||||
username = kwargs['user']
|
||||
filepath = kwargs["file"]
|
||||
username = kwargs["user"]
|
||||
with open(filepath) as html_file:
|
||||
html = html_file.read()
|
||||
user = User.objects.get(username=username)
|
||||
|
75
bookmarks/management/commands/migrate_tasks.py
Normal file
75
bookmarks/management/commands/migrate_tasks.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import importlib
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Migrate tasks from django-background-tasks to Huey"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
|
||||
|
||||
# Check if background_task table exists
|
||||
cursor = db.cursor()
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='background_task'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
self.stdout.write(
|
||||
"Legacy task table does not exist. Skipping task migration"
|
||||
)
|
||||
return
|
||||
|
||||
# Load legacy tasks
|
||||
cursor.execute("SELECT id, task_name, task_params FROM background_task")
|
||||
legacy_tasks = cursor.fetchall()
|
||||
|
||||
if len(legacy_tasks) == 0:
|
||||
self.stdout.write("No legacy tasks found. Skipping task migration")
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
f"Found {len(legacy_tasks)} legacy tasks. Migrating to Huey..."
|
||||
)
|
||||
|
||||
# Migrate tasks to Huey
|
||||
succeeded_tasks = []
|
||||
for task in legacy_tasks:
|
||||
task_id = task[0]
|
||||
task_name = task[1]
|
||||
task_params_json = task[2]
|
||||
try:
|
||||
task_params = json.loads(task_params_json)
|
||||
function_params = task_params[0]
|
||||
|
||||
# Resolve task function
|
||||
module_name, func_name = task_name.rsplit(".", 1)
|
||||
module = importlib.import_module(module_name)
|
||||
func = getattr(module, func_name)
|
||||
|
||||
# Call task function
|
||||
func(*function_params)
|
||||
succeeded_tasks.append(task_id)
|
||||
except Exception:
|
||||
self.stderr.write(f"Error migrating task [{task_id}] {task_name}")
|
||||
|
||||
self.stdout.write(f"Migrated {len(succeeded_tasks)} tasks successfully")
|
||||
|
||||
# Clean up
|
||||
try:
|
||||
placeholders = ", ".join("?" for _ in succeeded_tasks)
|
||||
sql = f"DELETE FROM background_task WHERE id IN ({placeholders})"
|
||||
cursor.execute(sql, succeeded_tasks)
|
||||
db.commit()
|
||||
self.stdout.write(
|
||||
f"Deleted {len(succeeded_tasks)} migrated tasks from legacy table"
|
||||
)
|
||||
except Exception:
|
||||
self.stderr.write("Error cleaning up legacy tasks")
|
||||
|
||||
cursor.close()
|
||||
db.close()
|
@@ -1,23 +1,40 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.models import UserProfile, GlobalSettings
|
||||
|
||||
|
||||
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
||||
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
||||
|
||||
|
||||
class UserProfileMiddleware:
|
||||
default_global_settings = GlobalSettings()
|
||||
|
||||
standard_profile = UserProfile()
|
||||
standard_profile.enable_favicons = True
|
||||
|
||||
|
||||
class LinkdingMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# add global settings to request
|
||||
try:
|
||||
global_settings = GlobalSettings.get()
|
||||
except:
|
||||
global_settings = default_global_settings
|
||||
request.global_settings = global_settings
|
||||
|
||||
# add user profile to request
|
||||
if request.user.is_authenticated:
|
||||
request.user_profile = request.user.profile
|
||||
else:
|
||||
request.user_profile = UserProfile()
|
||||
request.user_profile.enable_favicons = True
|
||||
# check if a custom profile for guests exists, otherwise use standard profile
|
||||
if global_settings.guest_profile_user:
|
||||
request.user_profile = global_settings.guest_profile_user.profile
|
||||
else:
|
||||
request.user_profile = standard_profile
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
|
@@ -15,19 +15,36 @@ class Migration(migrations.Migration):
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Bookmark',
|
||||
name="Bookmark",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField()),
|
||||
('title', models.CharField(max_length=512)),
|
||||
('description', models.TextField()),
|
||||
('website_title', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('website_description', models.TextField(blank=True, null=True)),
|
||||
('unread', models.BooleanField(default=True)),
|
||||
('date_added', models.DateTimeField()),
|
||||
('date_modified', models.DateTimeField()),
|
||||
('date_accessed', models.DateTimeField(blank=True, null=True)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("url", models.URLField()),
|
||||
("title", models.CharField(max_length=512)),
|
||||
("description", models.TextField()),
|
||||
(
|
||||
"website_title",
|
||||
models.CharField(blank=True, max_length=512, null=True),
|
||||
),
|
||||
("website_description", models.TextField(blank=True, null=True)),
|
||||
("unread", models.BooleanField(default=True)),
|
||||
("date_added", models.DateTimeField()),
|
||||
("date_modified", models.DateTimeField()),
|
||||
("date_accessed", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
@@ -9,22 +9,36 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bookmarks', '0001_initial'),
|
||||
("bookmarks", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tag',
|
||||
name="Tag",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=64)),
|
||||
('date_added', models.DateTimeField()),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=64)),
|
||||
("date_added", models.DateTimeField()),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='tags',
|
||||
field=models.ManyToManyField(to='bookmarks.Tag'),
|
||||
model_name="bookmark",
|
||||
name="tags",
|
||||
field=models.ManyToManyField(to="bookmarks.Tag"),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0002_auto_20190629_2303'),
|
||||
("bookmarks", "0002_auto_20190629_2303"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='url',
|
||||
model_name="bookmark",
|
||||
name="url",
|
||||
field=models.URLField(max_length=2048),
|
||||
),
|
||||
]
|
||||
|
@@ -6,18 +6,18 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0003_auto_20200913_0656'),
|
||||
("bookmarks", "0003_auto_20200913_0656"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='description',
|
||||
model_name="bookmark",
|
||||
name="description",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='title',
|
||||
model_name="bookmark",
|
||||
name="title",
|
||||
field=models.CharField(blank=True, max_length=512),
|
||||
),
|
||||
]
|
||||
|
@@ -7,13 +7,16 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0004_auto_20200926_1028'),
|
||||
("bookmarks", "0004_auto_20200926_1028"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='url',
|
||||
field=models.CharField(max_length=2048, validators=[bookmarks.validators.BookmarkURLValidator()]),
|
||||
model_name="bookmark",
|
||||
name="url",
|
||||
field=models.CharField(
|
||||
max_length=2048,
|
||||
validators=[bookmarks.validators.BookmarkURLValidator()],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0005_auto_20210103_1212'),
|
||||
("bookmarks", "0005_auto_20210103_1212"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='is_archived',
|
||||
model_name="bookmark",
|
||||
name="is_archived",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
@@ -6,8 +6,8 @@ import django.db.models.deletion
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
User = apps.get_model('auth', 'User')
|
||||
UserProfile = apps.get_model('bookmarks', 'UserProfile')
|
||||
User = apps.get_model("auth", "User")
|
||||
UserProfile = apps.get_model("bookmarks", "UserProfile")
|
||||
for user in User.objects.all():
|
||||
try:
|
||||
if user.profile:
|
||||
@@ -24,19 +24,42 @@ def reverse(apps, schema_editor):
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bookmarks', '0006_bookmark_is_archived'),
|
||||
("bookmarks", "0006_bookmark_is_archived"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserProfile',
|
||||
name="UserProfile",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('theme',
|
||||
models.CharField(choices=[('auto', 'Auto'), ('light', 'Light'), ('dark', 'Dark')], default='auto',
|
||||
max_length=10)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile',
|
||||
to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"theme",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("auto", "Auto"),
|
||||
("light", "Light"),
|
||||
("dark", "Dark"),
|
||||
],
|
||||
default="auto",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="profile",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(forwards, reverse),
|
||||
|
@@ -6,13 +6,21 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0007_userprofile'),
|
||||
("bookmarks", "0007_userprofile"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='bookmark_date_display',
|
||||
field=models.CharField(choices=[('relative', 'Relative'), ('absolute', 'Absolute'), ('hidden', 'Hidden')], default='relative', max_length=10),
|
||||
model_name="userprofile",
|
||||
name="bookmark_date_display",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("relative", "Relative"),
|
||||
("absolute", "Absolute"),
|
||||
("hidden", "Hidden"),
|
||||
],
|
||||
default="relative",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0008_userprofile_bookmark_date_display'),
|
||||
("bookmarks", "0008_userprofile_bookmark_date_display"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='web_archive_snapshot_url',
|
||||
model_name="bookmark",
|
||||
name="web_archive_snapshot_url",
|
||||
field=models.CharField(blank=True, max_length=2048),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,17 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0009_bookmark_web_archive_snapshot_url'),
|
||||
("bookmarks", "0009_bookmark_web_archive_snapshot_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='bookmark_link_target',
|
||||
field=models.CharField(choices=[('_blank', 'New page'), ('_self', 'Same page')], default='_blank', max_length=10),
|
||||
model_name="userprofile",
|
||||
name="bookmark_link_target",
|
||||
field=models.CharField(
|
||||
choices=[("_blank", "New page"), ("_self", "Same page")],
|
||||
default="_blank",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,17 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0010_userprofile_bookmark_link_target'),
|
||||
("bookmarks", "0010_userprofile_bookmark_link_target"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='web_archive_integration',
|
||||
field=models.CharField(choices=[('disabled', 'Disabled'), ('enabled', 'Enabled')], default='disabled', max_length=10),
|
||||
model_name="userprofile",
|
||||
name="web_archive_integration",
|
||||
field=models.CharField(
|
||||
choices=[("disabled", "Disabled"), ("enabled", "Enabled")],
|
||||
default="disabled",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@@ -9,18 +9,32 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bookmarks', '0011_userprofile_web_archive_integration'),
|
||||
("bookmarks", "0011_userprofile_web_archive_integration"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Toast',
|
||||
name="Toast",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=50)),
|
||||
('message', models.TextField()),
|
||||
('acknowledged', models.BooleanField(default=False)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("key", models.CharField(max_length=50)),
|
||||
("message", models.TextField()),
|
||||
("acknowledged", models.BooleanField(default=False)),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
@@ -10,19 +10,21 @@ User = get_user_model()
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
for user in User.objects.all():
|
||||
toast = Toast(key='web_archive_opt_in_hint',
|
||||
message='The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.',
|
||||
owner=user)
|
||||
toast = Toast(
|
||||
key="web_archive_opt_in_hint",
|
||||
message="The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.",
|
||||
owner=user,
|
||||
)
|
||||
toast.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
Toast.objects.filter(key='web_archive_opt_in_hint').delete()
|
||||
Toast.objects.filter(key="web_archive_opt_in_hint").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('bookmarks', '0012_toast'),
|
||||
("bookmarks", "0012_toast"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
@@ -4,7 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Bookmark = apps.get_model('bookmarks', 'Bookmark')
|
||||
Bookmark = apps.get_model("bookmarks", "Bookmark")
|
||||
Bookmark.objects.update(unread=False)
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ def reverse(apps, schema_editor):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('bookmarks', '0013_web_archive_optin_toast'),
|
||||
("bookmarks", "0013_web_archive_optin_toast"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='unread',
|
||||
model_name="bookmark",
|
||||
name="unread",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(forwards, reverse),
|
||||
|
@@ -9,16 +9,26 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bookmarks', '0014_alter_bookmark_unread'),
|
||||
("bookmarks", "0014_alter_bookmark_unread"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FeedToken',
|
||||
name="FeedToken",
|
||||
fields=[
|
||||
('key', models.CharField(max_length=40, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feed_token', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"key",
|
||||
models.CharField(max_length=40, primary_key=True, serialize=False),
|
||||
),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="feed_token",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0015_feedtoken'),
|
||||
("bookmarks", "0015_feedtoken"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='shared',
|
||||
model_name="bookmark",
|
||||
name="shared",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0016_bookmark_shared'),
|
||||
("bookmarks", "0016_bookmark_shared"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='enable_sharing',
|
||||
model_name="userprofile",
|
||||
name="enable_sharing",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0017_userprofile_enable_sharing'),
|
||||
("bookmarks", "0017_userprofile_enable_sharing"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='favicon_file',
|
||||
model_name="bookmark",
|
||||
name="favicon_file",
|
||||
field=models.CharField(blank=True, max_length=512),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0018_bookmark_favicon_file'),
|
||||
("bookmarks", "0018_bookmark_favicon_file"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='enable_favicons',
|
||||
model_name="userprofile",
|
||||
name="enable_favicons",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,17 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0019_userprofile_enable_favicons'),
|
||||
("bookmarks", "0019_userprofile_enable_favicons"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='tag_search',
|
||||
field=models.CharField(choices=[('strict', 'Strict'), ('lax', 'Lax')], default='strict', max_length=10),
|
||||
model_name="userprofile",
|
||||
name="tag_search",
|
||||
field=models.CharField(
|
||||
choices=[("strict", "Strict"), ("lax", "Lax")],
|
||||
default="strict",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0020_userprofile_tag_search'),
|
||||
("bookmarks", "0020_userprofile_tag_search"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='display_url',
|
||||
model_name="userprofile",
|
||||
name="display_url",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0021_userprofile_display_url'),
|
||||
("bookmarks", "0021_userprofile_display_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='notes',
|
||||
model_name="bookmark",
|
||||
name="notes",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0022_bookmark_notes'),
|
||||
("bookmarks", "0022_bookmark_notes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='permanent_notes',
|
||||
model_name="userprofile",
|
||||
name="permanent_notes",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0023_userprofile_permanent_notes'),
|
||||
("bookmarks", "0023_userprofile_permanent_notes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='enable_public_sharing',
|
||||
model_name="userprofile",
|
||||
name="enable_public_sharing",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0024_userprofile_enable_public_sharing'),
|
||||
("bookmarks", "0024_userprofile_enable_public_sharing"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='search_preferences',
|
||||
model_name="userprofile",
|
||||
name="search_preferences",
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
]
|
||||
|
18
bookmarks/migrations/0026_userprofile_custom_css.py
Normal file
18
bookmarks/migrations/0026_userprofile_custom_css.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-16 23:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0025_userprofile_search_preferences"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="custom_css",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-23 21:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0026_userprofile_custom_css"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="bookmark_description_display",
|
||||
field=models.CharField(
|
||||
choices=[("inline", "Inline"), ("separate", "Separate")],
|
||||
default="inline",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="bookmark_description_max_lines",
|
||||
field=models.IntegerField(default=1),
|
||||
),
|
||||
]
|
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-29 20:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0027_userprofile_bookmark_description_display_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="display_archive_bookmark_action",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="display_edit_bookmark_action",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="display_remove_bookmark_action",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="display_view_bookmark_action",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
34
bookmarks/migrations/0029_bookmark_list_actions_toast.py
Normal file
34
bookmarks/migrations/0029_bookmark_list_actions_toast.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-29 21:25
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from bookmarks.models import Toast
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
|
||||
for user in User.objects.all():
|
||||
toast = Toast(
|
||||
key="bookmark_list_actions_hint",
|
||||
message="This version adds a new link to each bookmark to view details in a dialog. If you feel there is too much clutter you can now hide individual links in the settings.",
|
||||
owner=user,
|
||||
)
|
||||
toast.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
Toast.objects.filter(key="bookmark_list_actions_hint").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0028_userprofile_display_archive_bookmark_action_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
43
bookmarks/migrations/0030_bookmarkasset.py
Normal file
43
bookmarks/migrations/0030_bookmarkasset.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-31 08:21
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0029_bookmark_list_actions_toast"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BookmarkAsset",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("date_created", models.DateTimeField(auto_now_add=True)),
|
||||
("file", models.CharField(blank=True, max_length=2048)),
|
||||
("file_size", models.IntegerField(null=True)),
|
||||
("asset_type", models.CharField(max_length=64)),
|
||||
("content_type", models.CharField(max_length=128)),
|
||||
("display_name", models.CharField(blank=True, max_length=2048)),
|
||||
("status", models.CharField(max_length=64)),
|
||||
("gzip", models.BooleanField(default=False)),
|
||||
(
|
||||
"bookmark",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="bookmarks.bookmark",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-04-01 10:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0030_bookmarkasset"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="enable_automatic_html_snapshots",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
34
bookmarks/migrations/0032_html_snapshots_hint_toast.py
Normal file
34
bookmarks/migrations/0032_html_snapshots_hint_toast.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.0.2 on 2024-04-01 12:17
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from bookmarks.models import Toast
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
|
||||
for user in User.objects.all():
|
||||
toast = Toast(
|
||||
key="html_snapshots_hint",
|
||||
message="This version adds a new feature for archiving snapshots of websites locally. To use it, you need to switch to a different Docker image. See the installation instructions on GitHub for details.",
|
||||
owner=user,
|
||||
)
|
||||
toast.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
Toast.objects.filter(key="bookmark_list_actions_hint").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0031_userprofile_enable_automatic_html_snapshots"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
18
bookmarks/migrations/0033_userprofile_default_mark_unread.py
Normal file
18
bookmarks/migrations/0033_userprofile_default_mark_unread.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-17 19:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0032_html_snapshots_hint_toast"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="default_mark_unread",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-10 07:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0033_userprofile_default_mark_unread"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="preview_image_file",
|
||||
field=models.CharField(blank=True, max_length=512),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="enable_preview_images",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
22
bookmarks/migrations/0035_userprofile_tag_grouping.py
Normal file
22
bookmarks/migrations/0035_userprofile_tag_grouping.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-14 08:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0034_bookmark_preview_image_file_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="tag_grouping",
|
||||
field=models.CharField(
|
||||
choices=[("alphabetical", "Alphabetical"), ("disabled", "Disabled")],
|
||||
default="alphabetical",
|
||||
max_length=12,
|
||||
),
|
||||
),
|
||||
]
|
18
bookmarks/migrations/0036_userprofile_auto_tagging_rules.py
Normal file
18
bookmarks/migrations/0036_userprofile_auto_tagging_rules.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-17 07:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0035_userprofile_tag_grouping"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="auto_tagging_rules",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
38
bookmarks/migrations/0037_globalsettings.py
Normal file
38
bookmarks/migrations/0037_globalsettings.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.0.8 on 2024-08-31 12:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0036_userprofile_auto_tagging_rules"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="GlobalSettings",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"landing_page",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("shared_bookmarks", "Shared Bookmarks"),
|
||||
],
|
||||
default="login",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.8 on 2024-08-31 17:54
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0037_globalsettings"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="globalsettings",
|
||||
name="guest_profile_user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.8 on 2024-09-14 07:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0038_globalsettings_guest_profile_user"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="globalsettings",
|
||||
name="enable_link_prefetch",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.8 on 2024-09-18 20:11
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0039_globalsettings_enable_link_prefetch"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="items_per_page",
|
||||
field=models.IntegerField(
|
||||
default=30, validators=[django.core.validators.MinValueValidator(10)]
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="sticky_pagination",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user