mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-15 06:29:21 +02:00
Compare commits
279 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 | ||
![]() |
30708cc5e3 | ||
![]() |
3e4f08f51b | ||
![]() |
41f79e35a0 | ||
![]() |
4a2642f16c | ||
![]() |
e70315ed26 | ||
![]() |
3e36f90b38 | ||
![]() |
28acf3299c | ||
![]() |
ffcc40b227 | ||
![]() |
b7ddee2d93 | ||
![]() |
d9c4ddb4d7 | ||
![]() |
0975914a86 | ||
![]() |
0c50906056 | ||
![]() |
54c79225ce | ||
![]() |
a382e171ad | ||
![]() |
9b8929e697 | ||
![]() |
5b8ff86029 | ||
![]() |
e2e5930985 | ||
![]() |
2ceac9a87d | ||
![]() |
bca9bf9b11 | ||
![]() |
768f1346a3 | ||
![]() |
f9496e2fe0 | ||
![]() |
62c40d1b7b | ||
![]() |
e076747f85 | ||
![]() |
f071423f1e | ||
![]() |
be789ea9e6 | ||
![]() |
8206705876 | ||
![]() |
5d9e487ec1 | ||
![]() |
ea240eefd9 | ||
![]() |
22e8750c24 | ||
![]() |
ac75fd2ebd | ||
![]() |
b05bf2534c | ||
![]() |
86a39e0433 | ||
![]() |
4220ea0b4c | ||
![]() |
5d48c64b2b | ||
![]() |
424df155d8 | ||
![]() |
d87611dbcb | ||
![]() |
cd66dcee7b | ||
![]() |
84f13dd792 | ||
![]() |
417dce785a | ||
![]() |
b28fc05d06 | ||
![]() |
17ab203f4f | ||
![]() |
a06f9035cf | ||
![]() |
5f28e87877 | ||
![]() |
f2ad826b11 | ||
![]() |
047d3be1b5 | ||
![]() |
43115fd8f2 | ||
![]() |
67ee896a46 | ||
![]() |
fd3070c6f3 | ||
![]() |
bc374e90a2 | ||
![]() |
a94eb5f85a | ||
![]() |
d1819c6503 | ||
![]() |
353ba433f0 | ||
![]() |
3af4e07eb6 | ||
![]() |
e9061f373a | ||
![]() |
f87398742a | ||
![]() |
81dc19958c | ||
![]() |
5049ff14cf | ||
![]() |
f9ab3d1f44 | ||
![]() |
b89e150088 | ||
![]() |
d17801ba84 | ||
![]() |
7b52663383 | ||
![]() |
0c86587b5d | ||
![]() |
74134d3896 | ||
![]() |
89a9271c71 | ||
![]() |
794b6d8932 | ||
![]() |
6b4664117b | ||
![]() |
621b497dc6 | ||
![]() |
4bb05f811b | ||
![]() |
fb8e6b3b5f | ||
![]() |
814401be2e | ||
![]() |
4cb39fae99 | ||
![]() |
30da1880a5 | ||
![]() |
da99b8b034 | ||
![]() |
894625aa25 | ||
![]() |
62d7fb5f63 | ||
![]() |
fa2633147a | ||
![]() |
ddf97b0a3f | ||
![]() |
d3b4aa7602 | ||
![]() |
021d1cd673 | ||
![]() |
43d52642a6 | ||
![]() |
4f9170c48d | ||
![]() |
313a0ee99f | ||
![]() |
4e32bafe89 | ||
![]() |
035399442a | ||
![]() |
c2d8cde86b | ||
![]() |
13e0516961 | ||
![]() |
7b03ceab98 | ||
![]() |
fee979a371 | ||
![]() |
9eaae1fcf5 | ||
![]() |
3abdd92430 | ||
![]() |
b99d7bf1cc | ||
![]() |
f84e2d2210 | ||
![]() |
2fd7704816 | ||
![]() |
277c1c76e3 | ||
![]() |
2787dcb769 | ||
![]() |
1c3651e91d | ||
![]() |
53be77aade | ||
![]() |
7148bc62c3 | ||
![]() |
2c7848aa46 | ||
![]() |
b94eaee833 | ||
![]() |
1b35d5b5ef | ||
![]() |
6420ec173a | ||
![]() |
a30571ac99 | ||
![]() |
3aca790212 | ||
![]() |
38f4dd2bea | ||
![]() |
6e0a345c2c | ||
![]() |
03c0dc04cb | ||
![]() |
f88cc30b48 | ||
![]() |
5841ba0f4c | ||
![]() |
e4636c0ceb | ||
![]() |
992dc69a36 | ||
![]() |
c9c6b097d0 | ||
![]() |
1308370027 | ||
![]() |
5af4d41ee1 | ||
![]() |
70b3f824eb | ||
![]() |
1b67081773 | ||
![]() |
ee7ac775d2 | ||
![]() |
8053468ca5 | ||
![]() |
eadae32eb3 | ||
![]() |
2f0dd0db0d | ||
![]() |
da4ed5b7c1 | ||
![]() |
fd2770efd8 | ||
![]() |
dd5e65ecd7 | ||
![]() |
fec966f687 | ||
![]() |
e6718be53b | ||
![]() |
3ac35677d8 | ||
![]() |
013ea16578 | ||
![]() |
cf1085c781 | ||
![]() |
5d1dc38d1d | ||
![]() |
de6e91fd75 | ||
![]() |
506d3cad25 | ||
![]() |
fdfafbbb0b | ||
![]() |
54ce6d5fe6 | ||
![]() |
13ff9ac4f8 | ||
![]() |
48e4958218 | ||
![]() |
b618a8b10b | ||
![]() |
90a46c1fb9 | ||
![]() |
3086926146 | ||
![]() |
b53bd9f112 | ||
![]() |
75c0429973 | ||
![]() |
0829d00e5f | ||
![]() |
88fcb42292 | ||
![]() |
aac8bf39b8 | ||
![]() |
49f648a908 | ||
![]() |
68c3c27b38 | ||
![]() |
792a19d15e | ||
![]() |
2de6d8151b | ||
![]() |
9e9d7ae7d2 | ||
![]() |
4e8a183082 |
29
.devcontainer/devcontainer.json
Normal file
29
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||||
|
{
|
||||||
|
"name": "Python 3",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/python:0-3.10",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
|
// "features": {},
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
"forwardPorts": [8000],
|
||||||
|
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
"postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.dev.txt && npm install && mkdir -p data && python3 manage.py migrate",
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"remoteUser": "vscode"
|
||||||
|
}
|
@@ -1,31 +1,21 @@
|
|||||||
# Remove project files, data, tmp files, build files
|
# Ignore everything
|
||||||
/.env
|
*
|
||||||
/.idea
|
|
||||||
/data
|
|
||||||
/node_modules
|
|
||||||
/tmp
|
|
||||||
/docs
|
|
||||||
/static
|
|
||||||
/build
|
|
||||||
/out
|
|
||||||
/.git
|
|
||||||
|
|
||||||
/.dockerignore
|
# Include files required for build or at runtime
|
||||||
/.gitignore
|
!/bookmarks
|
||||||
/Dockerfile
|
!/siteroot
|
||||||
/docker-compose.yml
|
|
||||||
/*.sh
|
|
||||||
/*.iml
|
|
||||||
/*.patch
|
|
||||||
/*.md
|
|
||||||
/*.js
|
|
||||||
/*.log
|
|
||||||
/*.pid
|
|
||||||
|
|
||||||
# Whitelist files needed in build or prod image
|
|
||||||
!/rollup.config.js
|
|
||||||
!/bootstrap.sh
|
!/bootstrap.sh
|
||||||
!/background-tasks-wrapper.sh
|
!/LICENSE.txt
|
||||||
|
!/manage.py
|
||||||
|
!/package.json
|
||||||
|
!/package-lock.json
|
||||||
|
!/requirements.dev.txt
|
||||||
|
!/requirements.txt
|
||||||
|
!/rollup.config.mjs
|
||||||
|
!/supervisord.conf
|
||||||
|
!/uwsgi.ini
|
||||||
|
!/version.txt
|
||||||
|
|
||||||
# Remove development settings
|
# Remove dev settings
|
||||||
/siteroot/settings/dev.py
|
/siteroot/settings/dev.py
|
||||||
|
38
.env.sample
38
.env.sample
@@ -5,7 +5,45 @@ LD_HOST_PORT=9090
|
|||||||
# Directory on the host system that should be mounted as data dir into the Docker container
|
# Directory on the host system that should be mounted as data dir into the Docker container
|
||||||
LD_HOST_DATA_DIR=./data
|
LD_HOST_DATA_DIR=./data
|
||||||
|
|
||||||
|
# Can be used to run linkding under a context path, for example: linkding/
|
||||||
|
# Must end with a slash `/`
|
||||||
|
LD_CONTEXT_PATH=
|
||||||
|
# Username of the initial superuser to create, leave empty to not create one
|
||||||
|
LD_SUPERUSER_NAME=
|
||||||
|
# Password for the initial superuser, leave empty to disable credentials authentication and rely on proxy authentication instead
|
||||||
|
LD_SUPERUSER_PASSWORD=
|
||||||
# Option to disable background tasks
|
# Option to disable background tasks
|
||||||
LD_DISABLE_BACKGROUND_TASKS=False
|
LD_DISABLE_BACKGROUND_TASKS=False
|
||||||
# Option to disable URL validation for bookmarks completely
|
# Option to disable URL validation for bookmarks completely
|
||||||
LD_DISABLE_URL_VALIDATION=False
|
LD_DISABLE_URL_VALIDATION=False
|
||||||
|
# Enables support for authentication proxies such as Authelia
|
||||||
|
LD_ENABLE_AUTH_PROXY=False
|
||||||
|
# Name of the request header that the auth proxy passes to the application to identify the user
|
||||||
|
# See docs/Options.md for more details
|
||||||
|
LD_AUTH_PROXY_USERNAME_HEADER=
|
||||||
|
# The URL that linkding should redirect to after a logout, when using an auth proxy
|
||||||
|
# See docs/Options.md for more details
|
||||||
|
LD_AUTH_PROXY_LOGOUT_URL=
|
||||||
|
# List of trusted origins from which to accept POST requests
|
||||||
|
# See docs/Options.md for more details
|
||||||
|
LD_CSRF_TRUSTED_ORIGINS=
|
||||||
|
|
||||||
|
# Database settings
|
||||||
|
# These are currently only required for configuring PostreSQL.
|
||||||
|
# By default, linkding uses SQLite for which you don't need to configure anything.
|
||||||
|
|
||||||
|
# Database engine, can be sqlite (default) or postgres
|
||||||
|
LD_DB_ENGINE=
|
||||||
|
# Database name (default: linkding)
|
||||||
|
LD_DB_DATABASE=
|
||||||
|
# Username to connect to the database server (default: linkding)
|
||||||
|
LD_DB_USER=
|
||||||
|
# Password to connect to the database server
|
||||||
|
LD_DB_PASSWORD=
|
||||||
|
# The hostname where the database is hosted (default: localhost)
|
||||||
|
LD_DB_HOST=
|
||||||
|
# Port use to connect to the database server
|
||||||
|
# Should use the default port if not set
|
||||||
|
LD_DB_PORT=
|
||||||
|
# Any additional options to pass to the database (default: {})
|
||||||
|
LD_DB_OPTIONS=
|
||||||
|
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto
|
||||||
|
*.sh text eol=lf
|
59
.github/workflows/main.yaml
vendored
59
.github/workflows/main.yaml
vendored
@@ -1,24 +1,59 @@
|
|||||||
name: linkding CI
|
name: linkding CI
|
||||||
|
|
||||||
on: [push]
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
run_tests:
|
unit_tests:
|
||||||
name: Run Django Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v1
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.7
|
python-version: "3.10"
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 14
|
node-version: 20
|
||||||
- name: Install Python dependencies
|
cache: 'npm'
|
||||||
run: pip install -r requirements.txt
|
|
||||||
- name: Install Node dependencies
|
- name: Install Node dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
- name: Setup Python environment
|
||||||
|
run: |
|
||||||
|
pip install -r requirements.txt -r requirements.dev.txt
|
||||||
|
mkdir data
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: python manage.py test
|
run: python manage.py test bookmarks.tests
|
||||||
|
e2e_tests:
|
||||||
|
name: E2E Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install Node dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Setup Python environment
|
||||||
|
run: |
|
||||||
|
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
|
||||||
|
- name: Run tests
|
||||||
|
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||||
|
9
.gitignore
vendored
9
.gitignore
vendored
@@ -182,10 +182,17 @@ typings/
|
|||||||
|
|
||||||
### Custom
|
### Custom
|
||||||
# Rollup compilation output
|
# Rollup compilation output
|
||||||
/build
|
/bookmarks/static/bundle.js*
|
||||||
|
# SASS compilation output
|
||||||
|
/bookmarks/static/theme-*.css*
|
||||||
# Collected static files for deployment
|
# Collected static files for deployment
|
||||||
/static
|
/static
|
||||||
# Build output, etc.
|
# Build output, etc.
|
||||||
/tmp
|
/tmp
|
||||||
# Database file
|
# Database file
|
||||||
/data
|
/data
|
||||||
|
# ublock + chromium
|
||||||
|
/uBlock0.chromium
|
||||||
|
/chromium-profile
|
||||||
|
# direnv
|
||||||
|
/.direnv
|
||||||
|
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
ignoreIssuesWith: [
|
|
||||||
"wontfix",
|
|
||||||
"duplicate"
|
|
||||||
]
|
|
||||||
}
|
|
689
CHANGELOG.md
689
CHANGELOG.md
@@ -1,125 +1,695 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
* Fix bulk edit to respect searched tags by @sissbruecker in https://github.com/sissbruecker/linkding/pull/537
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.21.0...v1.21.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.21.0 (25/08/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Make search autocomplete respect link target setting by @sissbruecker in https://github.com/sissbruecker/linkding/pull/513
|
||||||
|
* Various CSS improvements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/514
|
||||||
|
* Display shared state in bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/515
|
||||||
|
* Allow bulk editing unread and shared state of bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/517
|
||||||
|
* Bump uwsgi from 2.0.20 to 2.0.22 by @dependabot in https://github.com/sissbruecker/linkding/pull/516
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.20.1...v1.21.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.20.1 (23/08/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Update cached styles and scripts after version change by @sissbruecker in https://github.com/sissbruecker/linkding/pull/510
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.20.0...v1.20.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.20.0 (22/08/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add option to share bookmarks publicly by @sissbruecker in https://github.com/sissbruecker/linkding/pull/503
|
||||||
|
* Various improvements to favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/504
|
||||||
|
* Add support for PRIVATE flag in import and export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/505
|
||||||
|
* Avoid page reload when triggering actions in bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/506
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.1...v1.20.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.19.1 (29/07/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add Postman Collection to Community section of README by @gingerbeardman in https://github.com/sissbruecker/linkding/pull/476
|
||||||
|
* Added Dev Container support by @acbgbca in https://github.com/sissbruecker/linkding/pull/474
|
||||||
|
* Added Apple web-app meta tag #358 by @acbgbca in https://github.com/sissbruecker/linkding/pull/359
|
||||||
|
* Bump requests from 2.28.1 to 2.31.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/478
|
||||||
|
* Allow passing title and description to new bookmark form by @acbgbca in https://github.com/sissbruecker/linkding/pull/479
|
||||||
|
* Enable WAL to avoid locked database lock errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/480
|
||||||
|
* Fix website loader content encoding detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/482
|
||||||
|
* Bump certifi from 2022.12.7 to 2023.7.22 by @dependabot in https://github.com/sissbruecker/linkding/pull/497
|
||||||
|
* Bump django from 4.1.9 to 4.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/494
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @gingerbeardman made their first contribution in https://github.com/sissbruecker/linkding/pull/476
|
||||||
|
* @acbgbca made their first contribution in https://github.com/sissbruecker/linkding/pull/474
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.0...v1.19.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.19.0 (20/05/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add notes to bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/472
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.18.0...v1.19.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.18.0 (18/05/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Make search case-insensitive on Postgres by @sissbruecker in https://github.com/sissbruecker/linkding/pull/432
|
||||||
|
* Allow searching for tags without hash character by @sissbruecker in https://github.com/sissbruecker/linkding/pull/449
|
||||||
|
* Prevent zoom-in after focusing an input on small viewports on iOS devices by @puresick in https://github.com/sissbruecker/linkding/pull/440
|
||||||
|
* Add database options by @plockaby in https://github.com/sissbruecker/linkding/pull/406
|
||||||
|
* Allow to log real client ip in logs when using a reverse proxy by @fmenabe in https://github.com/sissbruecker/linkding/pull/398
|
||||||
|
* Add option to display URL below title by @bah0 in https://github.com/sissbruecker/linkding/pull/365
|
||||||
|
* Add LinkThing iOS app to community section by @amoscardino in https://github.com/sissbruecker/linkding/pull/446
|
||||||
|
* Bump django from 4.1.7 to 4.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/466
|
||||||
|
* Bump sqlparse from 0.4.2 to 0.4.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/455
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @amoscardino made their first contribution in https://github.com/sissbruecker/linkding/pull/446
|
||||||
|
* @puresick made their first contribution in https://github.com/sissbruecker/linkding/pull/440
|
||||||
|
* @plockaby made their first contribution in https://github.com/sissbruecker/linkding/pull/406
|
||||||
|
* @fmenabe made their first contribution in https://github.com/sissbruecker/linkding/pull/398
|
||||||
|
* @bah0 made their first contribution in https://github.com/sissbruecker/linkding/pull/365
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.2...v1.18.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.17.2 (18/02/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Escape texts in exported HTML by @sissbruecker in https://github.com/sissbruecker/linkding/pull/429
|
||||||
|
* Bump django from 4.1.2 to 4.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/427
|
||||||
|
* Make health check in Dockerfile honor context path setting by @mrex in https://github.com/sissbruecker/linkding/pull/407
|
||||||
|
* Disable autocapitalization for tag input form by @joshdick in https://github.com/sissbruecker/linkding/pull/395
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @mrex made their first contribution in https://github.com/sissbruecker/linkding/pull/407
|
||||||
|
* @joshdick made their first contribution in https://github.com/sissbruecker/linkding/pull/395
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.1...v1.17.2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.17.1 (22/01/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Fix favicon being cleared by web archive snapshot task by @sissbruecker in https://github.com/sissbruecker/linkding/pull/405
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.0...v1.17.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.17.0 (21/01/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add Health Check endpoint by @mckennajones in https://github.com/sissbruecker/linkding/pull/392
|
||||||
|
* Cache website metadata to avoid duplicate scraping by @sissbruecker in https://github.com/sissbruecker/linkding/pull/401
|
||||||
|
* Prefill form if URL is already bookmarked by @sissbruecker in https://github.com/sissbruecker/linkding/pull/402
|
||||||
|
* Add option for showing bookmark favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/390
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.1...v1.17.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.16.1 (20/01/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Fix bookmark website metadata not being updated when URL changes by @sissbruecker in https://github.com/sissbruecker/linkding/pull/400
|
||||||
|
* Bump django from 4.1 to 4.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/391
|
||||||
|
* Bump certifi from 2022.6.15 to 2022.12.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/374
|
||||||
|
* Bump minimatch from 3.0.4 to 3.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/366
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.0...v1.16.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.16.0 (12/01/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add postgres as database engine by @tomamplius in https://github.com/sissbruecker/linkding/pull/388
|
||||||
|
* Gracefully stop docker container when it receives SIGTERM by @mckennajones in https://github.com/sissbruecker/linkding/pull/368
|
||||||
|
* Limit document size for website scraper by @sissbruecker in https://github.com/sissbruecker/linkding/pull/354
|
||||||
|
* Add error handling for checking latest version by @sissbruecker in https://github.com/sissbruecker/linkding/pull/360
|
||||||
|
* Trim website metadata title and description by @luca1197 in https://github.com/sissbruecker/linkding/pull/383
|
||||||
|
* Only show admin link for superusers by @AlexanderS in https://github.com/sissbruecker/linkding/pull/384
|
||||||
|
* Add apache reverse proxy documentation. by @jhauris in https://github.com/sissbruecker/linkding/pull/371
|
||||||
|
* Correct LD_ENABLE_AUTH_PROXY documentation by @jhauris in https://github.com/sissbruecker/linkding/pull/379
|
||||||
|
* Android HTTP shortcuts v3 by @kzshantonu in https://github.com/sissbruecker/linkding/pull/387
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @jhauris made their first contribution in https://github.com/sissbruecker/linkding/pull/371
|
||||||
|
* @AlexanderS made their first contribution in https://github.com/sissbruecker/linkding/pull/384
|
||||||
|
* @mckennajones made their first contribution in https://github.com/sissbruecker/linkding/pull/368
|
||||||
|
* @tomamplius made their first contribution in https://github.com/sissbruecker/linkding/pull/388
|
||||||
|
* @luca1197 made their first contribution in https://github.com/sissbruecker/linkding/pull/383
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.1...v1.16.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.15.1 (05/10/2022)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Fix static file dir warning by @sissbruecker in https://github.com/sissbruecker/linkding/pull/350
|
||||||
|
* Add setting and documentation for fixing CSRF errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/349
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.0...v1.15.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.15.0 (11/09/2022)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Bump Django and other dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/331
|
||||||
|
* Add option to create initial superuser by @sissbruecker in https://github.com/sissbruecker/linkding/pull/323
|
||||||
|
* Improved Android HTTP Shortcuts doc by @kzshantonu in https://github.com/sissbruecker/linkding/pull/330
|
||||||
|
* Minify bookmark list HTML by @sissbruecker in https://github.com/sissbruecker/linkding/pull/332
|
||||||
|
* Bump python version to 3.10 by @sissbruecker in https://github.com/sissbruecker/linkding/pull/333
|
||||||
|
* Fix error when deleting all bookmarks in admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/336
|
||||||
|
* Improve bookmark query performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/334
|
||||||
|
* Prevent rate limit errors in wayback machine API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/339
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.14.0...v1.15.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.14.0 (14/08/2022)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add support for context path by @s2marine in https://github.com/sissbruecker/linkding/pull/313
|
||||||
|
* Add support for authentication proxies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/321
|
||||||
|
* Add bookmark list keyboard navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/320
|
||||||
|
* Skip updating website metadata on edit unless URL has changed by @sissbruecker in https://github.com/sissbruecker/linkding/pull/318
|
||||||
|
* Add simple docs of the new `shared` API parameter by @bachya in https://github.com/sissbruecker/linkding/pull/312
|
||||||
|
* Add project linka to community section in README by @cmsax in https://github.com/sissbruecker/linkding/pull/319
|
||||||
|
* Order tags in test_should_create_new_bookmark by @RoGryza in https://github.com/sissbruecker/linkding/pull/310
|
||||||
|
* Bump django from 3.2.14 to 3.2.15 by @dependabot in https://github.com/sissbruecker/linkding/pull/316
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @s2marine made their first contribution in https://github.com/sissbruecker/linkding/pull/313
|
||||||
|
* @RoGryza made their first contribution in https://github.com/sissbruecker/linkding/pull/310
|
||||||
|
* @cmsax made their first contribution in https://github.com/sissbruecker/linkding/pull/319
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.13.0...v1.14.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.13.0 (04/08/2022)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add bookmark sharing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/311
|
||||||
|
* Display selected tags in tag cloud by @sissbruecker and @jhauris in https://github.com/sissbruecker/linkding/pull/307
|
||||||
|
* Update unread flag when saving duplicate URL by @sissbruecker in https://github.com/sissbruecker/linkding/pull/306
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.12.0...v1.13.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.12.0 (23/07/2022)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add read it later functionality by @sissbruecker in https://github.com/sissbruecker/linkding/pull/304
|
||||||
|
* Add RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/305
|
||||||
|
* Add bookmarklet to community by @ukcuddlyguy in https://github.com/sissbruecker/linkding/pull/293
|
||||||
|
* Shorten and simplify example bookmarklet in documentation by @FunctionDJ in https://github.com/sissbruecker/linkding/pull/297
|
||||||
|
* Fix typo by @kianmeng in https://github.com/sissbruecker/linkding/pull/295
|
||||||
|
* Bump django from 3.2.13 to 3.2.14 by @dependabot in https://github.com/sissbruecker/linkding/pull/294
|
||||||
|
* Bump svelte from 3.46.4 to 3.49.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/299
|
||||||
|
* Bump terser from 5.5.1 to 5.14.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/302
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @ukcuddlyguy made their first contribution in https://github.com/sissbruecker/linkding/pull/293
|
||||||
|
* @FunctionDJ made their first contribution in https://github.com/sissbruecker/linkding/pull/297
|
||||||
|
* @kianmeng made their first contribution in https://github.com/sissbruecker/linkding/pull/295
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.11.1...v1.12.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.11.1 (03/07/2022)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Fix duplicate tags on import by @wahlm in https://github.com/sissbruecker/linkding/pull/289
|
||||||
|
* Add apple-touch-icon by @daveonkels in https://github.com/sissbruecker/linkding/pull/282
|
||||||
|
* Bump waybackpy to 3.0.6 by @dustinblackman in https://github.com/sissbruecker/linkding/pull/281
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @wahlm made their first contribution in https://github.com/sissbruecker/linkding/pull/289
|
||||||
|
* @daveonkels made their first contribution in https://github.com/sissbruecker/linkding/pull/282
|
||||||
|
* @dustinblackman made their first contribution in https://github.com/sissbruecker/linkding/pull/281
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.11.0...v1.11.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.11.0 (26/05/2022)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add background tasks to admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/264
|
||||||
|
* Improve about section by @sissbruecker in https://github.com/sissbruecker/linkding/pull/265
|
||||||
|
* Allow creating archived bookmark through REST API by @kencx in https://github.com/sissbruecker/linkding/pull/268
|
||||||
|
* Add PATCH support to bookmarks endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/269
|
||||||
|
* Add community reference to linkding-cli by @bachya in https://github.com/sissbruecker/linkding/pull/270
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @kencx made their first contribution in https://github.com/sissbruecker/linkding/pull/268
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.10.1...v1.11.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.10.1 (21/05/2022)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Fake request headers to reduce bot detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/263
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.10.0...v1.10.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.10.0 (21/05/2022)
|
## v1.10.0 (21/05/2022)
|
||||||
### What's Changed
|
|
||||||
* Add to managed hosting options by @m3nu in https://github.com/sissbruecker/linkding/pull/253
|
|
||||||
* Add community reference to aiolinkding by @bachya in https://github.com/sissbruecker/linkding/pull/259
|
|
||||||
* Improve import performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/261
|
|
||||||
* Update how-to.md to fix unclear/paraphrased Safari action in IOS Shortcuts by @feoh in https://github.com/sissbruecker/linkding/pull/260
|
|
||||||
* Allow searching for untagged bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/226
|
|
||||||
|
|
||||||
### New Contributors
|
|
||||||
* @m3nu made their first contribution in https://github.com/sissbruecker/linkding/pull/253
|
|
||||||
* @bachya made their first contribution in https://github.com/sissbruecker/linkding/pull/259
|
|
||||||
* @feoh made their first contribution in https://github.com/sissbruecker/linkding/pull/260
|
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add to managed hosting options by @m3nu in https://github.com/sissbruecker/linkding/pull/253
|
||||||
|
* Add community reference to aiolinkding by @bachya in https://github.com/sissbruecker/linkding/pull/259
|
||||||
|
* Improve import performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/261
|
||||||
|
* Update how-to.md to fix unclear/paraphrased Safari action in IOS Shortcuts by @feoh in https://github.com/sissbruecker/linkding/pull/260
|
||||||
|
* Allow searching for untagged bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/226
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @m3nu made their first contribution in https://github.com/sissbruecker/linkding/pull/253
|
||||||
|
* @bachya made their first contribution in https://github.com/sissbruecker/linkding/pull/259
|
||||||
|
* @feoh made their first contribution in https://github.com/sissbruecker/linkding/pull/260
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.9.0...v1.10.0
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.9.0...v1.10.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.9.0 (14/05/2022)
|
## v1.9.0 (14/05/2022)
|
||||||
### What's Changed
|
|
||||||
* Scroll menu items into view when using keyboard by @sissbruecker in https://github.com/sissbruecker/linkding/pull/248
|
|
||||||
* Add whitespace after auto-completed tag by @sissbruecker in https://github.com/sissbruecker/linkding/pull/249
|
|
||||||
* Bump django from 3.2.12 to 3.2.13 by @dependabot in https://github.com/sissbruecker/linkding/pull/244
|
|
||||||
* Add community helm chart reference to readme by @pascaliske in https://github.com/sissbruecker/linkding/pull/242
|
|
||||||
* Feature: Shortcut key for new bookmark by @rithask in https://github.com/sissbruecker/linkding/pull/241
|
|
||||||
* Clarify archive.org feature by @clach04 in https://github.com/sissbruecker/linkding/pull/229
|
|
||||||
* Make Internet Archive integration opt-in by @sissbruecker in https://github.com/sissbruecker/linkding/pull/250
|
|
||||||
|
|
||||||
### New Contributors
|
|
||||||
* @pascaliske made their first contribution in https://github.com/sissbruecker/linkding/pull/242
|
|
||||||
* @rithask made their first contribution in https://github.com/sissbruecker/linkding/pull/241
|
|
||||||
* @clach04 made their first contribution in https://github.com/sissbruecker/linkding/pull/229
|
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Scroll menu items into view when using keyboard by @sissbruecker in https://github.com/sissbruecker/linkding/pull/248
|
||||||
|
* Add whitespace after auto-completed tag by @sissbruecker in https://github.com/sissbruecker/linkding/pull/249
|
||||||
|
* Bump django from 3.2.12 to 3.2.13 by @dependabot in https://github.com/sissbruecker/linkding/pull/244
|
||||||
|
* Add community helm chart reference to readme by @pascaliske in https://github.com/sissbruecker/linkding/pull/242
|
||||||
|
* Feature: Shortcut key for new bookmark by @rithask in https://github.com/sissbruecker/linkding/pull/241
|
||||||
|
* Clarify archive.org feature by @clach04 in https://github.com/sissbruecker/linkding/pull/229
|
||||||
|
* Make Internet Archive integration opt-in by @sissbruecker in https://github.com/sissbruecker/linkding/pull/250
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @pascaliske made their first contribution in https://github.com/sissbruecker/linkding/pull/242
|
||||||
|
* @rithask made their first contribution in https://github.com/sissbruecker/linkding/pull/241
|
||||||
|
* @clach04 made their first contribution in https://github.com/sissbruecker/linkding/pull/229
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.8.8...v1.9.0
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.8.8...v1.9.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.8.8 (27/03/2022)
|
## v1.8.8 (27/03/2022)
|
||||||
- [**bug**] Prevent bookmark actions through get requests
|
|
||||||
|
- [**bug**] Prevent bookmark actions through get requests
|
||||||
- [**bug**] Prevent external redirects
|
- [**bug**] Prevent external redirects
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.8.7 (26/03/2022)
|
## v1.8.7 (26/03/2022)
|
||||||
- [**bug**] Increase request buffer size [#28](https://github.com/sissbruecker/linkding/issues/28)
|
|
||||||
- [**enhancement**] Allow specifying port through LINKDING_PORT environment variable [#156](https://github.com/sissbruecker/linkding/pull/156)
|
- [**bug**] Increase request buffer size [#28](https://github.com/sissbruecker/linkding/issues/28)
|
||||||
|
- [**enhancement**] Allow specifying port through LINKDING_PORT environment variable [#156](https://github.com/sissbruecker/linkding/pull/156)
|
||||||
- [**chore**] Bump NPM packages [#224](https://github.com/sissbruecker/linkding/pull/224)
|
- [**chore**] Bump NPM packages [#224](https://github.com/sissbruecker/linkding/pull/224)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.8.6 (25/03/2022)
|
## v1.8.6 (25/03/2022)
|
||||||
- [bug] fix bookmark access restrictions
|
|
||||||
- [bug] prevent external redirects
|
- [bug] fix bookmark access restrictions
|
||||||
|
- [bug] prevent external redirects
|
||||||
- [chore] bump dependencies
|
- [chore] bump dependencies
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.8.5 (13/12/2021)
|
## v1.8.5 (12/12/2021)
|
||||||
- [**bug**] Ensure tag names do not contain spaces [#182](https://github.com/sissbruecker/linkding/issues/182)
|
|
||||||
- [**bug**] Consider not copying whole GIT repository to Docker image [#174](https://github.com/sissbruecker/linkding/issues/174)
|
- [**bug**] Ensure tag names do not contain spaces [#182](https://github.com/sissbruecker/linkding/issues/182)
|
||||||
|
- [**bug**] Consider not copying whole GIT repository to Docker image [#174](https://github.com/sissbruecker/linkding/issues/174)
|
||||||
- [**enhancement**] Make bookmarks count column in admin sortable [#183](https://github.com/sissbruecker/linkding/pull/183)
|
- [**enhancement**] Make bookmarks count column in admin sortable [#183](https://github.com/sissbruecker/linkding/pull/183)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.8.4 (16/10/2021)
|
## v1.8.4 (16/10/2021)
|
||||||
|
|
||||||
- [**enhancement**] Allow non-admin users to change their password [#166](https://github.com/sissbruecker/linkding/issues/166)
|
- [**enhancement**] Allow non-admin users to change their password [#166](https://github.com/sissbruecker/linkding/issues/166)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.8.3 (03/10/2021)
|
## v1.8.3 (03/10/2021)
|
||||||
|
|
||||||
- [**enhancement**] Enhancement: let user configure to open links in same tab instead on a new window/tab [#27](https://github.com/sissbruecker/linkding/issues/27)
|
- [**enhancement**] Enhancement: let user configure to open links in same tab instead on a new window/tab [#27](https://github.com/sissbruecker/linkding/issues/27)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.8.2 (02/10/2021)
|
## v1.8.2 (02/10/2021)
|
||||||
|
|
||||||
- [**bug**] Fix jumping search box [#163](https://github.com/sissbruecker/linkding/pull/163)
|
- [**bug**] Fix jumping search box [#163](https://github.com/sissbruecker/linkding/pull/163)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.8.1 (01/10/2021)
|
## v1.8.1 (01/10/2021)
|
||||||
- [**enhancement**] Add global shortcut for search [#161](https://github.com/sissbruecker/linkding/pull/161)
|
|
||||||
|
- [**enhancement**] Add global shortcut for search [#161](https://github.com/sissbruecker/linkding/pull/161)
|
||||||
- allows to press `s` to focus the search input
|
- allows to press `s` to focus the search input
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.8.0 (04/09/2021)
|
## v1.8.0 (04/09/2021)
|
||||||
- [**enhancement**] Wayback Machine Integration [#59](https://github.com/sissbruecker/linkding/issues/59)
|
|
||||||
- Automatically creates snapshots of bookmarked websites on [web archive](https://archive.org/web/)
|
- [**enhancement**] Wayback Machine Integration [#59](https://github.com/sissbruecker/linkding/issues/59)
|
||||||
|
- Automatically creates snapshots of bookmarked websites on [web archive](https://archive.org/web/)
|
||||||
- This is one of the largest changes yet and adds a task processor that runs as a separate process in the background. If you run into issues with this feature, it can be disabled using the [LD_DISABLE_BACKGROUND_TASKS](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_disable_background_tasks) option
|
- This is one of the largest changes yet and adds a task processor that runs as a separate process in the background. If you run into issues with this feature, it can be disabled using the [LD_DISABLE_BACKGROUND_TASKS](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_disable_background_tasks) option
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.7.2 (26/08/2021)
|
## v1.7.2 (26/08/2021)
|
||||||
|
|
||||||
- [**enhancement**] Add support for nanosecond resolution timestamps for bookmark import (e.g. Google Bookmarks) [#146](https://github.com/sissbruecker/linkding/issues/146)
|
- [**enhancement**] Add support for nanosecond resolution timestamps for bookmark import (e.g. Google Bookmarks) [#146](https://github.com/sissbruecker/linkding/issues/146)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.7.1 (25/08/2021)
|
## v1.7.1 (25/08/2021)
|
||||||
- [**bug**] umlaut/non-ascii characters broken when using bookmarklet (firefox) [#148](https://github.com/sissbruecker/linkding/issues/148)
|
|
||||||
- [**bug**] Bookmark import accepts empty URL values [#124](https://github.com/sissbruecker/linkding/issues/124)
|
- [**bug**] umlaut/non-ascii characters broken when using bookmarklet (firefox) [#148](https://github.com/sissbruecker/linkding/issues/148)
|
||||||
|
- [**bug**] Bookmark import accepts empty URL values [#124](https://github.com/sissbruecker/linkding/issues/124)
|
||||||
- [**enhancement**] Show the version in the settings [#104](https://github.com/sissbruecker/linkding/issues/104)
|
- [**enhancement**] Show the version in the settings [#104](https://github.com/sissbruecker/linkding/issues/104)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.7.0 (17/08/2021)
|
## v1.7.0 (17/08/2021)
|
||||||
- Upgrade to Django 3
|
|
||||||
|
- Upgrade to Django 3
|
||||||
- Bump other dependencies
|
- Bump other dependencies
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.6.5 (15/08/2021)
|
## v1.6.5 (15/08/2021)
|
||||||
|
|
||||||
- [**enhancement**] query with multiple hashtags very slow [#112](https://github.com/sissbruecker/linkding/issues/112)
|
- [**enhancement**] query with multiple hashtags very slow [#112](https://github.com/sissbruecker/linkding/issues/112)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.6.4 (13/05/2021)
|
## v1.6.4 (13/05/2021)
|
||||||
|
|
||||||
- Update dependencies for security fixes
|
- Update dependencies for security fixes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.6.3 (07/04/2021)
|
## v1.6.3 (06/04/2021)
|
||||||
|
|
||||||
- [**bug**] relative names use the wrong "today" after day change [#107](https://github.com/sissbruecker/linkding/issues/107)
|
- [**bug**] relative names use the wrong "today" after day change [#107](https://github.com/sissbruecker/linkding/issues/107)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.6.2 (04/04/2021)
|
## v1.6.2 (04/04/2021)
|
||||||
|
|
||||||
- [**enhancement**] Expose `date_added` in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
|
- [**enhancement**] Expose `date_added` in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
|
||||||
- [**closed**] Archived bookmarks - no result when searching for a word which is used only as tag [#83](https://github.com/sissbruecker/linkding/issues/83)
|
- [**closed**] Archived bookmarks - no result when searching for a word which is used only as tag [#83](https://github.com/sissbruecker/linkding/issues/83)
|
||||||
- [**closed**] Add archive/unarchive button to edit bookmark page [#82](https://github.com/sissbruecker/linkding/issues/82)
|
- [**closed**] Add archive/unarchive button to edit bookmark page [#82](https://github.com/sissbruecker/linkding/issues/82)
|
||||||
@@ -128,46 +698,57 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## v1.6.1 (31/03/2021)
|
## v1.6.1 (31/03/2021)
|
||||||
|
|
||||||
- Expose date_added in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
|
- Expose date_added in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.6.0 (29/03/2021)
|
## v1.6.0 (28/03/2021)
|
||||||
|
|
||||||
- Bulk edit mode [#101](https://github.com/sissbruecker/linkding/pull/101)
|
- Bulk edit mode [#101](https://github.com/sissbruecker/linkding/pull/101)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.5.0 (28/03/2021)
|
## v1.5.0 (28/03/2021)
|
||||||
|
|
||||||
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
|
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.4.1 (20/03/2021)
|
## v1.4.1 (20/03/2021)
|
||||||
- Security patches
|
|
||||||
|
- Security patches
|
||||||
- Documentation improvements
|
- Documentation improvements
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.4.0 (24/02/2021)
|
## v1.4.0 (24/02/2021)
|
||||||
|
|
||||||
- [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76)
|
- [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.3.3 (18/02/2021)
|
## v1.3.3 (18/02/2021)
|
||||||
|
|
||||||
- [**closed**] Missing "description" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78)
|
- [**closed**] Missing "description" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.3.2 (18/02/2021)
|
## v1.3.2 (18/02/2021)
|
||||||
|
|
||||||
- [**closed**] /archive and /unarchive API routes return 404 [#77](https://github.com/sissbruecker/linkding/issues/77)
|
- [**closed**] /archive and /unarchive API routes return 404 [#77](https://github.com/sissbruecker/linkding/issues/77)
|
||||||
- [**closed**] API - /api/check_url?url= with token authetification [#55](https://github.com/sissbruecker/linkding/issues/55)
|
- [**closed**] API - /api/check_url?url= with token authetification [#55](https://github.com/sissbruecker/linkding/issues/55)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.3.1 (15/02/2021)
|
## v1.3.1 (15/02/2021)
|
||||||
|
|
||||||
[enhancement] Enhance delete links with inline confirmation
|
[enhancement] Enhance delete links with inline confirmation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.3.0 (14/02/2021)
|
## v1.3.0 (14/02/2021)
|
||||||
|
|
||||||
- [**closed**] Novice help. [#71](https://github.com/sissbruecker/linkding/issues/71)
|
- [**closed**] Novice help. [#71](https://github.com/sissbruecker/linkding/issues/71)
|
||||||
- [**closed**] Option to create bookmarks public [#70](https://github.com/sissbruecker/linkding/issues/70)
|
- [**closed**] Option to create bookmarks public [#70](https://github.com/sissbruecker/linkding/issues/70)
|
||||||
- [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64)
|
- [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64)
|
||||||
@@ -179,16 +760,40 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## v1.2.1 (12/01/2021)
|
## v1.2.1 (12/01/2021)
|
||||||
|
|
||||||
- [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65)
|
- [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65)
|
||||||
- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)
|
- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.2.0 (09/01/2021)
|
## v1.2.0 (09/01/2021)
|
||||||
|
|
||||||
- [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58)
|
- [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58)
|
||||||
- [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45)
|
- [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.1.1 (01/01/2021)
|
## v1.1.1 (01/01/2021)
|
||||||
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)
|
|
||||||
|
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.1.0 (31/12/2020)
|
||||||
|
|
||||||
|
- [**enhancement**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)
|
||||||
|
- [**enhancement**] Improve Netscape bookmarks file parsing [#50](https://github.com/sissbruecker/linkding/issues/50)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0.0 (31/12/2020)
|
||||||
|
|
||||||
|
- [**bug**] Import does not import bookmark descriptions [#47](https://github.com/sissbruecker/linkding/issues/47)
|
||||||
|
- [**enhancement**] Enhancement: return to same page we were on after editing a bookmark [#26](https://github.com/sissbruecker/linkding/issues/26)
|
||||||
|
- [**bug**] Increase limit on bookmark URL length [#25](https://github.com/sissbruecker/linkding/issues/25)
|
||||||
|
- [**enhancement**] API for app development [#24](https://github.com/sissbruecker/linkding/issues/24)
|
||||||
|
- [**enhancement**] Enhancement: detect duplicates at entry time [#23](https://github.com/sissbruecker/linkding/issues/23)
|
||||||
|
- [**bug**] Error importing bookmarks [#18](https://github.com/sissbruecker/linkding/issues/18)
|
||||||
|
- [**enhancement**] Enhancement: better administration page [#4](https://github.com/sissbruecker/linkding/issues/4)
|
||||||
|
- [**enhancement**] Bug: Navigation bar active link stays on add bookmark [#3](https://github.com/sissbruecker/linkding/issues/3)
|
||||||
|
- [**bug**] CSS Stylesheet presented as text/plain [#2](https://github.com/sissbruecker/linkding/issues/2)
|
54
Dockerfile
54
Dockerfile
@@ -1,54 +0,0 @@
|
|||||||
FROM node:current-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.9.6-slim-buster AS python-base
|
|
||||||
RUN apt-get update && apt-get -y install build-essential
|
|
||||||
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:3.9.6-slim-buster as final
|
|
||||||
RUN apt-get update && apt-get -y install mime-support
|
|
||||||
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 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"]
|
|
||||||
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 process_tasks
|
||||||
|
|
||||||
|
test:
|
||||||
|
pytest
|
||||||
|
|
||||||
|
format:
|
||||||
|
black bookmarks
|
||||||
|
black siteroot
|
||||||
|
npx prettier bookmarks/frontend --write
|
219
README.md
219
README.md
@@ -9,18 +9,20 @@
|
|||||||
## Overview
|
## Overview
|
||||||
- [Introduction](#introduction)
|
- [Introduction](#introduction)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Using Docker](#using-docker)
|
- [Using Docker](#using-docker)
|
||||||
- [Using Docker Compose](#using-docker-compose)
|
- [Using Docker Compose](#using-docker-compose)
|
||||||
- [User Setup](#user-setup)
|
- [User Setup](#user-setup)
|
||||||
- [Managed Hosting Options](#managed-hosting-options)
|
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||||
|
- [Managed Hosting Options](#managed-hosting-options)
|
||||||
- [Documentation](#documentation)
|
- [Documentation](#documentation)
|
||||||
- [Browser Extension](#browser-extension)
|
- [Browser Extension](#browser-extension)
|
||||||
- [Community](#community)
|
- [Community](#community)
|
||||||
|
- [Acknowledgements + Donations](#acknowledgements--donations)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
linkding is a simple bookmark service that you can host yourself.
|
linkding is a bookmark manager that you can host yourself.
|
||||||
It's designed be to be minimal, fast, and easy to set up using Docker.
|
It's designed be to be minimal, fast, and easy to set up using Docker.
|
||||||
|
|
||||||
The name comes from:
|
The name comes from:
|
||||||
@@ -29,21 +31,21 @@ The name comes from:
|
|||||||
- ...so basically something for managing your links
|
- ...so basically something for managing your links
|
||||||
|
|
||||||
**Feature Overview:**
|
**Feature Overview:**
|
||||||
- Tags for organizing bookmarks
|
- Clean UI optimized for readability
|
||||||
- Search by text or tags
|
- Organize bookmarks with tags
|
||||||
- Bulk editing
|
- Bulk editing, Markdown notes, read it later functionality
|
||||||
- Bookmark archive
|
- Share bookmarks with other users or guests
|
||||||
- Dark mode
|
- 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
|
||||||
- Automatically provides titles and descriptions of bookmarked websites
|
|
||||||
- Import and export bookmarks in Netscape HTML format
|
- 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), and a bookmarklet that should work in most browsers
|
- 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
|
- REST API for developing 3rd party apps
|
||||||
- Admin panel for user self-service and raw data access
|
- Admin panel for user self-service and raw data access
|
||||||
- Easy to set up using Docker, uses SQLite as database
|
|
||||||
|
|
||||||
|
|
||||||
**Demo:** https://demo.linkding.link/ (configured with open registration)
|
**Demo:** https://demo.linkding.link/
|
||||||
|
|
||||||
**Screenshot:**
|
**Screenshot:**
|
||||||
|
|
||||||
@@ -51,21 +53,59 @@ The name comes from:
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/). The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
|
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/).
|
||||||
|
The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
|
||||||
|
|
||||||
|
linkding uses an SQLite database by default.
|
||||||
|
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
|
||||||
|
|
||||||
### Using Docker
|
### 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:
|
The Docker image comes in several variants. To use a different image than the default, replace `latest` with the desired tag in the commands below, or in the docker-compose file.
|
||||||
```shell
|
|
||||||
docker run --name linkding -p 9090:9090 -d sissbruecker/linkding:latest
|
|
||||||
```
|
|
||||||
By default, the application runs on port `9090`, you can map it to a different host port by modifying the port mapping in the command above. If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090, provided you did not change the port mapping.
|
|
||||||
|
|
||||||
Note that the command above will store the linkding SQLite database in the container, which means that deleting the container, for example when upgrading the installation, will also remove the database. For hosting an actual installation you usually want to store the database on the host system, rather than in the container. To do so, run the following command, and 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:
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tag</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>latest</code></td>
|
||||||
|
<td>Provides the basic functionality of linkding</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>latest-plus</code></td>
|
||||||
|
<td>
|
||||||
|
Includes feature for archiving websites as HTML snapshots
|
||||||
|
<ul>
|
||||||
|
<li>Significantly larger image size as it includes a Chromium installation</li>
|
||||||
|
<li>Requires more runtime memory to run Chromium</li>
|
||||||
|
<li>Requires more disk space for storing HTML snapshots</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>latest-alpine</code></td>
|
||||||
|
<td><code>latest</code>, but based on Alpine Linux. 🧪 Experimental</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>latest-plus-alpine</code></td>
|
||||||
|
<td><code>latest-plus</code>, but based on Alpine Linux. 🧪 Experimental</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
To install linkding using Docker you can just run the image from [Docker Hub](https://hub.docker.com/repository/docker/sissbruecker/linkding):
|
||||||
```shell
|
```shell
|
||||||
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
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 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.
|
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||||
@@ -79,7 +119,7 @@ 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.
|
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||||
|
|
||||||
### User setup
|
### 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:
|
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:
|
||||||
|
|
||||||
@@ -95,12 +135,71 @@ docker-compose exec linkding python manage.py createsuperuser --username=joe --e
|
|||||||
|
|
||||||
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.
|
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
|
### Managed Hosting Options
|
||||||
|
|
||||||
Self-hosting web applications on your own hardware (unfortunately) 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 linkding, 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 (usually monthly) 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.
|
Self-hosting web applications still requires a lot of technical know-how and commitment to maintenance, in order to keep everything up-to-date and secure. This section is intended to provide simple alternatives in form of managed hosting solutions.
|
||||||
|
|
||||||
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
|
- [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)
|
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods))
|
||||||
|
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
|
||||||
|
- [linkding on railway.app](https://github.com/tianheg/linkding-on-railway) - Guide for hosting a linkding installation on [railway.app](https://railway.app/). By [tianheg](https://github.com/tianheg)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -110,13 +209,14 @@ Self-hosting web applications on your own hardware (unfortunately) still require
|
|||||||
| [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API |
|
||||||
|
|
||||||
## Browser Extension
|
## Browser Extension
|
||||||
|
|
||||||
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:
|
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/)
|
- [Mozilla Addon Store](https://addons.mozilla.org/firefox/addon/linkding-extension/)
|
||||||
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
||||||
|
|
||||||
The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).
|
The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).
|
||||||
@@ -125,17 +225,42 @@ The extension is open-source as well, and can be found [here](https://github.com
|
|||||||
|
|
||||||
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.
|
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
|
||||||
|
|
||||||
|
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||||
|
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
|
||||||
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||||
|
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
|
||||||
|
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
|
||||||
|
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
||||||
|
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
- [linkding-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)
|
- [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)
|
||||||
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
|
||||||
|
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||||
|
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||||
|
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||||
|
|
||||||
|
## Acknowledgements + Donations
|
||||||
|
|
||||||
|
### PikaPods
|
||||||
|
|
||||||
|
[PikaPods](https://www.pikapods.com/) has a revenue sharing agreement with this project, sharing some of their revenue from hosting linkding instances. I do not intend to profit from this project financially, so I am in turn donating that revenue. Big thanks to PikaPods for making this possible.
|
||||||
|
|
||||||
|
See the table below for a list of donations.
|
||||||
|
|
||||||
|
| Source | Description | Amount | Donated to |
|
||||||
|
|---------------------------------------|---------------------------------------------|---------|---------------------------------------------------------------------|
|
||||||
|
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/docs/donations/2023-10-11-internet-archive.png) |
|
||||||
|
|
||||||
|
### JetBrains
|
||||||
|
|
||||||
|
JetBrains has previously provided an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding.
|
||||||
|
|
||||||
## Development
|
## 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/3.2/. 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 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 🙂.
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Python 3
|
- Python 3.10
|
||||||
- Node.js
|
- Node.js
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
@@ -150,7 +275,7 @@ source ~/environments/linkding/bin/activate[.csh|.fish]
|
|||||||
```
|
```
|
||||||
Within the active environment install the application dependencies from the application folder:
|
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:
|
Install frontend dependencies:
|
||||||
```
|
```
|
||||||
@@ -174,3 +299,37 @@ Start the Django development server with:
|
|||||||
python3 manage.py runserver
|
python3 manage.py runserver
|
||||||
```
|
```
|
||||||
The frontend is now available under http://localhost:8000
|
The frontend is now available under http://localhost:8000
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Run all tests with pytest:
|
||||||
|
```
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
Once checked out, only the following commands are required to get started:
|
||||||
|
|
||||||
|
Create a user for the frontend:
|
||||||
|
```
|
||||||
|
python3 manage.py createsuperuser --username=joe --email=joe@example.com
|
||||||
|
```
|
||||||
|
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Start the Django development server with:
|
||||||
|
```
|
||||||
|
python3 manage.py runserver
|
||||||
|
```
|
||||||
|
The frontend is now available under http://localhost:8000
|
||||||
|
Binary file not shown.
@@ -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
|
|
@@ -2,54 +2,222 @@ from django.contrib import admin, messages
|
|||||||
from django.contrib.admin import AdminSite
|
from django.contrib.admin import AdminSite
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Count, QuerySet
|
from django.db.models import Count, QuerySet
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.urls import path
|
||||||
from django.utils.translation import ngettext, gettext
|
from django.utils.translation import ngettext, gettext
|
||||||
|
from huey.contrib.djhuey import HUEY as huey
|
||||||
from rest_framework.authtoken.admin import TokenAdmin
|
from rest_framework.authtoken.admin import TokenAdmin
|
||||||
from rest_framework.authtoken.models import TokenProxy
|
from rest_framework.authtoken.models import TokenProxy
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, Tag, UserProfile, Toast
|
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
|
||||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
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):
|
class LinkdingAdminSite(AdminSite):
|
||||||
site_header = 'linkding administration'
|
site_header = "linkding administration"
|
||||||
site_title = 'linkding Admin'
|
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):
|
class AdminBookmark(admin.ModelAdmin):
|
||||||
list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added')
|
list_display = ("resolved_title", "url", "is_archived", "owner", "date_added")
|
||||||
search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name')
|
search_fields = (
|
||||||
list_filter = ('owner__username', 'is_archived', 'tags',)
|
"title",
|
||||||
ordering = ('-date_added',)
|
"description",
|
||||||
actions = ['archive_selected_bookmarks', 'unarchive_selected_bookmarks']
|
"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"]
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
def archive_selected_bookmarks(self, request, queryset: QuerySet):
|
def archive_selected_bookmarks(self, request, queryset: QuerySet):
|
||||||
for bookmark in queryset:
|
for bookmark in queryset:
|
||||||
archive_bookmark(bookmark)
|
archive_bookmark(bookmark)
|
||||||
bookmarks_count = queryset.count()
|
bookmarks_count = queryset.count()
|
||||||
self.message_user(request, ngettext(
|
self.message_user(
|
||||||
'%d bookmark was successfully archived.',
|
request,
|
||||||
'%d bookmarks were successfully archived.',
|
ngettext(
|
||||||
bookmarks_count,
|
"%d bookmark was successfully archived.",
|
||||||
) % bookmarks_count, messages.SUCCESS)
|
"%d bookmarks were successfully archived.",
|
||||||
|
bookmarks_count,
|
||||||
|
)
|
||||||
|
% bookmarks_count,
|
||||||
|
messages.SUCCESS,
|
||||||
|
)
|
||||||
|
|
||||||
def unarchive_selected_bookmarks(self, request, queryset: QuerySet):
|
def unarchive_selected_bookmarks(self, request, queryset: QuerySet):
|
||||||
for bookmark in queryset:
|
for bookmark in queryset:
|
||||||
unarchive_bookmark(bookmark)
|
unarchive_bookmark(bookmark)
|
||||||
bookmarks_count = queryset.count()
|
bookmarks_count = queryset.count()
|
||||||
self.message_user(request, ngettext(
|
self.message_user(
|
||||||
'%d bookmark was successfully unarchived.',
|
request,
|
||||||
'%d bookmarks were successfully unarchived.',
|
ngettext(
|
||||||
bookmarks_count,
|
"%d bookmark was successfully unarchived.",
|
||||||
) % bookmarks_count, messages.SUCCESS)
|
"%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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class AdminTag(admin.ModelAdmin):
|
||||||
list_display = ('name', 'bookmarks_count', 'owner', 'date_added')
|
list_display = ("name", "bookmarks_count", "owner", "date_added")
|
||||||
search_fields = ('name', 'owner__username')
|
search_fields = ("name", "owner__username")
|
||||||
list_filter = ('owner__username',)
|
list_filter = ("owner__username",)
|
||||||
ordering = ('-date_added',)
|
ordering = ("-date_added",)
|
||||||
actions = ['delete_unused_tags']
|
actions = ["delete_unused_tags"]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
queryset = super().get_queryset(request)
|
queryset = super().get_queryset(request)
|
||||||
@@ -59,7 +227,7 @@ class AdminTag(admin.ModelAdmin):
|
|||||||
def bookmarks_count(self, obj):
|
def bookmarks_count(self, obj):
|
||||||
return obj.bookmarks_count
|
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):
|
def delete_unused_tags(self, request, queryset: QuerySet):
|
||||||
unused_tags = queryset.filter(bookmark__isnull=True)
|
unused_tags = queryset.filter(bookmark__isnull=True)
|
||||||
@@ -68,22 +236,32 @@ class AdminTag(admin.ModelAdmin):
|
|||||||
tag.delete()
|
tag.delete()
|
||||||
|
|
||||||
if unused_tags_count > 0:
|
if unused_tags_count > 0:
|
||||||
self.message_user(request, ngettext(
|
self.message_user(
|
||||||
'%d unused tag was successfully deleted.',
|
request,
|
||||||
'%d unused tags were successfully deleted.',
|
ngettext(
|
||||||
unused_tags_count,
|
"%d unused tag was successfully deleted.",
|
||||||
) % unused_tags_count, messages.SUCCESS)
|
"%d unused tags were successfully deleted.",
|
||||||
|
unused_tags_count,
|
||||||
|
)
|
||||||
|
% unused_tags_count,
|
||||||
|
messages.SUCCESS,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.message_user(request, gettext(
|
self.message_user(
|
||||||
'There were no unused tags in the selection',
|
request,
|
||||||
), messages.SUCCESS)
|
gettext(
|
||||||
|
"There were no unused tags in the selection",
|
||||||
|
),
|
||||||
|
messages.SUCCESS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AdminUserProfileInline(admin.StackedInline):
|
class AdminUserProfileInline(admin.StackedInline):
|
||||||
model = UserProfile
|
model = UserProfile
|
||||||
can_delete = False
|
can_delete = False
|
||||||
verbose_name_plural = 'Profile'
|
verbose_name_plural = "Profile"
|
||||||
fk_name = 'user'
|
fk_name = "user"
|
||||||
|
readonly_fields = ("search_preferences",)
|
||||||
|
|
||||||
|
|
||||||
class AdminCustomUser(UserAdmin):
|
class AdminCustomUser(UserAdmin):
|
||||||
@@ -96,14 +274,22 @@ class AdminCustomUser(UserAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class AdminToast(admin.ModelAdmin):
|
class AdminToast(admin.ModelAdmin):
|
||||||
list_display = ('key', 'message', 'owner', 'acknowledged')
|
list_display = ("key", "message", "owner", "acknowledged")
|
||||||
search_fields = ('key', 'message')
|
search_fields = ("key", "message")
|
||||||
list_filter = ('owner__username',)
|
list_filter = ("owner__username",)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminFeedToken(admin.ModelAdmin):
|
||||||
|
list_display = ("key", "user")
|
||||||
|
search_fields = ["key"]
|
||||||
|
list_filter = ("user__username",)
|
||||||
|
|
||||||
|
|
||||||
linkding_admin_site = LinkdingAdminSite()
|
linkding_admin_site = LinkdingAdminSite()
|
||||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||||
|
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
|
||||||
linkding_admin_site.register(Tag, AdminTag)
|
linkding_admin_site.register(Tag, AdminTag)
|
||||||
linkding_admin_site.register(User, AdminCustomUser)
|
linkding_admin_site.register(User, AdminCustomUser)
|
||||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||||
linkding_admin_site.register(Toast, AdminToast)
|
linkding_admin_site.register(Toast, AdminToast)
|
||||||
|
linkding_admin_site.register(FeedToken, AdminFeedToken)
|
||||||
|
@@ -1,83 +1,135 @@
|
|||||||
from django.urls import reverse
|
|
||||||
from rest_framework import viewsets, mixins, status
|
from rest_framework import viewsets, mixins, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
|
from bookmarks.api.serializers import (
|
||||||
from bookmarks.models import Bookmark, Tag
|
BookmarkSerializer,
|
||||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
TagSerializer,
|
||||||
from bookmarks.services.website_loader import load_website_metadata
|
UserProfileSerializer,
|
||||||
|
)
|
||||||
|
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
||||||
|
from bookmarks.services import auto_tagging
|
||||||
|
from bookmarks.services.bookmarks import (
|
||||||
|
archive_bookmark,
|
||||||
|
unarchive_bookmark,
|
||||||
|
website_loader,
|
||||||
|
)
|
||||||
|
from bookmarks.services.website_loader import WebsiteMetadata
|
||||||
|
|
||||||
|
|
||||||
class BookmarkViewSet(viewsets.GenericViewSet,
|
class BookmarkViewSet(
|
||||||
mixins.ListModelMixin,
|
viewsets.GenericViewSet,
|
||||||
mixins.RetrieveModelMixin,
|
mixins.ListModelMixin,
|
||||||
mixins.CreateModelMixin,
|
mixins.RetrieveModelMixin,
|
||||||
mixins.UpdateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
mixins.DestroyModelMixin):
|
mixins.UpdateModelMixin,
|
||||||
|
mixins.DestroyModelMixin,
|
||||||
|
):
|
||||||
serializer_class = BookmarkSerializer
|
serializer_class = BookmarkSerializer
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
# Allow unauthenticated access to shared bookmarks.
|
||||||
|
# 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":
|
||||||
|
return [AllowAny()]
|
||||||
|
|
||||||
|
# Otherwise use default permissions which should require authentication
|
||||||
|
return super().get_permissions()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
# For list action, use query set that applies search and tag projections
|
# For list action, use query set that applies search and tag projections
|
||||||
if self.action == 'list':
|
if self.action == "list":
|
||||||
query_string = self.request.GET.get('q')
|
search = BookmarkSearch.from_request(self.request.GET)
|
||||||
return queries.query_bookmarks(user, query_string)
|
return queries.query_bookmarks(user, user.profile, search)
|
||||||
|
|
||||||
# For single entity actions use default query set without projections
|
# For single entity actions use default query set without projections
|
||||||
return Bookmark.objects.all().filter(owner=user)
|
return Bookmark.objects.all().filter(owner=user)
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
return {'user': self.request.user}
|
return {"user": self.request.user}
|
||||||
|
|
||||||
@action(methods=['get'], detail=False)
|
@action(methods=["get"], detail=False)
|
||||||
def archived(self, request):
|
def archived(self, request):
|
||||||
user = request.user
|
user = request.user
|
||||||
query_string = request.GET.get('q')
|
search = BookmarkSearch.from_request(request.GET)
|
||||||
query_set = queries.query_archived_bookmarks(user, query_string)
|
query_set = queries.query_archived_bookmarks(user, user.profile, search)
|
||||||
page = self.paginate_queryset(query_set)
|
page = self.paginate_queryset(query_set)
|
||||||
serializer = self.get_serializer_class()
|
serializer = self.get_serializer_class()
|
||||||
data = serializer(page, many=True).data
|
data = serializer(page, many=True).data
|
||||||
return self.get_paginated_response(data)
|
return self.get_paginated_response(data)
|
||||||
|
|
||||||
@action(methods=['post'], detail=True)
|
@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=["post"], detail=True)
|
||||||
def archive(self, request, pk):
|
def archive(self, request, pk):
|
||||||
bookmark = self.get_object()
|
bookmark = self.get_object()
|
||||||
archive_bookmark(bookmark)
|
archive_bookmark(bookmark)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@action(methods=['post'], detail=True)
|
@action(methods=["post"], detail=True)
|
||||||
def unarchive(self, request, pk):
|
def unarchive(self, request, pk):
|
||||||
bookmark = self.get_object()
|
bookmark = self.get_object()
|
||||||
unarchive_bookmark(bookmark)
|
unarchive_bookmark(bookmark)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@action(methods=['get'], detail=False)
|
@action(methods=["get"], detail=False)
|
||||||
def check(self, request):
|
def check(self, request):
|
||||||
url = request.GET.get('url')
|
url = request.GET.get("url")
|
||||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||||
existing_bookmark_data = None
|
existing_bookmark_data = (
|
||||||
|
self.get_serializer(bookmark).data if bookmark else None
|
||||||
|
)
|
||||||
|
|
||||||
if bookmark is not None:
|
# Either return metadata from existing bookmark, or scrape from URL
|
||||||
existing_bookmark_data = {
|
if bookmark:
|
||||||
'id': bookmark.id,
|
metadata = WebsiteMetadata(
|
||||||
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
|
url,
|
||||||
}
|
bookmark.website_title,
|
||||||
|
bookmark.website_description,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
metadata = website_loader.load_website_metadata(url)
|
||||||
|
|
||||||
metadata = load_website_metadata(url)
|
# Return tags that would be automatically applied to the bookmark
|
||||||
|
profile = request.user.profile
|
||||||
|
auto_tags = []
|
||||||
|
if profile.auto_tagging_rules:
|
||||||
|
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
|
||||||
|
|
||||||
return Response({
|
return Response(
|
||||||
'bookmark': existing_bookmark_data,
|
{
|
||||||
'metadata': metadata.to_dict()
|
"bookmark": existing_bookmark_data,
|
||||||
}, status=status.HTTP_200_OK)
|
"metadata": metadata.to_dict(),
|
||||||
|
"auto_tags": auto_tags,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TagViewSet(viewsets.GenericViewSet,
|
class TagViewSet(
|
||||||
mixins.ListModelMixin,
|
viewsets.GenericViewSet,
|
||||||
mixins.RetrieveModelMixin,
|
mixins.ListModelMixin,
|
||||||
mixins.CreateModelMixin):
|
mixins.RetrieveModelMixin,
|
||||||
|
mixins.CreateModelMixin,
|
||||||
|
):
|
||||||
serializer_class = TagSerializer
|
serializer_class = TagSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@@ -85,9 +137,16 @@ class TagViewSet(viewsets.GenericViewSet,
|
|||||||
return Tag.objects.all().filter(owner=user)
|
return Tag.objects.all().filter(owner=user)
|
||||||
|
|
||||||
def get_serializer_context(self):
|
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):
|
||||||
|
return Response(UserProfileSerializer(request.user.profile).data)
|
||||||
|
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'bookmarks', BookmarkViewSet, basename='bookmark')
|
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
|
||||||
router.register(r'tags', TagViewSet, basename='tag')
|
router.register(r"tags", TagViewSet, basename="tag")
|
||||||
|
router.register(r"user", UserViewSet, basename="user")
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
|
from django.db.models import prefetch_related_objects
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.serializers import ListSerializer
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, Tag, build_tag_string
|
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile
|
||||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||||
from bookmarks.services.tags import get_or_create_tag
|
from bookmarks.services.tags import get_or_create_tag
|
||||||
|
|
||||||
@@ -9,54 +11,101 @@ class TagListField(serializers.ListField):
|
|||||||
child = serializers.CharField()
|
child = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkListSerializer(ListSerializer):
|
||||||
|
def to_representation(self, data):
|
||||||
|
# Prefetch nested relations to avoid n+1 queries
|
||||||
|
prefetch_related_objects(data, "tags")
|
||||||
|
|
||||||
|
return super().to_representation(data)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkSerializer(serializers.ModelSerializer):
|
class BookmarkSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Bookmark
|
model = Bookmark
|
||||||
fields = [
|
fields = [
|
||||||
'id',
|
"id",
|
||||||
'url',
|
"url",
|
||||||
'title',
|
"title",
|
||||||
'description',
|
"description",
|
||||||
'website_title',
|
"notes",
|
||||||
'website_description',
|
"website_title",
|
||||||
'tag_names',
|
"website_description",
|
||||||
'date_added',
|
"web_archive_snapshot_url",
|
||||||
'date_modified'
|
"is_archived",
|
||||||
|
"unread",
|
||||||
|
"shared",
|
||||||
|
"tag_names",
|
||||||
|
"date_added",
|
||||||
|
"date_modified",
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'website_title',
|
"website_title",
|
||||||
'website_description',
|
"website_description",
|
||||||
'date_added',
|
"web_archive_snapshot_url",
|
||||||
'date_modified'
|
"date_added",
|
||||||
|
"date_modified",
|
||||||
]
|
]
|
||||||
|
list_serializer_class = BookmarkListSerializer
|
||||||
|
|
||||||
# Override optional char fields to provide default value
|
# Override optional char fields to provide default value
|
||||||
title = serializers.CharField(required=False, allow_blank=True, default='')
|
title = serializers.CharField(required=False, allow_blank=True, default="")
|
||||||
description = 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
|
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||||
tag_names = TagListField(required=False, default=[])
|
tag_names = TagListField(required=False, default=[])
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
bookmark = Bookmark()
|
bookmark = Bookmark()
|
||||||
bookmark.url = validated_data['url']
|
bookmark.url = validated_data["url"]
|
||||||
bookmark.title = validated_data['title']
|
bookmark.title = validated_data["title"]
|
||||||
bookmark.description = validated_data['description']
|
bookmark.description = validated_data["description"]
|
||||||
tag_string = build_tag_string(validated_data['tag_names'])
|
bookmark.notes = validated_data["notes"]
|
||||||
return create_bookmark(bookmark, tag_string, self.context['user'])
|
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"])
|
||||||
|
|
||||||
def update(self, instance: Bookmark, validated_data):
|
def update(self, instance: Bookmark, validated_data):
|
||||||
instance.url = validated_data['url']
|
# Update fields if they were provided in the payload
|
||||||
instance.title = validated_data['title']
|
for key in ["url", "title", "description", "notes", "unread", "shared"]:
|
||||||
instance.description = validated_data['description']
|
if key in validated_data:
|
||||||
tag_string = build_tag_string(validated_data['tag_names'])
|
setattr(instance, key, validated_data[key])
|
||||||
return update_bookmark(instance, tag_string, self.context['user'])
|
|
||||||
|
# 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"])
|
||||||
|
|
||||||
|
return update_bookmark(instance, tag_string, self.context["user"])
|
||||||
|
|
||||||
|
|
||||||
class TagSerializer(serializers.ModelSerializer):
|
class TagSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
fields = ['id', 'name', 'date_added']
|
fields = ["id", "name", "date_added"]
|
||||||
read_only_fields = ['date_added']
|
read_only_fields = ["date_added"]
|
||||||
|
|
||||||
def create(self, validated_data):
|
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):
|
||||||
|
class Meta:
|
||||||
|
model = UserProfile
|
||||||
|
fields = [
|
||||||
|
"theme",
|
||||||
|
"bookmark_date_display",
|
||||||
|
"bookmark_link_target",
|
||||||
|
"web_archive_integration",
|
||||||
|
"tag_search",
|
||||||
|
"enable_sharing",
|
||||||
|
"enable_public_sharing",
|
||||||
|
"enable_favicons",
|
||||||
|
"display_url",
|
||||||
|
"permanent_notes",
|
||||||
|
"search_preferences",
|
||||||
|
]
|
||||||
|
@@ -2,7 +2,7 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
|
|
||||||
class BookmarksConfig(AppConfig):
|
class BookmarksConfig(AppConfig):
|
||||||
name = 'bookmarks'
|
name = "bookmarks"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
# Register signal handlers
|
# Register signal handlers
|
||||||
|
@@ -1,267 +0,0 @@
|
|||||||
<script>
|
|
||||||
import {SearchHistory} from "./SearchHistory";
|
|
||||||
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "./util";
|
|
||||||
|
|
||||||
const searchHistory = new SearchHistory()
|
|
||||||
|
|
||||||
export let name;
|
|
||||||
export let placeholder;
|
|
||||||
export let value;
|
|
||||||
export let tags;
|
|
||||||
export let mode = 'default';
|
|
||||||
export let apiClient;
|
|
||||||
|
|
||||||
let isFocus = false;
|
|
||||||
let isOpen = false;
|
|
||||||
let suggestions = []
|
|
||||||
let selectedIndex = undefined;
|
|
||||||
let input = null;
|
|
||||||
|
|
||||||
// Track current search query after loading the page
|
|
||||||
searchHistory.pushCurrent()
|
|
||||||
updateSuggestions()
|
|
||||||
|
|
||||||
function handleFocus() {
|
|
||||||
isFocus = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
isFocus = false;
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(e) {
|
|
||||||
value = e.target.value
|
|
||||||
debouncedLoadSuggestions()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e) {
|
|
||||||
// Enter
|
|
||||||
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
|
|
||||||
const suggestion = suggestions.total[selectedIndex];
|
|
||||||
if (suggestion) completeSuggestion(suggestion);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
// Escape
|
|
||||||
if (e.keyCode === 27) {
|
|
||||||
close();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
// Up arrow
|
|
||||||
if (e.keyCode === 38) {
|
|
||||||
updateSelection(-1);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
// Down arrow
|
|
||||||
if (e.keyCode === 40) {
|
|
||||||
if (!isOpen) {
|
|
||||||
loadSuggestions()
|
|
||||||
} else {
|
|
||||||
updateSelection(1);
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
isOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
isOpen = false;
|
|
||||||
updateSuggestions()
|
|
||||||
selectedIndex = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSuggestions() {
|
|
||||||
return suggestions.total.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSuggestions() {
|
|
||||||
|
|
||||||
let suggestionIndex = 0
|
|
||||||
|
|
||||||
function nextIndex() {
|
|
||||||
return suggestionIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tag suggestions
|
|
||||||
let tagSuggestions = []
|
|
||||||
const currentWord = getCurrentWord(input)
|
|
||||||
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
|
||||||
const searchTag = currentWord.substring(1, currentWord.length)
|
|
||||||
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(tagName => ({
|
|
||||||
type: 'tag',
|
|
||||||
index: nextIndex(),
|
|
||||||
label: `#${tagName}`,
|
|
||||||
tagName: tagName
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recent search suggestions
|
|
||||||
const search = searchHistory.getRecentSearches(value, 5).map(value => ({
|
|
||||||
type: 'search',
|
|
||||||
index: nextIndex(),
|
|
||||||
label: value,
|
|
||||||
value
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Bookmark suggestions
|
|
||||||
let bookmarks = []
|
|
||||||
|
|
||||||
if (value && value.length >= 3) {
|
|
||||||
const fetchedBookmarks = mode === 'archive'
|
|
||||||
? await apiClient.getArchivedBookmarks(value, {limit: 5, offset: 0})
|
|
||||||
: await apiClient.getBookmarks(value, {limit: 5, offset: 0})
|
|
||||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
|
||||||
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
|
||||||
const label = clampText(fullLabel, 60)
|
|
||||||
return {
|
|
||||||
type: 'bookmark',
|
|
||||||
index: nextIndex(),
|
|
||||||
label,
|
|
||||||
bookmark
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSuggestions(search, bookmarks, tagSuggestions)
|
|
||||||
|
|
||||||
if (hasSuggestions()) {
|
|
||||||
open()
|
|
||||||
} else {
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const debouncedLoadSuggestions = debounce(loadSuggestions)
|
|
||||||
|
|
||||||
function updateSuggestions(search, bookmarks, tagSuggestions) {
|
|
||||||
search = search || []
|
|
||||||
bookmarks = bookmarks || []
|
|
||||||
tagSuggestions = tagSuggestions || []
|
|
||||||
suggestions = {
|
|
||||||
search,
|
|
||||||
bookmarks,
|
|
||||||
tags: tagSuggestions,
|
|
||||||
total: [
|
|
||||||
...tagSuggestions,
|
|
||||||
...search,
|
|
||||||
...bookmarks,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function completeSuggestion(suggestion) {
|
|
||||||
if (suggestion.type === 'search') {
|
|
||||||
value = suggestion.value
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
if (suggestion.type === 'bookmark') {
|
|
||||||
window.open(suggestion.bookmark.url, '_blank')
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
if (suggestion.type === 'tag') {
|
|
||||||
const bounds = getCurrentWordBounds(input);
|
|
||||||
const inputValue = input.value;
|
|
||||||
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelection(dir) {
|
|
||||||
|
|
||||||
const length = suggestions.total.length;
|
|
||||||
|
|
||||||
if (length === 0) return
|
|
||||||
|
|
||||||
if (selectedIndex === undefined) {
|
|
||||||
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let newIndex = selectedIndex + dir;
|
|
||||||
|
|
||||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
|
||||||
if (newIndex >= length) newIndex = 0;
|
|
||||||
|
|
||||||
selectedIndex = newIndex;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="form-autocomplete">
|
|
||||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
|
||||||
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
|
|
||||||
bind:this={input}
|
|
||||||
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="menu" class:open={isOpen}>
|
|
||||||
{#if suggestions.tags.length > 0}
|
|
||||||
<li class="menu-item group-item">Tags</li>
|
|
||||||
{/if}
|
|
||||||
{#each suggestions.tags as suggestion}
|
|
||||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
|
||||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
|
||||||
<div class="tile tile-centered">
|
|
||||||
<div class="tile-content">
|
|
||||||
{suggestion.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if suggestions.search.length > 0}
|
|
||||||
<li class="menu-item group-item">Recent Searches</li>
|
|
||||||
{/if}
|
|
||||||
{#each suggestions.search as suggestion}
|
|
||||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
|
||||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
|
||||||
<div class="tile tile-centered">
|
|
||||||
<div class="tile-content">
|
|
||||||
{suggestion.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if suggestions.bookmarks.length > 0}
|
|
||||||
<li class="menu-item group-item">Bookmarks</li>
|
|
||||||
{/if}
|
|
||||||
{#each suggestions.bookmarks as suggestion}
|
|
||||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
|
||||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
|
||||||
<div class="tile tile-centered">
|
|
||||||
<div class="tile-content">
|
|
||||||
{suggestion.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.menu {
|
|
||||||
display: none;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu.open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-autocomplete-input {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.form-autocomplete-input.is-focused {
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
@@ -1,48 +0,0 @@
|
|||||||
const SEARCH_HISTORY_KEY = 'searchHistory'
|
|
||||||
const MAX_ENTRIES = 30
|
|
||||||
|
|
||||||
export class SearchHistory {
|
|
||||||
|
|
||||||
getHistory() {
|
|
||||||
const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY)
|
|
||||||
return historyJson ? JSON.parse(historyJson) : {
|
|
||||||
recent: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pushCurrent() {
|
|
||||||
// Skip if browser is not compatible
|
|
||||||
if (!window.URLSearchParams) return
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const searchParam = urlParams.get('q');
|
|
||||||
|
|
||||||
if (!searchParam) return
|
|
||||||
|
|
||||||
this.push(searchParam)
|
|
||||||
}
|
|
||||||
|
|
||||||
push(search) {
|
|
||||||
const history = this.getHistory()
|
|
||||||
|
|
||||||
history.recent.unshift(search)
|
|
||||||
|
|
||||||
// Remove duplicates and clamp to max entries
|
|
||||||
history.recent = history.recent.reduce((acc, cur) => {
|
|
||||||
if (acc.length >= MAX_ENTRIES) return acc
|
|
||||||
if (acc.indexOf(cur) >= 0) return acc
|
|
||||||
acc.push(cur)
|
|
||||||
return acc
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const newHistoryJson = JSON.stringify(history)
|
|
||||||
localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
getRecentSearches(query, max) {
|
|
||||||
const history = this.getHistory()
|
|
||||||
|
|
||||||
return history.recent
|
|
||||||
.filter(search => !query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0)
|
|
||||||
.slice(0, max)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,168 +0,0 @@
|
|||||||
<script>
|
|
||||||
import {getCurrentWord, getCurrentWordBounds} from "./util";
|
|
||||||
|
|
||||||
export let id;
|
|
||||||
export let name;
|
|
||||||
export let value;
|
|
||||||
export let apiClient;
|
|
||||||
export let variant = 'default';
|
|
||||||
|
|
||||||
let tags = [];
|
|
||||||
let isFocus = false;
|
|
||||||
let isOpen = false;
|
|
||||||
let input = null;
|
|
||||||
let suggestionList = null;
|
|
||||||
|
|
||||||
let suggestions = [];
|
|
||||||
let selectedIndex = 0;
|
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
// For now we cache all tags on load as the template did before
|
|
||||||
try {
|
|
||||||
tags = await apiClient.getTags({limit: 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
isFocus = false;
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(e) {
|
|
||||||
input = e.target;
|
|
||||||
|
|
||||||
const word = getCurrentWord(input);
|
|
||||||
|
|
||||||
suggestions = word
|
|
||||||
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (word && suggestions.length > 0) {
|
|
||||||
open();
|
|
||||||
} else {
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e) {
|
|
||||||
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
|
|
||||||
const suggestion = suggestions[selectedIndex];
|
|
||||||
complete(suggestion);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
if (e.keyCode === 27) {
|
|
||||||
close();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
if (e.keyCode === 38) {
|
|
||||||
updateSelection(-1);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
if (e.keyCode === 40) {
|
|
||||||
updateSelection(1);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
isOpen = true;
|
|
||||||
selectedIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
isOpen = false;
|
|
||||||
suggestions = [];
|
|
||||||
selectedIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function complete(suggestion) {
|
|
||||||
const bounds = getCurrentWordBounds(input);
|
|
||||||
const value = input.value;
|
|
||||||
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
|
||||||
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelection(dir) {
|
|
||||||
|
|
||||||
const length = suggestions.length;
|
|
||||||
let newIndex = selectedIndex + dir;
|
|
||||||
|
|
||||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
|
||||||
if (newIndex >= length) newIndex = 0;
|
|
||||||
|
|
||||||
selectedIndex = newIndex;
|
|
||||||
|
|
||||||
// Scroll to selected list item
|
|
||||||
setTimeout(() => {
|
|
||||||
if (suggestionList) {
|
|
||||||
const selectedListItem = suggestionList.querySelector('li.selected');
|
|
||||||
if (selectedListItem) {
|
|
||||||
selectedListItem.scrollIntoView({block: 'center'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="form-autocomplete" class:small={variant === 'small'}>
|
|
||||||
<!-- autocomplete input container -->
|
|
||||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
|
||||||
<!-- autocomplete real input box -->
|
|
||||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder=" "
|
|
||||||
class="form-input" type="text" autocomplete="off"
|
|
||||||
on:input={handleInput} on:keydown={handleKeyDown}
|
|
||||||
on:focus={handleFocus} on:blur={handleBlur}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- autocomplete suggestion list -->
|
|
||||||
<ul class="menu" class:open={isOpen && suggestions.length > 0}
|
|
||||||
bind:this={suggestionList}>
|
|
||||||
<!-- menu list items -->
|
|
||||||
{#each suggestions as tag,i}
|
|
||||||
<li class="menu-item" class:selected={selectedIndex === i}>
|
|
||||||
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
|
||||||
<div class="tile tile-centered">
|
|
||||||
<div class="tile-content">
|
|
||||||
{tag.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.menu {
|
|
||||||
display: none;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu.open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-autocomplete.small .form-autocomplete-input {
|
|
||||||
height: 1.4rem;
|
|
||||||
min-height: 1.4rem;
|
|
||||||
}
|
|
||||||
.form-autocomplete.small .form-autocomplete-input input {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
.form-autocomplete.small .menu .menu-item {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
@@ -1,31 +0,0 @@
|
|||||||
export class ApiClient {
|
|
||||||
constructor(baseUrl) {
|
|
||||||
this.baseUrl = baseUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
getBookmarks(query, options = {limit: 100, offset: 0}) {
|
|
||||||
const encodedQuery = encodeURIComponent(query)
|
|
||||||
const url = `${this.baseUrl}bookmarks/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
|
||||||
|
|
||||||
return fetch(url)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => data.results)
|
|
||||||
}
|
|
||||||
|
|
||||||
getArchivedBookmarks(query, options = {limit: 100, offset: 0}) {
|
|
||||||
const encodedQuery = encodeURIComponent(query)
|
|
||||||
const url = `${this.baseUrl}bookmarks/archived/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
|
||||||
|
|
||||||
return fetch(url)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => data.results)
|
|
||||||
}
|
|
||||||
|
|
||||||
getTags(options = {limit: 100, offset: 0}) {
|
|
||||||
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`
|
|
||||||
|
|
||||||
return fetch(url)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => data.results)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,10 +0,0 @@
|
|||||||
import TagAutoComplete from './TagAutocomplete.svelte'
|
|
||||||
import SearchAutoComplete from './SearchAutoComplete.svelte'
|
|
||||||
import {ApiClient} from './api'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
ApiClient,
|
|
||||||
TagAutoComplete,
|
|
||||||
SearchAutoComplete
|
|
||||||
}
|
|
||||||
|
|
@@ -1,37 +0,0 @@
|
|||||||
export function debounce(callback, delay = 250) {
|
|
||||||
let timeoutId
|
|
||||||
return (...args) => {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
timeoutId = null
|
|
||||||
callback(...args)
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampText(text, maxChars = 30) {
|
|
||||||
if(!text || text.length <= 30) return text
|
|
||||||
|
|
||||||
return text.substr(0, maxChars) + '...'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCurrentWordBounds(input) {
|
|
||||||
const text = input.value;
|
|
||||||
const end = input.selectionStart;
|
|
||||||
let start = end;
|
|
||||||
|
|
||||||
let currentChar = text.charAt(start - 1);
|
|
||||||
|
|
||||||
while (currentChar && currentChar !== ' ' && start > 0) {
|
|
||||||
start--;
|
|
||||||
currentChar = text.charAt(start - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {start, end};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCurrentWord(input) {
|
|
||||||
const bounds = getCurrentWordBounds(input);
|
|
||||||
|
|
||||||
return input.value.substring(bounds.start, bounds.end);
|
|
||||||
}
|
|
@@ -1,12 +1,36 @@
|
|||||||
from bookmarks.models import Toast
|
from bookmarks import queries
|
||||||
|
from bookmarks.models import BookmarkSearch, Toast
|
||||||
|
from bookmarks import utils
|
||||||
|
|
||||||
|
|
||||||
def toasts(request):
|
def toasts(request):
|
||||||
user = request.user if hasattr(request, 'user') else None
|
user = request.user
|
||||||
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user and user.is_authenticated else []
|
toast_messages = (
|
||||||
|
Toast.objects.filter(owner=user, acknowledged=False)
|
||||||
|
if user.is_authenticated
|
||||||
|
else []
|
||||||
|
)
|
||||||
has_toasts = len(toast_messages) > 0
|
has_toasts = len(toast_messages) > 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'has_toasts': has_toasts,
|
"has_toasts": has_toasts,
|
||||||
'toast_messages': toast_messages,
|
"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}
|
||||||
|
0
bookmarks/e2e/__init__.py
Normal file
0
bookmarks/e2e/__init__.py
Normal file
176
bookmarks/e2e/e2e_test_bookmark_details_modal.py
Normal file
176
bookmarks/e2e/e2e_test_bookmark_details_modal.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
from bookmarks.models import Bookmark
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_show_details(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
title = details_modal.locator("h2")
|
||||||
|
expect(title).to_have_text(bookmark.title)
|
||||||
|
|
||||||
|
def test_close_details(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
# close with close button
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
details_modal.locator("button.close").click()
|
||||||
|
expect(details_modal).to_be_hidden()
|
||||||
|
|
||||||
|
# close with backdrop
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
overlay = details_modal.locator(".modal-overlay")
|
||||||
|
overlay.click(position={"x": 0, "y": 0})
|
||||||
|
expect(details_modal).to_be_hidden()
|
||||||
|
|
||||||
|
# close with escape
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
self.page.keyboard.press("Escape")
|
||||||
|
expect(details_modal).to_be_hidden()
|
||||||
|
|
||||||
|
def test_toggle_archived(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# archive
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
details_modal.get_by_text("Archived", exact=False).click()
|
||||||
|
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
# unarchive
|
||||||
|
url = reverse("bookmarks:archived")
|
||||||
|
self.page.goto(self.live_server_url + url)
|
||||||
|
self.resetReloads()
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
details_modal.get_by_text("Archived", exact=False).click()
|
||||||
|
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_toggle_unread(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# mark as unread
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
|
||||||
|
details_modal.get_by_text("Unread").click()
|
||||||
|
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||||
|
expect(bookmark_item.get_by_text("Unread")).to_be_visible()
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
# mark as read
|
||||||
|
details_modal.get_by_text("Unread").click()
|
||||||
|
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||||
|
expect(bookmark_item.get_by_text("Unread")).not_to_be_visible()
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_toggle_shared(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# share bookmark
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
|
||||||
|
details_modal.get_by_text("Shared").click()
|
||||||
|
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||||
|
expect(bookmark_item.get_by_text("Shared")).to_be_visible()
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
# unshare bookmark
|
||||||
|
details_modal.get_by_text("Shared").click()
|
||||||
|
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||||
|
expect(bookmark_item.get_by_text("Shared")).not_to_be_visible()
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_edit_return_url(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
|
||||||
|
# Navigate to edit page
|
||||||
|
with self.page.expect_navigation():
|
||||||
|
details_modal.get_by_text("Edit").click()
|
||||||
|
|
||||||
|
# Cancel edit, verify return url
|
||||||
|
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||||
|
self.page.get_by_text("Nevermind").click()
|
||||||
|
|
||||||
|
def test_delete(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
|
||||||
|
# Delete bookmark, verify return url
|
||||||
|
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||||
|
details_modal.get_by_text("Delete...").click()
|
||||||
|
details_modal.get_by_text("Confirm").click()
|
||||||
|
|
||||||
|
# verify bookmark is deleted
|
||||||
|
self.locate_bookmark(bookmark.title)
|
||||||
|
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||||
|
|
||||||
|
self.assertEqual(Bookmark.objects.count(), 0)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_create_snapshot_remove_snapshot(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
asset_list = details_modal.locator(".assets")
|
||||||
|
|
||||||
|
# No snapshots initially
|
||||||
|
snapshot = asset_list.get_by_text("HTML snapshot from", exact=False)
|
||||||
|
expect(snapshot).not_to_be_visible()
|
||||||
|
|
||||||
|
# Create snapshot
|
||||||
|
details_modal.get_by_text("Create HTML snapshot", exact=False).click()
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
# Has new snapshots
|
||||||
|
expect(snapshot).to_be_visible()
|
||||||
|
|
||||||
|
# Create snapshot
|
||||||
|
asset_list.get_by_text("Remove", exact=False).click()
|
||||||
|
asset_list.get_by_text("Confirm", exact=False).click()
|
||||||
|
|
||||||
|
# Snapshot is removed
|
||||||
|
expect(snapshot).not_to_be_visible()
|
||||||
|
self.assertReloads(0)
|
37
bookmarks/e2e/e2e_test_bookmark_details_view.py
Normal file
37
bookmarks/e2e/e2e_test_bookmark_details_view.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_edit_return_url(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
||||||
|
|
||||||
|
# Navigate to edit page
|
||||||
|
with self.page.expect_navigation():
|
||||||
|
self.page.get_by_text("Edit").click()
|
||||||
|
|
||||||
|
# Cancel edit, verify return url
|
||||||
|
with self.page.expect_navigation(
|
||||||
|
url=self.live_server_url
|
||||||
|
+ reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
):
|
||||||
|
self.page.get_by_text("Nevermind").click()
|
||||||
|
|
||||||
|
def test_delete_return_url(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
||||||
|
|
||||||
|
# Trigger delete, verify return url
|
||||||
|
# Should probably return to last bookmark list page, but for now just returns to index
|
||||||
|
with self.page.expect_navigation(
|
||||||
|
url=self.live_server_url + reverse("bookmarks:index")
|
||||||
|
):
|
||||||
|
self.page.get_by_text("Delete...").click()
|
||||||
|
self.page.get_by_text("Confirm").click()
|
109
bookmarks/e2e/e2e_test_bookmark_form.py
Normal file
109
bookmarks/e2e/e2e_test_bookmark_form.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_create_should_check_for_existing_bookmark(self):
|
||||||
|
existing_bookmark = self.setup_bookmark(
|
||||||
|
title="Existing title",
|
||||||
|
description="Existing description",
|
||||||
|
notes="Existing notes",
|
||||||
|
tags=[self.setup_tag(name="tag1"), self.setup_tag(name="tag2")],
|
||||||
|
website_title="Existing website title",
|
||||||
|
website_description="Existing website description",
|
||||||
|
unread=True,
|
||||||
|
)
|
||||||
|
tag_names = " ".join(existing_bookmark.tag_names)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse("bookmarks:new"))
|
||||||
|
|
||||||
|
# Enter bookmarked URL
|
||||||
|
page.get_by_label("URL").fill(existing_bookmark.url)
|
||||||
|
# Already bookmarked hint should be visible
|
||||||
|
page.get_by_text("This URL is already bookmarked.").wait_for(timeout=2000)
|
||||||
|
# Form should be pre-filled with data from existing bookmark
|
||||||
|
self.assertEqual(
|
||||||
|
existing_bookmark.title, page.get_by_label("Title").input_value()
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
existing_bookmark.description,
|
||||||
|
page.get_by_label("Description").input_value(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
existing_bookmark.notes, page.get_by_label("Notes").input_value()
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
existing_bookmark.website_title,
|
||||||
|
page.get_by_label("Title").get_attribute("placeholder"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
existing_bookmark.website_description,
|
||||||
|
page.get_by_label("Description").get_attribute("placeholder"),
|
||||||
|
)
|
||||||
|
self.assertEqual(tag_names, page.get_by_label("Tags").input_value())
|
||||||
|
self.assertTrue(tag_names, page.get_by_label("Mark as unread").is_checked())
|
||||||
|
|
||||||
|
# Enter non-bookmarked URL
|
||||||
|
page.get_by_label("URL").fill("https://example.com/unknown")
|
||||||
|
# Already bookmarked hint should be hidden
|
||||||
|
page.get_by_text("This URL is already bookmarked.").wait_for(
|
||||||
|
state="hidden", timeout=2000
|
||||||
|
)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_edit_should_not_check_for_existing_bookmark(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(
|
||||||
|
self.live_server_url + reverse("bookmarks:edit", args=[bookmark.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
page.wait_for_timeout(timeout=1000)
|
||||||
|
page.get_by_text("This URL is already bookmarked.").wait_for(state="hidden")
|
||||||
|
|
||||||
|
def test_enter_url_of_existing_bookmark_should_show_notes(self):
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
notes="Existing notes", description="Existing description"
|
||||||
|
)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse("bookmarks:new"))
|
||||||
|
|
||||||
|
details = page.locator("details.notes")
|
||||||
|
expect(details).not_to_have_attribute("open", value="")
|
||||||
|
|
||||||
|
page.get_by_label("URL").fill(bookmark.url)
|
||||||
|
expect(details).to_have_attribute("open", value="")
|
||||||
|
|
||||||
|
def test_create_should_preview_auto_tags(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.auto_tagging_rules = "github.com dev github"
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# Open page with URL that should have auto tags
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
url = self.live_server_url + reverse("bookmarks:new")
|
||||||
|
url += f"?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
|
||||||
|
page.goto(url)
|
||||||
|
|
||||||
|
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
|
||||||
|
expect(auto_tags_hint).to_be_visible()
|
||||||
|
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
|
||||||
|
|
||||||
|
# Change to URL without auto tags
|
||||||
|
page.get_by_label("URL").fill("https://example.com")
|
||||||
|
|
||||||
|
expect(auto_tags_hint).to_be_hidden()
|
25
bookmarks/e2e/e2e_test_bookmark_item.py
Normal file
25
bookmarks/e2e/e2e_test_bookmark_item.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from unittest import skip
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
@skip("Fails in CI, needs investigation")
|
||||||
|
def test_toggle_notes_should_show_hide_notes(self):
|
||||||
|
bookmark = self.setup_bookmark(notes="Test notes")
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
page = self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
notes = self.locate_bookmark(bookmark.title).locator(".notes")
|
||||||
|
expect(notes).to_be_hidden()
|
||||||
|
|
||||||
|
toggle_notes = page.locator("li button.toggle-notes")
|
||||||
|
toggle_notes.click()
|
||||||
|
expect(notes).to_be_visible()
|
||||||
|
|
||||||
|
toggle_notes.click()
|
||||||
|
expect(notes).to_be_hidden()
|
335
bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
Normal file
335
bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
from bookmarks.models import Bookmark
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def setup_test_data(self):
|
||||||
|
self.setup_numbered_bookmarks(50)
|
||||||
|
self.setup_numbered_bookmarks(50, archived=True)
|
||||||
|
self.setup_numbered_bookmarks(50, prefix="foo")
|
||||||
|
self.setup_numbered_bookmarks(50, archived=True, prefix="foo")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(
|
||||||
|
is_archived=False, title__startswith="Bookmark"
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(
|
||||||
|
is_archived=True, title__startswith="Archived Bookmark"
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_active_bookmarks_bulk_select_across(self):
|
||||||
|
self.setup_test_data()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
self.locate_bulk_edit_select_across().click()
|
||||||
|
|
||||||
|
self.select_bulk_action("Delete")
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||||
|
expect(bookmark_list).not_to_be_visible()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
Bookmark.objects.filter(
|
||||||
|
is_archived=False, title__startswith="Bookmark"
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(
|
||||||
|
is_archived=True, title__startswith="Archived Bookmark"
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_archived_bookmarks_bulk_select_across(self):
|
||||||
|
self.setup_test_data()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:archived"), p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
self.locate_bulk_edit_select_across().click()
|
||||||
|
|
||||||
|
self.select_bulk_action("Delete")
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||||
|
expect(bookmark_list).not_to_be_visible()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(
|
||||||
|
is_archived=False, title__startswith="Bookmark"
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
Bookmark.objects.filter(
|
||||||
|
is_archived=True, title__startswith="Archived Bookmark"
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_active_bookmarks_bulk_select_across_respects_query(self):
|
||||||
|
self.setup_test_data()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index") + "?q=foo", p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
self.locate_bulk_edit_select_across().click()
|
||||||
|
|
||||||
|
self.select_bulk_action("Delete")
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||||
|
expect(bookmark_list).not_to_be_visible()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(
|
||||||
|
is_archived=False, title__startswith="Bookmark"
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(
|
||||||
|
is_archived=True, title__startswith="Archived Bookmark"
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_archived_bookmarks_bulk_select_across_respects_query(self):
|
||||||
|
self.setup_test_data()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:archived") + "?q=foo", p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
self.locate_bulk_edit_select_across().click()
|
||||||
|
|
||||||
|
self.select_bulk_action("Delete")
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||||
|
expect(bookmark_list).not_to_be_visible()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(
|
||||||
|
is_archived=False, title__startswith="Bookmark"
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(
|
||||||
|
is_archived=True, title__startswith="Archived Bookmark"
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
50,
|
||||||
|
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_select_all_toggles_all_checkboxes(self):
|
||||||
|
self.setup_numbered_bookmarks(5)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
page = self.open(url, p)
|
||||||
|
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
|
||||||
|
checkboxes = page.locator("label.bulk-edit-checkbox input")
|
||||||
|
self.assertEqual(6, checkboxes.count())
|
||||||
|
for i in range(checkboxes.count()):
|
||||||
|
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||||
|
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
|
||||||
|
for i in range(checkboxes.count()):
|
||||||
|
expect(checkboxes.nth(i)).to_be_checked()
|
||||||
|
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
|
||||||
|
for i in range(checkboxes.count()):
|
||||||
|
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||||
|
|
||||||
|
def test_select_all_shows_select_across(self):
|
||||||
|
self.setup_numbered_bookmarks(5)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
|
||||||
|
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||||
|
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
expect(self.locate_bulk_edit_select_across()).to_be_visible()
|
||||||
|
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||||
|
|
||||||
|
def test_select_across_is_unchecked_when_toggling_all(self):
|
||||||
|
self.setup_numbered_bookmarks(5)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
|
||||||
|
# Show select across, check it
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
self.locate_bulk_edit_select_across().click()
|
||||||
|
expect(self.locate_bulk_edit_select_across()).to_be_checked()
|
||||||
|
|
||||||
|
# Hide select across by toggling select all
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||||
|
|
||||||
|
# Show select across again, verify it is unchecked
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||||
|
|
||||||
|
def test_select_across_is_unchecked_when_toggling_bookmark(self):
|
||||||
|
self.setup_numbered_bookmarks(5)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
|
||||||
|
# Show select across, check it
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
self.locate_bulk_edit_select_across().click()
|
||||||
|
expect(self.locate_bulk_edit_select_across()).to_be_checked()
|
||||||
|
|
||||||
|
# Hide select across by toggling a single bookmark
|
||||||
|
self.locate_bookmark("Bookmark 1").locator(
|
||||||
|
"label.bulk-edit-checkbox"
|
||||||
|
).click()
|
||||||
|
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||||
|
|
||||||
|
# Show select across again, verify it is unchecked
|
||||||
|
self.locate_bookmark("Bookmark 1").locator(
|
||||||
|
"label.bulk-edit-checkbox"
|
||||||
|
).click()
|
||||||
|
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||||
|
|
||||||
|
def test_execute_resets_all_checkboxes(self):
|
||||||
|
self.setup_numbered_bookmarks(100)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
page = self.open(url, p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
|
|
||||||
|
# Select all bookmarks, enable select across
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
self.locate_bulk_edit_select_across().click()
|
||||||
|
|
||||||
|
# Execute bulk action
|
||||||
|
self.select_bulk_action("Mark as unread")
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
|
||||||
|
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||||
|
expect(bookmark_list).not_to_be_visible()
|
||||||
|
|
||||||
|
# Verify bulk edit checkboxes are reset
|
||||||
|
checkboxes = page.locator("label.bulk-edit-checkbox input")
|
||||||
|
self.assertEqual(31, checkboxes.count())
|
||||||
|
for i in range(checkboxes.count()):
|
||||||
|
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||||
|
|
||||||
|
# Toggle select all and verify select across is reset
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||||
|
|
||||||
|
def test_update_select_across_bookmark_count(self):
|
||||||
|
self.setup_numbered_bookmarks(100)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("All pages (100 bookmarks)")
|
||||||
|
).to_be_visible()
|
||||||
|
|
||||||
|
self.select_bulk_action("Delete")
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||||
|
expect(bookmark_list).not_to_be_visible()
|
||||||
|
|
||||||
|
expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
|
||||||
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("All pages (70 bookmarks)")
|
||||||
|
).to_be_visible()
|
308
bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py
Normal file
308
bookmarks/e2e/e2e_test_bookmark_page_partial_updates.py
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def setup_fixture(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
# create a number of bookmarks with different states / visibility to
|
||||||
|
# verify correct data is loaded on update
|
||||||
|
self.setup_numbered_bookmarks(3, with_tags=True)
|
||||||
|
self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
|
||||||
|
self.setup_numbered_bookmarks(
|
||||||
|
3,
|
||||||
|
shared=True,
|
||||||
|
prefix="Joe's Bookmark",
|
||||||
|
user=self.setup_user(enable_sharing=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def assertVisibleBookmarks(self, titles: List[str]):
|
||||||
|
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||||
|
expect(bookmark_tags).to_have_count(len(titles))
|
||||||
|
|
||||||
|
for title in titles:
|
||||||
|
matching_tag = bookmark_tags.filter(has_text=title)
|
||||||
|
expect(matching_tag).to_be_visible()
|
||||||
|
|
||||||
|
def assertVisibleTags(self, titles: List[str]):
|
||||||
|
tag_tags = self.page.locator(".tag-cloud .unselected-tags a")
|
||||||
|
expect(tag_tags).to_have_count(len(titles))
|
||||||
|
|
||||||
|
for title in titles:
|
||||||
|
matching_tag = tag_tags.filter(has_text=title)
|
||||||
|
expect(matching_tag).to_be_visible()
|
||||||
|
|
||||||
|
def test_partial_update_respects_query(self):
|
||||||
|
self.setup_numbered_bookmarks(5, prefix="foo")
|
||||||
|
self.setup_numbered_bookmarks(5, prefix="bar")
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index") + "?q=foo"
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(["foo 1", "foo 2", "foo 3", "foo 4", "foo 5"])
|
||||||
|
|
||||||
|
self.locate_bookmark("foo 2").get_by_text("Archive").click()
|
||||||
|
self.assertVisibleBookmarks(["foo 1", "foo 3", "foo 4", "foo 5"])
|
||||||
|
|
||||||
|
def test_partial_update_respects_sort(self):
|
||||||
|
self.setup_numbered_bookmarks(5, prefix="foo")
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index") + "?sort=title_asc"
|
||||||
|
page = self.open(url, p)
|
||||||
|
|
||||||
|
first_item = page.locator("li[ld-bookmark-item]").first
|
||||||
|
expect(first_item).to_contain_text("foo 1")
|
||||||
|
|
||||||
|
first_item.get_by_text("Archive").click()
|
||||||
|
|
||||||
|
first_item = page.locator("li[ld-bookmark-item]").first
|
||||||
|
expect(first_item).to_contain_text("foo 2")
|
||||||
|
|
||||||
|
def test_partial_update_respects_page(self):
|
||||||
|
# add a suffix, otherwise 'foo 1' also matches 'foo 10'
|
||||||
|
self.setup_numbered_bookmarks(50, prefix="foo", suffix="-")
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index") + "?q=foo&page=2"
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
# with descending sort, page two has 'foo 1' to 'foo 20'
|
||||||
|
expected_titles = [f"foo {i}-" for i in range(1, 21)]
|
||||||
|
self.assertVisibleBookmarks(expected_titles)
|
||||||
|
|
||||||
|
self.locate_bookmark("foo 20-").get_by_text("Archive").click()
|
||||||
|
|
||||||
|
expected_titles = [f"foo {i}-" for i in range(1, 20)]
|
||||||
|
self.assertVisibleBookmarks(expected_titles)
|
||||||
|
|
||||||
|
def test_multiple_partial_updates(self):
|
||||||
|
self.setup_numbered_bookmarks(5)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
self.locate_bookmark("Bookmark 1").get_by_text("Archive").click()
|
||||||
|
self.assertVisibleBookmarks(
|
||||||
|
["Bookmark 2", "Bookmark 3", "Bookmark 4", "Bookmark 5"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.locate_bookmark("Bookmark 2").get_by_text("Archive").click()
|
||||||
|
self.assertVisibleBookmarks(["Bookmark 3", "Bookmark 4", "Bookmark 5"])
|
||||||
|
|
||||||
|
self.locate_bookmark("Bookmark 3").get_by_text("Archive").click()
|
||||||
|
self.assertVisibleBookmarks(["Bookmark 4", "Bookmark 5"])
|
||||||
|
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_active_bookmarks_partial_update_on_archive(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
self.locate_bookmark("Bookmark 2").get_by_text("Archive").click()
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||||
|
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_active_bookmarks_partial_update_on_delete(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
self.locate_bookmark("Bookmark 2").get_by_text("Remove").click()
|
||||||
|
self.locate_bookmark("Bookmark 2").get_by_text("Confirm").click()
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||||
|
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_active_bookmarks_partial_update_on_mark_as_read(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
bookmark2 = self.get_numbered_bookmark("Bookmark 2")
|
||||||
|
bookmark2.unread = True
|
||||||
|
bookmark2.save()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread")
|
||||||
|
self.locate_bookmark("Bookmark 2").get_by_text("Unread").click()
|
||||||
|
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
|
||||||
|
|
||||||
|
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("unread")
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_active_bookmarks_partial_update_on_unshare(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
bookmark2 = self.get_numbered_bookmark("Bookmark 2")
|
||||||
|
bookmark2.shared = True
|
||||||
|
bookmark2.save()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared")
|
||||||
|
self.locate_bookmark("Bookmark 2").get_by_text("Shared").click()
|
||||||
|
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
|
||||||
|
|
||||||
|
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("shared")
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_active_bookmarks_partial_update_on_bulk_archive(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
self.locate_bookmark("Bookmark 2").locator(
|
||||||
|
"label.bulk-edit-checkbox"
|
||||||
|
).click()
|
||||||
|
self.select_bulk_action("Archive")
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||||
|
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_active_bookmarks_partial_update_on_bulk_delete(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
self.locate_bookmark("Bookmark 2").locator(
|
||||||
|
"label.bulk-edit-checkbox"
|
||||||
|
).click()
|
||||||
|
self.select_bulk_action("Delete")
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||||
|
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_archived_bookmarks_partial_update_on_unarchive(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:archived"), p)
|
||||||
|
|
||||||
|
self.locate_bookmark("Archived Bookmark 2").get_by_text("Unarchive").click()
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||||
|
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_archived_bookmarks_partial_update_on_delete(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:archived"), p)
|
||||||
|
|
||||||
|
self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click()
|
||||||
|
self.locate_bookmark("Archived Bookmark 2").get_by_text("Confirm").click()
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||||
|
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_archived_bookmarks_partial_update_on_bulk_unarchive(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:archived"), p)
|
||||||
|
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
self.locate_bookmark("Archived Bookmark 2").locator(
|
||||||
|
"label.bulk-edit-checkbox"
|
||||||
|
).click()
|
||||||
|
self.select_bulk_action("Unarchive")
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||||
|
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_archived_bookmarks_partial_update_on_bulk_delete(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:archived"), p)
|
||||||
|
|
||||||
|
self.locate_bulk_edit_toggle().click()
|
||||||
|
self.locate_bookmark("Archived Bookmark 2").locator(
|
||||||
|
"label.bulk-edit-checkbox"
|
||||||
|
).click()
|
||||||
|
self.select_bulk_action("Delete")
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||||
|
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_shared_bookmarks_partial_update_on_unarchive(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
self.setup_numbered_bookmarks(
|
||||||
|
3, shared=True, prefix="My Bookmark", with_tags=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:shared"), p)
|
||||||
|
|
||||||
|
self.locate_bookmark("My Bookmark 2").get_by_text("Archive").click()
|
||||||
|
|
||||||
|
# Shared bookmarks page also shows archived bookmarks, though it probably shouldn't
|
||||||
|
self.assertVisibleBookmarks(
|
||||||
|
[
|
||||||
|
"My Bookmark 1",
|
||||||
|
"My Bookmark 2",
|
||||||
|
"My Bookmark 3",
|
||||||
|
"Joe's Bookmark 1",
|
||||||
|
"Joe's Bookmark 2",
|
||||||
|
"Joe's Bookmark 3",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertVisibleTags(["Shared Tag 1", "Shared Tag 2", "Shared Tag 3"])
|
||||||
|
self.assertReloads(0)
|
||||||
|
|
||||||
|
def test_shared_bookmarks_partial_update_on_delete(self):
|
||||||
|
self.setup_fixture()
|
||||||
|
self.setup_numbered_bookmarks(
|
||||||
|
3, shared=True, prefix="My Bookmark", with_tags=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:shared"), p)
|
||||||
|
|
||||||
|
self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click()
|
||||||
|
self.locate_bookmark("My Bookmark 2").get_by_text("Confirm").click()
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(
|
||||||
|
[
|
||||||
|
"My Bookmark 1",
|
||||||
|
"My Bookmark 3",
|
||||||
|
"Joe's Bookmark 1",
|
||||||
|
"Joe's Bookmark 2",
|
||||||
|
"Joe's Bookmark 3",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertVisibleTags(["Shared Tag 1", "Shared Tag 3"])
|
||||||
|
self.assertReloads(0)
|
30
bookmarks/e2e/e2e_test_global_shortcuts.py
Normal file
30
bookmarks/e2e/e2e_test_global_shortcuts.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_focus_search(self):
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse("bookmarks:index"))
|
||||||
|
|
||||||
|
page.press("body", "s")
|
||||||
|
|
||||||
|
expect(page.get_by_placeholder("Search for words or #tags")).to_be_focused()
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_add_bookmark(self):
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse("bookmarks:index"))
|
||||||
|
|
||||||
|
page.press("body", "n")
|
||||||
|
|
||||||
|
expect(page).to_have_url(self.live_server_url + reverse("bookmarks:new"))
|
||||||
|
|
||||||
|
browser.close()
|
88
bookmarks/e2e/e2e_test_settings_general.py
Normal file
88
bookmarks/e2e/e2e_test_settings_general.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
from bookmarks.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_should_only_enable_public_sharing_if_sharing_is_enabled(self):
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||||
|
|
||||||
|
enable_sharing = page.get_by_label("Enable bookmark sharing")
|
||||||
|
enable_sharing_label = page.get_by_text("Enable bookmark sharing")
|
||||||
|
enable_public_sharing = page.get_by_label("Enable public bookmark sharing")
|
||||||
|
enable_public_sharing_label = page.get_by_text(
|
||||||
|
"Enable public bookmark sharing"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Public sharing is disabled by default
|
||||||
|
expect(enable_sharing).not_to_be_checked()
|
||||||
|
expect(enable_public_sharing).not_to_be_checked()
|
||||||
|
expect(enable_public_sharing).to_be_disabled()
|
||||||
|
|
||||||
|
# Enable sharing
|
||||||
|
enable_sharing_label.click()
|
||||||
|
expect(enable_sharing).to_be_checked()
|
||||||
|
expect(enable_public_sharing).not_to_be_checked()
|
||||||
|
expect(enable_public_sharing).to_be_enabled()
|
||||||
|
|
||||||
|
# Enable public sharing
|
||||||
|
enable_public_sharing_label.click()
|
||||||
|
expect(enable_public_sharing).to_be_checked()
|
||||||
|
expect(enable_public_sharing).to_be_enabled()
|
||||||
|
|
||||||
|
# Disable sharing
|
||||||
|
enable_sharing_label.click()
|
||||||
|
expect(enable_sharing).not_to_be_checked()
|
||||||
|
expect(enable_public_sharing).not_to_be_checked()
|
||||||
|
expect(enable_public_sharing).to_be_disabled()
|
||||||
|
|
||||||
|
def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.bookmark_description_display = (
|
||||||
|
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
|
||||||
|
)
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||||
|
|
||||||
|
max_lines = page.get_by_label("Bookmark description max lines")
|
||||||
|
expect(max_lines).to_be_hidden()
|
||||||
|
|
||||||
|
def test_should_show_bookmark_description_max_lines_when_display_separate(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.bookmark_description_display = (
|
||||||
|
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
|
||||||
|
)
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||||
|
|
||||||
|
max_lines = page.get_by_label("Bookmark description max lines")
|
||||||
|
expect(max_lines).to_be_visible()
|
||||||
|
|
||||||
|
def test_should_update_bookmark_description_max_lines_when_changing_display(self):
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||||
|
|
||||||
|
max_lines = page.get_by_label("Bookmark description max lines")
|
||||||
|
expect(max_lines).to_be_hidden()
|
||||||
|
|
||||||
|
display = page.get_by_label("Bookmark description", exact=True)
|
||||||
|
display.select_option("separate")
|
||||||
|
expect(max_lines).to_be_visible()
|
||||||
|
|
||||||
|
display.select_option("inline")
|
||||||
|
expect(max_lines).to_be_hidden()
|
76
bookmarks/e2e/e2e_test_tag_cloud_modal.py
Normal file
76
bookmarks/e2e/e2e_test_tag_cloud_modal.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect, Locator
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
from bookmarks.models import Bookmark
|
||||||
|
|
||||||
|
|
||||||
|
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_show_modal_close_modal(self):
|
||||||
|
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||||
|
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
page = self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
# use smaller viewport to make tags button visible
|
||||||
|
page.set_viewport_size({"width": 375, "height": 812})
|
||||||
|
|
||||||
|
# open tag cloud modal
|
||||||
|
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||||
|
"button", name="Tags"
|
||||||
|
)
|
||||||
|
modal_trigger.click()
|
||||||
|
|
||||||
|
# verify modal is visible
|
||||||
|
modal = page.locator(".modal")
|
||||||
|
expect(modal).to_be_visible()
|
||||||
|
expect(modal.locator(".modal-title")).to_have_text("Tags")
|
||||||
|
|
||||||
|
# close with close button
|
||||||
|
modal.locator("button.close").click()
|
||||||
|
expect(modal).to_be_hidden()
|
||||||
|
|
||||||
|
# open modal again
|
||||||
|
modal_trigger.click()
|
||||||
|
|
||||||
|
# close with backdrop
|
||||||
|
backdrop = modal.locator(".modal-overlay")
|
||||||
|
backdrop.click(position={"x": 0, "y": 0})
|
||||||
|
expect(modal).to_be_hidden()
|
||||||
|
|
||||||
|
def test_select_tag(self):
|
||||||
|
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||||
|
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
page = self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
# use smaller viewport to make tags button visible
|
||||||
|
page.set_viewport_size({"width": 375, "height": 812})
|
||||||
|
|
||||||
|
# open tag cloud modal
|
||||||
|
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||||
|
"button", name="Tags"
|
||||||
|
)
|
||||||
|
modal_trigger.click()
|
||||||
|
|
||||||
|
# verify tags are displayed
|
||||||
|
modal = page.locator(".modal")
|
||||||
|
unselected_tags = modal.locator(".unselected-tags")
|
||||||
|
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
||||||
|
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
||||||
|
|
||||||
|
# select tag
|
||||||
|
unselected_tags.get_by_text("cooking").click()
|
||||||
|
|
||||||
|
# open modal again
|
||||||
|
modal_trigger.click()
|
||||||
|
|
||||||
|
# verify tag is selected, other tag is not visible anymore
|
||||||
|
selected_tags = modal.locator(".selected-tags")
|
||||||
|
expect(selected_tags.get_by_text("cooking")).to_be_visible()
|
||||||
|
|
||||||
|
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
|
||||||
|
expect(unselected_tags.get_by_text("hiking")).not_to_be_visible()
|
81
bookmarks/e2e/helpers.py
Normal file
81
bookmarks/e2e/helpers.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from django.contrib.staticfiles.testing import LiveServerTestCase
|
||||||
|
from playwright.sync_api import BrowserContext, Playwright, Page
|
||||||
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.client.force_login(self.get_or_create_test_user())
|
||||||
|
self.cookie = self.client.cookies["sessionid"]
|
||||||
|
|
||||||
|
def setup_browser(self, playwright) -> BrowserContext:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context()
|
||||||
|
context.add_cookies(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "sessionid",
|
||||||
|
"value": self.cookie.value,
|
||||||
|
"domain": self.live_server_url.replace("http:", ""),
|
||||||
|
"path": "/",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def open(self, url: str, playwright: Playwright) -> Page:
|
||||||
|
browser = self.setup_browser(playwright)
|
||||||
|
self.page = browser.new_page()
|
||||||
|
self.page.goto(self.live_server_url + url)
|
||||||
|
self.page.on("load", self.on_load)
|
||||||
|
self.num_loads = 0
|
||||||
|
return self.page
|
||||||
|
|
||||||
|
def on_load(self):
|
||||||
|
self.num_loads += 1
|
||||||
|
|
||||||
|
def assertReloads(self, count: int):
|
||||||
|
self.assertEqual(self.num_loads, count)
|
||||||
|
|
||||||
|
def resetReloads(self):
|
||||||
|
self.num_loads = 0
|
||||||
|
|
||||||
|
def locate_bookmark_list(self):
|
||||||
|
return self.page.locator("ul[ld-bookmark-list]")
|
||||||
|
|
||||||
|
def locate_bookmark(self, title: str):
|
||||||
|
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||||
|
return bookmark_tags.filter(has_text=title)
|
||||||
|
|
||||||
|
def locate_details_modal(self):
|
||||||
|
return self.page.locator(".modal.bookmark-details")
|
||||||
|
|
||||||
|
def open_details_modal(self, bookmark):
|
||||||
|
details_button = self.locate_bookmark(bookmark.title).get_by_text("View")
|
||||||
|
details_button.click()
|
||||||
|
|
||||||
|
details_modal = self.locate_details_modal()
|
||||||
|
expect(details_modal).to_be_visible()
|
||||||
|
|
||||||
|
return details_modal
|
||||||
|
|
||||||
|
def locate_bulk_edit_bar(self):
|
||||||
|
return self.page.locator(".bulk-edit-bar")
|
||||||
|
|
||||||
|
def locate_bulk_edit_select_all(self):
|
||||||
|
return self.locate_bulk_edit_bar().locator("label.bulk-edit-checkbox.all")
|
||||||
|
|
||||||
|
def locate_bulk_edit_select_across(self):
|
||||||
|
return self.locate_bulk_edit_bar().locator("label.select-across")
|
||||||
|
|
||||||
|
def locate_bulk_edit_toggle(self):
|
||||||
|
return self.page.get_by_title("Bulk edit")
|
||||||
|
|
||||||
|
def select_bulk_action(self, value: str):
|
||||||
|
return (
|
||||||
|
self.locate_bulk_edit_bar()
|
||||||
|
.locator('select[name="bulk_action"]')
|
||||||
|
.select_option(value)
|
||||||
|
)
|
105
bookmarks/feeds.py
Normal file
105
bookmarks/feeds.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.contrib.syndication.views import Feed
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks import queries
|
||||||
|
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FeedContext:
|
||||||
|
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 item_title(self, item: Bookmark):
|
||||||
|
return sanitize(item.resolved_title)
|
||||||
|
|
||||||
|
def item_description(self, item: Bookmark):
|
||||||
|
return sanitize(item.resolved_description)
|
||||||
|
|
||||||
|
def item_link(self, item: Bookmark):
|
||||||
|
return item.url
|
||||||
|
|
||||||
|
def item_pubdate(self, item: Bookmark):
|
||||||
|
return item.date_added
|
||||||
|
|
||||||
|
|
||||||
|
class AllBookmarksFeed(BaseBookmarksFeed):
|
||||||
|
title = "All bookmarks"
|
||||||
|
description = "All bookmarks"
|
||||||
|
|
||||||
|
def link(self, context: FeedContext):
|
||||||
|
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
|
||||||
|
|
||||||
|
def items(self, context: FeedContext):
|
||||||
|
return context.query_set
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||||
|
title = "Unread bookmarks"
|
||||||
|
description = "All unread bookmarks"
|
||||||
|
|
||||||
|
def link(self, context: FeedContext):
|
||||||
|
return reverse("bookmarks: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_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_shared_bookmarks(
|
||||||
|
None, feed_token.user.profile, search, False
|
||||||
|
)
|
||||||
|
return FeedContext(feed_token, query_set)
|
||||||
|
|
||||||
|
def link(self, context: FeedContext):
|
||||||
|
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
||||||
|
|
||||||
|
def items(self, context: FeedContext):
|
||||||
|
return context.query_set
|
||||||
|
|
||||||
|
|
||||||
|
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||||
|
title = "Public shared bookmarks"
|
||||||
|
description = "All public shared bookmarks"
|
||||||
|
|
||||||
|
def get_object(self, request):
|
||||||
|
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||||
|
default_profile = UserProfile()
|
||||||
|
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
|
||||||
|
return FeedContext(None, query_set)
|
||||||
|
|
||||||
|
def link(self, context: FeedContext):
|
||||||
|
return reverse("bookmarks:feeds.public_shared")
|
||||||
|
|
||||||
|
def items(self, context: FeedContext):
|
||||||
|
return context.query_set
|
29
bookmarks/frontend/api.js
Normal file
29
bookmarks/frontend/api.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export class ApiClient {
|
||||||
|
constructor(baseUrl) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
listBookmarks(search, options = { limit: 100, offset: 0, path: "" }) {
|
||||||
|
const query = [`limit=${options.limit}`, `offset=${options.offset}`];
|
||||||
|
Object.keys(search).forEach((key) => {
|
||||||
|
const value = search[key];
|
||||||
|
if (value) {
|
||||||
|
query.push(`${key}=${encodeURIComponent(value)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const queryString = query.join("&");
|
||||||
|
const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`;
|
||||||
|
|
||||||
|
return fetch(url)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => data.results);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTags(options = { limit: 100, offset: 0 }) {
|
||||||
|
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`;
|
||||||
|
|
||||||
|
return fetch(url)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => data.results);
|
||||||
|
}
|
||||||
|
}
|
30
bookmarks/frontend/behaviors/bookmark-page.js
Normal file
30
bookmarks/frontend/behaviors/bookmark-page.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class BookmarkItem extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
// Toggle notes
|
||||||
|
const notesToggle = element.querySelector(".toggle-notes");
|
||||||
|
if (notesToggle) {
|
||||||
|
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tooltip to title if it is truncated
|
||||||
|
const titleAnchor = element.querySelector(".title > a");
|
||||||
|
const titleSpan = titleAnchor.querySelector("span");
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
||||||
|
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleNotes(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.element.classList.toggle("show-notes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-bookmark-item", BookmarkItem);
|
116
bookmarks/frontend/behaviors/bulk-edit.js
Normal file
116
bookmarks/frontend/behaviors/bulk-edit.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class BulkEdit extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
this.active = false;
|
||||||
|
|
||||||
|
this.onToggleActive = this.onToggleActive.bind(this);
|
||||||
|
this.onToggleAll = this.onToggleAll.bind(this);
|
||||||
|
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
||||||
|
this.onActionSelected = this.onActionSelected.bind(this);
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
// Reset when bookmarks are refreshed
|
||||||
|
document.addEventListener("refresh-bookmark-list-done", () => this.init());
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Update elements
|
||||||
|
this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
|
||||||
|
this.actionSelect = this.element.querySelector(
|
||||||
|
"select[name='bulk_action']",
|
||||||
|
);
|
||||||
|
this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
|
||||||
|
this.selectAcross = this.element.querySelector("label.select-across");
|
||||||
|
this.allCheckbox = this.element.querySelector(
|
||||||
|
".bulk-edit-checkbox.all input",
|
||||||
|
);
|
||||||
|
this.bookmarkCheckboxes = Array.from(
|
||||||
|
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove previous listeners if elements are the same
|
||||||
|
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
||||||
|
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
||||||
|
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
||||||
|
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset checkbox states
|
||||||
|
this.reset();
|
||||||
|
|
||||||
|
// Update total number of bookmarks
|
||||||
|
const totalHolder = this.element.querySelector("[data-bookmarks-total]");
|
||||||
|
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||||
|
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||||
|
totalSpan.textContent = total;
|
||||||
|
|
||||||
|
// Add new listeners
|
||||||
|
this.activeToggle.addEventListener("click", this.onToggleActive);
|
||||||
|
this.actionSelect.addEventListener("change", this.onActionSelected);
|
||||||
|
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||||
|
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.addEventListener("change", this.onToggleBookmark);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleActive() {
|
||||||
|
this.active = !this.active;
|
||||||
|
if (this.active) {
|
||||||
|
this.element.classList.add("active", "activating");
|
||||||
|
setTimeout(() => {
|
||||||
|
this.element.classList.remove("activating");
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
this.element.classList.remove("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleBookmark() {
|
||||||
|
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
|
||||||
|
return checkbox.checked;
|
||||||
|
});
|
||||||
|
this.allCheckbox.checked = allChecked;
|
||||||
|
this.updateSelectAcross(allChecked);
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleAll() {
|
||||||
|
const allChecked = this.allCheckbox.checked;
|
||||||
|
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.checked = allChecked;
|
||||||
|
});
|
||||||
|
this.updateSelectAcross(allChecked);
|
||||||
|
}
|
||||||
|
|
||||||
|
onActionSelected() {
|
||||||
|
const action = this.actionSelect.value;
|
||||||
|
|
||||||
|
if (action === "bulk_tag" || action === "bulk_untag") {
|
||||||
|
this.tagAutoComplete.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
this.tagAutoComplete.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectAcross(allChecked) {
|
||||||
|
if (allChecked) {
|
||||||
|
this.selectAcross.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
this.selectAcross.classList.add("d-none");
|
||||||
|
this.selectAcross.querySelector("input").checked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.allCheckbox.checked = false;
|
||||||
|
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.checked = false;
|
||||||
|
});
|
||||||
|
this.updateSelectAcross(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-bulk-edit", BulkEdit);
|
79
bookmarks/frontend/behaviors/confirm-button.js
Normal file
79
bookmarks/frontend/behaviors/confirm-button.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class ConfirmButtonBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
element.dataset.type = element.type;
|
||||||
|
element.dataset.name = element.name;
|
||||||
|
element.dataset.value = element.value;
|
||||||
|
element.removeAttribute("type");
|
||||||
|
element.removeAttribute("name");
|
||||||
|
element.removeAttribute("value");
|
||||||
|
element.addEventListener("click", this.onClick.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
Behavior.interacting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
Behavior.interacting = true;
|
||||||
|
|
||||||
|
const container = document.createElement("span");
|
||||||
|
container.className = "confirmation";
|
||||||
|
|
||||||
|
const icon = this.element.getAttribute("ld-confirm-icon");
|
||||||
|
if (icon) {
|
||||||
|
const iconElement = document.createElementNS(
|
||||||
|
"http://www.w3.org/2000/svg",
|
||||||
|
"svg",
|
||||||
|
);
|
||||||
|
iconElement.style.width = "16px";
|
||||||
|
iconElement.style.height = "16px";
|
||||||
|
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
|
||||||
|
container.append(iconElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = this.element.getAttribute("ld-confirm-question");
|
||||||
|
if (question) {
|
||||||
|
const questionElement = document.createElement("span");
|
||||||
|
questionElement.innerText = question;
|
||||||
|
container.append(question);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonClasses = Array.from(this.element.classList.values())
|
||||||
|
.filter((cls) => cls.startsWith("btn"))
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const cancelButton = document.createElement(this.element.nodeName);
|
||||||
|
cancelButton.type = "button";
|
||||||
|
cancelButton.innerText = question ? "No" : "Cancel";
|
||||||
|
cancelButton.className = `${buttonClasses} mr-1`;
|
||||||
|
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||||
|
|
||||||
|
const confirmButton = document.createElement(this.element.nodeName);
|
||||||
|
confirmButton.type = this.element.dataset.type;
|
||||||
|
confirmButton.name = this.element.dataset.name;
|
||||||
|
confirmButton.value = this.element.dataset.value;
|
||||||
|
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||||
|
confirmButton.className = buttonClasses;
|
||||||
|
confirmButton.addEventListener("click", this.reset.bind(this));
|
||||||
|
|
||||||
|
container.append(cancelButton, confirmButton);
|
||||||
|
this.container = container;
|
||||||
|
|
||||||
|
this.element.before(container);
|
||||||
|
this.element.classList.add("d-none");
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
setTimeout(() => {
|
||||||
|
Behavior.interacting = false;
|
||||||
|
this.container.remove();
|
||||||
|
this.element.classList.remove("d-none");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);
|
36
bookmarks/frontend/behaviors/dropdown.js
Normal file
36
bookmarks/frontend/behaviors/dropdown.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class DropdownBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
this.opened = false;
|
||||||
|
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||||
|
|
||||||
|
const toggle = element.querySelector(".dropdown-toggle");
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
if (this.opened) {
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.element.classList.add("active");
|
||||||
|
document.addEventListener("click", this.onOutsideClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.element.classList.remove("active");
|
||||||
|
document.removeEventListener("click", this.onOutsideClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOutsideClick(event) {
|
||||||
|
if (!this.element.contains(event.target)) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-dropdown", DropdownBehavior);
|
48
bookmarks/frontend/behaviors/fetch.js
Normal file
48
bookmarks/frontend/behaviors/fetch.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Behavior, fireEvents, registerBehavior, swap } from "./index";
|
||||||
|
|
||||||
|
class FetchBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
const eventName = element.getAttribute("ld-on");
|
||||||
|
const interval = parseInt(element.getAttribute("ld-interval")) * 1000;
|
||||||
|
|
||||||
|
this.onFetch = this.onFetch.bind(this);
|
||||||
|
this.onInterval = this.onInterval.bind(this);
|
||||||
|
|
||||||
|
element.addEventListener(eventName, this.onFetch);
|
||||||
|
if (interval) {
|
||||||
|
this.intervalId = setInterval(this.onInterval, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onFetch(maybeEvent) {
|
||||||
|
if (maybeEvent) {
|
||||||
|
maybeEvent.preventDefault();
|
||||||
|
}
|
||||||
|
const url = this.element.getAttribute("ld-fetch");
|
||||||
|
const html = await fetch(url).then((response) => response.text());
|
||||||
|
|
||||||
|
const target = this.element.getAttribute("ld-target");
|
||||||
|
const select = this.element.getAttribute("ld-select");
|
||||||
|
swap(this.element, html, { target, select });
|
||||||
|
|
||||||
|
const events = this.element.getAttribute("ld-fire");
|
||||||
|
fireEvents(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
onInterval() {
|
||||||
|
if (Behavior.interacting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.onFetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-fetch", FetchBehavior);
|
64
bookmarks/frontend/behaviors/form.js
Normal file
64
bookmarks/frontend/behaviors/form.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Behavior, fireEvents, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class FormBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
element.addEventListener("submit", this.onSubmit.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const url = this.element.action;
|
||||||
|
const formData = new FormData(this.element);
|
||||||
|
if (event.submitter) {
|
||||||
|
formData.append(event.submitter.name, event.submitter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
redirect: "manual", // ignore redirect
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = this.element.getAttribute("ld-fire");
|
||||||
|
if (fireEvents) {
|
||||||
|
fireEvents(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AutoSubmitBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
element.addEventListener("change", () => {
|
||||||
|
const form = element.closest("form");
|
||||||
|
form.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadButton extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
const fileInput = element.nextElementSibling;
|
||||||
|
|
||||||
|
element.addEventListener("click", () => {
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
const form = fileInput.closest("form");
|
||||||
|
const event = new Event("submit", { cancelable: true });
|
||||||
|
event.submitter = element;
|
||||||
|
form.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-form", FormBehavior);
|
||||||
|
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||||
|
registerBehavior("ld-upload-button", UploadButton);
|
75
bookmarks/frontend/behaviors/global-shortcuts.js
Normal file
75
bookmarks/frontend/behaviors/global-shortcuts.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class GlobalShortcuts extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
document.addEventListener("keydown", this.onKeyDown.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
// Skip if event occurred within an input element
|
||||||
|
const targetNodeName = event.target.nodeName;
|
||||||
|
const isInputTarget =
|
||||||
|
targetNodeName === "INPUT" ||
|
||||||
|
targetNodeName === "SELECT" ||
|
||||||
|
targetNodeName === "TEXTAREA";
|
||||||
|
|
||||||
|
if (isInputTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shortcuts for navigating bookmarks with arrow keys
|
||||||
|
const isArrowUp = event.key === "ArrowUp";
|
||||||
|
const isArrowDown = event.key === "ArrowDown";
|
||||||
|
if (isArrowUp || isArrowDown) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Detect current bookmark list item
|
||||||
|
const path = event.composedPath();
|
||||||
|
const currentItem = path.find(
|
||||||
|
(item) => item.hasAttribute && item.hasAttribute("ld-bookmark-item"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find next item
|
||||||
|
let nextItem;
|
||||||
|
if (currentItem) {
|
||||||
|
nextItem = isArrowUp
|
||||||
|
? currentItem.previousElementSibling
|
||||||
|
: currentItem.nextElementSibling;
|
||||||
|
} else {
|
||||||
|
// Select first item
|
||||||
|
nextItem = document.querySelector("[ld-bookmark-item]");
|
||||||
|
}
|
||||||
|
// Focus first link
|
||||||
|
if (nextItem) {
|
||||||
|
nextItem.querySelector("a").focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shortcut for toggling all notes
|
||||||
|
if (event.key === "e") {
|
||||||
|
const list = document.querySelector(".bookmark-list");
|
||||||
|
if (list) {
|
||||||
|
list.classList.toggle("show-notes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shortcut for focusing search input
|
||||||
|
if (event.key === "s") {
|
||||||
|
const searchInput = document.querySelector('input[type="search"]');
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shortcut for adding new bookmark
|
||||||
|
if (event.key === "n") {
|
||||||
|
window.location.assign("/bookmarks/new");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-global-shortcuts", GlobalShortcuts);
|
145
bookmarks/frontend/behaviors/index.js
Normal file
145
bookmarks/frontend/behaviors/index.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mutationObserver.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export class Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
this.element = element;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior.interacting = false;
|
||||||
|
|
||||||
|
export function registerBehavior(name, behavior) {
|
||||||
|
behaviorRegistry[name] = behavior;
|
||||||
|
applyBehaviors(document, [name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBehaviors(container, behaviorNames = null) {
|
||||||
|
if (!behaviorNames) {
|
||||||
|
behaviorNames = Object.keys(behaviorRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
behaviorNames.forEach((behaviorName) => {
|
||||||
|
const behavior = behaviorRegistry[behaviorName];
|
||||||
|
const elements = Array.from(
|
||||||
|
container.querySelectorAll(`[${behaviorName}]`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Include the container element if it has the behavior
|
||||||
|
if (container.hasAttribute && container.hasAttribute(behaviorName)) {
|
||||||
|
elements.push(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.forEach((element) => {
|
||||||
|
element.__behaviors = element.__behaviors || [];
|
||||||
|
const hasBehavior = element.__behaviors.some(
|
||||||
|
(b) => b instanceof behavior,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasBehavior) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const behaviorInstance = new behavior(element);
|
||||||
|
element.__behaviors.push(behaviorInstance);
|
||||||
|
if (debug) {
|
||||||
|
console.log(
|
||||||
|
`[Behavior] ${behaviorInstance.constructor.name} initialized`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyBehaviors(element) {
|
||||||
|
const behaviorNames = Object.keys(behaviorRegistry);
|
||||||
|
|
||||||
|
behaviorNames.forEach((behaviorName) => {
|
||||||
|
const elements = Array.from(element.querySelectorAll(`[${behaviorName}]`));
|
||||||
|
elements.push(element);
|
||||||
|
|
||||||
|
elements.forEach((element) => {
|
||||||
|
if (!element.__behaviors) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.__behaviors.forEach((behavior) => {
|
||||||
|
behavior.destroy();
|
||||||
|
if (debug) {
|
||||||
|
console.log(`[Behavior] ${behavior.constructor.name} destroyed`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
delete element.__behaviors;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swap(element, html, options) {
|
||||||
|
const dom = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
|
||||||
|
let targetElement = element;
|
||||||
|
let strategy = "innerHTML";
|
||||||
|
if (options.target) {
|
||||||
|
const parts = options.target.split("|");
|
||||||
|
targetElement =
|
||||||
|
parts[0] === "self" ? element : document.querySelector(parts[0]);
|
||||||
|
strategy = parts[1] || "innerHTML";
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = Array.from(dom.body.children);
|
||||||
|
if (options.select) {
|
||||||
|
contents = Array.from(dom.querySelectorAll(options.select));
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (strategy) {
|
||||||
|
case "append":
|
||||||
|
targetElement.append(...contents);
|
||||||
|
break;
|
||||||
|
case "outerHTML":
|
||||||
|
targetElement.parentElement.replaceChild(contents[0], targetElement);
|
||||||
|
break;
|
||||||
|
case "innerHTML":
|
||||||
|
default:
|
||||||
|
Array.from(targetElement.children).forEach((child) => {
|
||||||
|
child.remove();
|
||||||
|
});
|
||||||
|
targetElement.append(...contents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fireEvents(events) {
|
||||||
|
if (!events) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.split(",").forEach((eventName) => {
|
||||||
|
const targets = Array.from(
|
||||||
|
document.querySelectorAll(`[ld-on='${eventName}']`),
|
||||||
|
);
|
||||||
|
targets.push(document);
|
||||||
|
targets.forEach((target) => {
|
||||||
|
target.dispatchEvent(new CustomEvent(eventName));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
51
bookmarks/frontend/behaviors/modal.js
Normal file
51
bookmarks/frontend/behaviors/modal.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class ModalBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
this.onClose = this.onClose.bind(this);
|
||||||
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
|
||||||
|
const modalOverlay = element.querySelector(".modal-overlay");
|
||||||
|
const closeButton = element.querySelector("button.close");
|
||||||
|
modalOverlay.addEventListener("click", this.onClose);
|
||||||
|
closeButton.addEventListener("click", this.onClose);
|
||||||
|
|
||||||
|
document.addEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
document.removeEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
// Skip if event occurred within an input element
|
||||||
|
const targetNodeName = event.target.nodeName;
|
||||||
|
const isInputTarget =
|
||||||
|
targetNodeName === "INPUT" ||
|
||||||
|
targetNodeName === "SELECT" ||
|
||||||
|
targetNodeName === "TEXTAREA";
|
||||||
|
|
||||||
|
if (isInputTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
document.removeEventListener("keydown", this.onKeyDown);
|
||||||
|
this.element.classList.add("closing");
|
||||||
|
this.element.addEventListener("animationend", (event) => {
|
||||||
|
if (event.animationName === "fade-out") {
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-modal", ModalBehavior);
|
28
bookmarks/frontend/behaviors/tag-autocomplete.js
Normal file
28
bookmarks/frontend/behaviors/tag-autocomplete.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||||
|
import { ApiClient } from "../api";
|
||||||
|
|
||||||
|
class TagAutocomplete extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
||||||
|
const apiClient = new ApiClient(apiBaseUrl);
|
||||||
|
|
||||||
|
new TagAutoCompleteComponent({
|
||||||
|
target: wrapper,
|
||||||
|
props: {
|
||||||
|
id: element.id,
|
||||||
|
name: element.name,
|
||||||
|
value: element.value,
|
||||||
|
placeholder: element.getAttribute("placeholder") || "",
|
||||||
|
apiClient: apiClient,
|
||||||
|
variant: element.getAttribute("variant"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
element.replaceWith(wrapper.firstElementChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-tag-autocomplete", TagAutocomplete);
|
261
bookmarks/frontend/components/SearchAutoComplete.svelte
Normal file
261
bookmarks/frontend/components/SearchAutoComplete.svelte
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<script>
|
||||||
|
import {SearchHistory} from "./SearchHistory";
|
||||||
|
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
|
||||||
|
|
||||||
|
const searchHistory = new SearchHistory()
|
||||||
|
|
||||||
|
export let name;
|
||||||
|
export let placeholder;
|
||||||
|
export let value;
|
||||||
|
export let tags;
|
||||||
|
export let mode = '';
|
||||||
|
export let apiClient;
|
||||||
|
export let search;
|
||||||
|
export let linkTarget = '_blank';
|
||||||
|
|
||||||
|
let isFocus = false;
|
||||||
|
let isOpen = false;
|
||||||
|
let suggestions = []
|
||||||
|
let selectedIndex = undefined;
|
||||||
|
let input = null;
|
||||||
|
|
||||||
|
// Track current search query after loading the page
|
||||||
|
searchHistory.pushCurrent()
|
||||||
|
updateSuggestions()
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
isFocus = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
isFocus = false;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e) {
|
||||||
|
value = e.target.value
|
||||||
|
debouncedLoadSuggestions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
// Enter
|
||||||
|
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
|
||||||
|
const suggestion = suggestions.total[selectedIndex];
|
||||||
|
if (suggestion) completeSuggestion(suggestion);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
// Escape
|
||||||
|
if (e.keyCode === 27) {
|
||||||
|
close();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
// Up arrow
|
||||||
|
if (e.keyCode === 38) {
|
||||||
|
updateSelection(-1);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
// Down arrow
|
||||||
|
if (e.keyCode === 40) {
|
||||||
|
if (!isOpen) {
|
||||||
|
loadSuggestions()
|
||||||
|
} else {
|
||||||
|
updateSelection(1);
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
isOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen = false;
|
||||||
|
updateSuggestions()
|
||||||
|
selectedIndex = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSuggestions() {
|
||||||
|
return suggestions.total.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSuggestions() {
|
||||||
|
|
||||||
|
let suggestionIndex = 0
|
||||||
|
|
||||||
|
function nextIndex() {
|
||||||
|
return suggestionIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag suggestions
|
||||||
|
let tagSuggestions = []
|
||||||
|
const currentWord = getCurrentWord(input)
|
||||||
|
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
||||||
|
const searchTag = currentWord.substring(1, currentWord.length)
|
||||||
|
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(tagName => ({
|
||||||
|
type: 'tag',
|
||||||
|
index: nextIndex(),
|
||||||
|
label: `#${tagName}`,
|
||||||
|
tagName: tagName
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent search suggestions
|
||||||
|
const recentSearches = searchHistory.getRecentSearches(value, 5).map(value => ({
|
||||||
|
type: 'search',
|
||||||
|
index: nextIndex(),
|
||||||
|
label: value,
|
||||||
|
value
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Bookmark suggestions
|
||||||
|
let bookmarks = []
|
||||||
|
|
||||||
|
if (value && value.length >= 3) {
|
||||||
|
const path = mode ? `/${mode}` : ''
|
||||||
|
const suggestionSearch = {
|
||||||
|
...search,
|
||||||
|
q: value
|
||||||
|
}
|
||||||
|
const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
||||||
|
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||||
|
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
||||||
|
const label = clampText(fullLabel, 60)
|
||||||
|
return {
|
||||||
|
type: 'bookmark',
|
||||||
|
index: nextIndex(),
|
||||||
|
label,
|
||||||
|
bookmark
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSuggestions(recentSearches, bookmarks, tagSuggestions)
|
||||||
|
|
||||||
|
if (hasSuggestions()) {
|
||||||
|
open()
|
||||||
|
} else {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedLoadSuggestions = debounce(loadSuggestions)
|
||||||
|
|
||||||
|
function updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
|
||||||
|
recentSearches = recentSearches || []
|
||||||
|
bookmarks = bookmarks || []
|
||||||
|
tagSuggestions = tagSuggestions || []
|
||||||
|
suggestions = {
|
||||||
|
recentSearches,
|
||||||
|
bookmarks,
|
||||||
|
tags: tagSuggestions,
|
||||||
|
total: [
|
||||||
|
...tagSuggestions,
|
||||||
|
...recentSearches,
|
||||||
|
...bookmarks,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeSuggestion(suggestion) {
|
||||||
|
if (suggestion.type === 'search') {
|
||||||
|
value = suggestion.value
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
if (suggestion.type === 'bookmark') {
|
||||||
|
window.open(suggestion.bookmark.url, linkTarget)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
if (suggestion.type === 'tag') {
|
||||||
|
const bounds = getCurrentWordBounds(input);
|
||||||
|
const inputValue = input.value;
|
||||||
|
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelection(dir) {
|
||||||
|
|
||||||
|
const length = suggestions.total.length;
|
||||||
|
|
||||||
|
if (length === 0) return
|
||||||
|
|
||||||
|
if (selectedIndex === undefined) {
|
||||||
|
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let newIndex = selectedIndex + dir;
|
||||||
|
|
||||||
|
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||||
|
if (newIndex >= length) newIndex = 0;
|
||||||
|
|
||||||
|
selectedIndex = newIndex;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-autocomplete">
|
||||||
|
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||||
|
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
|
||||||
|
bind:this={input}
|
||||||
|
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="menu" class:open={isOpen}>
|
||||||
|
{#if suggestions.tags.length > 0}
|
||||||
|
<li class="menu-item group-item">Tags</li>
|
||||||
|
{/if}
|
||||||
|
{#each suggestions.tags as suggestion}
|
||||||
|
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||||
|
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||||
|
{suggestion.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if suggestions.recentSearches.length > 0}
|
||||||
|
<li class="menu-item group-item">Recent Searches</li>
|
||||||
|
{/if}
|
||||||
|
{#each suggestions.recentSearches as suggestion}
|
||||||
|
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||||
|
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||||
|
{suggestion.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if suggestions.bookmarks.length > 0}
|
||||||
|
<li class="menu-item group-item">Bookmarks</li>
|
||||||
|
{/if}
|
||||||
|
{#each suggestions.bookmarks as suggestion}
|
||||||
|
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||||
|
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||||
|
{suggestion.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.menu {
|
||||||
|
display: none;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-autocomplete-input {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-autocomplete-input.is-focused {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
52
bookmarks/frontend/components/SearchHistory.js
Normal file
52
bookmarks/frontend/components/SearchHistory.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const SEARCH_HISTORY_KEY = "searchHistory";
|
||||||
|
const MAX_ENTRIES = 30;
|
||||||
|
|
||||||
|
export class SearchHistory {
|
||||||
|
getHistory() {
|
||||||
|
const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||||
|
return historyJson
|
||||||
|
? JSON.parse(historyJson)
|
||||||
|
: {
|
||||||
|
recent: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pushCurrent() {
|
||||||
|
// Skip if browser is not compatible
|
||||||
|
if (!window.URLSearchParams) return;
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const searchParam = urlParams.get("q");
|
||||||
|
|
||||||
|
if (!searchParam) return;
|
||||||
|
|
||||||
|
this.push(searchParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
push(search) {
|
||||||
|
const history = this.getHistory();
|
||||||
|
|
||||||
|
history.recent.unshift(search);
|
||||||
|
|
||||||
|
// Remove duplicates and clamp to max entries
|
||||||
|
history.recent = history.recent.reduce((acc, cur) => {
|
||||||
|
if (acc.length >= MAX_ENTRIES) return acc;
|
||||||
|
if (acc.indexOf(cur) >= 0) return acc;
|
||||||
|
acc.push(cur);
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const newHistoryJson = JSON.stringify(history);
|
||||||
|
localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecentSearches(query, max) {
|
||||||
|
const history = this.getHistory();
|
||||||
|
|
||||||
|
return history.recent
|
||||||
|
.filter(
|
||||||
|
(search) =>
|
||||||
|
!query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0,
|
||||||
|
)
|
||||||
|
.slice(0, max);
|
||||||
|
}
|
||||||
|
}
|
180
bookmarks/frontend/components/TagAutocomplete.svelte
Normal file
180
bookmarks/frontend/components/TagAutocomplete.svelte
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<script>
|
||||||
|
import {getCurrentWord, getCurrentWordBounds} from "../util";
|
||||||
|
|
||||||
|
export let id;
|
||||||
|
export let name;
|
||||||
|
export let value;
|
||||||
|
export let placeholder;
|
||||||
|
export let apiClient;
|
||||||
|
export let variant = 'default';
|
||||||
|
|
||||||
|
let tags = [];
|
||||||
|
let isFocus = false;
|
||||||
|
let isOpen = false;
|
||||||
|
let input = null;
|
||||||
|
let suggestionList = null;
|
||||||
|
|
||||||
|
let suggestions = [];
|
||||||
|
let selectedIndex = 0;
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// For now we cache all tags on load as the template did before
|
||||||
|
try {
|
||||||
|
tags = await apiClient.getTags({limit: 5000, offset: 0});
|
||||||
|
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('TagAutocomplete: Error loading tag list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
isFocus = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
isFocus = false;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e) {
|
||||||
|
input = e.target;
|
||||||
|
|
||||||
|
const word = getCurrentWord(input);
|
||||||
|
|
||||||
|
suggestions = word
|
||||||
|
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (word && suggestions.length > 0) {
|
||||||
|
open();
|
||||||
|
} else {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
|
||||||
|
const suggestion = suggestions[selectedIndex];
|
||||||
|
complete(suggestion);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
if (e.keyCode === 27) {
|
||||||
|
close();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
if (e.keyCode === 38) {
|
||||||
|
updateSelection(-1);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
if (e.keyCode === 40) {
|
||||||
|
updateSelection(1);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
isOpen = true;
|
||||||
|
selectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen = false;
|
||||||
|
suggestions = [];
|
||||||
|
selectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function complete(suggestion) {
|
||||||
|
const bounds = getCurrentWordBounds(input);
|
||||||
|
const value = input.value;
|
||||||
|
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
||||||
|
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelection(dir) {
|
||||||
|
|
||||||
|
const length = suggestions.length;
|
||||||
|
let newIndex = selectedIndex + dir;
|
||||||
|
|
||||||
|
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||||
|
if (newIndex >= length) newIndex = 0;
|
||||||
|
|
||||||
|
selectedIndex = newIndex;
|
||||||
|
|
||||||
|
// Scroll to selected list item
|
||||||
|
setTimeout(() => {
|
||||||
|
if (suggestionList) {
|
||||||
|
const selectedListItem = suggestionList.querySelector('li.selected');
|
||||||
|
if (selectedListItem) {
|
||||||
|
selectedListItem.scrollIntoView({block: 'center'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-autocomplete" class:small={variant === 'small'}>
|
||||||
|
<!-- autocomplete input container -->
|
||||||
|
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||||
|
<!-- autocomplete real input box -->
|
||||||
|
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
|
||||||
|
class="form-input" type="text" autocomplete="off" autocapitalize="off"
|
||||||
|
on:input={handleInput} on:keydown={handleKeyDown}
|
||||||
|
on:focus={handleFocus} on:blur={handleBlur}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- autocomplete suggestion list -->
|
||||||
|
<ul class="menu" class:open={isOpen && suggestions.length > 0}
|
||||||
|
bind:this={suggestionList}>
|
||||||
|
<!-- menu list items -->
|
||||||
|
{#each suggestions as tag,i}
|
||||||
|
<li class="menu-item" class:selected={selectedIndex === i}>
|
||||||
|
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
||||||
|
{tag.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.menu {
|
||||||
|
display: none;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-autocomplete-input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: var(--control-size);
|
||||||
|
min-height: var(--control-size);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-autocomplete-input input {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-autocomplete.small .form-autocomplete-input {
|
||||||
|
height: var(--control-size-sm);
|
||||||
|
min-height: var(--control-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-autocomplete.small .form-autocomplete-input input {
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-autocomplete.small .menu .menu-item {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
</style>
|
12
bookmarks/frontend/index.js
Normal file
12
bookmarks/frontend/index.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import "./behaviors/bookmark-page";
|
||||||
|
import "./behaviors/bulk-edit";
|
||||||
|
import "./behaviors/confirm-button";
|
||||||
|
import "./behaviors/dropdown";
|
||||||
|
import "./behaviors/fetch";
|
||||||
|
import "./behaviors/form";
|
||||||
|
import "./behaviors/modal";
|
||||||
|
import "./behaviors/global-shortcuts";
|
||||||
|
import "./behaviors/tag-autocomplete";
|
||||||
|
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||||
|
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||||
|
export { ApiClient } from "./api";
|
37
bookmarks/frontend/util.js
Normal file
37
bookmarks/frontend/util.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export function debounce(callback, delay = 250) {
|
||||||
|
let timeoutId;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
timeoutId = null;
|
||||||
|
callback(...args);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampText(text, maxChars = 30) {
|
||||||
|
if (!text || text.length <= 30) return text;
|
||||||
|
|
||||||
|
return text.substr(0, maxChars) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentWordBounds(input) {
|
||||||
|
const text = input.value;
|
||||||
|
const end = input.selectionStart;
|
||||||
|
let start = end;
|
||||||
|
|
||||||
|
let currentChar = text.charAt(start - 1);
|
||||||
|
|
||||||
|
while (currentChar && currentChar !== " " && start > 0) {
|
||||||
|
start--;
|
||||||
|
currentChar = text.charAt(start - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentWord(input) {
|
||||||
|
const bounds = getCurrentWordBounds(input);
|
||||||
|
|
||||||
|
return input.value.substring(bounds.start, bounds.end);
|
||||||
|
}
|
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()
|
|
39
bookmarks/management/commands/create_initial_superuser.py
Normal file
39
bookmarks/management/commands/create_initial_superuser.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Creates an initial superuser for a deployment using env variables"
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Skip if option is undefined
|
||||||
|
if not superuser_name:
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
|
||||||
|
user = User(username=superuser_name, is_superuser=True, is_staff=True)
|
||||||
|
|
||||||
|
if superuser_password:
|
||||||
|
user.set_password(superuser_password)
|
||||||
|
else:
|
||||||
|
user.set_unusable_password()
|
||||||
|
|
||||||
|
user.save()
|
||||||
|
logger.info("Created initial superuser")
|
24
bookmarks/management/commands/enable_wal.py
Normal file
24
bookmarks/management/commands/enable_wal.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import connections
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Enable WAL journal mode when using an SQLite database"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if not settings.USE_SQLITE:
|
||||||
|
return
|
||||||
|
|
||||||
|
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":
|
||||||
|
cursor.execute("PRAGMA journal_mode=wal;")
|
||||||
|
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"
|
help = "Creates an admin user non-interactively if it doesn't exist"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('--username', help="Admin's username")
|
parser.add_argument("--username", help="Admin's username")
|
||||||
parser.add_argument('--email', help="Admin's email")
|
parser.add_argument("--email", help="Admin's email")
|
||||||
parser.add_argument('--password', help="Admin's password")
|
parser.add_argument("--password", help="Admin's password")
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
if not User.objects.filter(username=options['username']).exists():
|
if not User.objects.filter(username=options["username"]).exists():
|
||||||
User.objects.create_superuser(username=options['username'],
|
User.objects.create_superuser(
|
||||||
email=options['email'],
|
username=options["username"],
|
||||||
password=options['password'])
|
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):
|
class Command(BaseCommand):
|
||||||
help = 'Import Netscape HTML bookmark file'
|
help = "Import Netscape HTML bookmark file"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('file', type=str, help='Path to file')
|
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(
|
||||||
|
"user", type=str, help="Name of the user for which to import"
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
filepath = kwargs['file']
|
filepath = kwargs["file"]
|
||||||
username = kwargs['user']
|
username = kwargs["user"]
|
||||||
with open(filepath) as html_file:
|
with open(filepath) as html_file:
|
||||||
html = html_file.read()
|
html = html_file.read()
|
||||||
user = User.objects.get(username=username)
|
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()
|
24
bookmarks/middlewares.py
Normal file
24
bookmarks/middlewares.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||||
|
|
||||||
|
from bookmarks.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
||||||
|
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
request.user_profile = request.user.profile
|
||||||
|
else:
|
||||||
|
request.user_profile = UserProfile()
|
||||||
|
request.user_profile.enable_favicons = True
|
||||||
|
|
||||||
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
return response
|
@@ -15,19 +15,36 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Bookmark',
|
name="Bookmark",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('url', models.URLField()),
|
"id",
|
||||||
('title', models.CharField(max_length=512)),
|
models.AutoField(
|
||||||
('description', models.TextField()),
|
auto_created=True,
|
||||||
('website_title', models.CharField(blank=True, max_length=512, null=True)),
|
primary_key=True,
|
||||||
('website_description', models.TextField(blank=True, null=True)),
|
serialize=False,
|
||||||
('unread', models.BooleanField(default=True)),
|
verbose_name="ID",
|
||||||
('date_added', models.DateTimeField()),
|
),
|
||||||
('date_modified', models.DateTimeField()),
|
),
|
||||||
('date_accessed', models.DateTimeField(blank=True, null=True)),
|
("url", models.URLField()),
|
||||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
("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 = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
('bookmarks', '0001_initial'),
|
("bookmarks", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Tag',
|
name="Tag",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=64)),
|
"id",
|
||||||
('date_added', models.DateTimeField()),
|
models.AutoField(
|
||||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
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(
|
migrations.AddField(
|
||||||
model_name='bookmark',
|
model_name="bookmark",
|
||||||
name='tags',
|
name="tags",
|
||||||
field=models.ManyToManyField(to='bookmarks.Tag'),
|
field=models.ManyToManyField(to="bookmarks.Tag"),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('bookmarks', '0002_auto_20190629_2303'),
|
("bookmarks", "0002_auto_20190629_2303"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='bookmark',
|
model_name="bookmark",
|
||||||
name='url',
|
name="url",
|
||||||
field=models.URLField(max_length=2048),
|
field=models.URLField(max_length=2048),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@@ -6,18 +6,18 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('bookmarks', '0003_auto_20200913_0656'),
|
("bookmarks", "0003_auto_20200913_0656"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='bookmark',
|
model_name="bookmark",
|
||||||
name='description',
|
name="description",
|
||||||
field=models.TextField(blank=True),
|
field=models.TextField(blank=True),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='bookmark',
|
model_name="bookmark",
|
||||||
name='title',
|
name="title",
|
||||||
field=models.CharField(blank=True, max_length=512),
|
field=models.CharField(blank=True, max_length=512),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@@ -7,13 +7,16 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('bookmarks', '0004_auto_20200926_1028'),
|
("bookmarks", "0004_auto_20200926_1028"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='bookmark',
|
model_name="bookmark",
|
||||||
name='url',
|
name="url",
|
||||||
field=models.CharField(max_length=2048, validators=[bookmarks.validators.BookmarkURLValidator()]),
|
field=models.CharField(
|
||||||
|
max_length=2048,
|
||||||
|
validators=[bookmarks.validators.BookmarkURLValidator()],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('bookmarks', '0005_auto_20210103_1212'),
|
("bookmarks", "0005_auto_20210103_1212"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='bookmark',
|
model_name="bookmark",
|
||||||
name='is_archived',
|
name="is_archived",
|
||||||
field=models.BooleanField(default=False),
|
field=models.BooleanField(default=False),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@@ -6,8 +6,8 @@ import django.db.models.deletion
|
|||||||
|
|
||||||
|
|
||||||
def forwards(apps, schema_editor):
|
def forwards(apps, schema_editor):
|
||||||
User = apps.get_model('auth', 'User')
|
User = apps.get_model("auth", "User")
|
||||||
UserProfile = apps.get_model('bookmarks', 'UserProfile')
|
UserProfile = apps.get_model("bookmarks", "UserProfile")
|
||||||
for user in User.objects.all():
|
for user in User.objects.all():
|
||||||
try:
|
try:
|
||||||
if user.profile:
|
if user.profile:
|
||||||
@@ -24,19 +24,42 @@ def reverse(apps, schema_editor):
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
('bookmarks', '0006_bookmark_is_archived'),
|
("bookmarks", "0006_bookmark_is_archived"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='UserProfile',
|
name="UserProfile",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('theme',
|
"id",
|
||||||
models.CharField(choices=[('auto', 'Auto'), ('light', 'Light'), ('dark', 'Dark')], default='auto',
|
models.AutoField(
|
||||||
max_length=10)),
|
auto_created=True,
|
||||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile',
|
primary_key=True,
|
||||||
to=settings.AUTH_USER_MODEL)),
|
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),
|
migrations.RunPython(forwards, reverse),
|
||||||
|
@@ -6,13 +6,21 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('bookmarks', '0007_userprofile'),
|
("bookmarks", "0007_userprofile"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='userprofile',
|
model_name="userprofile",
|
||||||
name='bookmark_date_display',
|
name="bookmark_date_display",
|
||||||
field=models.CharField(choices=[('relative', 'Relative'), ('absolute', 'Absolute'), ('hidden', 'Hidden')], default='relative', max_length=10),
|
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('bookmarks', '0008_userprofile_bookmark_date_display'),
|
("bookmarks", "0008_userprofile_bookmark_date_display"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='bookmark',
|
model_name="bookmark",
|
||||||
name='web_archive_snapshot_url',
|
name="web_archive_snapshot_url",
|
||||||
field=models.CharField(blank=True, max_length=2048),
|
field=models.CharField(blank=True, max_length=2048),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@@ -6,13 +6,17 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('bookmarks', '0009_bookmark_web_archive_snapshot_url'),
|
("bookmarks", "0009_bookmark_web_archive_snapshot_url"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='userprofile',
|
model_name="userprofile",
|
||||||
name='bookmark_link_target',
|
name="bookmark_link_target",
|
||||||
field=models.CharField(choices=[('_blank', 'New page'), ('_self', 'Same page')], default='_blank', max_length=10),
|
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('bookmarks', '0010_userprofile_bookmark_link_target'),
|
("bookmarks", "0010_userprofile_bookmark_link_target"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='userprofile',
|
model_name="userprofile",
|
||||||
name='web_archive_integration',
|
name="web_archive_integration",
|
||||||
field=models.CharField(choices=[('disabled', 'Disabled'), ('enabled', 'Enabled')], default='disabled', max_length=10),
|
field=models.CharField(
|
||||||
|
choices=[("disabled", "Disabled"), ("enabled", "Enabled")],
|
||||||
|
default="disabled",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@@ -9,18 +9,32 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
('bookmarks', '0011_userprofile_web_archive_integration'),
|
("bookmarks", "0011_userprofile_web_archive_integration"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Toast',
|
name="Toast",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('key', models.CharField(max_length=50)),
|
"id",
|
||||||
('message', models.TextField()),
|
models.AutoField(
|
||||||
('acknowledged', models.BooleanField(default=False)),
|
auto_created=True,
|
||||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
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):
|
def forwards(apps, schema_editor):
|
||||||
for user in User.objects.all():
|
for user in User.objects.all():
|
||||||
toast = Toast(key='web_archive_opt_in_hint',
|
toast = Toast(
|
||||||
message='The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.',
|
key="web_archive_opt_in_hint",
|
||||||
owner=user)
|
message="The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.",
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
toast.save()
|
toast.save()
|
||||||
|
|
||||||
|
|
||||||
def reverse(apps, schema_editor):
|
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):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('bookmarks', '0012_toast'),
|
("bookmarks", "0012_toast"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
27
bookmarks/migrations/0014_alter_bookmark_unread.py
Normal file
27
bookmarks/migrations/0014_alter_bookmark_unread.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-07-23 12:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def forwards(apps, schema_editor):
|
||||||
|
Bookmark = apps.get_model("bookmarks", "Bookmark")
|
||||||
|
Bookmark.objects.update(unread=False)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0013_web_archive_optin_toast"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="unread",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.RunPython(forwards, reverse),
|
||||||
|
]
|
34
bookmarks/migrations/0015_feedtoken.py
Normal file
34
bookmarks/migrations/0015_feedtoken.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-07-23 20:35
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("bookmarks", "0014_alter_bookmark_unread"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
18
bookmarks/migrations/0016_bookmark_shared.py
Normal file
18
bookmarks/migrations/0016_bookmark_shared.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.14 on 2022-08-02 18:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0015_feedtoken"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="shared",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
18
bookmarks/migrations/0017_userprofile_enable_sharing.py
Normal file
18
bookmarks/migrations/0017_userprofile_enable_sharing.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.14 on 2022-08-04 09:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0016_bookmark_shared"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="enable_sharing",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
18
bookmarks/migrations/0018_bookmark_favicon_file.py
Normal file
18
bookmarks/migrations/0018_bookmark_favicon_file.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1 on 2023-01-07 23:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0017_userprofile_enable_sharing"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="favicon_file",
|
||||||
|
field=models.CharField(blank=True, max_length=512),
|
||||||
|
),
|
||||||
|
]
|
18
bookmarks/migrations/0019_userprofile_enable_favicons.py
Normal file
18
bookmarks/migrations/0019_userprofile_enable_favicons.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1 on 2023-01-09 21:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0018_bookmark_favicon_file"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="enable_favicons",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
22
bookmarks/migrations/0020_userprofile_tag_search.py
Normal file
22
bookmarks/migrations/0020_userprofile_tag_search.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.1.7 on 2023-04-10 01:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
18
bookmarks/migrations/0021_userprofile_display_url.py
Normal file
18
bookmarks/migrations/0021_userprofile_display_url.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.7 on 2023-05-18 07:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0020_userprofile_tag_search"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="display_url",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
18
bookmarks/migrations/0022_bookmark_notes.py
Normal file
18
bookmarks/migrations/0022_bookmark_notes.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.7 on 2023-05-19 10:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0021_userprofile_display_url"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="notes",
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
18
bookmarks/migrations/0023_userprofile_permanent_notes.py
Normal file
18
bookmarks/migrations/0023_userprofile_permanent_notes.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-05-20 08:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0022_bookmark_notes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="permanent_notes",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-08-14 07:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0023_userprofile_permanent_notes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="enable_public_sharing",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
18
bookmarks/migrations/0025_userprofile_search_preferences.py
Normal file
18
bookmarks/migrations/0025_userprofile_search_preferences.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-09-30 10:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0024_userprofile_enable_public_sharing"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
]
|
@@ -1,15 +1,22 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
import binascii
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save, post_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
from django.http import QueryDict
|
||||||
|
|
||||||
from bookmarks.utils import unique
|
from bookmarks.utils import unique
|
||||||
from bookmarks.validators import BookmarkURLValidator
|
from bookmarks.validators import BookmarkURLValidator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Tag(models.Model):
|
class Tag(models.Model):
|
||||||
name = models.CharField(max_length=64)
|
name = models.CharField(max_length=64)
|
||||||
@@ -23,10 +30,10 @@ class Tag(models.Model):
|
|||||||
def sanitize_tag_name(tag_name: str):
|
def sanitize_tag_name(tag_name: str):
|
||||||
# strip leading/trailing spaces
|
# strip leading/trailing spaces
|
||||||
# replace inner spaces with replacement char
|
# replace inner spaces with replacement char
|
||||||
return tag_name.strip().replace(' ', '-')
|
return tag_name.strip().replace(" ", "-")
|
||||||
|
|
||||||
|
|
||||||
def parse_tag_string(tag_string: str, delimiter: str = ','):
|
def parse_tag_string(tag_string: str, delimiter: str = ","):
|
||||||
if not tag_string:
|
if not tag_string:
|
||||||
return []
|
return []
|
||||||
names = tag_string.strip().split(delimiter)
|
names = tag_string.strip().split(delimiter)
|
||||||
@@ -39,7 +46,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ','):
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
def build_tag_string(tag_names: List[str], delimiter: str = ','):
|
def build_tag_string(tag_names: List[str], delimiter: str = ","):
|
||||||
return delimiter.join(tag_names)
|
return delimiter.join(tag_names)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,22 +54,21 @@ class Bookmark(models.Model):
|
|||||||
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
|
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
|
||||||
title = models.CharField(max_length=512, blank=True)
|
title = models.CharField(max_length=512, blank=True)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
website_title = models.CharField(max_length=512, blank=True, null=True)
|
website_title = models.CharField(max_length=512, blank=True, null=True)
|
||||||
website_description = models.TextField(blank=True, null=True)
|
website_description = models.TextField(blank=True, null=True)
|
||||||
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
|
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
|
||||||
unread = models.BooleanField(default=True)
|
favicon_file = models.CharField(max_length=512, blank=True)
|
||||||
|
preview_image_file = models.CharField(max_length=512, blank=True)
|
||||||
|
unread = models.BooleanField(default=False)
|
||||||
is_archived = models.BooleanField(default=False)
|
is_archived = models.BooleanField(default=False)
|
||||||
|
shared = models.BooleanField(default=False)
|
||||||
date_added = models.DateTimeField()
|
date_added = models.DateTimeField()
|
||||||
date_modified = models.DateTimeField()
|
date_modified = models.DateTimeField()
|
||||||
date_accessed = models.DateTimeField(blank=True, null=True)
|
date_accessed = models.DateTimeField(blank=True, null=True)
|
||||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||||
tags = models.ManyToManyField(Tag)
|
tags = models.ManyToManyField(Tag)
|
||||||
|
|
||||||
# Attributes might be calculated in query
|
|
||||||
tag_count = 0 # Projection for number of associated tags
|
|
||||||
tag_string = '' # Projection for list of tag names, comma-separated
|
|
||||||
tag_projection = False # Tracks if the above projections were loaded
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resolved_title(self):
|
def resolved_title(self):
|
||||||
if self.title:
|
if self.title:
|
||||||
@@ -78,14 +84,55 @@ class Bookmark(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def tag_names(self):
|
def tag_names(self):
|
||||||
# If tag projections were loaded then avoid querying all tags (=executing further selects)
|
return [tag.name for tag in self.tags.all()]
|
||||||
if self.tag_projection:
|
|
||||||
return parse_tag_string(self.tag_string)
|
|
||||||
else:
|
|
||||||
return [tag.name for tag in self.tags.all()]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.resolved_title + ' (' + self.url[:30] + '...)'
|
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkAsset(models.Model):
|
||||||
|
TYPE_SNAPSHOT = "snapshot"
|
||||||
|
TYPE_UPLOAD = "upload"
|
||||||
|
|
||||||
|
CONTENT_TYPE_HTML = "text/html"
|
||||||
|
|
||||||
|
STATUS_PENDING = "pending"
|
||||||
|
STATUS_COMPLETE = "complete"
|
||||||
|
STATUS_FAILURE = "failure"
|
||||||
|
|
||||||
|
bookmark = models.ForeignKey(Bookmark, on_delete=models.CASCADE)
|
||||||
|
date_created = models.DateTimeField(auto_now_add=True, null=False)
|
||||||
|
file = models.CharField(max_length=2048, blank=True, null=False)
|
||||||
|
file_size = models.IntegerField(null=True)
|
||||||
|
asset_type = models.CharField(max_length=64, blank=False, null=False)
|
||||||
|
content_type = models.CharField(max_length=128, blank=False, null=False)
|
||||||
|
display_name = models.CharField(max_length=2048, blank=True, null=False)
|
||||||
|
status = models.CharField(max_length=64, blank=False, null=False)
|
||||||
|
gzip = models.BooleanField(default=False, null=False)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.file:
|
||||||
|
try:
|
||||||
|
file_path = os.path.join(settings.LD_ASSET_FOLDER, self.file)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
self.file_size = os.path.getsize(file_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.display_name or f"Bookmark Asset #{self.pk}"
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=BookmarkAsset)
|
||||||
|
def bookmark_asset_deleted(sender, instance, **kwargs):
|
||||||
|
if instance.file:
|
||||||
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, instance.file)
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkForm(forms.ModelForm):
|
class BookmarkForm(forms.ModelForm):
|
||||||
@@ -93,61 +140,314 @@ class BookmarkForm(forms.ModelForm):
|
|||||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||||
tag_string = forms.CharField(required=False)
|
tag_string = forms.CharField(required=False)
|
||||||
# Do not require title and description in form as we fill these automatically if they are empty
|
# Do not require title and description in form as we fill these automatically if they are empty
|
||||||
title = forms.CharField(max_length=512,
|
title = forms.CharField(max_length=512, required=False)
|
||||||
required=False)
|
description = forms.CharField(required=False, widget=forms.Textarea())
|
||||||
description = forms.CharField(required=False,
|
# Include website title and description as hidden field as they only provide info when editing bookmarks
|
||||||
widget=forms.Textarea())
|
website_title = forms.CharField(
|
||||||
|
max_length=512, required=False, widget=forms.HiddenInput()
|
||||||
|
)
|
||||||
|
website_description = forms.CharField(required=False, widget=forms.HiddenInput())
|
||||||
|
unread = forms.BooleanField(required=False)
|
||||||
|
shared = forms.BooleanField(required=False)
|
||||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||||
auto_close = forms.CharField(required=False)
|
auto_close = forms.CharField(required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Bookmark
|
model = Bookmark
|
||||||
fields = ['url', 'tag_string', 'title', 'description', 'auto_close']
|
fields = [
|
||||||
|
"url",
|
||||||
|
"tag_string",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"notes",
|
||||||
|
"website_title",
|
||||||
|
"website_description",
|
||||||
|
"unread",
|
||||||
|
"shared",
|
||||||
|
"auto_close",
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_notes(self):
|
||||||
|
return self.instance and self.instance.notes
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkSearch:
|
||||||
|
SORT_ADDED_ASC = "added_asc"
|
||||||
|
SORT_ADDED_DESC = "added_desc"
|
||||||
|
SORT_TITLE_ASC = "title_asc"
|
||||||
|
SORT_TITLE_DESC = "title_desc"
|
||||||
|
|
||||||
|
FILTER_SHARED_OFF = "off"
|
||||||
|
FILTER_SHARED_SHARED = "yes"
|
||||||
|
FILTER_SHARED_UNSHARED = "no"
|
||||||
|
|
||||||
|
FILTER_UNREAD_OFF = "off"
|
||||||
|
FILTER_UNREAD_YES = "yes"
|
||||||
|
FILTER_UNREAD_NO = "no"
|
||||||
|
|
||||||
|
params = ["q", "user", "sort", "shared", "unread"]
|
||||||
|
preferences = ["sort", "shared", "unread"]
|
||||||
|
defaults = {
|
||||||
|
"q": "",
|
||||||
|
"user": "",
|
||||||
|
"sort": SORT_ADDED_DESC,
|
||||||
|
"shared": FILTER_SHARED_OFF,
|
||||||
|
"unread": FILTER_UNREAD_OFF,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
q: str = None,
|
||||||
|
user: str = None,
|
||||||
|
sort: str = None,
|
||||||
|
shared: str = None,
|
||||||
|
unread: str = None,
|
||||||
|
preferences: dict = None,
|
||||||
|
):
|
||||||
|
if not preferences:
|
||||||
|
preferences = {}
|
||||||
|
self.defaults = {**BookmarkSearch.defaults, **preferences}
|
||||||
|
|
||||||
|
self.q = q or self.defaults["q"]
|
||||||
|
self.user = user or self.defaults["user"]
|
||||||
|
self.sort = sort or self.defaults["sort"]
|
||||||
|
self.shared = shared or self.defaults["shared"]
|
||||||
|
self.unread = unread or self.defaults["unread"]
|
||||||
|
|
||||||
|
def is_modified(self, param):
|
||||||
|
value = self.__dict__[param]
|
||||||
|
return value != self.defaults[param]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modified_params(self):
|
||||||
|
return [field for field in self.params if self.is_modified(field)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modified_preferences(self):
|
||||||
|
return [
|
||||||
|
preference
|
||||||
|
for preference in self.preferences
|
||||||
|
if self.is_modified(preference)
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_modifications(self):
|
||||||
|
return len(self.modified_params) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_modified_preferences(self):
|
||||||
|
return len(self.modified_preferences) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def query_params(self):
|
||||||
|
return {param: self.__dict__[param] for param in self.modified_params}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preferences_dict(self):
|
||||||
|
return {
|
||||||
|
preference: self.__dict__[preference] for preference in self.preferences
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_request(query_dict: QueryDict, preferences: dict = None):
|
||||||
|
initial_values = {}
|
||||||
|
for param in BookmarkSearch.params:
|
||||||
|
value = query_dict.get(param)
|
||||||
|
if value:
|
||||||
|
initial_values[param] = value
|
||||||
|
|
||||||
|
return BookmarkSearch(**initial_values, preferences=preferences)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkSearchForm(forms.Form):
|
||||||
|
SORT_CHOICES = [
|
||||||
|
(BookmarkSearch.SORT_ADDED_ASC, "Added ↑"),
|
||||||
|
(BookmarkSearch.SORT_ADDED_DESC, "Added ↓"),
|
||||||
|
(BookmarkSearch.SORT_TITLE_ASC, "Title ↑"),
|
||||||
|
(BookmarkSearch.SORT_TITLE_DESC, "Title ↓"),
|
||||||
|
]
|
||||||
|
FILTER_SHARED_CHOICES = [
|
||||||
|
(BookmarkSearch.FILTER_SHARED_OFF, "Off"),
|
||||||
|
(BookmarkSearch.FILTER_SHARED_SHARED, "Shared"),
|
||||||
|
(BookmarkSearch.FILTER_SHARED_UNSHARED, "Unshared"),
|
||||||
|
]
|
||||||
|
FILTER_UNREAD_CHOICES = [
|
||||||
|
(BookmarkSearch.FILTER_UNREAD_OFF, "Off"),
|
||||||
|
(BookmarkSearch.FILTER_UNREAD_YES, "Unread"),
|
||||||
|
(BookmarkSearch.FILTER_UNREAD_NO, "Read"),
|
||||||
|
]
|
||||||
|
|
||||||
|
q = forms.CharField()
|
||||||
|
user = forms.ChoiceField()
|
||||||
|
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||||
|
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||||
|
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
search: BookmarkSearch,
|
||||||
|
editable_fields: List[str] = None,
|
||||||
|
users: List[User] = None,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
editable_fields = editable_fields or []
|
||||||
|
self.editable_fields = editable_fields
|
||||||
|
|
||||||
|
# set choices for user field if users are provided
|
||||||
|
if users:
|
||||||
|
user_choices = [(user.username, user.username) for user in users]
|
||||||
|
user_choices.insert(0, ("", "Everyone"))
|
||||||
|
self.fields["user"].choices = user_choices
|
||||||
|
|
||||||
|
for param in search.params:
|
||||||
|
# set initial values for modified params
|
||||||
|
self.fields[param].initial = search.__dict__[param]
|
||||||
|
|
||||||
|
# Mark non-editable modified fields as hidden. That way, templates
|
||||||
|
# rendering a form can just loop over hidden_fields to ensure that
|
||||||
|
# all necessary search options are kept when submitting the form.
|
||||||
|
if search.is_modified(param) and param not in editable_fields:
|
||||||
|
self.fields[param].widget = forms.HiddenInput()
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(models.Model):
|
class UserProfile(models.Model):
|
||||||
THEME_AUTO = 'auto'
|
THEME_AUTO = "auto"
|
||||||
THEME_LIGHT = 'light'
|
THEME_LIGHT = "light"
|
||||||
THEME_DARK = 'dark'
|
THEME_DARK = "dark"
|
||||||
THEME_CHOICES = [
|
THEME_CHOICES = [
|
||||||
(THEME_AUTO, 'Auto'),
|
(THEME_AUTO, "Auto"),
|
||||||
(THEME_LIGHT, 'Light'),
|
(THEME_LIGHT, "Light"),
|
||||||
(THEME_DARK, 'Dark'),
|
(THEME_DARK, "Dark"),
|
||||||
]
|
]
|
||||||
BOOKMARK_DATE_DISPLAY_RELATIVE = 'relative'
|
BOOKMARK_DATE_DISPLAY_RELATIVE = "relative"
|
||||||
BOOKMARK_DATE_DISPLAY_ABSOLUTE = 'absolute'
|
BOOKMARK_DATE_DISPLAY_ABSOLUTE = "absolute"
|
||||||
BOOKMARK_DATE_DISPLAY_HIDDEN = 'hidden'
|
BOOKMARK_DATE_DISPLAY_HIDDEN = "hidden"
|
||||||
BOOKMARK_DATE_DISPLAY_CHOICES = [
|
BOOKMARK_DATE_DISPLAY_CHOICES = [
|
||||||
(BOOKMARK_DATE_DISPLAY_RELATIVE, 'Relative'),
|
(BOOKMARK_DATE_DISPLAY_RELATIVE, "Relative"),
|
||||||
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, 'Absolute'),
|
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, "Absolute"),
|
||||||
(BOOKMARK_DATE_DISPLAY_HIDDEN, 'Hidden'),
|
(BOOKMARK_DATE_DISPLAY_HIDDEN, "Hidden"),
|
||||||
]
|
]
|
||||||
BOOKMARK_LINK_TARGET_BLANK = '_blank'
|
BOOKMARK_DESCRIPTION_DISPLAY_INLINE = "inline"
|
||||||
BOOKMARK_LINK_TARGET_SELF = '_self'
|
BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE = "separate"
|
||||||
|
BOOKMARK_DESCRIPTION_DISPLAY_CHOICES = [
|
||||||
|
(BOOKMARK_DESCRIPTION_DISPLAY_INLINE, "Inline"),
|
||||||
|
(BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE, "Separate"),
|
||||||
|
]
|
||||||
|
BOOKMARK_LINK_TARGET_BLANK = "_blank"
|
||||||
|
BOOKMARK_LINK_TARGET_SELF = "_self"
|
||||||
BOOKMARK_LINK_TARGET_CHOICES = [
|
BOOKMARK_LINK_TARGET_CHOICES = [
|
||||||
(BOOKMARK_LINK_TARGET_BLANK, 'New page'),
|
(BOOKMARK_LINK_TARGET_BLANK, "New page"),
|
||||||
(BOOKMARK_LINK_TARGET_SELF, 'Same page'),
|
(BOOKMARK_LINK_TARGET_SELF, "Same page"),
|
||||||
]
|
]
|
||||||
WEB_ARCHIVE_INTEGRATION_DISABLED = 'disabled'
|
WEB_ARCHIVE_INTEGRATION_DISABLED = "disabled"
|
||||||
WEB_ARCHIVE_INTEGRATION_ENABLED = 'enabled'
|
WEB_ARCHIVE_INTEGRATION_ENABLED = "enabled"
|
||||||
WEB_ARCHIVE_INTEGRATION_CHOICES = [
|
WEB_ARCHIVE_INTEGRATION_CHOICES = [
|
||||||
(WEB_ARCHIVE_INTEGRATION_DISABLED, 'Disabled'),
|
(WEB_ARCHIVE_INTEGRATION_DISABLED, "Disabled"),
|
||||||
(WEB_ARCHIVE_INTEGRATION_ENABLED, 'Enabled'),
|
(WEB_ARCHIVE_INTEGRATION_ENABLED, "Enabled"),
|
||||||
]
|
]
|
||||||
user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE)
|
TAG_SEARCH_STRICT = "strict"
|
||||||
theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO)
|
TAG_SEARCH_LAX = "lax"
|
||||||
bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False,
|
TAG_SEARCH_CHOICES = [
|
||||||
default=BOOKMARK_DATE_DISPLAY_RELATIVE)
|
(TAG_SEARCH_STRICT, "Strict"),
|
||||||
bookmark_link_target = models.CharField(max_length=10, choices=BOOKMARK_LINK_TARGET_CHOICES, blank=False,
|
(TAG_SEARCH_LAX, "Lax"),
|
||||||
default=BOOKMARK_LINK_TARGET_BLANK)
|
]
|
||||||
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
|
TAG_GROUPING_ALPHABETICAL = "alphabetical"
|
||||||
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
|
TAG_GROUPING_DISABLED = "disabled"
|
||||||
|
TAG_GROUPING_CHOICES = [
|
||||||
|
(TAG_GROUPING_ALPHABETICAL, "Alphabetical"),
|
||||||
|
(TAG_GROUPING_DISABLED, "Disabled"),
|
||||||
|
]
|
||||||
|
user = models.OneToOneField(
|
||||||
|
get_user_model(), related_name="profile", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
theme = models.CharField(
|
||||||
|
max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO
|
||||||
|
)
|
||||||
|
bookmark_date_display = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=BOOKMARK_DATE_DISPLAY_CHOICES,
|
||||||
|
blank=False,
|
||||||
|
default=BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||||
|
)
|
||||||
|
bookmark_description_display = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=BOOKMARK_DESCRIPTION_DISPLAY_CHOICES,
|
||||||
|
blank=False,
|
||||||
|
default=BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
|
||||||
|
)
|
||||||
|
bookmark_description_max_lines = models.IntegerField(
|
||||||
|
null=False,
|
||||||
|
default=1,
|
||||||
|
)
|
||||||
|
bookmark_link_target = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=BOOKMARK_LINK_TARGET_CHOICES,
|
||||||
|
blank=False,
|
||||||
|
default=BOOKMARK_LINK_TARGET_BLANK,
|
||||||
|
)
|
||||||
|
web_archive_integration = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=WEB_ARCHIVE_INTEGRATION_CHOICES,
|
||||||
|
blank=False,
|
||||||
|
default=WEB_ARCHIVE_INTEGRATION_DISABLED,
|
||||||
|
)
|
||||||
|
tag_search = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=TAG_SEARCH_CHOICES,
|
||||||
|
blank=False,
|
||||||
|
default=TAG_SEARCH_STRICT,
|
||||||
|
)
|
||||||
|
tag_grouping = models.CharField(
|
||||||
|
max_length=12,
|
||||||
|
choices=TAG_GROUPING_CHOICES,
|
||||||
|
blank=False,
|
||||||
|
default=TAG_GROUPING_ALPHABETICAL,
|
||||||
|
)
|
||||||
|
enable_sharing = models.BooleanField(default=False, null=False)
|
||||||
|
enable_public_sharing = models.BooleanField(default=False, null=False)
|
||||||
|
enable_favicons = models.BooleanField(default=False, null=False)
|
||||||
|
enable_preview_images = models.BooleanField(default=False, null=False)
|
||||||
|
display_url = models.BooleanField(default=False, null=False)
|
||||||
|
display_view_bookmark_action = models.BooleanField(default=True, null=False)
|
||||||
|
display_edit_bookmark_action = models.BooleanField(default=True, null=False)
|
||||||
|
display_archive_bookmark_action = models.BooleanField(default=True, null=False)
|
||||||
|
display_remove_bookmark_action = models.BooleanField(default=True, null=False)
|
||||||
|
permanent_notes = models.BooleanField(default=False, null=False)
|
||||||
|
custom_css = models.TextField(blank=True, null=False)
|
||||||
|
auto_tagging_rules = models.TextField(blank=True, null=False)
|
||||||
|
search_preferences = models.JSONField(default=dict, null=False)
|
||||||
|
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||||
|
default_mark_unread = models.BooleanField(default=False, null=False)
|
||||||
|
|
||||||
|
|
||||||
class UserProfileForm(forms.ModelForm):
|
class UserProfileForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserProfile
|
model = UserProfile
|
||||||
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration']
|
fields = [
|
||||||
|
"theme",
|
||||||
|
"bookmark_date_display",
|
||||||
|
"bookmark_description_display",
|
||||||
|
"bookmark_description_max_lines",
|
||||||
|
"bookmark_link_target",
|
||||||
|
"web_archive_integration",
|
||||||
|
"tag_search",
|
||||||
|
"tag_grouping",
|
||||||
|
"enable_sharing",
|
||||||
|
"enable_public_sharing",
|
||||||
|
"enable_favicons",
|
||||||
|
"enable_preview_images",
|
||||||
|
"enable_automatic_html_snapshots",
|
||||||
|
"display_url",
|
||||||
|
"display_view_bookmark_action",
|
||||||
|
"display_edit_bookmark_action",
|
||||||
|
"display_archive_bookmark_action",
|
||||||
|
"display_remove_bookmark_action",
|
||||||
|
"permanent_notes",
|
||||||
|
"default_mark_unread",
|
||||||
|
"custom_css",
|
||||||
|
"auto_tagging_rules",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=get_user_model())
|
@receiver(post_save, sender=get_user_model())
|
||||||
@@ -166,3 +466,29 @@ class Toast(models.Model):
|
|||||||
message = models.TextField()
|
message = models.TextField()
|
||||||
acknowledged = models.BooleanField(default=False)
|
acknowledged = models.BooleanField(default=False)
|
||||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
|
||||||
|
class FeedToken(models.Model):
|
||||||
|
"""
|
||||||
|
Adapted from authtoken.models.Token
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = models.CharField(max_length=40, primary_key=True)
|
||||||
|
user = models.OneToOneField(
|
||||||
|
get_user_model(),
|
||||||
|
related_name="feed_token",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.key:
|
||||||
|
self.key = self.generate_key()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_key(cls):
|
||||||
|
return binascii.hexlify(os.urandom(20)).decode()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.key
|
||||||
|
@@ -1,110 +1,200 @@
|
|||||||
from django.contrib.auth.models import User
|
from typing import Optional
|
||||||
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField, QuerySet
|
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, Tag
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
|
||||||
|
from django.db.models.expressions import RawSQL
|
||||||
|
from django.db.models.functions import Lower
|
||||||
|
|
||||||
|
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||||
from bookmarks.utils import unique
|
from bookmarks.utils import unique
|
||||||
|
|
||||||
|
|
||||||
class Concat(Aggregate):
|
def query_bookmarks(
|
||||||
function = 'GROUP_CONCAT'
|
user: User, profile: UserProfile, search: BookmarkSearch
|
||||||
template = '%(function)s(%(distinct)s%(expressions)s)'
|
) -> QuerySet:
|
||||||
|
return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
|
||||||
def __init__(self, expression, distinct=False, **extra):
|
|
||||||
super(Concat, self).__init__(
|
|
||||||
expression,
|
|
||||||
distinct='DISTINCT ' if distinct else '',
|
|
||||||
output_field=CharField(),
|
|
||||||
**extra)
|
|
||||||
|
|
||||||
|
|
||||||
def query_bookmarks(user: User, query_string: str) -> QuerySet:
|
def query_archived_bookmarks(
|
||||||
return _base_bookmarks_query(user, query_string) \
|
user: User, profile: UserProfile, search: BookmarkSearch
|
||||||
.filter(is_archived=False)
|
) -> QuerySet:
|
||||||
|
return _base_bookmarks_query(user, profile, search).filter(is_archived=True)
|
||||||
|
|
||||||
|
|
||||||
def query_archived_bookmarks(user: User, query_string: str) -> QuerySet:
|
def query_shared_bookmarks(
|
||||||
return _base_bookmarks_query(user, query_string) \
|
user: Optional[User],
|
||||||
.filter(is_archived=True)
|
profile: UserProfile,
|
||||||
|
search: BookmarkSearch,
|
||||||
|
public_only: bool,
|
||||||
|
) -> QuerySet:
|
||||||
|
conditions = Q(shared=True) & Q(owner__profile__enable_sharing=True)
|
||||||
|
if public_only:
|
||||||
|
conditions = conditions & Q(owner__profile__enable_public_sharing=True)
|
||||||
|
|
||||||
|
return _base_bookmarks_query(user, profile, search).filter(conditions)
|
||||||
|
|
||||||
|
|
||||||
def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
|
def _base_bookmarks_query(
|
||||||
# Add aggregated tag info to bookmark instances
|
user: Optional[User], profile: UserProfile, search: BookmarkSearch
|
||||||
query_set = Bookmark.objects \
|
) -> QuerySet:
|
||||||
.annotate(tag_count=Count('tags'),
|
query_set = Bookmark.objects
|
||||||
tag_string=Concat('tags__name'),
|
|
||||||
tag_projection=Value(True, BooleanField()))
|
|
||||||
|
|
||||||
# Filter for user
|
# Filter for user
|
||||||
query_set = query_set.filter(owner=user)
|
if user:
|
||||||
|
query_set = query_set.filter(owner=user)
|
||||||
|
|
||||||
# Split query into search terms and tags
|
# Split query into search terms and tags
|
||||||
query = _parse_query_string(query_string)
|
query = parse_query_string(search.q)
|
||||||
|
|
||||||
# Filter for search terms and tags
|
# Filter for search terms and tags
|
||||||
for term in query['search_terms']:
|
for term in query["search_terms"]:
|
||||||
query_set = query_set.filter(
|
conditions = (
|
||||||
Q(title__contains=term)
|
Q(title__icontains=term)
|
||||||
| Q(description__contains=term)
|
| Q(description__icontains=term)
|
||||||
| Q(website_title__contains=term)
|
| Q(notes__icontains=term)
|
||||||
| Q(website_description__contains=term)
|
| Q(website_title__icontains=term)
|
||||||
| Q(url__contains=term)
|
| Q(website_description__icontains=term)
|
||||||
|
| Q(url__icontains=term)
|
||||||
)
|
)
|
||||||
|
|
||||||
for tag_name in query['tag_names']:
|
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||||
query_set = query_set.filter(
|
conditions = conditions | Exists(
|
||||||
tags__name__iexact=tag_name
|
Bookmark.objects.filter(id=OuterRef("id"), tags__name__iexact=term)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
query_set = query_set.filter(conditions)
|
||||||
|
|
||||||
|
for tag_name in query["tag_names"]:
|
||||||
|
query_set = query_set.filter(tags__name__iexact=tag_name)
|
||||||
|
|
||||||
# Untagged bookmarks
|
# Untagged bookmarks
|
||||||
if query['untagged']:
|
if query["untagged"]:
|
||||||
query_set = query_set.filter(
|
query_set = query_set.filter(tags=None)
|
||||||
tags=None
|
# Legacy unread bookmarks filter from query
|
||||||
)
|
if query["unread"]:
|
||||||
|
query_set = query_set.filter(unread=True)
|
||||||
|
|
||||||
|
# Unread filter from bookmark search
|
||||||
|
if search.unread == BookmarkSearch.FILTER_UNREAD_YES:
|
||||||
|
query_set = query_set.filter(unread=True)
|
||||||
|
elif search.unread == BookmarkSearch.FILTER_UNREAD_NO:
|
||||||
|
query_set = query_set.filter(unread=False)
|
||||||
|
|
||||||
|
# Shared filter
|
||||||
|
if search.shared == BookmarkSearch.FILTER_SHARED_SHARED:
|
||||||
|
query_set = query_set.filter(shared=True)
|
||||||
|
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
|
||||||
|
query_set = query_set.filter(shared=False)
|
||||||
|
|
||||||
# Sort by date added
|
# Sort by date added
|
||||||
query_set = query_set.order_by('-date_added')
|
if search.sort == BookmarkSearch.SORT_ADDED_ASC:
|
||||||
|
query_set = query_set.order_by("date_added")
|
||||||
|
elif search.sort == BookmarkSearch.SORT_ADDED_DESC:
|
||||||
|
query_set = query_set.order_by("-date_added")
|
||||||
|
|
||||||
|
# Sort by title
|
||||||
|
if (
|
||||||
|
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
||||||
|
or search.sort == BookmarkSearch.SORT_TITLE_DESC
|
||||||
|
):
|
||||||
|
# For the title, the resolved_title logic from the Bookmark entity needs
|
||||||
|
# to be replicated as there is no corresponding database field
|
||||||
|
query_set = query_set.annotate(
|
||||||
|
effective_title=Case(
|
||||||
|
When(Q(title__isnull=False) & ~Q(title__exact=""), then=Lower("title")),
|
||||||
|
When(
|
||||||
|
Q(website_title__isnull=False) & ~Q(website_title__exact=""),
|
||||||
|
then=Lower("website_title"),
|
||||||
|
),
|
||||||
|
default=Lower("url"),
|
||||||
|
output_field=CharField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# For SQLite, if the ICU extension is loaded, use the custom collation
|
||||||
|
# loaded into the connection. This results in an improved sort order for
|
||||||
|
# unicode characters (umlauts, etc.)
|
||||||
|
if settings.USE_SQLITE and settings.USE_SQLITE_ICU_EXTENSION:
|
||||||
|
order_field = RawSQL("effective_title COLLATE ICU", ())
|
||||||
|
else:
|
||||||
|
order_field = "effective_title"
|
||||||
|
|
||||||
|
if search.sort == BookmarkSearch.SORT_TITLE_ASC:
|
||||||
|
query_set = query_set.order_by(order_field)
|
||||||
|
elif search.sort == BookmarkSearch.SORT_TITLE_DESC:
|
||||||
|
query_set = query_set.order_by(order_field).reverse()
|
||||||
|
|
||||||
return query_set
|
return query_set
|
||||||
|
|
||||||
|
|
||||||
def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
|
def query_bookmark_tags(
|
||||||
bookmarks_query = query_bookmarks(user, query_string)
|
user: User, profile: UserProfile, search: BookmarkSearch
|
||||||
|
) -> QuerySet:
|
||||||
|
bookmarks_query = query_bookmarks(user, profile, search)
|
||||||
|
|
||||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||||
|
|
||||||
return query_set.distinct()
|
return query_set.distinct()
|
||||||
|
|
||||||
|
|
||||||
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
|
def query_archived_bookmark_tags(
|
||||||
bookmarks_query = query_archived_bookmarks(user, query_string)
|
user: User, profile: UserProfile, search: BookmarkSearch
|
||||||
|
) -> QuerySet:
|
||||||
|
bookmarks_query = query_archived_bookmarks(user, profile, search)
|
||||||
|
|
||||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||||
|
|
||||||
return query_set.distinct()
|
return query_set.distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def query_shared_bookmark_tags(
|
||||||
|
user: Optional[User],
|
||||||
|
profile: UserProfile,
|
||||||
|
search: BookmarkSearch,
|
||||||
|
public_only: bool,
|
||||||
|
) -> QuerySet:
|
||||||
|
bookmarks_query = query_shared_bookmarks(user, profile, search, public_only)
|
||||||
|
|
||||||
|
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||||
|
|
||||||
|
return query_set.distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def query_shared_bookmark_users(
|
||||||
|
profile: UserProfile, search: BookmarkSearch, public_only: bool
|
||||||
|
) -> QuerySet:
|
||||||
|
bookmarks_query = query_shared_bookmarks(None, profile, search, public_only)
|
||||||
|
|
||||||
|
query_set = User.objects.filter(bookmark__in=bookmarks_query)
|
||||||
|
|
||||||
|
return query_set.distinct()
|
||||||
|
|
||||||
|
|
||||||
def get_user_tags(user: User):
|
def get_user_tags(user: User):
|
||||||
return Tag.objects.filter(owner=user).all()
|
return Tag.objects.filter(owner=user).all()
|
||||||
|
|
||||||
|
|
||||||
def _parse_query_string(query_string):
|
def parse_query_string(query_string):
|
||||||
# Sanitize query params
|
# Sanitize query params
|
||||||
if not query_string:
|
if not query_string:
|
||||||
query_string = ''
|
query_string = ""
|
||||||
|
|
||||||
# Split query into search terms and tags
|
# Split query into search terms and tags
|
||||||
keywords = query_string.strip().split(' ')
|
keywords = query_string.strip().split(" ")
|
||||||
keywords = [word for word in keywords if word]
|
keywords = [word for word in keywords if word]
|
||||||
|
|
||||||
search_terms = [word for word in keywords if word[0] != '#' and word[0] != '!']
|
search_terms = [word for word in keywords if word[0] != "#" and word[0] != "!"]
|
||||||
tag_names = [word[1:] for word in keywords if word[0] == '#']
|
tag_names = [word[1:] for word in keywords if word[0] == "#"]
|
||||||
tag_names = unique(tag_names, str.lower)
|
tag_names = unique(tag_names, str.lower)
|
||||||
|
|
||||||
# Special search commands
|
# Special search commands
|
||||||
untagged = '!untagged' in keywords
|
untagged = "!untagged" in keywords
|
||||||
|
unread = "!unread" in keywords
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'search_terms': search_terms,
|
"search_terms": search_terms,
|
||||||
'tag_names': tag_names,
|
"tag_names": tag_names,
|
||||||
'untagged': untagged,
|
"untagged": untagged,
|
||||||
|
"unread": unread,
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user