mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-15 06:29:21 +02:00
Compare commits
267 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 | ||
![]() |
7719c5b1ba | ||
![]() |
e08bf9fd03 | ||
![]() |
a9bf111ff1 | ||
![]() |
54b0b32b80 | ||
![]() |
bd7a937430 | ||
![]() |
138dfe392c | ||
![]() |
d7f257b3c6 | ||
![]() |
ebbf0022bc | ||
![]() |
f4e3d724f0 | ||
![]() |
117160ea87 | ||
![]() |
e14458f5cd | ||
![]() |
179d0c26ca | ||
![]() |
5f5f470f52 | ||
![]() |
3e521493b9 | ||
![]() |
bbaa1669cd | ||
![]() |
fb779cf6d6 | ||
![]() |
14e4950fec | ||
![]() |
f92c3dd403 | ||
![]() |
56173aea3f | ||
![]() |
c97d5c3dc5 | ||
![]() |
6dd07edf90 | ||
![]() |
1274d6dd4f | ||
![]() |
6cf35ecca6 | ||
![]() |
c5c527400c | ||
![]() |
dc0a4e33bd | ||
![]() |
ca5dcb882c | ||
![]() |
04649e0901 | ||
![]() |
a85f1cfe83 | ||
![]() |
3906d9e5b8 | ||
![]() |
eca98a13f5 | ||
![]() |
10e5861f01 | ||
![]() |
f68c67e272 | ||
![]() |
e2a52b9cba | ||
![]() |
4ad2d2111a | ||
![]() |
c16e87f9c7 | ||
![]() |
673466ab28 | ||
![]() |
13f27f5412 | ||
![]() |
530c4b74c4 | ||
![]() |
3eb8cfe45e | ||
![]() |
f5b07eebba | ||
![]() |
3ba8f7e30b | ||
![]() |
9a63c367a8 | ||
![]() |
edb71286e7 | ||
![]() |
1ffc3e0266 | ||
![]() |
66995cfab2 | ||
![]() |
68143de992 | ||
![]() |
b93a9fadb6 | ||
![]() |
77fea02f77 | ||
![]() |
fcc0b6f591 | ||
![]() |
e1c9a7add6 | ||
![]() |
82b4268a26 | ||
![]() |
5287eb3f8b | ||
![]() |
d298260122 | ||
![]() |
12e5810aee | ||
![]() |
1dabd0266b | ||
![]() |
7390fc3f4f | ||
![]() |
5e003ede92 | ||
![]() |
984eef92e2 | ||
![]() |
eae6ca6e07 | ||
![]() |
a6bfaa7c78 | ||
![]() |
7aa1630be2 | ||
![]() |
4f9fcb41bd | ||
![]() |
da4a81305a | ||
![]() |
df33144dd0 | ||
![]() |
123fa54d5a | ||
![]() |
2ab4aa5566 | ||
![]() |
d4cba7d5fa | ||
![]() |
3d8fd66e50 | ||
![]() |
3ff7a5ba91 | ||
![]() |
88c109c9a4 | ||
![]() |
a1d5ff6532 | ||
![]() |
e7c55cd318 | ||
![]() |
d87dde6bae | ||
![]() |
8d214649b7 | ||
![]() |
dfb040bbb1 | ||
![]() |
076c5d7658 | ||
![]() |
e47c00bd07 | ||
![]() |
55a0d189dd | ||
![]() |
d39ce076ec | ||
![]() |
aa0258d3b6 | ||
![]() |
937858cf58 | ||
![]() |
8047ba6c63 | ||
![]() |
de903bc341 | ||
![]() |
c8fcc426b0 | ||
![]() |
eb915210d3 | ||
![]() |
ad9a0f84f2 | ||
![]() |
cc04a17e2f | ||
![]() |
69105d3d3c | ||
![]() |
c269d16855 | ||
![]() |
90ee3cdb94 | ||
![]() |
2c19266ef8 | ||
![]() |
048a8b1162 | ||
![]() |
2fb0bb1224 | ||
![]() |
3e48b22095 | ||
![]() |
9aa17d0528 | ||
![]() |
d643fca98f | ||
![]() |
f293fa15bc | ||
![]() |
f58434077b | ||
![]() |
59641e787c | ||
![]() |
0d36a3bb86 | ||
![]() |
b25f3d5529 | ||
![]() |
24746deaae | ||
![]() |
e4a082231f | ||
![]() |
5a380212d9 | ||
![]() |
96068719cd | ||
![]() |
e42d562750 | ||
![]() |
ff456b10ee | ||
![]() |
3a05666680 | ||
![]() |
dbe92b4b84 | ||
![]() |
90f62d3482 | ||
![]() |
847f9644f4 | ||
![]() |
bf84b3ddfd | ||
![]() |
2d19e97212 | ||
![]() |
c083997399 | ||
![]() |
36f134db9a | ||
![]() |
593d90d8e2 | ||
![]() |
7a68a4abed | ||
![]() |
8dd1575dc6 | ||
![]() |
d4d23daebc | ||
![]() |
a73910d9c7 | ||
![]() |
0c1c21c8d1 | ||
![]() |
2e4f271490 | ||
![]() |
61b13dc531 | ||
![]() |
e976fd054c | ||
![]() |
119d8f7efb | ||
![]() |
3e5e825032 | ||
![]() |
dc1f6f9c44 | ||
![]() |
9e0114ea49 | ||
![]() |
84508e07cd | ||
![]() |
496c5badbf | ||
![]() |
1c5d92dc73 | ||
![]() |
b11444f98e | ||
![]() |
ad070e7019 | ||
![]() |
6880c9ee56 | ||
![]() |
e773ad1dc4 | ||
![]() |
a02338cdec | ||
![]() |
8c161ba119 | ||
![]() |
5644dae14e | ||
![]() |
58836c3c76 | ||
![]() |
b7a8f9e53d | ||
![]() |
afe081d3b5 | ||
![]() |
7a14c6e2d1 | ||
![]() |
f7e6fbc588 | ||
![]() |
778f1b2ff3 | ||
![]() |
79dd4179d2 | ||
![]() |
0980e6a2b2 |
3
.coveragerc
Normal file
3
.coveragerc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[run]
|
||||||
|
source = bookmarks
|
||||||
|
omit = bookmarks/tests/*
|
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 && npm install && mkdir -p data && python3 manage.py migrate",
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"remoteUser": "vscode"
|
||||||
|
}
|
@@ -5,13 +5,30 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/tmp
|
/tmp
|
||||||
/docs
|
/docs
|
||||||
|
/static
|
||||||
|
/scripts
|
||||||
|
/build
|
||||||
|
/out
|
||||||
|
/.git
|
||||||
|
/.devcontainer
|
||||||
|
|
||||||
/.dockerignore
|
/.dockerignore
|
||||||
/.gitignore
|
/.gitignore
|
||||||
/build-*.sh
|
/.gitattributes
|
||||||
/Dockerfile
|
/Dockerfile
|
||||||
|
/docker-compose.yml
|
||||||
|
/*.sh
|
||||||
/*.iml
|
/*.iml
|
||||||
/package*.json
|
/*.patch
|
||||||
|
/*.md
|
||||||
|
/*.js
|
||||||
|
/*.log
|
||||||
|
/*.pid
|
||||||
|
|
||||||
|
# Whitelist files needed in build or prod image
|
||||||
|
!/rollup.config.js
|
||||||
|
!/bootstrap.sh
|
||||||
|
!/background-tasks-wrapper.sh
|
||||||
|
|
||||||
# Remove development settings
|
# Remove development settings
|
||||||
/siteroot/settings/dev.py
|
/siteroot/settings/dev.py
|
||||||
|
42
.env.sample
42
.env.sample
@@ -5,5 +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
|
||||||
|
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
|
43
.github/workflows/main.yaml
vendored
43
.github/workflows/main.yaml
vendored
@@ -3,16 +3,45 @@ name: linkding CI
|
|||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
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@v3
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v1
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.7
|
python-version: "3.10"
|
||||||
- name: Install dependencies
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
- name: Install Node dependencies
|
||||||
|
run: npm install
|
||||||
|
- name: Setup Python environment
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
- 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@v3
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
- name: Install Node dependencies
|
||||||
|
run: npm install
|
||||||
|
- name: Setup Python environment
|
||||||
|
run: |
|
||||||
|
pip install -r requirements.txt
|
||||||
|
playwright install chromium
|
||||||
|
python manage.py compilescss
|
||||||
|
python manage.py collectstatic --ignore=*.scss
|
||||||
|
- name: Run tests
|
||||||
|
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||||
|
49
.gitignore
vendored
49
.gitignore
vendored
@@ -3,55 +3,14 @@
|
|||||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
|
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
|
||||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
# User-specific stuff
|
|
||||||
.idea/**/workspace.xml
|
|
||||||
.idea/**/tasks.xml
|
|
||||||
.idea/**/dictionaries
|
|
||||||
.idea/**/shelf
|
|
||||||
|
|
||||||
# Sensitive or high-churn files
|
|
||||||
.idea/**/dataSources/
|
|
||||||
.idea/**/dataSources.ids
|
|
||||||
.idea/**/dataSources.local.xml
|
|
||||||
.idea/**/sqlDataSources.xml
|
|
||||||
.idea/**/dynamic.xml
|
|
||||||
.idea/**/uiDesigner.xml
|
|
||||||
.idea/**/dbnavigator.xml
|
|
||||||
|
|
||||||
# Gradle
|
|
||||||
.idea/**/gradle.xml
|
|
||||||
.idea/**/libraries
|
|
||||||
|
|
||||||
# CMake
|
|
||||||
cmake-build-debug/
|
|
||||||
cmake-build-release/
|
|
||||||
|
|
||||||
# Mongo Explorer plugin
|
|
||||||
.idea/**/mongoSettings.xml
|
|
||||||
|
|
||||||
# File-based project format
|
# File-based project format
|
||||||
*.iws
|
*.iws
|
||||||
|
|
||||||
# IntelliJ
|
# IntelliJ
|
||||||
|
.idea
|
||||||
|
*.iml
|
||||||
out/
|
out/
|
||||||
|
|
||||||
# mpeltonen/sbt-idea plugin
|
|
||||||
.idea_modules/
|
|
||||||
|
|
||||||
# JIRA plugin
|
|
||||||
atlassian-ide-plugin.xml
|
|
||||||
|
|
||||||
# Cursive Clojure plugin
|
|
||||||
.idea/replstate.xml
|
|
||||||
|
|
||||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
|
||||||
com_crashlytics_export_strings.xml
|
|
||||||
crashlytics.properties
|
|
||||||
crashlytics-build.properties
|
|
||||||
fabric.properties
|
|
||||||
|
|
||||||
# Editor-based Rest Client
|
|
||||||
.idea/httpRequests
|
|
||||||
### Python template
|
### Python template
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -223,7 +182,9 @@ 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.
|
||||||
|
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
ignoreIssuesWith: [
|
|
||||||
"wontfix",
|
|
||||||
"duplicate"
|
|
||||||
]
|
|
||||||
}
|
|
15
.idea/compiler.xml
generated
15
.idea/compiler.xml
generated
@@ -1,15 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="CompilerConfiguration">
|
|
||||||
<wildcardResourcePatterns>
|
|
||||||
<entry name="!?*.java" />
|
|
||||||
<entry name="!?*.form" />
|
|
||||||
<entry name="!?*.class" />
|
|
||||||
<entry name="!?*.groovy" />
|
|
||||||
<entry name="!?*.scala" />
|
|
||||||
<entry name="!?*.flex" />
|
|
||||||
<entry name="!?*.kt" />
|
|
||||||
<entry name="!?*.clj" />
|
|
||||||
</wildcardResourcePatterns>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
11
.idea/dataSources.xml
generated
11
.idea/dataSources.xml
generated
@@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
|
||||||
<data-source source="LOCAL" name="SQLite - db.sqlite3" uuid="c880bd6d-554c-484d-a5be-45581d9a9377">
|
|
||||||
<driver-ref>sqlite.xerial</driver-ref>
|
|
||||||
<synchronize>true</synchronize>
|
|
||||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
|
||||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/db.sqlite3</jdbc-url>
|
|
||||||
</data-source>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
4
.idea/encodings.xml
generated
4
.idea/encodings.xml
generated
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
|
|
||||||
</project>
|
|
12
.idea/inspectionProfiles/Project_Default.xml
generated
12
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,12 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<profile version="1.0">
|
|
||||||
<option name="myName" value="Project Default" />
|
|
||||||
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
|
||||||
<option name="ignoredErrors">
|
|
||||||
<list>
|
|
||||||
<option value="E402" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</inspection_tool>
|
|
||||||
</profile>
|
|
||||||
</component>
|
|
13
.idea/misc.xml
generated
13
.idea/misc.xml
generated
@@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="HaskellBuildOptions">
|
|
||||||
<ghcPath>/usr/local/bin/ghc</ghcPath>
|
|
||||||
<stackPath>/usr/local/bin/stack</stackPath>
|
|
||||||
</component>
|
|
||||||
<component name="JavaScriptSettings">
|
|
||||||
<option name="languageLevel" value="ES6" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_6" default="false" project-jdk-name="Python 3.7 (linkding)" project-jdk-type="Python SDK">
|
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/linkdings.iml" filepath="$PROJECT_DIR$/linkdings.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
4
.idea/watcherTasks.xml
generated
4
.idea/watcherTasks.xml
generated
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
|
|
||||||
</project>
|
|
446
CHANGELOG.md
446
CHANGELOG.md
@@ -1,6 +1,441 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.8.8...v1.9.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.8.8 (27/03/2022)
|
||||||
|
|
||||||
|
- [**bug**] Prevent bookmark actions through get requests
|
||||||
|
- [**bug**] Prevent external redirects
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- [**chore**] Bump NPM packages [#224](https://github.com/sissbruecker/linkding/pull/224)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.8.6 (25/03/2022)
|
||||||
|
|
||||||
|
- [bug] fix bookmark access restrictions
|
||||||
|
- [bug] prevent external redirects
|
||||||
|
- [chore] bump dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- [**enhancement**] Make bookmarks count column in admin sortable [#183](https://github.com/sissbruecker/linkding/pull/183)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.8.4 (16/10/2021)
|
||||||
|
|
||||||
|
- [**enhancement**] Allow non-admin users to change their password [#166](https://github.com/sissbruecker/linkding/issues/166)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.8.2 (02/10/2021)
|
||||||
|
|
||||||
|
- [**bug**] Fix jumping search box [#163](https://github.com/sissbruecker/linkding/pull/163)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.8.1 (01/10/2021)
|
||||||
|
|
||||||
|
- [**enhancement**] Add global shortcut for search [#161](https://github.com/sissbruecker/linkding/pull/161)
|
||||||
|
- allows to press `s` to focus the search input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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/)
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
- [**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)
|
||||||
|
|
||||||
|
- [**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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.7.0 (17/08/2021)
|
||||||
|
|
||||||
|
- Upgrade to Django 3
|
||||||
|
- Bump other dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.6.5 (15/08/2021)
|
||||||
|
|
||||||
|
- [**enhancement**] query with multiple hashtags very slow [#112](https://github.com/sissbruecker/linkding/issues/112)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.6.4 (13/05/2021)
|
||||||
|
|
||||||
|
- Update dependencies for security fixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.6.3 (06/04/2021)
|
||||||
|
|
||||||
|
- [**bug**] relative names use the wrong "today" after day change [#107](https://github.com/sissbruecker/linkding/issues/107)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.6.2 (04/04/2021)
|
||||||
|
|
||||||
|
- [**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**] Add archive/unarchive button to edit bookmark page [#82](https://github.com/sissbruecker/linkding/issues/82)
|
||||||
|
- [**enhancement**] Make scraped title and description editable [#80](https://github.com/sissbruecker/linkding/issues/80)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.6.1 (31/03/2021)
|
||||||
|
|
||||||
|
- Expose date_added in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.6.0 (28/03/2021)
|
||||||
|
|
||||||
|
- Bulk edit mode [#101](https://github.com/sissbruecker/linkding/pull/101)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.5.0 (28/03/2021)
|
||||||
|
|
||||||
|
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.4.1 (20/03/2021)
|
||||||
|
|
||||||
|
- Security patches
|
||||||
|
- Documentation improvements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.4.0 (24/02/2021)
|
||||||
|
|
||||||
|
- [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.3.3 (18/02/2021)
|
||||||
|
|
||||||
|
- [**closed**] Missing "description" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.3.2 (18/02/2021)
|
||||||
|
|
||||||
|
- [**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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.3.1 (15/02/2021)
|
||||||
|
|
||||||
|
[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)
|
||||||
@@ -12,27 +447,34 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 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)
|
## v1.1.0 (31/12/2020)
|
||||||
- [**enhancement**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)
|
|
||||||
|
- [**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)
|
- [**enhancement**] Improve Netscape bookmarks file parsing [#50](https://github.com/sissbruecker/linkding/issues/50)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.0.0 (31/12/2020)
|
## v1.0.0 (31/12/2020)
|
||||||
|
|
||||||
- [**bug**] Import does not import bookmark descriptions [#47](https://github.com/sissbruecker/linkding/issues/47)
|
- [**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)
|
- [**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)
|
- [**bug**] Increase limit on bookmark URL length [#25](https://github.com/sissbruecker/linkding/issues/25)
|
||||||
@@ -41,4 +483,4 @@
|
|||||||
- [**bug**] Error importing bookmarks [#18](https://github.com/sissbruecker/linkding/issues/18)
|
- [**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**] 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)
|
- [**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)
|
- [**bug**] CSS Stylesheet presented as text/plain [#2](https://github.com/sissbruecker/linkding/issues/2)
|
64
Dockerfile
64
Dockerfile
@@ -1,22 +1,58 @@
|
|||||||
FROM python:3.7-slim-stretch
|
FROM node:18.13.0-alpine AS node-build
|
||||||
|
|
||||||
# Install packages required for uswgi
|
|
||||||
RUN apt-get update
|
|
||||||
RUN apt-get -y install build-essential
|
|
||||||
RUN apt-get -y install mime-support
|
|
||||||
|
|
||||||
# Install requirements and uwsgi server for running python web apps
|
|
||||||
WORKDIR /etc/linkding
|
WORKDIR /etc/linkding
|
||||||
COPY requirements.prod.txt ./requirements.txt
|
# install build dependencies
|
||||||
RUN pip install -U pip
|
COPY package.json package-lock.json ./
|
||||||
RUN pip install -Ur requirements.txt
|
RUN npm install -g npm && \
|
||||||
|
npm install
|
||||||
# Copy application
|
# compile JS components
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.10.6-slim-buster AS python-base
|
||||||
|
RUN apt-get update && apt-get -y install build-essential libpq-dev
|
||||||
|
WORKDIR /etc/linkding
|
||||||
|
|
||||||
|
|
||||||
|
FROM python-base AS python-build
|
||||||
|
# install build dependencies
|
||||||
|
COPY requirements.txt requirements.txt
|
||||||
|
RUN pip install -U pip && pip install -Ur requirements.txt
|
||||||
|
# run Django part of the build
|
||||||
|
COPY --from=node-build /etc/linkding .
|
||||||
|
RUN python manage.py compilescss && \
|
||||||
|
python manage.py collectstatic --ignore=*.scss && \
|
||||||
|
python manage.py compilescss --delete-files
|
||||||
|
|
||||||
|
|
||||||
|
FROM python-base AS prod-deps
|
||||||
|
COPY requirements.prod.txt ./requirements.txt
|
||||||
|
RUN mkdir /opt/venv && \
|
||||||
|
python -m venv --upgrade-deps --copies /opt/venv && \
|
||||||
|
/opt/venv/bin/pip install --upgrade pip wheel && \
|
||||||
|
/opt/venv/bin/pip install -Ur requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.10.6-slim-buster as final
|
||||||
|
RUN apt-get update && apt-get -y install mime-support libpq-dev curl
|
||||||
|
WORKDIR /etc/linkding
|
||||||
|
# copy prod dependencies
|
||||||
|
COPY --from=prod-deps /opt/venv /opt/venv
|
||||||
|
# copy output from build stage
|
||||||
|
COPY --from=python-build /etc/linkding/static static/
|
||||||
|
# copy application code
|
||||||
|
COPY . .
|
||||||
# Expose uwsgi server at port 9090
|
# Expose uwsgi server at port 9090
|
||||||
EXPOSE 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 bootstrap logic
|
||||||
RUN ["chmod", "+x", "./bootstrap.sh"]
|
RUN ["chmod", "+x", "./bootstrap.sh"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
||||||
|
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
||||||
|
|
||||||
CMD ["./bootstrap.sh"]
|
CMD ["./bootstrap.sh"]
|
||||||
|
38
Options.md
38
Options.md
@@ -1,38 +0,0 @@
|
|||||||
# Options
|
|
||||||
|
|
||||||
This document lists the options that linkding can be configured with and explains how to use them in the individual install scenarios.
|
|
||||||
|
|
||||||
## Using options
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
Options are passed as environment variables to the Docker container by using the `-e` argument when using `docker run`. For example:
|
|
||||||
|
|
||||||
```
|
|
||||||
docker run --name linkding -p 9090:9090 -d -e LD_DISABLE_URL_VALIDATION=True sissbruecker/linkding:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
For multiple options, use one `-e` argument per option.
|
|
||||||
|
|
||||||
### Docker-compose
|
|
||||||
|
|
||||||
For docker-compose options are configured using an `.env` file.
|
|
||||||
Follow the docker-compose setup in the README and copy `.env.sample` to `.env`. Then modify the options in `.env`.
|
|
||||||
|
|
||||||
### Manual setup
|
|
||||||
|
|
||||||
All options need to be defined as environment variables in the environment that linkding runs in.
|
|
||||||
|
|
||||||
## List of options
|
|
||||||
|
|
||||||
### `LD_DISABLE_URL_VALIDATION`
|
|
||||||
|
|
||||||
Values: `True`, `False` | Default = `False`
|
|
||||||
|
|
||||||
Completely disables URL validation for bookmarks. This can be useful if you intend to store non fully qualified domain name URLs, such as network paths, or you want to store URLs that use another protocol than `http` or `https`.
|
|
||||||
|
|
||||||
### `LD_REQUEST_TIMEOUT`
|
|
||||||
|
|
||||||
Values: `Integer` as seconds | Default = `60`
|
|
||||||
|
|
||||||
Configures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts.
|
|
214
README.md
214
README.md
@@ -1,13 +1,53 @@
|
|||||||
# linkding
|
<div align="center">
|
||||||
|
<br>
|
||||||
|
<a href="https://github.com/sissbruecker/linkding">
|
||||||
|
<img src="docs/header.svg" height="50">
|
||||||
|
</a>
|
||||||
|
<br>
|
||||||
|
</div>
|
||||||
|
|
||||||
*linkding* is a simple bookmark service that you can host yourself. It supports managing bookmarks, categorizing them with tags and has a search function. It provides a bookmarklet for quickly adding new bookmarks while browsing the web. It also supports import / export of bookmarks in the Netscape HTML format. And that's basically it 🙂.
|
## Overview
|
||||||
|
- [Introduction](#introduction)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Using Docker](#using-docker)
|
||||||
|
- [Using Docker Compose](#using-docker-compose)
|
||||||
|
- [User Setup](#user-setup)
|
||||||
|
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||||
|
- [Managed Hosting Options](#managed-hosting-options)
|
||||||
|
- [Documentation](#documentation)
|
||||||
|
- [Browser Extension](#browser-extension)
|
||||||
|
- [Community](#community)
|
||||||
|
- [Acknowledgements](#acknowledgements)
|
||||||
|
- [Development](#development)
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
linkding is a bookmark manager that you can host yourself.
|
||||||
|
It's designed be to be minimal, fast, and easy to set up using Docker.
|
||||||
|
|
||||||
The name comes from:
|
The name comes from:
|
||||||
- *link* which is often used as a synonym for URLs and bookmarks in common language
|
- *link* which is often used as a synonym for URLs and bookmarks in common language
|
||||||
- *Ding* which is german for *thing*
|
- *Ding* which is German for thing
|
||||||
- ...so basically some thing for managing your links
|
- ...so basically something for managing your links
|
||||||
|
|
||||||
**Demo:** https://demo.linkding.link/ (configured with open registration)
|
**Feature Overview:**
|
||||||
|
- Clean UI optimized for readability
|
||||||
|
- Organize bookmarks with tags
|
||||||
|
- Add notes using Markdown
|
||||||
|
- Read it later functionality
|
||||||
|
- Share bookmarks with other users
|
||||||
|
- Bulk editing
|
||||||
|
- 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/)
|
||||||
|
- Import and export bookmarks in Netscape HTML format
|
||||||
|
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
|
||||||
|
- Light and dark themes
|
||||||
|
- REST API for developing 3rd party apps
|
||||||
|
- Admin panel for user self-service and raw data access
|
||||||
|
- Easy setup using Docker and a SQLite database, with PostgreSQL as an option
|
||||||
|
|
||||||
|
|
||||||
|
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)
|
||||||
|
|
||||||
**Screenshot:**
|
**Screenshot:**
|
||||||
|
|
||||||
@@ -15,98 +55,160 @@ The name comes from:
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The easiest way to run linkding is to use [Docker](https://docs.docker.com/get-started/). The Docker image should be 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.
|
||||||
|
|
||||||
There is also the option to set up the installation manually which I do not support, but can give some pointers on below.
|
By default, linkding uses SQLite as a database.
|
||||||
|
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
|
||||||
|
|
||||||
### Docker setup
|
### Using Docker
|
||||||
|
|
||||||
To install linkding using Docker you can just run the image from the Docker registry:
|
To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
|
||||||
```
|
|
||||||
docker run --name linkding -p 9090:9090 -d sissbruecker/linkding:latest
|
|
||||||
```
|
|
||||||
By default the application runs on port `9090`, but you can map it to a different host port by modifying the command above.
|
|
||||||
|
|
||||||
However for **production use** you also want to mount a data folder on your system, so that the applications database is not stored in the container, but on your hosts file system. This is safer in case something happens to the container and makes it easier to update the container later on, or to run backups. To do so you can use the following extended command, where you replace `{host-data-folder}` with the absolute path to a folder on your system where you want to store the data:
|
|
||||||
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Automated Docker setup
|
If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090.
|
||||||
|
|
||||||
If you are using a Linux system you can use the following [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) for an automated setup. The script does basically everything described above, but also handles updating an existing container to a new application version (technically replaces the existing container with a new container built from a newer image, while mounting the same data folder).
|
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.
|
||||||
|
|
||||||
The script can be configured using shell variables - for more details have a look at the script itself.
|
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||||
|
|
||||||
### Docker-compose setup
|
### Using Docker Compose
|
||||||
|
|
||||||
To install linkding using docker-compose you can use the `docker-compose.yml` file. Copy the `.env.sample` file to `.env` and set your parameters, then run:
|
To install linkding using [Docker Compose](https://docs.docker.com/compose/), you can use the [`docker-compose.yml`](https://github.com/sissbruecker/linkding/blob/master/docker-compose.yml) file. Copy the [`.env.sample`](https://github.com/sissbruecker/linkding/blob/master/.env.sample) file to `.env`, configure the parameters, and then run:
|
||||||
```shell
|
```shell
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||||
|
|
||||||
### User setup
|
### User setup
|
||||||
|
|
||||||
Finally you need to create a user so that you can access the frontend. 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:
|
||||||
|
|
||||||
**Docker**
|
**Docker**
|
||||||
```shell
|
```shell
|
||||||
docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker-compose**
|
**Docker Compose**
|
||||||
```shell
|
```shell
|
||||||
docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
|
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.
|
||||||
|
|
||||||
### Manual setup
|
Alternatively you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](docs/Options.md#LD_SUPERUSER_NAME).
|
||||||
|
|
||||||
If you can not or don't want to use Docker you can install the application manually on your server. To do so you can basically follow the steps from the *Development* section below while cross-referencing the `Dockerfile` and `bootstrap.sh` on how to make the application production-ready.
|
### Reverse Proxy Setup
|
||||||
|
|
||||||
### Options
|
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.
|
||||||
|
|
||||||
Check the [options document](Options.md) on how to configure your linkding installation.
|
<details>
|
||||||
|
<summary>Apache</summary>
|
||||||
|
|
||||||
### Hosting
|
Apache2 does not change the headers by default, and should not
|
||||||
|
need additional configuration.
|
||||||
|
|
||||||
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support support the process here, but I can give some pointers on what to search for:
|
An example virtual host that proxies to linkding might look like:
|
||||||
- first get the app running (described in this document)
|
```
|
||||||
- open the port that the application is running on in your servers firewall
|
<VirtualHost *:9100>
|
||||||
- depending on your network configuration, forward the opened port in your network router, so that the application can be addressed from the internet using your public IP address and the opened port
|
<Proxy *>
|
||||||
|
Order deny,allow
|
||||||
|
Allow from all
|
||||||
|
</Proxy>
|
||||||
|
|
||||||
### Backups
|
ProxyPass / http://linkding:9090/
|
||||||
|
ProxyPassReverse / http://linkding:9090/
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
For backups you have two options: manually or automatic.
|
For a full example, see the docker-compose configuration in [jhauris/apache2-reverse-proxy](https://github.com/jhauris/linkding/tree/apache2-reverse-proxy)
|
||||||
|
|
||||||
For manual backups you can export your bookmarks from the UI and store them on a backup device or online service.
|
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||||
|
|
||||||
For automatic backups you want to backup the applications database. As described above, for production setups you should [mount](https://stackoverflow.com/questions/23439126/how-to-mount-a-host-directory-in-a-docker-container) the `/etc/linkding/data` directory from the Docker container to a directory on your host system. You can then use a backup tool of your choice to backup the contents of that directory.
|
</details>
|
||||||
|
|
||||||
## API
|
<details>
|
||||||
|
<summary>Caddy 2</summary>
|
||||||
|
|
||||||
The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](API.md) for further information.
|
Caddy does not change the headers by default, and should not need any further configuration.
|
||||||
|
|
||||||
## Troubleshooting
|
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||||
|
|
||||||
**Import fails with `502 Bad Gateway`**
|
</details>
|
||||||
|
|
||||||
The default timeout for requests is 60 seconds, after which the application server will cancel the request and return the above error.
|
<details>
|
||||||
Depending on the system that the application runs on, and the number of bookmarks that need to be imported, the import may take longer than the default 60 seconds.
|
<summary>Nginx</summary>
|
||||||
|
|
||||||
To increase the timeout you can configure the [`LD_REQUEST_TIMEOUT` option](Options.md#LD_REQUEST_TIMEOUT).
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Note that any proxy servers that you are running in front of linkding may have their own timeout settings, which are not affected by the variable.
|
</details>
|
||||||
|
|
||||||
|
Instead of configuring header forwarding in your proxy, you can also configure the URL from which you want to access your linkding instance with the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
|
||||||
|
|
||||||
|
### Managed Hosting Options
|
||||||
|
|
||||||
|
Self-hosting web applications still requires a lot of technical know-how, and commitment to maintenance, with regard to keeping everything up-to-date and secure. This can be a huge entry barrier for people who are interested in self-hosting, but lack the technical knowledge to do so. This section is intended to provide alternatives in form of managed hosting solutions. Note that these options are usually commercial offerings, that require paying a fee for the convenience of being managed by another party. The technical knowledge required to make use of individual options is going to vary, and no guarantees can be made that every option is accessible for everyone. That being said, I hope this section helps in making the application accessible to a wider audience.
|
||||||
|
|
||||||
|
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
|
||||||
|
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
|-------------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||||
|
| [Options](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md) | Lists available options, and describes how to apply them |
|
||||||
|
| [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database |
|
||||||
|
| [Troubleshooting](https://github.com/sissbruecker/linkding/blob/master/docs/troubleshooting.md) | Advice for troubleshooting common problems |
|
||||||
|
| [How To](https://github.com/sissbruecker/linkding/blob/master/docs/how-to.md) | Tips and tricks around using linking |
|
||||||
|
| [Keyboard shortcuts](https://github.com/sissbruecker/linkding/blob/master/docs/shortcuts.md) | List of available keyboard shortcuts |
|
||||||
|
| [Admin documentation](https://github.com/sissbruecker/linkding/blob/master/docs/Admin.md) | User documentation for the Admin UI |
|
||||||
|
| [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API |
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
- [Mozilla Addon Store](https://addons.mozilla.org/de/firefox/addon/linkding-extension/)
|
||||||
|
- [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).
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
|
||||||
|
|
||||||
|
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||||
|
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||||
|
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
||||||
|
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||||
|
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||||
|
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
|
||||||
|
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||||
|
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||||
|
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
JetBrains provides an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding.
|
||||||
|
|
||||||
## 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.0/. 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
|
||||||
@@ -146,6 +248,22 @@ python3 manage.py runserver
|
|||||||
```
|
```
|
||||||
The frontend is now available under http://localhost:8000
|
The frontend is now available under http://localhost:8000
|
||||||
|
|
||||||
## Community
|
### DevContainers
|
||||||
|
|
||||||
- [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)
|
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
|
||||||
|
13
SECURITY.md
Normal file
13
SECURITY.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 1.10.x | :white_check_mark: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
To report a vulnerability, please send a mail to: 588ex5zl8@mozmail.com
|
||||||
|
|
||||||
|
I'll try to get back to you as soon as possible.
|
Binary file not shown.
5
background-tasks-wrapper.sh
Executable file
5
background-tasks-wrapper.sh
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Wrapper script used by supervisord to first clear task locks before starting the background task processor
|
||||||
|
|
||||||
|
python manage.py clean_tasks
|
||||||
|
exec python manage.py process_tasks
|
@@ -1,17 +1,156 @@
|
|||||||
from django.contrib import admin
|
from background_task.admin import TaskAdmin, CompletedTaskAdmin
|
||||||
|
from background_task.models import Task, CompletedTask
|
||||||
|
from django.contrib import admin, messages
|
||||||
|
from django.contrib.admin import AdminSite
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Count, QuerySet
|
||||||
|
from django.utils.translation import ngettext, gettext
|
||||||
|
from rest_framework.authtoken.admin import TokenAdmin
|
||||||
|
from rest_framework.authtoken.models import TokenProxy
|
||||||
|
|
||||||
|
from bookmarks.models import Bookmark, Tag, UserProfile, Toast, FeedToken
|
||||||
|
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||||
|
|
||||||
|
|
||||||
|
class LinkdingAdminSite(AdminSite):
|
||||||
|
site_header = 'linkding administration'
|
||||||
|
site_title = 'linkding Admin'
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, Tag
|
|
||||||
|
|
||||||
@admin.register(Bookmark)
|
|
||||||
class AdminBookmark(admin.ModelAdmin):
|
class AdminBookmark(admin.ModelAdmin):
|
||||||
list_display = ('title', 'url', 'date_added')
|
list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added')
|
||||||
search_fields = ('title', 'url', 'tags__name')
|
search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name')
|
||||||
list_filter = ('tags',)
|
list_filter = ('owner__username', 'is_archived', 'unread', 'tags',)
|
||||||
ordering = ('-date_added', )
|
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):
|
||||||
|
for bookmark in queryset:
|
||||||
|
archive_bookmark(bookmark)
|
||||||
|
bookmarks_count = queryset.count()
|
||||||
|
self.message_user(request, ngettext(
|
||||||
|
'%d bookmark was successfully archived.',
|
||||||
|
'%d bookmarks were successfully archived.',
|
||||||
|
bookmarks_count,
|
||||||
|
) % bookmarks_count, messages.SUCCESS)
|
||||||
|
|
||||||
|
def unarchive_selected_bookmarks(self, request, queryset: QuerySet):
|
||||||
|
for bookmark in queryset:
|
||||||
|
unarchive_bookmark(bookmark)
|
||||||
|
bookmarks_count = queryset.count()
|
||||||
|
self.message_user(request, ngettext(
|
||||||
|
'%d bookmark was successfully unarchived.',
|
||||||
|
'%d bookmarks were successfully unarchived.',
|
||||||
|
bookmarks_count,
|
||||||
|
) % bookmarks_count, messages.SUCCESS)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Tag)
|
|
||||||
class AdminTag(admin.ModelAdmin):
|
class AdminTag(admin.ModelAdmin):
|
||||||
list_display = ('name', 'date_added', 'owner')
|
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']
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
queryset = super().get_queryset(request)
|
||||||
|
queryset = queryset.annotate(bookmarks_count=Count("bookmark"))
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def bookmarks_count(self, obj):
|
||||||
|
return obj.bookmarks_count
|
||||||
|
|
||||||
|
bookmarks_count.admin_order_field = 'bookmarks_count'
|
||||||
|
|
||||||
|
def delete_unused_tags(self, request, queryset: QuerySet):
|
||||||
|
unused_tags = queryset.filter(bookmark__isnull=True)
|
||||||
|
unused_tags_count = unused_tags.count()
|
||||||
|
for tag in unused_tags:
|
||||||
|
tag.delete()
|
||||||
|
|
||||||
|
if unused_tags_count > 0:
|
||||||
|
self.message_user(request, ngettext(
|
||||||
|
'%d unused tag was successfully deleted.',
|
||||||
|
'%d unused tags were successfully deleted.',
|
||||||
|
unused_tags_count,
|
||||||
|
) % unused_tags_count, messages.SUCCESS)
|
||||||
|
else:
|
||||||
|
self.message_user(request, gettext(
|
||||||
|
'There were no unused tags in the selection',
|
||||||
|
), messages.SUCCESS)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserProfileInline(admin.StackedInline):
|
||||||
|
model = UserProfile
|
||||||
|
can_delete = False
|
||||||
|
verbose_name_plural = 'Profile'
|
||||||
|
fk_name = 'user'
|
||||||
|
|
||||||
|
|
||||||
|
class AdminCustomUser(UserAdmin):
|
||||||
|
inlines = (AdminUserProfileInline,)
|
||||||
|
|
||||||
|
def get_inline_instances(self, request, obj=None):
|
||||||
|
if not obj:
|
||||||
|
return list()
|
||||||
|
return super(AdminCustomUser, self).get_inline_instances(request, obj)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminToast(admin.ModelAdmin):
|
||||||
|
list_display = ('key', 'message', 'owner', 'acknowledged')
|
||||||
|
search_fields = ('key', 'message')
|
||||||
|
list_filter = ('owner__username',)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminFeedToken(admin.ModelAdmin):
|
||||||
|
list_display = ('key', 'user')
|
||||||
|
search_fields = ['key']
|
||||||
|
list_filter = ('user__username',)
|
||||||
|
|
||||||
|
|
||||||
|
linkding_admin_site = LinkdingAdminSite()
|
||||||
|
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||||
|
linkding_admin_site.register(Tag, AdminTag)
|
||||||
|
linkding_admin_site.register(User, AdminCustomUser)
|
||||||
|
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||||
|
linkding_admin_site.register(Toast, AdminToast)
|
||||||
|
linkding_admin_site.register(FeedToken, AdminFeedToken)
|
||||||
|
linkding_admin_site.register(Task, TaskAdmin)
|
||||||
|
linkding_admin_site.register(CompletedTask, CompletedTaskAdmin)
|
||||||
|
@@ -5,7 +5,9 @@ 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 BookmarkSerializer, TagSerializer
|
||||||
from bookmarks.models import Bookmark, Tag
|
from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
|
||||||
|
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark, website_loader
|
||||||
|
from bookmarks.services.website_loader import WebsiteMetadata
|
||||||
|
|
||||||
|
|
||||||
class BookmarkViewSet(viewsets.GenericViewSet,
|
class BookmarkViewSet(viewsets.GenericViewSet,
|
||||||
@@ -21,7 +23,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
|||||||
# 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')
|
query_string = self.request.GET.get('q')
|
||||||
return queries.query_bookmarks(user, query_string)
|
return queries.query_bookmarks(user, user.profile, query_string)
|
||||||
|
|
||||||
# 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)
|
||||||
@@ -33,12 +35,51 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
|||||||
def archived(self, request):
|
def archived(self, request):
|
||||||
user = request.user
|
user = request.user
|
||||||
query_string = request.GET.get('q')
|
query_string = request.GET.get('q')
|
||||||
query_set = queries.query_archived_bookmarks(user, query_string)
|
query_set = queries.query_archived_bookmarks(user, user.profile, query_string)
|
||||||
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=['get'], detail=False)
|
||||||
|
def shared(self, request):
|
||||||
|
filters = BookmarkFilters(request)
|
||||||
|
user = User.objects.filter(username=filters.user).first()
|
||||||
|
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query)
|
||||||
|
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):
|
||||||
|
bookmark = self.get_object()
|
||||||
|
archive_bookmark(bookmark)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
@action(methods=['post'], detail=True)
|
||||||
|
def unarchive(self, request, pk):
|
||||||
|
bookmark = self.get_object()
|
||||||
|
unarchive_bookmark(bookmark)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
@action(methods=['get'], detail=False)
|
||||||
|
def check(self, request):
|
||||||
|
url = request.GET.get('url')
|
||||||
|
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||||
|
existing_bookmark_data = self.get_serializer(bookmark).data if bookmark else None
|
||||||
|
|
||||||
|
# Either return metadata from existing bookmark, or scrape from URL
|
||||||
|
if bookmark:
|
||||||
|
metadata = WebsiteMetadata(url, bookmark.website_title, bookmark.website_description)
|
||||||
|
else:
|
||||||
|
metadata = website_loader.load_website_metadata(url)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'bookmark': existing_bookmark_data,
|
||||||
|
'metadata': metadata.to_dict()
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class TagViewSet(viewsets.GenericViewSet,
|
class TagViewSet(viewsets.GenericViewSet,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
|
@@ -1,4 +1,6 @@
|
|||||||
|
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
|
||||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||||
@@ -9,6 +11,14 @@ 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
|
||||||
@@ -17,8 +27,12 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
'url',
|
'url',
|
||||||
'title',
|
'title',
|
||||||
'description',
|
'description',
|
||||||
|
'notes',
|
||||||
'website_title',
|
'website_title',
|
||||||
'website_description',
|
'website_description',
|
||||||
|
'is_archived',
|
||||||
|
'unread',
|
||||||
|
'shared',
|
||||||
'tag_names',
|
'tag_names',
|
||||||
'date_added',
|
'date_added',
|
||||||
'date_modified'
|
'date_modified'
|
||||||
@@ -29,23 +43,41 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
'date_added',
|
'date_added',
|
||||||
'date_modified'
|
'date_modified'
|
||||||
]
|
]
|
||||||
|
list_serializer_class = BookmarkListSerializer
|
||||||
|
|
||||||
|
# Override optional char fields to provide default value
|
||||||
|
title = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
|
description = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
|
notes = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
|
is_archived = serializers.BooleanField(required=False, default=False)
|
||||||
|
unread = serializers.BooleanField(required=False, default=False)
|
||||||
|
shared = serializers.BooleanField(required=False, default=False)
|
||||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||||
tag_names = TagListField()
|
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']
|
||||||
|
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'])
|
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])
|
||||||
|
|
||||||
|
# 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'])
|
return update_bookmark(instance, tag_string, self.context['user'])
|
||||||
|
|
||||||
|
|
||||||
|
@@ -3,3 +3,7 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
class BookmarksConfig(AppConfig):
|
class BookmarksConfig(AppConfig):
|
||||||
name = 'bookmarks'
|
name = 'bookmarks'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
# Register signal handlers
|
||||||
|
import bookmarks.signals
|
||||||
|
@@ -8,8 +8,9 @@
|
|||||||
export let placeholder;
|
export let placeholder;
|
||||||
export let value;
|
export let value;
|
||||||
export let tags;
|
export let tags;
|
||||||
export let mode = 'default';
|
export let mode = '';
|
||||||
export let apiClient;
|
export let apiClient;
|
||||||
|
export let filters;
|
||||||
|
|
||||||
let isFocus = false;
|
let isFocus = false;
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
@@ -112,9 +113,12 @@
|
|||||||
let bookmarks = []
|
let bookmarks = []
|
||||||
|
|
||||||
if (value && value.length >= 3) {
|
if (value && value.length >= 3) {
|
||||||
const fetchedBookmarks = mode === 'archive'
|
const path = mode ? `/${mode}` : ''
|
||||||
? await apiClient.getArchivedBookmarks(value, {limit: 5, offset: 0})
|
const suggestionFilters = {
|
||||||
: await apiClient.getBookmarks(value, {limit: 5, offset: 0})
|
...filters,
|
||||||
|
q: value
|
||||||
|
}
|
||||||
|
const fetchedBookmarks = await apiClient.listBookmarks(suggestionFilters, {limit: 5, offset: 0, path})
|
||||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||||
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
||||||
const label = clampText(fullLabel, 60)
|
const label = clampText(fullLabel, 60)
|
||||||
@@ -264,17 +268,4 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TODO: Should be read from theme */
|
|
||||||
.menu-item.selected > a {
|
|
||||||
background: #f1f1fc;
|
|
||||||
color: #5755d9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-item, .group-item:hover {
|
|
||||||
color: #999999;
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: none;
|
|
||||||
font-size: 0.6rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
@@ -4,15 +4,30 @@
|
|||||||
export let id;
|
export let id;
|
||||||
export let name;
|
export let name;
|
||||||
export let value;
|
export let value;
|
||||||
export let tags;
|
export let apiClient;
|
||||||
|
export let variant = 'default';
|
||||||
|
|
||||||
|
let tags = [];
|
||||||
let isFocus = false;
|
let isFocus = false;
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
let input = null;
|
let input = null;
|
||||||
|
let suggestionList = null;
|
||||||
|
|
||||||
let suggestions = [];
|
let suggestions = [];
|
||||||
let selectedIndex = 0;
|
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() {
|
function handleFocus() {
|
||||||
isFocus = true;
|
isFocus = true;
|
||||||
}
|
}
|
||||||
@@ -27,7 +42,9 @@
|
|||||||
|
|
||||||
const word = getCurrentWord(input);
|
const word = getCurrentWord(input);
|
||||||
|
|
||||||
suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : [];
|
suggestions = word
|
||||||
|
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (word && suggestions.length > 0) {
|
if (word && suggestions.length > 0) {
|
||||||
open();
|
open();
|
||||||
@@ -70,7 +87,7 @@
|
|||||||
function complete(suggestion) {
|
function complete(suggestion) {
|
||||||
const bounds = getCurrentWordBounds(input);
|
const bounds = getCurrentWordBounds(input);
|
||||||
const value = input.value;
|
const value = input.value;
|
||||||
input.value = value.substring(0, bounds.start) + suggestion + value.substring(bounds.end);
|
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
||||||
|
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
@@ -84,28 +101,39 @@
|
|||||||
if (newIndex >= length) newIndex = 0;
|
if (newIndex >= length) newIndex = 0;
|
||||||
|
|
||||||
selectedIndex = newIndex;
|
selectedIndex = newIndex;
|
||||||
|
|
||||||
|
// Scroll to selected list item
|
||||||
|
setTimeout(() => {
|
||||||
|
if (suggestionList) {
|
||||||
|
const selectedListItem = suggestionList.querySelector('li.selected');
|
||||||
|
if (selectedListItem) {
|
||||||
|
selectedListItem.scrollIntoView({block: 'center'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="form-autocomplete">
|
<div class="form-autocomplete" class:small={variant === 'small'}>
|
||||||
<!-- autocomplete input container -->
|
<!-- autocomplete input container -->
|
||||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||||
<!-- autocomplete real input box -->
|
<!-- autocomplete real input box -->
|
||||||
<input id="{id}" name="{name}" value="{value ||''}"
|
<input id="{id}" name="{name}" value="{value ||''}" placeholder=" "
|
||||||
class="form-input" type="text" autocomplete="off"
|
class="form-input" type="text" autocomplete="off" autocapitalize="off"
|
||||||
on:input={handleInput} on:keydown={handleKeyDown}
|
on:input={handleInput} on:keydown={handleKeyDown}
|
||||||
on:focus={handleFocus} on:blur={handleBlur}>
|
on:focus={handleFocus} on:blur={handleBlur}>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- autocomplete suggestion list -->
|
<!-- autocomplete suggestion list -->
|
||||||
<ul class="menu" class:open={isOpen && suggestions.length > 0}>
|
<ul class="menu" class:open={isOpen && suggestions.length > 0}
|
||||||
|
bind:this={suggestionList}>
|
||||||
<!-- menu list items -->
|
<!-- menu list items -->
|
||||||
{#each suggestions as tag,i}
|
{#each suggestions as tag,i}
|
||||||
<li class="menu-item" class:selected={selectedIndex === i}>
|
<li class="menu-item" class:selected={selectedIndex === i}>
|
||||||
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
||||||
<div class="tile tile-centered">
|
<div class="tile tile-centered">
|
||||||
<div class="tile-content">
|
<div class="tile-content">
|
||||||
{tag}
|
{tag.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -125,9 +153,16 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TODO: Should be read from theme */
|
.form-autocomplete.small .form-autocomplete-input {
|
||||||
.menu-item.selected > a {
|
height: 1.4rem;
|
||||||
background: #f1f1fc;
|
min-height: 1.4rem;
|
||||||
color: #5755d9;
|
|
||||||
}
|
}
|
||||||
</style>
|
.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>
|
||||||
|
@@ -3,18 +3,27 @@ export class ApiClient {
|
|||||||
this.baseUrl = baseUrl
|
this.baseUrl = baseUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
getBookmarks(query, options = {limit: 100, offset: 0}) {
|
listBookmarks(filters, options = {limit: 100, offset: 0, path: ''}) {
|
||||||
const encodedQuery = encodeURIComponent(query)
|
const query = [
|
||||||
const url = `${this.baseUrl}bookmarks?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
`limit=${options.limit}`,
|
||||||
|
`offset=${options.offset}`,
|
||||||
|
]
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
const value = filters[key]
|
||||||
|
if (value) {
|
||||||
|
query.push(`${key}=${encodeURIComponent(value)}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const queryString = query.join('&')
|
||||||
|
const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`
|
||||||
|
|
||||||
return fetch(url)
|
return fetch(url)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => data.results)
|
.then(data => data.results)
|
||||||
}
|
}
|
||||||
|
|
||||||
getArchivedBookmarks(query, options = {limit: 100, offset: 0}) {
|
getTags(options = {limit: 100, offset: 0}) {
|
||||||
const encodedQuery = encodeURIComponent(query)
|
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`
|
||||||
const url = `${this.baseUrl}bookmarks/archived?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
|
||||||
|
|
||||||
return fetch(url)
|
return fetch(url)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
|
12
bookmarks/context_processors.py
Normal file
12
bookmarks/context_processors.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from bookmarks.models import Toast
|
||||||
|
|
||||||
|
|
||||||
|
def toasts(request):
|
||||||
|
user = request.user if hasattr(request, 'user') else None
|
||||||
|
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user and user.is_authenticated else []
|
||||||
|
has_toasts = len(toast_messages) > 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'has_toasts': has_toasts,
|
||||||
|
'toast_messages': toast_messages,
|
||||||
|
}
|
0
bookmarks/e2e/__init__.py
Normal file
0
bookmarks/e2e/__init__.py
Normal file
67
bookmarks/e2e/e2e_test_bookmark_form.py
Normal file
67
bookmarks/e2e/e2e_test_bookmark_form.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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='')
|
27
bookmarks/e2e/e2e_test_bookmark_list.py
Normal file
27
bookmarks/e2e/e2e_test_bookmark_list.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from unittest import skip
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
|
@skip("Fails in CI, needs investigation")
|
||||||
|
class BookmarkListE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_toggle_notes_should_show_hide_notes(self):
|
||||||
|
self.setup_bookmark(notes='Test notes')
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse('bookmarks:index'))
|
||||||
|
|
||||||
|
notes = page.locator('li .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()
|
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()
|
21
bookmarks/e2e/helpers.py
Normal file
21
bookmarks/e2e/helpers.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from django.contrib.staticfiles.testing import LiveServerTestCase
|
||||||
|
from playwright.sync_api import BrowserContext
|
||||||
|
|
||||||
|
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
|
56
bookmarks/feeds.py
Normal file
56
bookmarks/feeds.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.contrib.syndication.views import Feed
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.models import Bookmark, FeedToken
|
||||||
|
from bookmarks import queries
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FeedContext:
|
||||||
|
feed_token: FeedToken
|
||||||
|
query_set: QuerySet[Bookmark]
|
||||||
|
|
||||||
|
|
||||||
|
class BaseBookmarksFeed(Feed):
|
||||||
|
def get_object(self, request, feed_key: str):
|
||||||
|
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||||
|
query_string = request.GET.get('q')
|
||||||
|
query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, query_string)
|
||||||
|
return FeedContext(feed_token, query_set)
|
||||||
|
|
||||||
|
def item_title(self, item: Bookmark):
|
||||||
|
return item.resolved_title
|
||||||
|
|
||||||
|
def item_description(self, item: Bookmark):
|
||||||
|
return 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)
|
15
bookmarks/management/commands/clean_tasks.py
Normal file
15
bookmarks/management/commands/clean_tasks.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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()
|
37
bookmarks/management/commands/create_initial_superuser.py
Normal file
37
bookmarks/management/commands/create_initial_superuser.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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 settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
|
||||||
|
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
bookmarks/middlewares.py
Normal file
6
bookmarks/middlewares.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
||||||
|
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
43
bookmarks/migrations/0007_userprofile.py
Normal file
43
bookmarks/migrations/0007_userprofile.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 2.2.18 on 2021-03-26 22:39
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def forwards(apps, schema_editor):
|
||||||
|
User = apps.get_model('auth', 'User')
|
||||||
|
UserProfile = apps.get_model('bookmarks', 'UserProfile')
|
||||||
|
for user in User.objects.all():
|
||||||
|
try:
|
||||||
|
if user.profile:
|
||||||
|
continue
|
||||||
|
except UserProfile.DoesNotExist:
|
||||||
|
profile = UserProfile(user=user)
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('bookmarks', '0006_bookmark_is_archived'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserProfile',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('theme',
|
||||||
|
models.CharField(choices=[('auto', 'Auto'), ('light', 'Light'), ('dark', 'Dark')], default='auto',
|
||||||
|
max_length=10)),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile',
|
||||||
|
to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RunPython(forwards, reverse),
|
||||||
|
]
|
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 2.2.18 on 2021-03-30 10:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookmarks', '0007_userprofile'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='bookmark_date_display',
|
||||||
|
field=models.CharField(choices=[('relative', 'Relative'), ('absolute', 'Absolute'), ('hidden', 'Hidden')], default='relative', max_length=10),
|
||||||
|
),
|
||||||
|
]
|
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 2.2.20 on 2021-05-16 14:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookmarks', '0008_userprofile_bookmark_date_display'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='bookmark',
|
||||||
|
name='web_archive_snapshot_url',
|
||||||
|
field=models.CharField(blank=True, max_length=2048),
|
||||||
|
),
|
||||||
|
]
|
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.6 on 2021-10-03 06:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookmarks', '0009_bookmark_web_archive_snapshot_url'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='bookmark_link_target',
|
||||||
|
field=models.CharField(choices=[('_blank', 'New page'), ('_self', 'Same page')], default='_blank', max_length=10),
|
||||||
|
),
|
||||||
|
]
|
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.6 on 2022-01-08 12:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookmarks', '0010_userprofile_bookmark_link_target'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='web_archive_integration',
|
||||||
|
field=models.CharField(choices=[('disabled', 'Disabled'), ('enabled', 'Enabled')], default='disabled', max_length=10),
|
||||||
|
),
|
||||||
|
]
|
26
bookmarks/migrations/0012_toast.py
Normal file
26
bookmarks/migrations/0012_toast.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 3.2.6 on 2022-01-08 19:24
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('bookmarks', '0011_userprofile_web_archive_integration'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Toast',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('key', models.CharField(max_length=50)),
|
||||||
|
('message', models.TextField()),
|
||||||
|
('acknowledged', models.BooleanField(default=False)),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
30
bookmarks/migrations/0013_web_archive_optin_toast.py
Normal file
30
bookmarks/migrations/0013_web_archive_optin_toast.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 3.2.6 on 2022-01-08 19:27
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from bookmarks.models import Toast
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def forwards(apps, schema_editor):
|
||||||
|
for user in User.objects.all():
|
||||||
|
toast = Toast(key='web_archive_opt_in_hint',
|
||||||
|
message='The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.',
|
||||||
|
owner=user)
|
||||||
|
toast.save()
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(apps, schema_editor):
|
||||||
|
Toast.objects.filter(key='web_archive_opt_in_hint').delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('bookmarks', '0012_toast'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forwards, reverse),
|
||||||
|
]
|
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),
|
||||||
|
]
|
24
bookmarks/migrations/0015_feedtoken.py
Normal file
24
bookmarks/migrations/0015_feedtoken.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 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),
|
||||||
|
),
|
||||||
|
]
|
18
bookmarks/migrations/0020_userprofile_tag_search.py
Normal file
18
bookmarks/migrations/0020_userprofile_tag_search.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 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),
|
||||||
|
),
|
||||||
|
]
|
@@ -1,8 +1,14 @@
|
|||||||
|
import binascii
|
||||||
|
import os
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
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.core.handlers.wsgi import WSGIRequest
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from bookmarks.utils import unique
|
from bookmarks.utils import unique
|
||||||
from bookmarks.validators import BookmarkURLValidator
|
from bookmarks.validators import BookmarkURLValidator
|
||||||
@@ -17,11 +23,19 @@ class Tag(models.Model):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_tag_name(tag_name: str):
|
||||||
|
# strip leading/trailing spaces
|
||||||
|
# replace inner spaces with replacement char
|
||||||
|
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)
|
||||||
names = [name.strip() for name in names if name]
|
# remove empty names, sanitize remaining names
|
||||||
|
names = [sanitize_tag_name(name) for name in names if name]
|
||||||
|
# remove duplicates
|
||||||
names = unique(names, str.lower)
|
names = unique(names, str.lower)
|
||||||
names.sort(key=str.lower)
|
names.sort(key=str.lower)
|
||||||
|
|
||||||
@@ -36,21 +50,20 @@ 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)
|
||||||
unread = models.BooleanField(default=True)
|
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
|
||||||
|
favicon_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:
|
||||||
@@ -66,11 +79,7 @@ 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] + '...)'
|
||||||
@@ -85,11 +94,137 @@ class BookmarkForm(forms.ModelForm):
|
|||||||
required=False)
|
required=False)
|
||||||
description = forms.CharField(required=False,
|
description = forms.CharField(required=False,
|
||||||
widget=forms.Textarea())
|
widget=forms.Textarea())
|
||||||
|
# Include website title and description as hidden field as they only provide info when editing bookmarks
|
||||||
|
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)
|
||||||
# Hidden field that determines where to redirect after saving the form
|
|
||||||
return_url = forms.CharField(required=False)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Bookmark
|
model = Bookmark
|
||||||
fields = ['url', 'tag_string', 'title', 'description', 'auto_close', 'return_url']
|
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 BookmarkFilters:
|
||||||
|
def __init__(self, request: WSGIRequest):
|
||||||
|
self.query = request.GET.get('q') or ''
|
||||||
|
self.user = request.GET.get('user') or ''
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(models.Model):
|
||||||
|
THEME_AUTO = 'auto'
|
||||||
|
THEME_LIGHT = 'light'
|
||||||
|
THEME_DARK = 'dark'
|
||||||
|
THEME_CHOICES = [
|
||||||
|
(THEME_AUTO, 'Auto'),
|
||||||
|
(THEME_LIGHT, 'Light'),
|
||||||
|
(THEME_DARK, 'Dark'),
|
||||||
|
]
|
||||||
|
BOOKMARK_DATE_DISPLAY_RELATIVE = 'relative'
|
||||||
|
BOOKMARK_DATE_DISPLAY_ABSOLUTE = 'absolute'
|
||||||
|
BOOKMARK_DATE_DISPLAY_HIDDEN = 'hidden'
|
||||||
|
BOOKMARK_DATE_DISPLAY_CHOICES = [
|
||||||
|
(BOOKMARK_DATE_DISPLAY_RELATIVE, 'Relative'),
|
||||||
|
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, 'Absolute'),
|
||||||
|
(BOOKMARK_DATE_DISPLAY_HIDDEN, 'Hidden'),
|
||||||
|
]
|
||||||
|
BOOKMARK_LINK_TARGET_BLANK = '_blank'
|
||||||
|
BOOKMARK_LINK_TARGET_SELF = '_self'
|
||||||
|
BOOKMARK_LINK_TARGET_CHOICES = [
|
||||||
|
(BOOKMARK_LINK_TARGET_BLANK, 'New page'),
|
||||||
|
(BOOKMARK_LINK_TARGET_SELF, 'Same page'),
|
||||||
|
]
|
||||||
|
WEB_ARCHIVE_INTEGRATION_DISABLED = 'disabled'
|
||||||
|
WEB_ARCHIVE_INTEGRATION_ENABLED = 'enabled'
|
||||||
|
WEB_ARCHIVE_INTEGRATION_CHOICES = [
|
||||||
|
(WEB_ARCHIVE_INTEGRATION_DISABLED, 'Disabled'),
|
||||||
|
(WEB_ARCHIVE_INTEGRATION_ENABLED, 'Enabled'),
|
||||||
|
]
|
||||||
|
TAG_SEARCH_STRICT = 'strict'
|
||||||
|
TAG_SEARCH_LAX = 'lax'
|
||||||
|
TAG_SEARCH_CHOICES = [
|
||||||
|
(TAG_SEARCH_STRICT, 'Strict'),
|
||||||
|
(TAG_SEARCH_LAX, 'Lax'),
|
||||||
|
]
|
||||||
|
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_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)
|
||||||
|
enable_sharing = models.BooleanField(default=False, null=False)
|
||||||
|
enable_favicons = models.BooleanField(default=False, null=False)
|
||||||
|
display_url = models.BooleanField(default=False, null=False)
|
||||||
|
permanent_notes = models.BooleanField(default=False, null=False)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = UserProfile
|
||||||
|
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search',
|
||||||
|
'enable_sharing', 'enable_favicons', 'display_url', 'permanent_notes']
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=get_user_model())
|
||||||
|
def create_user_profile(sender, instance, created, **kwargs):
|
||||||
|
if created:
|
||||||
|
UserProfile.objects.create(user=instance)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=get_user_model())
|
||||||
|
def save_user_profile(sender, instance, **kwargs):
|
||||||
|
instance.profile.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Toast(models.Model):
|
||||||
|
key = models.CharField(max_length=50)
|
||||||
|
message = models.TextField()
|
||||||
|
acknowledged = models.BooleanField(default=False)
|
||||||
|
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
|
||||||
|
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,104 +1,102 @@
|
|||||||
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.contrib.auth.models import User
|
||||||
|
from django.db.models import Q, QuerySet, Exists, OuterRef
|
||||||
|
|
||||||
|
from bookmarks.models import Bookmark, Tag, UserProfile
|
||||||
from bookmarks.utils import unique
|
from bookmarks.utils import unique
|
||||||
|
|
||||||
|
|
||||||
class Concat(Aggregate):
|
def query_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||||
function = 'GROUP_CONCAT'
|
return _base_bookmarks_query(user, profile, query_string) \
|
||||||
template = '%(function)s(%(distinct)s%(expressions)s)'
|
|
||||||
|
|
||||||
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:
|
|
||||||
return _base_bookmarks_query(user, query_string) \
|
|
||||||
.filter(is_archived=False)
|
.filter(is_archived=False)
|
||||||
|
|
||||||
|
|
||||||
def query_archived_bookmarks(user: User, query_string: str) -> QuerySet:
|
def query_archived_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||||
return _base_bookmarks_query(user, query_string) \
|
return _base_bookmarks_query(user, profile, query_string) \
|
||||||
.filter(is_archived=True)
|
.filter(is_archived=True)
|
||||||
|
|
||||||
|
|
||||||
def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
|
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
||||||
# Add aggregated tag info to bookmark instances
|
return _base_bookmarks_query(user, profile, query_string) \
|
||||||
query_set = Bookmark.objects \
|
.filter(shared=True) \
|
||||||
.annotate(tag_count=Count('tags'),
|
.filter(owner__profile__enable_sharing=True)
|
||||||
tag_string=Concat('tags__name'),
|
|
||||||
tag_projection=Value(True, BooleanField()))
|
|
||||||
|
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
||||||
|
query_set = Bookmark.objects
|
||||||
|
|
||||||
# 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(query_string)
|
||||||
|
|
||||||
# Filter for search terms and tags
|
# Filter for search terms and tags
|
||||||
for term in query['search_terms']:
|
for term in query['search_terms']:
|
||||||
query_set = query_set.filter(
|
conditions = Q(title__icontains=term) \
|
||||||
Q(title__contains=term)
|
| Q(description__icontains=term) \
|
||||||
| Q(description__contains=term)
|
| Q(notes__icontains=term) \
|
||||||
| Q(website_title__contains=term)
|
| Q(website_title__icontains=term) \
|
||||||
| Q(website_description__contains=term)
|
| Q(website_description__icontains=term) \
|
||||||
| Q(url__contains=term)
|
| Q(url__icontains=term)
|
||||||
)
|
|
||||||
|
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||||
|
conditions = conditions | Exists(Bookmark.objects.filter(id=OuterRef('id'), tags__name__iexact=term))
|
||||||
|
|
||||||
|
query_set = query_set.filter(conditions)
|
||||||
|
|
||||||
for tag_name in query['tag_names']:
|
for tag_name in query['tag_names']:
|
||||||
query_set = query_set.filter(
|
query_set = query_set.filter(
|
||||||
tags__name__iexact=tag_name
|
tags__name__iexact=tag_name
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sort by modification date
|
# Untagged bookmarks
|
||||||
query_set = query_set.order_by('-date_modified')
|
if query['untagged']:
|
||||||
|
query_set = query_set.filter(
|
||||||
|
tags=None
|
||||||
|
)
|
||||||
|
# Unread bookmarks
|
||||||
|
if query['unread']:
|
||||||
|
query_set = query_set.filter(
|
||||||
|
unread=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by date added
|
||||||
|
query_set = query_set.order_by('-date_added')
|
||||||
|
|
||||||
return query_set
|
return query_set
|
||||||
|
|
||||||
|
|
||||||
def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
|
def query_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||||
return _base_bookmark_tags_query(user, query_string) \
|
bookmarks_query = query_bookmarks(user, profile, query_string)
|
||||||
.filter(bookmark__is_archived=False) \
|
|
||||||
.distinct()
|
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||||
|
|
||||||
|
return query_set.distinct()
|
||||||
|
|
||||||
|
|
||||||
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
|
def query_archived_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||||
return _base_bookmark_tags_query(user, query_string) \
|
bookmarks_query = query_archived_bookmarks(user, profile, query_string)
|
||||||
.filter(bookmark__is_archived=True) \
|
|
||||||
.distinct()
|
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||||
|
|
||||||
|
return query_set.distinct()
|
||||||
|
|
||||||
|
|
||||||
def _base_bookmark_tags_query(user: User, query_string: str) -> QuerySet:
|
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
||||||
query_set = Tag.objects
|
bookmarks_query = query_shared_bookmarks(user, profile, query_string)
|
||||||
|
|
||||||
# Filter for user
|
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||||
query_set = query_set.filter(owner=user)
|
|
||||||
|
|
||||||
# Only show tags which have bookmarks
|
return query_set.distinct()
|
||||||
query_set = query_set.filter(bookmark__isnull=False)
|
|
||||||
|
|
||||||
# Split query into search terms and tags
|
|
||||||
query = _parse_query_string(query_string)
|
|
||||||
|
|
||||||
# Filter for search terms and tags
|
def query_shared_bookmark_users(profile: UserProfile, query_string: str) -> QuerySet:
|
||||||
for term in query['search_terms']:
|
bookmarks_query = query_shared_bookmarks(None, profile, query_string)
|
||||||
query_set = query_set.filter(
|
|
||||||
Q(bookmark__title__contains=term)
|
|
||||||
| Q(bookmark__description__contains=term)
|
|
||||||
| Q(bookmark__website_title__contains=term)
|
|
||||||
| Q(bookmark__website_description__contains=term)
|
|
||||||
| Q(bookmark__url__contains=term)
|
|
||||||
)
|
|
||||||
|
|
||||||
for tag_name in query['tag_names']:
|
query_set = User.objects.filter(bookmark__in=bookmarks_query)
|
||||||
query_set = query_set.filter(
|
|
||||||
bookmark__tags__name__iexact=tag_name
|
|
||||||
)
|
|
||||||
|
|
||||||
return query_set.distinct()
|
return query_set.distinct()
|
||||||
|
|
||||||
@@ -107,7 +105,7 @@ 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 = ''
|
||||||
@@ -116,11 +114,17 @@ def _parse_query_string(query_string):
|
|||||||
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] != '#']
|
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
|
||||||
|
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,
|
||||||
|
'unread': unread,
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,12 @@
|
|||||||
|
from typing import Union
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, parse_tag_string
|
from bookmarks.models import Bookmark, parse_tag_string
|
||||||
from bookmarks.services.tags import get_or_create_tags
|
from bookmarks.services.tags import get_or_create_tags
|
||||||
from bookmarks.services.website_loader import load_website_metadata
|
from bookmarks.services import website_loader
|
||||||
|
from bookmarks.services import tasks
|
||||||
|
|
||||||
|
|
||||||
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||||
@@ -25,17 +28,33 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
|||||||
# Update tag list
|
# Update tag list
|
||||||
_update_bookmark_tags(bookmark, tag_string, current_user)
|
_update_bookmark_tags(bookmark, tag_string, current_user)
|
||||||
bookmark.save()
|
bookmark.save()
|
||||||
|
# Create snapshot on web archive
|
||||||
|
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
||||||
|
# Load favicon
|
||||||
|
tasks.load_favicon(current_user, bookmark)
|
||||||
|
|
||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
|
|
||||||
def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||||
# Update website info
|
# Detect URL change
|
||||||
_update_website_metadata(bookmark)
|
original_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||||
|
has_url_changed = original_bookmark.url != bookmark.url
|
||||||
# Update tag list
|
# Update tag list
|
||||||
_update_bookmark_tags(bookmark, tag_string, current_user)
|
_update_bookmark_tags(bookmark, tag_string, current_user)
|
||||||
# Update dates
|
# Update dates
|
||||||
bookmark.date_modified = timezone.now()
|
bookmark.date_modified = timezone.now()
|
||||||
bookmark.save()
|
bookmark.save()
|
||||||
|
# Update favicon
|
||||||
|
tasks.load_favicon(current_user, bookmark)
|
||||||
|
|
||||||
|
if has_url_changed:
|
||||||
|
# Update web archive snapshot, if URL changed
|
||||||
|
tasks.create_web_archive_snapshot(current_user, bookmark, True)
|
||||||
|
# Only update website metadata if URL changed
|
||||||
|
_update_website_metadata(bookmark)
|
||||||
|
bookmark.save()
|
||||||
|
|
||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +65,13 @@ def archive_bookmark(bookmark: Bookmark):
|
|||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
|
|
||||||
|
def archive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||||
|
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||||
|
|
||||||
|
bookmarks.update(is_archived=True, date_modified=timezone.now())
|
||||||
|
|
||||||
|
|
||||||
def unarchive_bookmark(bookmark: Bookmark):
|
def unarchive_bookmark(bookmark: Bookmark):
|
||||||
bookmark.is_archived = False
|
bookmark.is_archived = False
|
||||||
bookmark.date_modified = timezone.now()
|
bookmark.date_modified = timezone.now()
|
||||||
@@ -53,18 +79,66 @@ def unarchive_bookmark(bookmark: Bookmark):
|
|||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
|
|
||||||
|
def unarchive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||||
|
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||||
|
|
||||||
|
bookmarks.update(is_archived=False, date_modified=timezone.now())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||||
|
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||||
|
|
||||||
|
bookmarks.delete()
|
||||||
|
|
||||||
|
|
||||||
|
def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||||
|
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||||
|
tag_names = parse_tag_string(tag_string)
|
||||||
|
tags = get_or_create_tags(tag_names, current_user)
|
||||||
|
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
bookmark.tags.add(*tags)
|
||||||
|
bookmark.date_modified = timezone.now()
|
||||||
|
|
||||||
|
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
|
||||||
|
|
||||||
|
|
||||||
|
def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||||
|
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||||
|
tag_names = parse_tag_string(tag_string)
|
||||||
|
tags = get_or_create_tags(tag_names, current_user)
|
||||||
|
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
bookmark.tags.remove(*tags)
|
||||||
|
bookmark.date_modified = timezone.now()
|
||||||
|
|
||||||
|
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
|
||||||
|
|
||||||
|
|
||||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||||
to_bookmark.title = from_bookmark.title
|
to_bookmark.title = from_bookmark.title
|
||||||
to_bookmark.description = from_bookmark.description
|
to_bookmark.description = from_bookmark.description
|
||||||
|
to_bookmark.notes = from_bookmark.notes
|
||||||
|
to_bookmark.unread = from_bookmark.unread
|
||||||
|
to_bookmark.shared = from_bookmark.shared
|
||||||
|
|
||||||
|
|
||||||
def _update_website_metadata(bookmark: Bookmark):
|
def _update_website_metadata(bookmark: Bookmark):
|
||||||
metadata = load_website_metadata(bookmark.url)
|
metadata = website_loader.load_website_metadata(bookmark.url)
|
||||||
bookmark.website_title = metadata.title
|
bookmark.website_title = metadata.title
|
||||||
bookmark.website_description = metadata.description
|
bookmark.website_description = metadata.description
|
||||||
|
|
||||||
|
|
||||||
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
|
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
|
||||||
tag_names = parse_tag_string(tag_string, ' ')
|
tag_names = parse_tag_string(tag_string)
|
||||||
tags = get_or_create_tags(tag_names, user)
|
tags = get_or_create_tags(tag_names, user)
|
||||||
bookmark.tags.set(tags)
|
bookmark.tags.set(tags)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_id_list(bookmark_ids: [Union[int, str]]) -> [int]:
|
||||||
|
# Convert string ids to int if necessary
|
||||||
|
return [int(bm_id) if isinstance(bm_id, str) else bm_id for bm_id in bookmark_ids]
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import html
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from bookmarks.models import Bookmark
|
from bookmarks.models import Bookmark
|
||||||
@@ -28,8 +29,8 @@ def append_list_start(doc: BookmarkDocument):
|
|||||||
|
|
||||||
def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
||||||
url = bookmark.url
|
url = bookmark.url
|
||||||
title = bookmark.resolved_title
|
title = html.escape(bookmark.resolved_title or '')
|
||||||
desc = bookmark.resolved_description
|
desc = html.escape(bookmark.resolved_description or '')
|
||||||
tags = ','.join(bookmark.tag_names)
|
tags = ','.join(bookmark.tag_names)
|
||||||
toread = '1' if bookmark.unread else '0'
|
toread = '1' if bookmark.unread else '0'
|
||||||
added = int(bookmark.date_added.timestamp())
|
added = int(bookmark.date_added.timestamp())
|
||||||
|
57
bookmarks/services/favicon_loader.py
Normal file
57
bookmarks/services/favicon_loader.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
max_file_age = 60 * 60 * 24 # 1 day
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_favicon_folder():
|
||||||
|
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _url_to_filename(url: str) -> str:
|
||||||
|
name = re.sub(r'\W+', '_', url)
|
||||||
|
return f'{name}.png'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_base_url(url: str) -> str:
|
||||||
|
parsed_uri = urlparse(url)
|
||||||
|
return f'{parsed_uri.scheme}://{parsed_uri.hostname}'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_favicon_path(favicon_file: str) -> Path:
|
||||||
|
return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_stale(path: Path) -> bool:
|
||||||
|
stat = path.stat()
|
||||||
|
file_age = time.time() - stat.st_mtime
|
||||||
|
return file_age >= max_file_age
|
||||||
|
|
||||||
|
|
||||||
|
def load_favicon(url: str) -> str:
|
||||||
|
# Get base URL so that we can reuse favicons for multiple bookmarks with the same host
|
||||||
|
base_url = _get_base_url(url)
|
||||||
|
favicon_name = _url_to_filename(base_url)
|
||||||
|
favicon_path = _get_favicon_path(favicon_name)
|
||||||
|
|
||||||
|
# Load icon if it doesn't exist yet or has become stale
|
||||||
|
if not favicon_path.exists() or _is_stale(favicon_path):
|
||||||
|
# Create favicon folder if not exists
|
||||||
|
_ensure_favicon_folder()
|
||||||
|
# Load favicon from provider, save to file
|
||||||
|
favicon_url = settings.LD_FAVICON_PROVIDER.format(url=base_url)
|
||||||
|
response = requests.get(favicon_url, stream=True)
|
||||||
|
|
||||||
|
with open(favicon_path, 'wb') as file:
|
||||||
|
shutil.copyfileobj(response.raw, file)
|
||||||
|
|
||||||
|
del response
|
||||||
|
|
||||||
|
return favicon_name
|
@@ -1,12 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from typing import List
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, parse_tag_string
|
from bookmarks.models import Bookmark, Tag, parse_tag_string
|
||||||
|
from bookmarks.services import tasks
|
||||||
from bookmarks.services.parser import parse, NetscapeBookmark
|
from bookmarks.services.parser import parse, NetscapeBookmark
|
||||||
from bookmarks.services.tags import get_or_create_tags
|
from bookmarks.utils import parse_timestamp
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -18,8 +20,39 @@ class ImportResult:
|
|||||||
failed: int = 0
|
failed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TagCache:
|
||||||
|
def __init__(self, user: User):
|
||||||
|
self.user = user
|
||||||
|
self.cache = dict()
|
||||||
|
# Init cache with all existing tags for that user
|
||||||
|
tags = Tag.objects.filter(owner=user)
|
||||||
|
for tag in tags:
|
||||||
|
self.put(tag)
|
||||||
|
|
||||||
|
def get(self, tag_name: str):
|
||||||
|
tag_name_lowercase = tag_name.lower()
|
||||||
|
if tag_name_lowercase in self.cache:
|
||||||
|
return self.cache[tag_name_lowercase]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all(self, tag_names: List[str]):
|
||||||
|
result = []
|
||||||
|
for tag_name in tag_names:
|
||||||
|
tag = self.get(tag_name)
|
||||||
|
# Prevent returning duplicates
|
||||||
|
if not (tag in result):
|
||||||
|
result.append(tag)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def put(self, tag: Tag):
|
||||||
|
self.cache[tag.name.lower()] = tag
|
||||||
|
|
||||||
|
|
||||||
def import_netscape_html(html: str, user: User):
|
def import_netscape_html(html: str, user: User):
|
||||||
result = ImportResult()
|
result = ImportResult()
|
||||||
|
import_start = timezone.now()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
netscape_bookmarks = parse(html)
|
netscape_bookmarks = parse(html)
|
||||||
@@ -27,44 +60,142 @@ def import_netscape_html(html: str, user: User):
|
|||||||
logging.exception('Could not read bookmarks file.')
|
logging.exception('Could not read bookmarks file.')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
parse_end = timezone.now()
|
||||||
|
logger.debug(f'Parse duration: {parse_end - import_start}')
|
||||||
|
|
||||||
|
# Create and cache all tags beforehand
|
||||||
|
_create_missing_tags(netscape_bookmarks, user)
|
||||||
|
tag_cache = TagCache(user)
|
||||||
|
|
||||||
|
# Split bookmarks to import into batches, to keep memory usage for bulk operations manageable
|
||||||
|
batches = _get_batches(netscape_bookmarks, 200)
|
||||||
|
for batch in batches:
|
||||||
|
_import_batch(batch, user, tag_cache, result)
|
||||||
|
|
||||||
|
# Create snapshots for newly imported bookmarks
|
||||||
|
tasks.schedule_bookmarks_without_snapshots(user)
|
||||||
|
# Load favicons for newly imported bookmarks
|
||||||
|
tasks.schedule_bookmarks_without_favicons(user)
|
||||||
|
|
||||||
|
end = timezone.now()
|
||||||
|
logger.debug(f'Import duration: {end - import_start}')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User):
|
||||||
|
tag_cache = TagCache(user)
|
||||||
|
tags_to_create = []
|
||||||
|
|
||||||
|
for netscape_bookmark in netscape_bookmarks:
|
||||||
|
tag_names = parse_tag_string(netscape_bookmark.tag_string)
|
||||||
|
for tag_name in tag_names:
|
||||||
|
tag = tag_cache.get(tag_name)
|
||||||
|
if not tag:
|
||||||
|
tag = Tag(name=tag_name, owner=user)
|
||||||
|
tag.date_added = timezone.now()
|
||||||
|
tags_to_create.append(tag)
|
||||||
|
tag_cache.put(tag)
|
||||||
|
|
||||||
|
Tag.objects.bulk_create(tags_to_create)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_batches(items: List, batch_size: int):
|
||||||
|
batches = []
|
||||||
|
offset = 0
|
||||||
|
num_items = len(items)
|
||||||
|
|
||||||
|
while offset < num_items:
|
||||||
|
batch = items[offset:min(offset + batch_size, num_items)]
|
||||||
|
if len(batch) > 0:
|
||||||
|
batches.append(batch)
|
||||||
|
offset = offset + batch_size
|
||||||
|
|
||||||
|
return batches
|
||||||
|
|
||||||
|
|
||||||
|
def _import_batch(netscape_bookmarks: List[NetscapeBookmark], user: User, tag_cache: TagCache, result: ImportResult):
|
||||||
|
# Query existing bookmarks
|
||||||
|
batch_urls = [bookmark.href for bookmark in netscape_bookmarks]
|
||||||
|
existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)
|
||||||
|
|
||||||
|
# Create or update bookmarks from parsed Netscape bookmarks
|
||||||
|
bookmarks_to_create = []
|
||||||
|
bookmarks_to_update = []
|
||||||
|
|
||||||
for netscape_bookmark in netscape_bookmarks:
|
for netscape_bookmark in netscape_bookmarks:
|
||||||
result.total = result.total + 1
|
result.total = result.total + 1
|
||||||
try:
|
try:
|
||||||
_import_bookmark_tag(netscape_bookmark, user)
|
# Lookup existing bookmark by URL, or create new bookmark if there is no bookmark for that URL yet
|
||||||
|
bookmark = next(
|
||||||
|
(bookmark for bookmark in existing_bookmarks if bookmark.url == netscape_bookmark.href), None)
|
||||||
|
if not bookmark:
|
||||||
|
bookmark = Bookmark(owner=user)
|
||||||
|
is_update = False
|
||||||
|
else:
|
||||||
|
is_update = True
|
||||||
|
# Copy data from parsed bookmark
|
||||||
|
_copy_bookmark_data(netscape_bookmark, bookmark)
|
||||||
|
# Validate bookmark fields, exclude owner to prevent n+1 database query,
|
||||||
|
# also there is no specific validation on owner
|
||||||
|
bookmark.clean_fields(exclude=['owner'])
|
||||||
|
# Schedule for update or insert
|
||||||
|
if is_update:
|
||||||
|
bookmarks_to_update.append(bookmark)
|
||||||
|
else:
|
||||||
|
bookmarks_to_create.append(bookmark)
|
||||||
|
|
||||||
result.success = result.success + 1
|
result.success = result.success + 1
|
||||||
except:
|
except:
|
||||||
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...'
|
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...'
|
||||||
logging.exception('Error importing bookmark: ' + shortened_bookmark_tag_str)
|
logging.exception('Error importing bookmark: ' + shortened_bookmark_tag_str)
|
||||||
result.failed = result.failed + 1
|
result.failed = result.failed + 1
|
||||||
|
|
||||||
return result
|
# Bulk update bookmarks in DB
|
||||||
|
Bookmark.objects.bulk_update(bookmarks_to_update,
|
||||||
|
['url', 'date_added', 'date_modified', 'unread', 'title', 'description', 'owner'])
|
||||||
|
# Bulk insert new bookmarks into DB
|
||||||
|
Bookmark.objects.bulk_create(bookmarks_to_create)
|
||||||
|
|
||||||
|
# Bulk assign tags
|
||||||
|
# In Django 3, bulk_create does not return the auto-generated IDs when bulk inserting,
|
||||||
|
# so we have to reload the inserted bookmarks, and match them to the parsed bookmarks by URL
|
||||||
|
existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)
|
||||||
|
|
||||||
|
BookmarkToTagRelationShip = Bookmark.tags.through
|
||||||
|
relationships = []
|
||||||
|
|
||||||
|
for netscape_bookmark in netscape_bookmarks:
|
||||||
|
# Lookup bookmark by URL again
|
||||||
|
bookmark = next(
|
||||||
|
(bookmark for bookmark in existing_bookmarks if bookmark.url == netscape_bookmark.href), None)
|
||||||
|
|
||||||
|
if not bookmark:
|
||||||
|
# Something is wrong, we should have just created this bookmark
|
||||||
|
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...'
|
||||||
|
logging.warning(
|
||||||
|
f'Failed to assign tags to the bookmark: {shortened_bookmark_tag_str}. Could not find bookmark by URL.')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get tag models by string, schedule inserts for bookmark -> tag associations
|
||||||
|
tag_names = parse_tag_string(netscape_bookmark.tag_string)
|
||||||
|
tags = tag_cache.get_all(tag_names)
|
||||||
|
for tag in tags:
|
||||||
|
relationships.append(BookmarkToTagRelationShip(bookmark=bookmark, tag=tag))
|
||||||
|
|
||||||
|
# Insert all bookmark -> tag associations at once, should ignore errors if association already exists
|
||||||
|
BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)
|
||||||
|
|
||||||
|
|
||||||
def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
|
def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark):
|
||||||
# Either modify existing bookmark for the URL or create new one
|
|
||||||
bookmark = _get_or_create_bookmark(netscape_bookmark.href, user)
|
|
||||||
|
|
||||||
bookmark.url = netscape_bookmark.href
|
bookmark.url = netscape_bookmark.href
|
||||||
bookmark.date_added = datetime.utcfromtimestamp(int(netscape_bookmark.date_added)).astimezone()
|
if netscape_bookmark.date_added:
|
||||||
|
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
||||||
|
else:
|
||||||
|
bookmark.date_added = timezone.now()
|
||||||
bookmark.date_modified = bookmark.date_added
|
bookmark.date_modified = bookmark.date_added
|
||||||
bookmark.unread = False
|
bookmark.unread = netscape_bookmark.to_read
|
||||||
bookmark.title = netscape_bookmark.title
|
if netscape_bookmark.title:
|
||||||
|
bookmark.title = netscape_bookmark.title
|
||||||
if netscape_bookmark.description:
|
if netscape_bookmark.description:
|
||||||
bookmark.description = netscape_bookmark.description
|
bookmark.description = netscape_bookmark.description
|
||||||
bookmark.owner = user
|
|
||||||
|
|
||||||
bookmark.save()
|
|
||||||
|
|
||||||
# Set tags
|
|
||||||
tag_names = parse_tag_string(netscape_bookmark.tag_string)
|
|
||||||
tags = get_or_create_tags(tag_names, user)
|
|
||||||
|
|
||||||
bookmark.tags.set(tags)
|
|
||||||
bookmark.save()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_bookmark(url: str, user: User):
|
|
||||||
try:
|
|
||||||
return Bookmark.objects.get(url=url, owner=user)
|
|
||||||
except Bookmark.DoesNotExist:
|
|
||||||
return Bookmark()
|
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from html.parser import HTMLParser
|
||||||
|
from typing import Dict, List
|
||||||
import pyparsing as pp
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -9,65 +8,80 @@ class NetscapeBookmark:
|
|||||||
href: str
|
href: str
|
||||||
title: str
|
title: str
|
||||||
description: str
|
description: str
|
||||||
date_added: int
|
date_added: str
|
||||||
tag_string: str
|
tag_string: str
|
||||||
|
to_read: bool
|
||||||
|
|
||||||
|
|
||||||
def extract_bookmark_link(tag):
|
class BookmarkParser(HTMLParser):
|
||||||
href = tag[0].href
|
def __init__(self):
|
||||||
title = tag[0].text
|
super().__init__()
|
||||||
tag_string = tag[0].tags
|
self.bookmarks = []
|
||||||
date_added_string = tag[0].add_date if tag[0].add_date else datetime.now().timestamp()
|
|
||||||
date_added = int(date_added_string)
|
|
||||||
|
|
||||||
return {
|
self.current_tag = None
|
||||||
'href': href,
|
self.bookmark = None
|
||||||
'title': title,
|
self.href = ''
|
||||||
'tag_string': tag_string,
|
self.add_date = ''
|
||||||
'date_added': date_added
|
self.tags = ''
|
||||||
}
|
self.title = ''
|
||||||
|
self.description = ''
|
||||||
|
self.toread = ''
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: list):
|
||||||
|
name = 'handle_start_' + tag.lower()
|
||||||
|
if name in dir(self):
|
||||||
|
getattr(self, name)({k.lower(): v for k, v in attrs})
|
||||||
|
self.current_tag = tag
|
||||||
|
|
||||||
def extract_bookmark(tag):
|
def handle_endtag(self, tag: str):
|
||||||
link = tag[0].link
|
name = 'handle_end_' + tag.lower()
|
||||||
description = tag[0].description
|
if name in dir(self):
|
||||||
description = description[0] if description else ''
|
getattr(self, name)()
|
||||||
|
self.current_tag = None
|
||||||
|
|
||||||
return {
|
def handle_data(self, data):
|
||||||
'link': link,
|
name = f'handle_{self.current_tag}_data'
|
||||||
'description': description,
|
if name in dir(self):
|
||||||
}
|
getattr(self, name)(data)
|
||||||
|
|
||||||
|
def handle_end_dl(self):
|
||||||
|
self.add_bookmark()
|
||||||
|
|
||||||
def extract_description(tag):
|
def handle_start_dt(self, attrs: Dict[str, str]):
|
||||||
return tag[0].strip()
|
self.add_bookmark()
|
||||||
|
|
||||||
|
def handle_start_a(self, attrs: Dict[str, str]):
|
||||||
# define grammar
|
vars(self).update(attrs)
|
||||||
dt_start, _ = pp.makeHTMLTags("DT")
|
self.bookmark = NetscapeBookmark(
|
||||||
dd_start, _ = pp.makeHTMLTags("DD")
|
href=self.href,
|
||||||
a_start, a_end = pp.makeHTMLTags("A")
|
title='',
|
||||||
bookmark_link_tag = pp.Group(a_start + a_start.tag_body("text") + a_end.suppress())
|
description='',
|
||||||
bookmark_link_tag.addParseAction(extract_bookmark_link)
|
date_added=self.add_date,
|
||||||
bookmark_description_tag = dd_start.suppress() + pp.SkipTo(pp.anyOpenTag | pp.anyCloseTag)("description")
|
tag_string=self.tags,
|
||||||
bookmark_description_tag.addParseAction(extract_description)
|
to_read=self.toread == '1'
|
||||||
bookmark_tag = pp.Group(dt_start + bookmark_link_tag("link") + pp.ZeroOrMore(bookmark_description_tag)("description"))
|
|
||||||
bookmark_tag.addParseAction(extract_bookmark)
|
|
||||||
|
|
||||||
|
|
||||||
def parse(html: str) -> [NetscapeBookmark]:
|
|
||||||
matches = bookmark_tag.searchString(html)
|
|
||||||
bookmarks = []
|
|
||||||
|
|
||||||
for match in matches:
|
|
||||||
bookmark_match = match[0]
|
|
||||||
bookmark = NetscapeBookmark(
|
|
||||||
href=bookmark_match['link']['href'],
|
|
||||||
title=bookmark_match['link']['title'],
|
|
||||||
description=bookmark_match['description'],
|
|
||||||
tag_string=bookmark_match['link']['tag_string'],
|
|
||||||
date_added=bookmark_match['link']['date_added'],
|
|
||||||
)
|
)
|
||||||
bookmarks.append(bookmark)
|
|
||||||
|
|
||||||
return bookmarks
|
def handle_a_data(self, data):
|
||||||
|
self.title = data.strip()
|
||||||
|
|
||||||
|
def handle_dd_data(self, data):
|
||||||
|
self.description = data.strip()
|
||||||
|
|
||||||
|
def add_bookmark(self):
|
||||||
|
if self.bookmark:
|
||||||
|
self.bookmark.title = self.title
|
||||||
|
self.bookmark.description = self.description
|
||||||
|
self.bookmarks.append(self.bookmark)
|
||||||
|
self.bookmark = None
|
||||||
|
self.href = ''
|
||||||
|
self.add_date = ''
|
||||||
|
self.tags = ''
|
||||||
|
self.title = ''
|
||||||
|
self.description = ''
|
||||||
|
self.toread = ''
|
||||||
|
|
||||||
|
|
||||||
|
def parse(html: str) -> List[NetscapeBookmark]:
|
||||||
|
parser = BookmarkParser()
|
||||||
|
parser.feed(html)
|
||||||
|
return parser.bookmarks
|
||||||
|
174
bookmarks/services/tasks.py
Normal file
174
bookmarks/services/tasks.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import waybackpy
|
||||||
|
from background_task import background
|
||||||
|
from background_task.models import Task
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound
|
||||||
|
|
||||||
|
import bookmarks.services.wayback
|
||||||
|
from bookmarks.models import Bookmark, UserProfile
|
||||||
|
from bookmarks.services import favicon_loader
|
||||||
|
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_web_archive_integration_active(user: User) -> bool:
|
||||||
|
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||||
|
web_archive_integration_enabled = \
|
||||||
|
user.profile.web_archive_integration == UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||||
|
|
||||||
|
return background_tasks_enabled and web_archive_integration_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bool):
|
||||||
|
if is_web_archive_integration_active(user):
|
||||||
|
_create_web_archive_snapshot_task(bookmark.id, force_update)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_newest_snapshot(bookmark: Bookmark):
|
||||||
|
try:
|
||||||
|
logger.info(f'Load existing snapshot for bookmark. url={bookmark.url}')
|
||||||
|
cdx_api = bookmarks.services.wayback.CustomWaybackMachineCDXServerAPI(bookmark.url)
|
||||||
|
existing_snapshot = cdx_api.newest()
|
||||||
|
|
||||||
|
if existing_snapshot:
|
||||||
|
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
|
||||||
|
bookmark.save(update_fields=['web_archive_snapshot_url'])
|
||||||
|
logger.info(f'Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}')
|
||||||
|
|
||||||
|
except NoCDXRecordFound:
|
||||||
|
logger.info(f'Could not find any snapshots for bookmark. url={bookmark.url}')
|
||||||
|
except WaybackError as error:
|
||||||
|
logger.error(f'Failed to load existing snapshot. url={bookmark.url}', exc_info=error)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_snapshot(bookmark: Bookmark):
|
||||||
|
logger.info(f'Create new snapshot for bookmark. url={bookmark.url}...')
|
||||||
|
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT, max_tries=1)
|
||||||
|
archive.save()
|
||||||
|
bookmark.web_archive_snapshot_url = archive.archive_url
|
||||||
|
bookmark.save(update_fields=['web_archive_snapshot_url'])
|
||||||
|
logger.info(f'Successfully created new snapshot for bookmark:. url={bookmark.url}')
|
||||||
|
|
||||||
|
|
||||||
|
@background()
|
||||||
|
def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Skip if snapshot exists and update is not explicitly requested
|
||||||
|
if bookmark.web_archive_snapshot_url and not force_update:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create new snapshot
|
||||||
|
try:
|
||||||
|
_create_snapshot(bookmark)
|
||||||
|
return
|
||||||
|
except TooManyRequestsError:
|
||||||
|
logger.error(
|
||||||
|
f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}')
|
||||||
|
except WaybackError as error:
|
||||||
|
logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}',
|
||||||
|
exc_info=error)
|
||||||
|
|
||||||
|
# Load the newest snapshot as fallback
|
||||||
|
_load_newest_snapshot(bookmark)
|
||||||
|
|
||||||
|
|
||||||
|
@background()
|
||||||
|
def _load_web_archive_snapshot_task(bookmark_id: int):
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
return
|
||||||
|
# Skip if snapshot exists
|
||||||
|
if bookmark.web_archive_snapshot_url:
|
||||||
|
return
|
||||||
|
# Load the newest snapshot
|
||||||
|
_load_newest_snapshot(bookmark)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_bookmarks_without_snapshots(user: User):
|
||||||
|
if is_web_archive_integration_active(user):
|
||||||
|
_schedule_bookmarks_without_snapshots_task(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@background()
|
||||||
|
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
||||||
|
user = get_user_model().objects.get(id=user_id)
|
||||||
|
bookmarks_without_snapshots = Bookmark.objects.filter(web_archive_snapshot_url__exact='', owner=user)
|
||||||
|
|
||||||
|
for bookmark in bookmarks_without_snapshots:
|
||||||
|
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
|
||||||
|
# new ones when processing bookmarks in bulk
|
||||||
|
_load_web_archive_snapshot_task(bookmark.id)
|
||||||
|
|
||||||
|
|
||||||
|
def is_favicon_feature_active(user: User) -> bool:
|
||||||
|
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||||
|
|
||||||
|
return background_tasks_enabled and user.profile.enable_favicons
|
||||||
|
|
||||||
|
|
||||||
|
def load_favicon(user: User, bookmark: Bookmark):
|
||||||
|
if is_favicon_feature_active(user):
|
||||||
|
_load_favicon_task(bookmark.id)
|
||||||
|
|
||||||
|
|
||||||
|
@background()
|
||||||
|
def _load_favicon_task(bookmark_id: int):
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f'Load favicon for bookmark. url={bookmark.url}')
|
||||||
|
|
||||||
|
new_favicon = favicon_loader.load_favicon(bookmark.url)
|
||||||
|
|
||||||
|
if new_favicon != bookmark.favicon_file:
|
||||||
|
bookmark.favicon_file = new_favicon
|
||||||
|
bookmark.save(update_fields=['favicon_file'])
|
||||||
|
logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon}')
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_bookmarks_without_favicons(user: User):
|
||||||
|
if is_favicon_feature_active(user):
|
||||||
|
_schedule_bookmarks_without_favicons_task(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@background()
|
||||||
|
def _schedule_bookmarks_without_favicons_task(user_id: int):
|
||||||
|
user = get_user_model().objects.get(id=user_id)
|
||||||
|
bookmarks = Bookmark.objects.filter(favicon_file__exact='', owner=user)
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,))
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
Task.objects.bulk_create(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_refresh_favicons(user: User):
|
||||||
|
if is_favicon_feature_active(user) and settings.LD_ENABLE_REFRESH_FAVICONS:
|
||||||
|
_schedule_refresh_favicons_task(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@background()
|
||||||
|
def _schedule_refresh_favicons_task(user_id: int):
|
||||||
|
user = get_user_model().objects.get(id=user_id)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=user)
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,))
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
Task.objects.bulk_create(tasks)
|
40
bookmarks/services/wayback.py
Normal file
40
bookmarks/services/wayback.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import time
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import waybackpy
|
||||||
|
import waybackpy.utils
|
||||||
|
from waybackpy.exceptions import NoCDXRecordFound
|
||||||
|
|
||||||
|
|
||||||
|
class CustomWaybackMachineCDXServerAPI(waybackpy.WaybackMachineCDXServerAPI):
|
||||||
|
"""
|
||||||
|
Customized WaybackMachineCDXServerAPI to work around some issues with retrieving the newest snapshot.
|
||||||
|
See https://github.com/akamhy/waybackpy/issues/176
|
||||||
|
"""
|
||||||
|
|
||||||
|
def newest(self):
|
||||||
|
unix_timestamp = int(time.time())
|
||||||
|
self.closest = waybackpy.utils.unix_timestamp_to_wayback_timestamp(unix_timestamp)
|
||||||
|
self.sort = 'closest'
|
||||||
|
self.limit = -5
|
||||||
|
|
||||||
|
newest_snapshot = None
|
||||||
|
for snapshot in self.snapshots():
|
||||||
|
newest_snapshot = snapshot
|
||||||
|
break
|
||||||
|
|
||||||
|
if not newest_snapshot:
|
||||||
|
raise NoCDXRecordFound(
|
||||||
|
"Wayback Machine's CDX server did not return any records "
|
||||||
|
+ "for the query. The URL may not have any archives "
|
||||||
|
+ " on the Wayback Machine or the URL may have been recently "
|
||||||
|
+ "archived and is still not available on the CDX server."
|
||||||
|
)
|
||||||
|
|
||||||
|
return newest_snapshot
|
||||||
|
|
||||||
|
def add_payload(self, payload: Dict[str, str]) -> None:
|
||||||
|
super().add_payload(payload)
|
||||||
|
# Set fastLatest query param, as we are only using this API to get the latest snapshot and using fastLatest
|
||||||
|
# makes searching for latest snapshots faster
|
||||||
|
payload['fastLatest'] = 'true'
|
@@ -1,7 +1,13 @@
|
|||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from charset_normalizer import from_bytes
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -18,20 +24,81 @@ class WebsiteMetadata:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Caching metadata avoids scraping again when saving bookmarks, in case the
|
||||||
|
# metadata was already scraped to show preview values in the bookmark form
|
||||||
|
@lru_cache(maxsize=10)
|
||||||
def load_website_metadata(url: str):
|
def load_website_metadata(url: str):
|
||||||
title = None
|
title = None
|
||||||
description = None
|
description = None
|
||||||
try:
|
try:
|
||||||
|
start = timezone.now()
|
||||||
page_text = load_page(url)
|
page_text = load_page(url)
|
||||||
|
end = timezone.now()
|
||||||
|
logger.debug(f'Load duration: {end - start}')
|
||||||
|
|
||||||
|
start = timezone.now()
|
||||||
soup = BeautifulSoup(page_text, 'html.parser')
|
soup = BeautifulSoup(page_text, 'html.parser')
|
||||||
|
|
||||||
title = soup.title.string if soup.title is not None else None
|
title = soup.title.string.strip() if soup.title is not None else None
|
||||||
description_tag = soup.find('meta', attrs={'name': 'description'})
|
description_tag = soup.find('meta', attrs={'name': 'description'})
|
||||||
description = description_tag['content'] if description_tag is not None else None
|
description = description = description_tag['content'].strip() if description_tag and description_tag[
|
||||||
|
'content'] else None
|
||||||
|
end = timezone.now()
|
||||||
|
logger.debug(f'Parsing duration: {end - start}')
|
||||||
finally:
|
finally:
|
||||||
return WebsiteMetadata(url=url, title=title, description=description)
|
return WebsiteMetadata(url=url, title=title, description=description)
|
||||||
|
|
||||||
|
|
||||||
|
CHUNK_SIZE = 50 * 1024
|
||||||
|
MAX_CONTENT_LIMIT = 5000 * 1024
|
||||||
|
|
||||||
|
|
||||||
def load_page(url: str):
|
def load_page(url: str):
|
||||||
r = requests.get(url)
|
headers = fake_request_headers()
|
||||||
return r.text
|
size = 0
|
||||||
|
content = None
|
||||||
|
iteration = 0
|
||||||
|
# Use with to ensure request gets closed even if it's only read partially
|
||||||
|
with requests.get(url, timeout=10, headers=headers, stream=True) as r:
|
||||||
|
for chunk in r.iter_content(chunk_size=CHUNK_SIZE):
|
||||||
|
size += len(chunk)
|
||||||
|
iteration = iteration + 1
|
||||||
|
if content is None:
|
||||||
|
content = chunk
|
||||||
|
else:
|
||||||
|
content = content + chunk
|
||||||
|
|
||||||
|
logger.debug(f'Loaded chunk (iteration={iteration}, total={size / 1024})')
|
||||||
|
|
||||||
|
# Stop reading if we have parsed end of head tag
|
||||||
|
end_of_head = '</head>'.encode('utf-8')
|
||||||
|
if end_of_head in content:
|
||||||
|
logger.debug(f'Found closing head tag after {size} bytes')
|
||||||
|
content = content.split(end_of_head)[0] + end_of_head
|
||||||
|
break
|
||||||
|
# Stop reading if we exceed limit
|
||||||
|
if size > MAX_CONTENT_LIMIT:
|
||||||
|
logger.debug(f'Cancel reading document after {size} bytes')
|
||||||
|
break
|
||||||
|
if hasattr(r, '_content_consumed'):
|
||||||
|
logger.debug(f'Request consumed: {r._content_consumed}')
|
||||||
|
|
||||||
|
# Use charset_normalizer to determine encoding that best matches the response content
|
||||||
|
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
|
||||||
|
# This is different from Response.text which does respect the encoding specified in the response first,
|
||||||
|
# before trying to determine one
|
||||||
|
results = from_bytes(content or '')
|
||||||
|
return str(results.best())
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36'
|
||||||
|
|
||||||
|
|
||||||
|
def fake_request_headers():
|
||||||
|
return {
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml",
|
||||||
|
"Accept-Encoding": "gzip, deflate",
|
||||||
|
"Dnt": "1",
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
|
"User-Agent": DEFAULT_USER_AGENT,
|
||||||
|
}
|
||||||
|
8
bookmarks/signals.py
Normal file
8
bookmarks/signals.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.contrib.auth import user_logged_in
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from bookmarks.services import tasks
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(user_logged_in)
|
||||||
|
def user_logged_in(sender, request, user, **kwargs):
|
||||||
|
tasks.schedule_bookmarks_without_snapshots(user)
|
BIN
bookmarks/static/apple-touch-icon.png
Normal file
BIN
bookmarks/static/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
171
bookmarks/static/bookmark_list.js
Normal file
171
bookmarks/static/bookmark_list.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
(function () {
|
||||||
|
function allowBulkEdit() {
|
||||||
|
return !!document.getElementById('bulk-edit-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupBulkEdit() {
|
||||||
|
if (!allowBulkEdit()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bulkEditToggle = document.getElementById('bulk-edit-mode')
|
||||||
|
const bulkEditBar = document.querySelector('.bulk-edit-bar')
|
||||||
|
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
|
||||||
|
const allToggle = document.querySelector('.bulk-edit-all-toggle input')
|
||||||
|
|
||||||
|
function isAllSelected() {
|
||||||
|
let result = true
|
||||||
|
|
||||||
|
singleToggles.forEach(function (toggle) {
|
||||||
|
result = result && toggle.checked
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
singleToggles.forEach(function (toggle) {
|
||||||
|
toggle.checked = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAll() {
|
||||||
|
singleToggles.forEach(function (toggle) {
|
||||||
|
toggle.checked = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle all
|
||||||
|
allToggle.addEventListener('change', function (e) {
|
||||||
|
if (e.target.checked) {
|
||||||
|
selectAll()
|
||||||
|
} else {
|
||||||
|
deselectAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Toggle single
|
||||||
|
singleToggles.forEach(function (toggle) {
|
||||||
|
toggle.addEventListener('change', function () {
|
||||||
|
allToggle.checked = isAllSelected()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Allow overflow when bulk edit mode is active to be able to display tag auto complete menu
|
||||||
|
let bulkEditToggleTimeout
|
||||||
|
if (bulkEditToggle.checked) {
|
||||||
|
bulkEditBar.style.overflow = 'visible';
|
||||||
|
}
|
||||||
|
bulkEditToggle.addEventListener('change', function (e) {
|
||||||
|
if (bulkEditToggleTimeout) {
|
||||||
|
clearTimeout(bulkEditToggleTimeout);
|
||||||
|
bulkEditToggleTimeout = null;
|
||||||
|
}
|
||||||
|
if (e.target.checked) {
|
||||||
|
bulkEditToggleTimeout = setTimeout(function () {
|
||||||
|
bulkEditBar.style.overflow = 'visible';
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
bulkEditBar.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupBulkEditTagAutoComplete() {
|
||||||
|
if (!allowBulkEdit()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
const tagInput = document.getElementById('bulk-edit-tags-input');
|
||||||
|
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || '';
|
||||||
|
const apiClient = new linkding.ApiClient(apiBaseUrl)
|
||||||
|
|
||||||
|
new linkding.TagAutoComplete({
|
||||||
|
target: wrapper,
|
||||||
|
props: {
|
||||||
|
id: 'bulk-edit-tags-input',
|
||||||
|
name: tagInput.name,
|
||||||
|
value: tagInput.value,
|
||||||
|
apiClient: apiClient,
|
||||||
|
variant: 'small'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tagInput.parentElement.replaceChild(wrapper, tagInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupListNavigation() {
|
||||||
|
// Add logic for navigating bookmarks with arrow keys
|
||||||
|
document.addEventListener('keydown', event => {
|
||||||
|
// Skip if event occurred within an input element
|
||||||
|
// or does not use arrow keys
|
||||||
|
const targetNodeName = event.target.nodeName;
|
||||||
|
const isInputTarget = targetNodeName === 'INPUT'
|
||||||
|
|| targetNodeName === 'SELECT'
|
||||||
|
|| targetNodeName === 'TEXTAREA';
|
||||||
|
const isArrowUp = event.key === 'ArrowUp';
|
||||||
|
const isArrowDown = event.key === 'ArrowDown';
|
||||||
|
|
||||||
|
if (isInputTarget || !(isArrowUp || isArrowDown)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Detect current bookmark list item
|
||||||
|
const path = event.composedPath();
|
||||||
|
const currentItem = path.find(item => item.hasAttribute && item.hasAttribute('data-is-bookmark-item'));
|
||||||
|
|
||||||
|
// Find next item
|
||||||
|
let nextItem;
|
||||||
|
if (currentItem) {
|
||||||
|
nextItem = isArrowUp
|
||||||
|
? currentItem.previousElementSibling
|
||||||
|
: currentItem.nextElementSibling;
|
||||||
|
} else {
|
||||||
|
// Select first item
|
||||||
|
nextItem = document.querySelector('li[data-is-bookmark-item]');
|
||||||
|
}
|
||||||
|
// Focus first link
|
||||||
|
if (nextItem) {
|
||||||
|
nextItem.querySelector('a').focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupNotes() {
|
||||||
|
// Shortcut for toggling all notes
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
// Filter for shortcut key
|
||||||
|
if (event.key !== 'e') return;
|
||||||
|
// Skip if event occurred within an input element
|
||||||
|
const targetNodeName = event.target.nodeName;
|
||||||
|
const isInputTarget = targetNodeName === 'INPUT'
|
||||||
|
|| targetNodeName === 'SELECT'
|
||||||
|
|| targetNodeName === 'TEXTAREA';
|
||||||
|
|
||||||
|
if (isInputTarget) return;
|
||||||
|
|
||||||
|
const list = document.querySelector('.bookmark-list');
|
||||||
|
list.classList.toggle('show-notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle notes for single bookmark
|
||||||
|
const bookmarks = document.querySelectorAll('.bookmark-list li');
|
||||||
|
bookmarks.forEach(bookmark => {
|
||||||
|
const toggleButton = bookmark.querySelector('.toggle-notes');
|
||||||
|
if (toggleButton) {
|
||||||
|
toggleButton.addEventListener('click', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
bookmark.classList.toggle('show-notes');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupBulkEdit();
|
||||||
|
setupBulkEditTagAutoComplete();
|
||||||
|
setupListNavigation();
|
||||||
|
setupNotes();
|
||||||
|
})()
|
BIN
bookmarks/static/logo.png
Normal file
BIN
bookmarks/static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
83
bookmarks/static/shared.js
Normal file
83
bookmarks/static/shared.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
(function () {
|
||||||
|
|
||||||
|
function initConfirmationButtons() {
|
||||||
|
const buttonEls = document.querySelectorAll('.btn-confirmation');
|
||||||
|
|
||||||
|
function showConfirmation(buttonEl) {
|
||||||
|
const cancelEl = document.createElement(buttonEl.nodeName);
|
||||||
|
cancelEl.innerText = 'Cancel';
|
||||||
|
cancelEl.className = 'btn btn-link btn-sm btn-confirmation-action mr-1';
|
||||||
|
cancelEl.addEventListener('click', function () {
|
||||||
|
container.remove();
|
||||||
|
buttonEl.style = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmEl = document.createElement(buttonEl.nodeName);
|
||||||
|
confirmEl.innerText = 'Confirm';
|
||||||
|
confirmEl.className = 'btn btn-link btn-delete btn-sm btn-confirmation-action';
|
||||||
|
|
||||||
|
if (buttonEl.nodeName === 'BUTTON') {
|
||||||
|
confirmEl.type = buttonEl.type;
|
||||||
|
confirmEl.name = buttonEl.name;
|
||||||
|
confirmEl.value = buttonEl.value;
|
||||||
|
}
|
||||||
|
if (buttonEl.nodeName === 'A') {
|
||||||
|
confirmEl.href = buttonEl.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement('span');
|
||||||
|
container.className = 'confirmation'
|
||||||
|
container.appendChild(cancelEl);
|
||||||
|
container.appendChild(confirmEl);
|
||||||
|
buttonEl.parentElement.insertBefore(container, buttonEl);
|
||||||
|
buttonEl.style = 'display: none';
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonEls.forEach(function (linkEl) {
|
||||||
|
linkEl.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
showConfirmation(linkEl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initGlobalShortcuts() {
|
||||||
|
// Focus search button
|
||||||
|
document.addEventListener('keydown', function (event) {
|
||||||
|
// Filter for shortcut key
|
||||||
|
if (event.key !== 's') return;
|
||||||
|
// Skip if event occurred within an input element
|
||||||
|
const targetNodeName = event.target.nodeName;
|
||||||
|
const isInputTarget = targetNodeName === 'INPUT'
|
||||||
|
|| targetNodeName === 'SELECT'
|
||||||
|
|| targetNodeName === 'TEXTAREA';
|
||||||
|
|
||||||
|
if (isInputTarget) return;
|
||||||
|
|
||||||
|
const searchInput = document.querySelector('input[type="search"]');
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new bookmark
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
// Filter for new entry shortcut key
|
||||||
|
if (event.key !== 'n') return;
|
||||||
|
// Skip if event occurred within an input element
|
||||||
|
const targetNodeName = event.target.nodeName;
|
||||||
|
const isInputTarget = targetNodeName === 'INPUT'
|
||||||
|
|| targetNodeName === 'SELECT'
|
||||||
|
|| targetNodeName === 'TEXTAREA';
|
||||||
|
|
||||||
|
if (isInputTarget) return;
|
||||||
|
|
||||||
|
window.location.assign("/bookmarks/new");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initConfirmationButtons();
|
||||||
|
initGlobalShortcuts();
|
||||||
|
})()
|
@@ -1,29 +1,48 @@
|
|||||||
body {
|
body {
|
||||||
margin: 20px 10px;
|
margin: 20px 10px;
|
||||||
|
|
||||||
|
@media (min-width: $size-sm) {
|
||||||
|
// High horizontal padding accounts for checkboxes that show up in bulk edit mode
|
||||||
|
margin: 20px 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header .toasts {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast a.btn-clear:visited {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
background-color: $primary-color;
|
width: 28px;
|
||||||
color: $light-color;
|
height: 28px;
|
||||||
padding: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
margin: 0 0 0 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-toggle {
|
.dropdown-toggle {
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,9 +58,24 @@ h2 {
|
|||||||
color: $gray-color-dark;
|
color: $gray-color-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Button color should not change for anchor elements
|
// Fix up visited styles
|
||||||
.btn:visited:not(.btn-primary) {
|
a:visited {
|
||||||
color: $primary-color;
|
color: $link-color;
|
||||||
|
}
|
||||||
|
a:visited:hover {
|
||||||
|
color: $link-color-dark;
|
||||||
|
}
|
||||||
|
.btn-link:visited:not(.btn-primary) {
|
||||||
|
color: $link-color;
|
||||||
|
}
|
||||||
|
.btn-link:visited:not(.btn-primary):hover {
|
||||||
|
color: $link-color-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: $gray-color-dark;
|
||||||
|
background-color: $code-bg-color;
|
||||||
|
box-shadow: 1px 1px 0 $code-shadow-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increase spacing between columns
|
// Increase spacing between columns
|
||||||
@@ -52,4 +86,34 @@ h2 {
|
|||||||
// Remove left padding from first pagination link
|
// Remove left padding from first pagination link
|
||||||
.pagination .page-item:first-child a {
|
.pagination .page-item:first-child a {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Override border color for tab block
|
||||||
|
.tab-block {
|
||||||
|
border-bottom: solid 1px $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form auto-complete menu
|
||||||
|
.form-autocomplete .menu {
|
||||||
|
.menu-item.selected > a, .menu-item > a:hover {
|
||||||
|
background: $secondary-color;
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item, .group-item:hover {
|
||||||
|
color: $gray-color;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increase input font size on small viewports to prevent zooming on focus the input
|
||||||
|
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
||||||
|
// viewport size
|
||||||
|
@media screen and (max-width: 430px) {
|
||||||
|
.form-input {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,14 +1,16 @@
|
|||||||
.bookmarks-page .search {
|
.bookmarks-page .search {
|
||||||
|
$searchbox-width: 180px;
|
||||||
|
$searchbox-width-md: 300px;
|
||||||
$searchbox-height: 1.8rem;
|
$searchbox-height: 1.8rem;
|
||||||
|
|
||||||
// Regular input
|
// Regular input
|
||||||
input[type='search'] {
|
input[type='search'] {
|
||||||
width: 180px;
|
width: $searchbox-width;
|
||||||
height: $searchbox-height;
|
height: $searchbox-height;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
|
||||||
@media (min-width: $control-width-md) {
|
@media (min-width: $control-width-md) {
|
||||||
width: 300px;
|
width: $searchbox-width-md;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,49 +20,107 @@
|
|||||||
height: $searchbox-height;
|
height: $searchbox-height;
|
||||||
|
|
||||||
.form-autocomplete-input {
|
.form-autocomplete-input {
|
||||||
|
width: $searchbox-width;
|
||||||
height: $searchbox-height;
|
height: $searchbox-height;
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
input[type='search'] {
|
input[type='search'] {
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: $control-width-md) {
|
||||||
|
width: $searchbox-width-md;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.bookmark-list {
|
.bookmarks-page .content-area-header {
|
||||||
|
span.btn {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark list */
|
||||||
|
ul.bookmark-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmarks */
|
||||||
|
ul.bookmark-list li {
|
||||||
|
|
||||||
|
.bulk-edit-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title a {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
vertical-align: text-top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-display {
|
||||||
|
color: $secondary-link-color;
|
||||||
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
color: $gray-color-dark;
|
color: $gray-color-dark;
|
||||||
|
|
||||||
a {
|
a, a:visited:hover {
|
||||||
color: $alternative-color;
|
color: $alternative-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions .btn-link {
|
.actions {
|
||||||
color: $gray-color;
|
display: flex;
|
||||||
padding-left: 0;
|
align-items: baseline;
|
||||||
padding-right: 0;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
&:focus,
|
|
||||||
&:hover,
|
|
||||||
&:active,
|
|
||||||
&.active {
|
|
||||||
color: darken($gray-color, 10%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions .btn-link.bm-remove-confirm {
|
.actions {
|
||||||
color: $error-color;
|
> *:not(:last-child) {
|
||||||
&:hover {
|
margin-right: 0.4rem;
|
||||||
text-decoration: underline;
|
}
|
||||||
|
|
||||||
|
a, button {
|
||||||
|
color: $gray-color;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
vertical-align: unset;
|
||||||
|
border: none;
|
||||||
|
transition: none;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
color: $gray-color-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-notes {
|
||||||
|
align-self: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,8 +131,18 @@ ul.bookmark-list {
|
|||||||
|
|
||||||
.tag-cloud {
|
.tag-cloud {
|
||||||
|
|
||||||
a {
|
.selected-tags {
|
||||||
color: $alternative-color;
|
margin-bottom: 0.8rem;
|
||||||
|
|
||||||
|
a, a:visited:hover {
|
||||||
|
color: $error-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unselected-tags {
|
||||||
|
a, a:visited:hover {
|
||||||
|
color: $alternative-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
.group {
|
||||||
@@ -88,12 +158,41 @@ ul.bookmark-list {
|
|||||||
|
|
||||||
.bookmarks-form {
|
.bookmarks-form {
|
||||||
|
|
||||||
|
.btn.form-icon {
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
visibility: hidden;
|
||||||
|
color: $gray-color;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
color: $gray-color-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-icon-right > input, .has-icon-right > textarea {
|
||||||
|
padding-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
|
||||||
|
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.form-icon.loading {
|
.form-icon.loading {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input-hint.bookmark-exists {
|
.form-input-hint.bookmark-exists {
|
||||||
visibility: hidden;
|
display: none;
|
||||||
color: $warning-color;
|
color: $warning-color;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -102,4 +201,165 @@ ul.bookmark-list {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
details.notes textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark notes */
|
||||||
|
ul.bookmark-list {
|
||||||
|
.notes {
|
||||||
|
display: none;
|
||||||
|
max-height: 300px;
|
||||||
|
margin: 4px 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.show-notes .notes,
|
||||||
|
li.show-notes .notes {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark notes markdown styles */
|
||||||
|
ul.bookmark-list .notes-content {
|
||||||
|
& {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, ul, ol, pre, blockquote {
|
||||||
|
margin: 0 0 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
> *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin-left: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li, ol li {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
background-color: $code-bg-color;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> pre:first-child:last-child {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark actions / bulk edit */
|
||||||
|
$bulk-edit-toggle-width: 16px;
|
||||||
|
$bulk-edit-toggle-offset: 8px;
|
||||||
|
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
|
||||||
|
$bulk-edit-transition-duration: 400ms;
|
||||||
|
|
||||||
|
.bookmarks-page form.bookmark-actions {
|
||||||
|
|
||||||
|
.bulk-edit-bar {
|
||||||
|
margin-top: -17px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
margin-left: -$bulk-edit-bar-offset;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height $bulk-edit-transition-duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-top: solid 1px $border-color;
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
> label.form-checkbox {
|
||||||
|
min-height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button {
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> input, .form-autocomplete {
|
||||||
|
width: auto;
|
||||||
|
margin-left: 4px;
|
||||||
|
max-width: 200px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.confirmation {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.confirmation button {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-edit-all-toggle {
|
||||||
|
width: $bulk-edit-toggle-width;
|
||||||
|
margin: 0 0 0 $bulk-edit-toggle-offset;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.bookmark-list li {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.bookmark-list li .bulk-edit-toggle {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: $bulk-edit-toggle-width;
|
||||||
|
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
|
||||||
|
top: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all $bulk-edit-transition-duration;
|
||||||
|
|
||||||
|
i {
|
||||||
|
top: 0.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk-edit-mode {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-toggle {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar {
|
||||||
|
max-height: 37px;
|
||||||
|
border-bottom: solid 1px $border-color;
|
||||||
}
|
}
|
||||||
|
32
bookmarks/styles/dark.scss
Normal file
32
bookmarks/styles/dark.scss
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* Dark theme overrides */
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn.btn-primary {
|
||||||
|
background: $dt-primary-button-color;
|
||||||
|
border-color: darken($dt-primary-button-color, 5%);
|
||||||
|
|
||||||
|
&:hover, &:active, &:focus {
|
||||||
|
background: darken($dt-primary-button-color, 5%);
|
||||||
|
border-color: darken($dt-primary-button-color, 10%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring*/
|
||||||
|
a:focus, .btn:focus {
|
||||||
|
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.has-error .form-input, .form-input.is-error, .has-error .form-select, .form-select.is-error {
|
||||||
|
background: darken($error-color, 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||||
|
background: $dt-primary-button-color;
|
||||||
|
border-color: $dt-primary-button-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination .page-item.active a {
|
||||||
|
background: $dt-primary-button-color;
|
||||||
|
}
|
@@ -1,27 +0,0 @@
|
|||||||
// Font sizes
|
|
||||||
$html-font-size: 18px !default;
|
|
||||||
|
|
||||||
//$alternative-color: #c84e00;
|
|
||||||
//$alternative-color: #FF84E8;
|
|
||||||
//$alternative-color: #98C1D9;
|
|
||||||
//$alternative-color: #7B287D;
|
|
||||||
$alternative-color: #05a6a3;
|
|
||||||
$alternative-color-dark: darken($alternative-color, 5%);
|
|
||||||
|
|
||||||
// Import Spectre CSS lib
|
|
||||||
@import "../../node_modules/spectre.css/src/spectre";
|
|
||||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
|
||||||
// Import Spectre icons
|
|
||||||
@import "../../node_modules/spectre.css/src/icons/icons-core";
|
|
||||||
@import "../../node_modules/spectre.css/src/icons/icons-navigation";
|
|
||||||
@import "../../node_modules/spectre.css/src/icons/icons-action";
|
|
||||||
@import "../../node_modules/spectre.css/src/icons/icons-object";
|
|
||||||
|
|
||||||
|
|
||||||
// Import style modules
|
|
||||||
@import "base";
|
|
||||||
@import "util";
|
|
||||||
@import "shared";
|
|
||||||
@import "bookmarks";
|
|
||||||
@import "settings";
|
|
||||||
@import "auth";
|
|
@@ -1,9 +1,18 @@
|
|||||||
.settings-page {
|
.settings-page {
|
||||||
section.content-area {
|
section.content-area {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.0rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group > input[type=submit] {
|
.input-group > input[type=submit] {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
section.about table {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
// Content area component
|
||||||
section.content-area {
|
section.content-area {
|
||||||
|
|
||||||
.content-area-header {
|
.content-area-header {
|
||||||
@@ -11,3 +12,11 @@ section.content-area {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Confirm button component
|
||||||
|
.btn-confirmation-action {
|
||||||
|
color: $error-color !important;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
17
bookmarks/styles/theme-dark.scss
Normal file
17
bookmarks/styles/theme-dark.scss
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Import custom variables
|
||||||
|
@import "variables-dark";
|
||||||
|
|
||||||
|
// Import Spectre CSS lib
|
||||||
|
@import "../../node_modules/spectre.css/src/spectre";
|
||||||
|
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||||
|
|
||||||
|
// Import style modules
|
||||||
|
@import "base";
|
||||||
|
@import "util";
|
||||||
|
@import "shared";
|
||||||
|
@import "bookmarks";
|
||||||
|
@import "settings";
|
||||||
|
@import "auth";
|
||||||
|
|
||||||
|
// Dark theme overrides
|
||||||
|
@import "dark";
|
14
bookmarks/styles/theme-light.scss
Normal file
14
bookmarks/styles/theme-light.scss
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Import custom variables
|
||||||
|
@import "variables-light";
|
||||||
|
|
||||||
|
// Import Spectre CSS lib
|
||||||
|
@import "../../node_modules/spectre.css/src/spectre";
|
||||||
|
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||||
|
|
||||||
|
// Import style modules
|
||||||
|
@import "base";
|
||||||
|
@import "util";
|
||||||
|
@import "shared";
|
||||||
|
@import "bookmarks";
|
||||||
|
@import "settings";
|
||||||
|
@import "auth";
|
@@ -7,3 +7,15 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-dark {
|
||||||
|
color: $gray-color-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-baseline {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
33
bookmarks/styles/variables-dark.scss
Normal file
33
bookmarks/styles/variables-dark.scss
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
$html-font-size: 18px !default;
|
||||||
|
|
||||||
|
$body-bg: #161822 !default;
|
||||||
|
$bg-color: lighten($body-bg, 5%) !default;
|
||||||
|
$bg-color-light: lighten($body-bg, 5%) !default;
|
||||||
|
|
||||||
|
$border-color: #4C4E53 !default;
|
||||||
|
$border-color-dark: $border-color !default;
|
||||||
|
|
||||||
|
$body-font-color: #b5bec8 !default;
|
||||||
|
$light-color: #fafafa !default;
|
||||||
|
|
||||||
|
$gray-color: #7f879b !default;
|
||||||
|
$gray-color-dark: lighten($gray-color, 20%) !default;
|
||||||
|
|
||||||
|
$primary-color: #a8b1ff !default;
|
||||||
|
$primary-color-dark: saturate($primary-color, 5%) !default;
|
||||||
|
$secondary-color: lighten($body-bg, 10%) !default;
|
||||||
|
|
||||||
|
$link-color: $primary-color !default;
|
||||||
|
$link-color-dark: darken($link-color, 5%) !default;
|
||||||
|
$link-color-light: $link-color !default;
|
||||||
|
|
||||||
|
$secondary-link-color: rgba(168, 177, 255, 0.73);
|
||||||
|
|
||||||
|
$alternative-color: #59bdb9;
|
||||||
|
$alternative-color-dark: #73f1eb;
|
||||||
|
|
||||||
|
$code-bg-color: rgba(255, 255, 255, 0.1);
|
||||||
|
$code-shadow-color: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
|
/* Dark theme specific */
|
||||||
|
$dt-primary-button-color: #5761cb !default;
|
9
bookmarks/styles/variables-light.scss
Normal file
9
bookmarks/styles/variables-light.scss
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
$html-font-size: 18px !default;
|
||||||
|
|
||||||
|
$alternative-color: #05a6a3;
|
||||||
|
$alternative-color-dark: darken($alternative-color, 5%);
|
||||||
|
|
||||||
|
$secondary-link-color: rgba(87, 85, 217, 0.64);
|
||||||
|
|
||||||
|
$code-bg-color: rgba(0, 0, 0, 0.05);
|
||||||
|
$code-shadow-color: rgba(0, 0, 0, 0.15);
|
@@ -4,31 +4,43 @@
|
|||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="bookmarks-page columns">
|
|
||||||
|
|
||||||
{# Bookmark list #}
|
{% include 'bookmarks/bulk_edit/state.html' %}
|
||||||
<section class="content-area column col-8 col-md-12">
|
|
||||||
<div class="content-area-header">
|
|
||||||
<h2>Archived bookmarks</h2>
|
|
||||||
<div class="spacer"></div>
|
|
||||||
{% bookmark_search query tags mode='archive' %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if empty %}
|
<div class="bookmarks-page columns">
|
||||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
|
||||||
{% else %}
|
|
||||||
{% bookmark_list bookmarks return_url %}
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{# Tag list #}
|
{# Bookmark list #}
|
||||||
<section class="content-area column col-4 hide-md">
|
<section class="content-area column col-8 col-md-12">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Tags</h2>
|
<h2>Archived bookmarks</h2>
|
||||||
</div>
|
<div class="spacer"></div>
|
||||||
{% tag_cloud tags %}
|
{% bookmark_search filters tags mode='archived' %}
|
||||||
</section>
|
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{% static "bundle.js" %}"></script>
|
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}"
|
||||||
|
method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %}
|
||||||
|
|
||||||
|
{% if empty %}
|
||||||
|
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||||
|
{% else %}
|
||||||
|
{% bookmark_list bookmarks return_url link_target %}
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# Tag list #}
|
||||||
|
<section class="content-area column col-4 hide-md">
|
||||||
|
<div class="content-area-header">
|
||||||
|
<h2>Tags</h2>
|
||||||
|
</div>
|
||||||
|
{% tag_cloud tags selected_tags %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static "bundle.js" %}"></script>
|
||||||
|
<script src="{% static "shared.js" %}"></script>
|
||||||
|
<script src="{% static "bookmark_list.js" %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -1,78 +1,128 @@
|
|||||||
|
{% load static %}
|
||||||
{% load shared %}
|
{% load shared %}
|
||||||
{% load pagination %}
|
{% load pagination %}
|
||||||
|
<ul class="bookmark-list{% if request.user.profile.permanent_notes %} show-notes{% endif %}">
|
||||||
<ul class="bookmark-list">
|
{% for bookmark in bookmarks %}
|
||||||
{% for bookmark in bookmarks %}
|
<li data-is-bookmark-item>
|
||||||
<li>
|
<label class="form-checkbox bulk-edit-toggle">
|
||||||
<div class="title truncate">
|
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
|
||||||
<a href="{{ bookmark.url }}" target="_blank" rel="noopener">{{ bookmark.resolved_title }}</a>
|
<i class="form-icon"></i>
|
||||||
</div>
|
</label>
|
||||||
<div class="description truncate">
|
<div class="title">
|
||||||
{% if bookmark.tag_names %}
|
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
|
||||||
<span>
|
class="{% if bookmark.unread %}text-italic{% endif %}">
|
||||||
{% for tag_name in bookmark.tag_names %}
|
{% if bookmark.favicon_file and request.user.profile.enable_favicons %}
|
||||||
<a href="?{% append_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
|
<img src="{% static bookmark.favicon_file %}" alt="">
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</span>
|
{{ bookmark.resolved_title }}
|
||||||
{% endif %}
|
</a>
|
||||||
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
|
</div>
|
||||||
|
{% if request.user.profile.display_url %}
|
||||||
{% if bookmark.resolved_description %}
|
<div class="url-path truncate">
|
||||||
<span>{{ bookmark.resolved_description }}</span>
|
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
|
||||||
{% endif %}
|
class="url-display text-sm">
|
||||||
</div>
|
{{ bookmark.url }}
|
||||||
<div class="actions">
|
</a>
|
||||||
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
|
</div>
|
||||||
class="btn btn-link btn-sm">Edit</a>
|
{% endif %}
|
||||||
{% if bookmark.is_archived %}
|
<div class="description truncate">
|
||||||
<a href="{% url 'bookmarks:unarchive' bookmark.id %}?return_url={{ return_url }}"
|
{% if bookmark.tag_names %}
|
||||||
class="btn btn-link btn-sm">Unarchive</a>
|
<span>
|
||||||
{% else %}
|
{% for tag_name in bookmark.tag_names %}
|
||||||
<a href="{% url 'bookmarks:archive' bookmark.id %}?return_url={{ return_url }}"
|
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||||
class="btn btn-link btn-sm">Archive</a>
|
{% endfor %}
|
||||||
{% endif %}
|
</span>
|
||||||
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
|
{% endif %}
|
||||||
class="btn btn-link btn-sm bm-remove">Remove</a>
|
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
|
||||||
</div>
|
{% if bookmark.resolved_description %}
|
||||||
</li>
|
<span>{{ bookmark.resolved_description }}</span>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if bookmark.notes %}
|
||||||
|
<div class="notes bg-gray text-gray-dark">
|
||||||
|
<div class="notes-content">
|
||||||
|
{% markdown bookmark.notes %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="actions text-gray text-sm">
|
||||||
|
{% if request.user.profile.bookmark_date_display == 'relative' %}
|
||||||
|
<span>
|
||||||
|
{% if bookmark.web_archive_snapshot_url %}
|
||||||
|
<a href="{{ bookmark.web_archive_snapshot_url }}"
|
||||||
|
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}"
|
||||||
|
rel="noopener">
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
|
||||||
|
{% if bookmark.web_archive_snapshot_url %}
|
||||||
|
∞
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="separator">|</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if request.user.profile.bookmark_date_display == 'absolute' %}
|
||||||
|
<span>
|
||||||
|
{% if bookmark.web_archive_snapshot_url %}
|
||||||
|
<a href="{{ bookmark.web_archive_snapshot_url }}"
|
||||||
|
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}"
|
||||||
|
rel="noopener">
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
|
||||||
|
{% if bookmark.web_archive_snapshot_url %}
|
||||||
|
∞
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="separator">|</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark.owner == request.user %}
|
||||||
|
{# Bookmark owner actions #}
|
||||||
|
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}">Edit</a>
|
||||||
|
{% if bookmark.is_archived %}
|
||||||
|
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
|
||||||
|
class="btn btn-link btn-sm">Unarchive
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" name="archive" value="{{ bookmark.id }}"
|
||||||
|
class="btn btn-link btn-sm">Archive
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" name="remove" value="{{ bookmark.id }}"
|
||||||
|
class="btn btn-link btn-sm btn-confirmation">Remove
|
||||||
|
</button>
|
||||||
|
{% if bookmark.unread %}
|
||||||
|
<span class="separator">|</span>
|
||||||
|
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
|
||||||
|
class="btn btn-link btn-sm">Mark as read
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{# Shared bookmark actions #}
|
||||||
|
<span>Shared by
|
||||||
|
<a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark.notes and not request.user.profile.permanent_notes %}
|
||||||
|
<span class="separator">|</span>
|
||||||
|
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16"
|
||||||
|
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
|
||||||
|
<path d="M9 7l6 0"></path>
|
||||||
|
<path d="M9 11l6 0"></path>
|
||||||
|
<path d="M9 15l4 0"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Notes</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="bookmark-pagination">
|
<div class="bookmark-pagination">
|
||||||
{% pagination bookmarks %}
|
{% pagination bookmarks %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Enhance delete links to show inline confirmation #}
|
|
||||||
<script type="application/javascript">
|
|
||||||
window.addEventListener("load", function () {
|
|
||||||
const linkEls = document.querySelectorAll('.bookmark-list a.bm-remove');
|
|
||||||
|
|
||||||
function showConfirmation(linkEl) {
|
|
||||||
const cancelEl = document.createElement('span');
|
|
||||||
cancelEl.innerText = 'Cancel';
|
|
||||||
cancelEl.className = 'btn btn-link btn-sm bm-remove-confirm mr-1';
|
|
||||||
cancelEl.addEventListener('click', function() {
|
|
||||||
container.remove();
|
|
||||||
linkEl.style = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const confirmEl = document.createElement('a');
|
|
||||||
confirmEl.innerText = 'Confirm';
|
|
||||||
confirmEl.className = 'btn btn-link btn-delete btn-sm bm-remove-confirm';
|
|
||||||
confirmEl.href = linkEl.href;
|
|
||||||
|
|
||||||
const container = document.createElement('span');
|
|
||||||
container.appendChild(cancelEl);
|
|
||||||
container.appendChild(confirmEl);
|
|
||||||
linkEl.parentElement.appendChild(container);
|
|
||||||
linkEl.style = 'display: none';
|
|
||||||
}
|
|
||||||
|
|
||||||
linkEls.forEach(function (linkEl) {
|
|
||||||
linkEl.addEventListener('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
showConfirmation(linkEl);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
@@ -1,9 +1,9 @@
|
|||||||
(function() {
|
(function () {
|
||||||
var bookmarkUrl = window.location;
|
var bookmarkUrl = window.location;
|
||||||
var applicationUrl = '{{ application_url }}';
|
var applicationUrl = '{{ application_url }}';
|
||||||
|
|
||||||
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
|
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
|
||||||
applicationUrl += '&auto_close';
|
applicationUrl += '&auto_close';
|
||||||
|
|
||||||
window.open(applicationUrl);
|
window.open(applicationUrl);
|
||||||
})();
|
})();
|
||||||
|
34
bookmarks/templates/bookmarks/bulk_edit/bar.html
Normal file
34
bookmarks/templates/bookmarks/bulk_edit/bar.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{% load shared %}
|
||||||
|
{% htmlmin %}
|
||||||
|
<div class="bulk-edit-bar">
|
||||||
|
<div class="bulk-edit-actions bg-gray">
|
||||||
|
<label class="form-checkbox bulk-edit-all-toggle">
|
||||||
|
<input type="checkbox" style="display: none">
|
||||||
|
<i class="form-icon"></i>
|
||||||
|
</label>
|
||||||
|
{% if mode == 'archive' %}
|
||||||
|
<button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm btn-confirmation"
|
||||||
|
title="Unarchive selected bookmarks">Unarchive
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" name="bulk_archive" class="btn btn-link btn-sm btn-confirmation"
|
||||||
|
title="Archive selected bookmarks">Archive
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-sm text-gray-dark">•</span>
|
||||||
|
<button type="submit" name="bulk_delete" class="btn btn-link btn-sm btn-confirmation"
|
||||||
|
title="Delete selected bookmarks">Delete
|
||||||
|
</button>
|
||||||
|
<span class="text-sm text-gray-dark">•</span>
|
||||||
|
<span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span>
|
||||||
|
<input id="bulk-edit-tags-input" name="bulk_tag_string" class="form-input input-sm"
|
||||||
|
placeholder=" ">
|
||||||
|
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm"
|
||||||
|
title="Add tags to selected bookmarks">Add
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="bulk_untag" class="btn btn-link btn-sm"
|
||||||
|
title="Remove tags from selected bookmarks">Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endhtmlmin %}
|
1
bookmarks/templates/bookmarks/bulk_edit/state.html
Normal file
1
bookmarks/templates/bookmarks/bulk_edit/state.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<input id="bulk-edit-mode" type="checkbox">
|
9
bookmarks/templates/bookmarks/bulk_edit/toggle.html
Normal file
9
bookmarks/templates/bookmarks/bulk_edit/toggle.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<label for="bulk-edit-mode" class="hide-sm">
|
||||||
|
<span class="btn" title="Bulk edit">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
|
||||||
|
height="20px">
|
||||||
|
<path
|
||||||
|
d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</label>
|
@@ -1,9 +1,9 @@
|
|||||||
{% extends "bookmarks/layout.html" %}
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
window.close()
|
window.close()
|
||||||
</script>
|
</script>
|
||||||
<p>You can now close this window.</p>
|
<p>You can now close this window.</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@@ -2,14 +2,15 @@
|
|||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<section class="content-area column col-12">
|
<section class="content-area column col-12">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Edit bookmark</h2>
|
<h2>Edit bookmark</h2>
|
||||||
</div>
|
</div>
|
||||||
<form action="{% url 'bookmarks:edit' bookmark_id %}" method="post" class="col-6 col-md-12" novalidate>
|
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||||
{% bookmark_form form all_tags return_url bookmark_id %}
|
class="col-6 col-md-12" novalidate>
|
||||||
</form>
|
{% bookmark_form form return_url bookmark_id %}
|
||||||
</section>
|
</form>
|
||||||
</div>
|
</section>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
<div class="empty">
|
<div class="empty">
|
||||||
<p class="empty-title h5">You have no bookmarks yet</p>
|
<p class="empty-title h5">You have no bookmarks yet</p>
|
||||||
<p class="empty-subtitle">
|
<p class="empty-subtitle">
|
||||||
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
|
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
|
||||||
<a href="{% url 'bookmarks:settings.index' %}">importing</a> your existing bookmarks or configuring the
|
<a href="{% url 'bookmarks:settings.general' %}">importing</a> your existing bookmarks or configuring the
|
||||||
<a href="{% url 'bookmarks:settings.index' %}#bookmarklet">bookmarklet</a>.
|
<a href="{% url 'bookmarks:settings.integrations' %}">browser extension</a> or the <a
|
||||||
</p>
|
href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -2,130 +2,243 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
<div class="bookmarks-form">
|
<div class="bookmarks-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.auto_close|attr:"type:hidden" }}
|
{{ form.website_title }}
|
||||||
{{ form.return_url|attr:"type:hidden" }}
|
{{ form.website_description }}
|
||||||
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
|
{{ form.auto_close|attr:"type:hidden" }}
|
||||||
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
|
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
|
||||||
{{ form.url|add_class:"form-input"|attr:"autofocus" }}
|
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
|
||||||
{% if form.url.errors %}
|
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
|
||||||
<div class="form-input-hint">
|
{% if form.url.errors %}
|
||||||
{{ form.url.errors }}
|
<div class="form-input-hint">
|
||||||
</div>
|
{{ form.url.errors }}
|
||||||
{% endif %}
|
</div>
|
||||||
<div class="form-input-hint bookmark-exists">
|
{% endif %}
|
||||||
This URL is already bookmarked. You can <a href="#">edit</a> it or you can overwrite the existing bookmark by saving this form.
|
<div class="form-input-hint bookmark-exists">
|
||||||
</div>
|
This URL is already bookmarked.
|
||||||
|
The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
||||||
|
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
|
||||||
|
exist it will be
|
||||||
|
automatically created.
|
||||||
|
</div>
|
||||||
|
{{ form.tag_string.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group has-icon-right">
|
||||||
|
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
||||||
|
<div class="has-icon-right">
|
||||||
|
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
|
||||||
|
<i class="form-icon loading"></i>
|
||||||
|
<a class="btn btn-link form-icon" title="Edit title from website">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Optional, leave empty to use title from website.
|
||||||
|
</div>
|
||||||
|
{{ form.title.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
|
||||||
|
<div class="has-icon-right">
|
||||||
|
{{ form.description|add_class:"form-input"|attr:"rows:2" }}
|
||||||
|
<i class="form-icon loading"></i>
|
||||||
|
<a class="btn btn-link form-icon" title="Edit description from website">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Optional, leave empty to use description from website.
|
||||||
|
</div>
|
||||||
|
{{ form.description.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<details class="notes"{% if form.has_notes %} open{% endif %}>
|
||||||
|
<summary>
|
||||||
|
<span class="form-label d-inline-block">Notes</span>
|
||||||
|
</summary>
|
||||||
|
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
|
||||||
|
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Additional notes, supports Markdown.
|
||||||
|
</div>
|
||||||
|
{{ form.notes.errors }}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ form.unread }}
|
||||||
|
<i class="form-icon"></i>
|
||||||
|
<span>Mark as unread</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if request.user.profile.enable_sharing %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
|
||||||
{{ form.tag_string|add_class:"form-input" }}
|
{{ form.shared }}
|
||||||
<div class="form-input-hint">
|
<i class="form-icon"></i>
|
||||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
|
<span>Share</span>
|
||||||
exist it will be
|
</label>
|
||||||
automatically created.
|
<div class="form-input-hint">
|
||||||
</div>
|
Share this bookmark with other users.
|
||||||
{{ form.tag_string.errors }}
|
</div>
|
||||||
</div>
|
|
||||||
<div class="form-group has-icon-right">
|
|
||||||
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
|
||||||
<div class="has-icon-right">
|
|
||||||
{{ form.title|add_class:"form-input" }}
|
|
||||||
<i class="form-icon loading"></i>
|
|
||||||
</div>
|
|
||||||
<div class="form-input-hint">
|
|
||||||
Optional, leave empty to use title from website.
|
|
||||||
</div>
|
|
||||||
{{ form.title.errors }}
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
|
|
||||||
<div class="has-icon-right">
|
|
||||||
{{ form.description|add_class:"form-input"|attr:"rows:4" }}
|
|
||||||
<i class="form-icon loading"></i>
|
|
||||||
</div>
|
|
||||||
<div class="form-input-hint">
|
|
||||||
Optional, leave empty to use description from website.
|
|
||||||
</div>
|
|
||||||
{{ form.description.errors }}
|
|
||||||
</div>
|
|
||||||
<br/>
|
|
||||||
<div class="form-group">
|
|
||||||
{% if auto_close %}
|
|
||||||
<input type="submit" value="Save and close" class="btn btn-primary mr-2">
|
|
||||||
{% else %}
|
|
||||||
<input type="submit" value="Save" class="btn btn-primary mr-2">
|
|
||||||
{% endif %}
|
|
||||||
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<br/>
|
||||||
|
<div class="form-group">
|
||||||
|
{% if auto_close %}
|
||||||
|
<input type="submit" value="Save and close" class="btn btn-primary mr-2">
|
||||||
|
{% else %}
|
||||||
|
<input type="submit" value="Save" class="btn btn-primary mr-2">
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Replace tag input with auto-complete component #}
|
{# Replace tag input with auto-complete component #}
|
||||||
<script src="{% static "bundle.js" %}"></script>
|
<script src="{% static "bundle.js" %}"></script>
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
const tagInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
const tagInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
||||||
const allTagsString = '{{ all_tags }}';
|
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
|
||||||
const allTags = allTagsString.split(' ');
|
|
||||||
|
|
||||||
new linkding.TagAutoComplete({
|
new linkding.TagAutoComplete({
|
||||||
target: wrapper,
|
target: wrapper,
|
||||||
props: {
|
props: {
|
||||||
id: '{{ form.tag_string.id_for_label }}',
|
id: '{{ form.tag_string.id_for_label }}',
|
||||||
name: '{{ form.tag_string.name }}',
|
name: '{{ form.tag_string.name }}',
|
||||||
value: tagInput.value,
|
value: tagInput.value,
|
||||||
tags: allTags
|
apiClient: apiClient
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tagInput.parentElement.replaceChild(wrapper, tagInput);
|
||||||
|
</script>
|
||||||
|
<script type="application/javascript">
|
||||||
|
/**
|
||||||
|
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
|
||||||
|
* - Show hint if URL is already bookmarked
|
||||||
|
* - Setup buttons that allow editing of scraped website values
|
||||||
|
*/
|
||||||
|
(function init() {
|
||||||
|
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
||||||
|
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
||||||
|
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
|
||||||
|
const notesDetails = document.querySelector('form details.notes');
|
||||||
|
const notesInput = document.getElementById('{{ form.notes.id_for_label }}');
|
||||||
|
const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
||||||
|
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
|
||||||
|
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
|
||||||
|
const websiteTitleInput = document.getElementById('{{ form.website_title.id_for_label }}');
|
||||||
|
const websiteDescriptionInput = document.getElementById('{{ form.website_description.id_for_label }}');
|
||||||
|
const editedBookmarkId = {{ bookmark_id }};
|
||||||
|
|
||||||
|
function toggleLoadingIcon(input, show) {
|
||||||
|
const icon = input.parentNode.querySelector('i.form-icon');
|
||||||
|
icon.style['visibility'] = show ? 'visible' : 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlaceholder(input, value) {
|
||||||
|
if (value) {
|
||||||
|
input.setAttribute('placeholder', value);
|
||||||
|
} else {
|
||||||
|
input.removeAttribute('placeholder');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInput(input, value) {
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCheckbox(input, value) {
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.checked = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkUrl() {
|
||||||
|
toggleLoadingIcon(titleInput, true);
|
||||||
|
toggleLoadingIcon(descriptionInput, true);
|
||||||
|
updatePlaceholder(titleInput, null);
|
||||||
|
updatePlaceholder(descriptionInput, null);
|
||||||
|
|
||||||
|
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||||
|
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
||||||
|
fetch(requestUrl)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const metadata = data.metadata;
|
||||||
|
updatePlaceholder(titleInput, metadata.title);
|
||||||
|
updatePlaceholder(descriptionInput, metadata.description);
|
||||||
|
toggleLoadingIcon(titleInput, false);
|
||||||
|
toggleLoadingIcon(descriptionInput, false);
|
||||||
|
|
||||||
|
// Prefill form and display hint if URL is already bookmarked
|
||||||
|
const existingBookmark = data.bookmark;
|
||||||
|
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
|
||||||
|
|
||||||
|
if (existingBookmark && !editedBookmarkId) {
|
||||||
|
bookmarkExistsHint.style['display'] = 'block';
|
||||||
|
notesDetails.open = !!existingBookmark.notes;
|
||||||
|
updateInput(titleInput, existingBookmark.title);
|
||||||
|
updateInput(descriptionInput, existingBookmark.description);
|
||||||
|
updateInput(notesInput, existingBookmark.notes);
|
||||||
|
updateInput(tagsInput, existingBookmark.tag_names.join(" "));
|
||||||
|
updateCheckbox(unreadCheckbox, existingBookmark.unread);
|
||||||
|
updateCheckbox(sharedCheckbox, existingBookmark.shared);
|
||||||
|
} else {
|
||||||
|
bookmarkExistsHint.style['display'] = 'none';
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEditAutoValueButton(input) {
|
||||||
|
const editAutoValueButton = input.parentNode.querySelector('.btn.form-icon');
|
||||||
|
if (!editAutoValueButton) return;
|
||||||
|
editAutoValueButton.addEventListener('click', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
input.value = input.getAttribute('placeholder');
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
tagInput.parentElement.replaceChild(wrapper, tagInput);
|
setupEditAutoValueButton(titleInput);
|
||||||
</script>
|
setupEditAutoValueButton(descriptionInput);
|
||||||
<script type="application/javascript">
|
|
||||||
/**
|
|
||||||
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
|
|
||||||
* - Show hint if URL is already bookmarked
|
|
||||||
*/
|
|
||||||
(function init() {
|
|
||||||
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
|
||||||
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
|
||||||
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
|
|
||||||
const editedBookmarkId = {{ bookmark_id }}
|
|
||||||
|
|
||||||
urlInput.addEventListener('input', checkUrl);
|
// Fetch initial website data if we have a URL, and we are not editing an existing bookmark
|
||||||
|
// For existing bookmarks we get the website metadata through hidden inputs
|
||||||
|
if (urlInput.value && !editedBookmarkId) {
|
||||||
|
checkUrl();
|
||||||
|
}
|
||||||
|
urlInput.addEventListener('input', checkUrl);
|
||||||
|
|
||||||
function checkUrl() {
|
// Set initial website title and description for edited bookmarks
|
||||||
toggleIcon(titleInput, true);
|
if (editedBookmarkId) {
|
||||||
toggleIcon(descriptionInput, true);
|
updatePlaceholder(titleInput, websiteTitleInput.value);
|
||||||
|
updatePlaceholder(descriptionInput, websiteDescriptionInput.value);
|
||||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
}
|
||||||
const requestUrl = `{% url 'bookmarks:api.check_url' %}?url=${websiteUrl}`;
|
})();
|
||||||
fetch(requestUrl)
|
</script>
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
const metadata = data.metadata
|
|
||||||
titleInput.setAttribute('placeholder', metadata.title || '');
|
|
||||||
descriptionInput.setAttribute('placeholder', metadata.description || '');
|
|
||||||
toggleIcon(titleInput, false);
|
|
||||||
toggleIcon(descriptionInput, false);
|
|
||||||
|
|
||||||
// Display hint if URL is already bookmarked
|
|
||||||
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists')
|
|
||||||
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a')
|
|
||||||
|
|
||||||
if(data.bookmark && data.bookmark.id !== editedBookmarkId) {
|
|
||||||
bookmarkExistsHint.style['visibility'] = 'visible'
|
|
||||||
editExistingBookmarkLink.href = data.bookmark.edit_url
|
|
||||||
} else {
|
|
||||||
bookmarkExistsHint.style['visibility'] = 'hidden'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleIcon(input, show) {
|
|
||||||
const icon = input.parentNode.querySelector('i.form-icon');
|
|
||||||
icon.style['visibility'] = show ? 'visible' : 'hidden';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (urlInput.value) checkUrl();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
|
@@ -4,31 +4,43 @@
|
|||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="bookmarks-page columns">
|
|
||||||
|
|
||||||
{# Bookmark list #}
|
{% include 'bookmarks/bulk_edit/state.html' %}
|
||||||
<section class="content-area column col-8 col-md-12">
|
|
||||||
<div class="content-area-header">
|
|
||||||
<h2>Bookmarks</h2>
|
|
||||||
<div class="spacer"></div>
|
|
||||||
{% bookmark_search query tags %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if empty %}
|
<div class="bookmarks-page columns">
|
||||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
|
||||||
{% else %}
|
|
||||||
{% bookmark_list bookmarks return_url %}
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{# Tag list #}
|
{# Bookmark list #}
|
||||||
<section class="content-area column col-4 hide-md">
|
<section class="content-area column col-8 col-md-12">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>Tags</h2>
|
<h2>Bookmarks</h2>
|
||||||
</div>
|
<div class="spacer"></div>
|
||||||
{% tag_cloud tags %}
|
{% bookmark_search filters tags %}
|
||||||
</section>
|
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{% static "bundle.js" %}"></script>
|
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}"
|
||||||
|
method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %}
|
||||||
|
|
||||||
|
{% if empty %}
|
||||||
|
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||||
|
{% else %}
|
||||||
|
{% bookmark_list bookmarks return_url link_target %}
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# Tag list #}
|
||||||
|
<section class="content-area column col-4 hide-md">
|
||||||
|
<div class="content-area-header">
|
||||||
|
<h2>Tags</h2>
|
||||||
|
</div>
|
||||||
|
{% tag_cloud tags selected_tags %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static "bundle.js" %}"></script>
|
||||||
|
<script src="{% static "shared.js" %}"></script>
|
||||||
|
<script src="{% static "bookmark_list.js" %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -2,37 +2,66 @@
|
|||||||
{% load sass_tags %}
|
{% load sass_tags %}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
{# Use data attributes as storage for access in static scripts #}
|
||||||
|
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="{% static 'favicon.png' %}"/>
|
<link rel="icon" href="{% static 'favicon.png' %}"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
|
||||||
<meta name="description" content="Self-hosted bookmark service">
|
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
|
||||||
<meta name="robots" content="index,follow">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="author" content="Sascha Ißbrücker">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||||
<title>linkding</title>
|
<meta name="description" content="Self-hosted bookmark service">
|
||||||
{# Include SASS styles, files are resolved from bookmarks/styles #}
|
<meta name="robots" content="index,follow">
|
||||||
<link href="{% sass_src 'index.scss' %}" rel="stylesheet" type="text/css"/>
|
<meta name="author" content="Sascha Ißbrücker">
|
||||||
|
<title>linkding</title>
|
||||||
|
{# Include SASS styles, files are resolved from bookmarks/styles #}
|
||||||
|
{# Include specific theme variant based on user profile setting #}
|
||||||
|
{% if request.user.profile.theme == 'light' %}
|
||||||
|
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"/>
|
||||||
|
{% elif request.user.profile.theme == 'dark' %}
|
||||||
|
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"/>
|
||||||
|
{% else %}
|
||||||
|
{# Use auto theme as fallback #}
|
||||||
|
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"
|
||||||
|
media="(prefers-color-scheme: dark)"/>
|
||||||
|
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"
|
||||||
|
media="(prefers-color-scheme: light)"/>
|
||||||
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="navbar container grid-lg">
|
<header>
|
||||||
|
{% if has_toasts %}
|
||||||
|
<div class="toasts container grid-lg">
|
||||||
|
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for toast in toast_messages %}
|
||||||
|
<div class="toast">
|
||||||
|
{{ toast.message }}
|
||||||
|
<button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear float-right"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="navbar container grid-lg">
|
||||||
<section class="navbar-section">
|
<section class="navbar-section">
|
||||||
<a href="/" class="navbar-brand text-bold">
|
<a href="{% url 'bookmarks:index' %}" class="navbar-brand text-bold">
|
||||||
<i class="logo icon icon-link s-circle"></i>
|
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||||
|
<h1>linkding</h1>
|
||||||
<h1>linkding</h1>
|
</a>
|
||||||
</a>
|
|
||||||
</section>
|
</section>
|
||||||
{# Only show nav items menu when logged in #}
|
{# Only show nav items menu when logged in #}
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
<section class="navbar-section">
|
<section class="navbar-section">
|
||||||
{% include 'bookmarks/nav_menu.html' %}
|
{% include 'bookmarks/nav_menu.html' %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="content container grid-lg">
|
<div class="content container grid-lg">
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@@ -1,49 +1,101 @@
|
|||||||
|
{% load shared %}
|
||||||
|
{% htmlmin %}
|
||||||
{# Basic menu list #}
|
{# Basic menu list #}
|
||||||
<div class="hide-md">
|
<div class="hide-md">
|
||||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||||
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a>
|
<div class="dropdown">
|
||||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
|
||||||
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
Bookmarks
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
|
||||||
|
style="height:1rem;width:1rem;vertical-align: text-bottom;">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<ul class="menu">
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Active</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
|
||||||
|
</li>
|
||||||
|
{% if request.user.profile.enable_sharing %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||||
|
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
{# Menu drop-down for smaller devices #}
|
{# Menu drop-down for smaller devices #}
|
||||||
<div class="show-md">
|
<div class="show-md">
|
||||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">
|
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
|
||||||
<i class="icon icon-plus"></i>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
style="width: 24px; height: 24px">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown dropdown-right">
|
||||||
|
<a href="#" id="mobile-nav-menu-trigger" class="btn btn-link dropdown-toggle" tabindex="0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
style="width: 24px; height: 24px">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown dropdown-right">
|
<!-- menu component -->
|
||||||
<a href="#" id="mobile-nav-menu-trigger" class="btn btn-link dropdown-toggle" tabindex="0">
|
<ul class="menu">
|
||||||
<i class="icon icon-menu icon-2x"></i>
|
<li>
|
||||||
</a>
|
<a href="{% url 'bookmarks:index' %}" class="btn btn-link">Bookmarks</a>
|
||||||
<!-- menu component -->
|
</li>
|
||||||
<ul class="menu">
|
<li style="padding-left: 1rem">
|
||||||
<li>
|
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
|
||||||
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a>
|
</li>
|
||||||
</li>
|
{% if request.user.profile.enable_sharing %}
|
||||||
<li>
|
<li style="padding-left: 1rem">
|
||||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
{% endif %}
|
||||||
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
<li style="padding-left: 1rem">
|
||||||
</li>
|
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
<li style="padding-left: 1rem">
|
||||||
|
<a href="{% url 'bookmarks:index' %}?q=!untagged" class="btn btn-link">Untagged</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endhtmlmin %}
|
||||||
<script>
|
<script>
|
||||||
// Hide mobile menu on outside click
|
// Hide mobile menu on outside click
|
||||||
// The Spectre CSS component relies on focus changes to show/hide the dropdown, however mobile browsers like
|
// The Spectre CSS component relies on focus changes to show/hide the dropdown, however mobile browsers like
|
||||||
// Safari will not trigger a blur event when clicking on a non-focusable element, so we have to simulate the
|
// Safari will not trigger a blur event when clicking on a non-focusable element, so we have to simulate the
|
||||||
// behaviour through Javascript
|
// behaviour through Javascript
|
||||||
const mobileNavMenuTrigger = document.getElementById('mobile-nav-menu-trigger');
|
const mobileNavMenuTrigger = document.getElementById('mobile-nav-menu-trigger');
|
||||||
|
|
||||||
function mobileNavMenuOutsideClickHandler(clickEvent) {
|
function mobileNavMenuOutsideClickHandler(clickEvent) {
|
||||||
if (mobileNavMenuTrigger.parentElement.contains(clickEvent.target)) return
|
if (mobileNavMenuTrigger.parentElement.contains(clickEvent.target)) return
|
||||||
mobileNavMenuTrigger.blur();
|
mobileNavMenuTrigger.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
mobileNavMenuTrigger.addEventListener('focus', function () {
|
mobileNavMenuTrigger.addEventListener('focus', function () {
|
||||||
document.addEventListener('click', mobileNavMenuOutsideClickHandler);
|
document.addEventListener('click', mobileNavMenuOutsideClickHandler);
|
||||||
})
|
})
|
||||||
mobileNavMenuTrigger.addEventListener('blur', function () {
|
mobileNavMenuTrigger.addEventListener('blur', function () {
|
||||||
document.removeEventListener('click', mobileNavMenuOutsideClickHandler);
|
document.removeEventListener('click', mobileNavMenuOutsideClickHandler);
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
@@ -2,14 +2,14 @@
|
|||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<section class="content-area column col-12">
|
<section class="content-area column col-12">
|
||||||
<div class="content-area-header">
|
<div class="content-area-header">
|
||||||
<h2>New bookmark</h2>
|
<h2>New bookmark</h2>
|
||||||
</div>
|
</div>
|
||||||
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate>
|
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate>
|
||||||
{% bookmark_form form all_tags return_url auto_close=auto_close %}
|
{% bookmark_form form return_url auto_close=auto_close %}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -1,35 +1,35 @@
|
|||||||
{% load shared %}
|
{% load shared %}
|
||||||
|
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
{% if page.has_previous %}
|
{% if page.has_previous %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="?{% update_query_string page=page.previous_page_number %}" tabindex="-1">Previous</a>
|
<a href="?{% update_query_string page=page.previous_page_number %}" tabindex="-1">Previous</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled">
|
<li class="page-item disabled">
|
||||||
<a href="#" tabindex="-1">Previous</a>
|
<a href="#" tabindex="-1">Previous</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for page_number in visible_page_numbers %}
|
{% for page_number in visible_page_numbers %}
|
||||||
{% if page_number >= 0 %}
|
{% if page_number >= 0 %}
|
||||||
<li class="page-item {% if page.number == page_number %}active{% endif %}">
|
<li class="page-item {% if page.number == page_number %}active{% endif %}">
|
||||||
<a href="?{% update_query_string page=page_number %}">{{ page_number }}</a>
|
<a href="?{% update_query_string page=page_number %}">{{ page_number }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
|
||||||
<li class="page-item">
|
|
||||||
<span>...</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if page.has_next %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a href="?{% update_query_string page=page.next_page_number %}" tabindex="-1">Next</a>
|
|
||||||
</li>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled">
|
<li class="page-item">
|
||||||
<a href="#" tabindex="-1">Next</a>
|
<span>...</span>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a href="?{% update_query_string page=page.next_page_number %}" tabindex="-1">Next</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a href="#" tabindex="-1">Next</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
@@ -1,34 +1,43 @@
|
|||||||
<div class="search">
|
<div class="search">
|
||||||
<form action="" method="get" role="search">
|
<form action="" method="get" role="search">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span id="search-input-wrap">
|
<span id="search-input-wrap">
|
||||||
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
|
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
|
||||||
value="{{ query }}">
|
value="{{ filters.query }}">
|
||||||
</span>
|
</span>
|
||||||
<input type="submit" value="Search" class="btn input-group-btn">
|
<input type="submit" value="Search" class="btn input-group-btn">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{% if filters.user %}
|
||||||
|
<input type="hidden" name="user" value="{{ filters.user }}">
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Replace search input with auto-complete component #}
|
{# Replace search input with auto-complete component #}
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
window.addEventListener("load", function() {
|
window.addEventListener("load", function () {
|
||||||
const currentTagsString = '{{ tags_string }}';
|
const currentTagsString = '{{ tags_string }}';
|
||||||
const currentTags = currentTagsString.split(' ');
|
const currentTags = currentTagsString.split(' ');
|
||||||
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
|
const uniqueTags = [...new Set(currentTags)]
|
||||||
const wrapper = document.getElementById('search-input-wrap')
|
const filters = {
|
||||||
const newWrapper = document.createElement('div')
|
q: '{{ filters.query }}',
|
||||||
new linkding.SearchAutoComplete({
|
user: '{{ filters.user }}',
|
||||||
target: newWrapper,
|
}
|
||||||
props: {
|
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
|
||||||
name: 'q',
|
const wrapper = document.getElementById('search-input-wrap')
|
||||||
placeholder: 'Search for words or #tags',
|
const newWrapper = document.createElement('div')
|
||||||
value: '{{ query }}',
|
new linkding.SearchAutoComplete({
|
||||||
tags: currentTags,
|
target: newWrapper,
|
||||||
mode: '{{ mode }}',
|
props: {
|
||||||
apiClient
|
name: 'q',
|
||||||
}
|
placeholder: 'Search for words or #tags',
|
||||||
})
|
value: '{{ filters.query }}',
|
||||||
wrapper.parentElement.replaceChild(newWrapper, wrapper)
|
tags: uniqueTags,
|
||||||
});
|
mode: '{{ mode }}',
|
||||||
|
apiClient,
|
||||||
|
filters,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
wrapper.parentElement.replaceChild(newWrapper, wrapper)
|
||||||
|
});
|
||||||
</script>
|
</script>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user