mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-10 20:03:12 +08:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
128e1afbce | ||
|
|
d33719dc7c | ||
|
|
357c2d1399 | ||
|
|
9cda5a54d3 | ||
|
|
67d5b17450 | ||
|
|
3ec6c0a7f8 | ||
|
|
86c2bdd138 | ||
|
|
82e5b7d9d5 | ||
|
|
d873342105 | ||
|
|
d519cb74eb | ||
|
|
ff0e6f0ff6 | ||
|
|
77c45c63f3 | ||
|
|
e45e63bfb1 | ||
|
|
004319adae | ||
|
|
d8358f1b12 | ||
|
|
b90ae1b202 | ||
|
|
6c874afff2 | ||
|
|
723b843c13 | ||
|
|
96176ba50e | ||
|
|
f6fb46e8ad | ||
|
|
3804640574 | ||
|
|
8f61fbd04a | ||
|
|
22bc713ed8 | ||
|
|
04248a7fba | ||
|
|
0ff36a94fe | ||
|
|
f83eb25569 | ||
|
|
c746afcf76 | ||
|
|
aaa0f6e119 | ||
|
|
cd215a9237 | ||
|
|
1e56b0e6f3 | ||
|
|
5cc8c9c010 | ||
|
|
846808d870 | ||
|
|
6d9a694756 | ||
|
|
de38e56b3f | ||
|
|
c6fb695af2 | ||
|
|
93faf70b37 | ||
|
|
5330252db9 | ||
|
|
ef00d289f5 | ||
|
|
4e8318d0ae | ||
|
|
a8623d11ef | ||
|
|
8cd992ca30 | ||
|
|
68c104ba54 | ||
|
|
7a4236d179 | ||
|
|
e87304501f | ||
|
|
809e9e02f3 | ||
|
|
2bb33ff96d | ||
|
|
549554cc17 | ||
|
|
20e31397cc | ||
|
|
94ae5fb41c | ||
|
|
2a550e2315 | ||
|
|
a79e8bcd59 | ||
|
|
1710d44df7 | ||
|
|
9967b3e27b | ||
|
|
1672dc0152 | ||
|
|
8be72a5d1f | ||
|
|
bb796c9bdb | ||
|
|
578680c3c1 | ||
|
|
8debb5c5aa | ||
|
|
be752f8146 |
@@ -10,10 +10,10 @@
|
|||||||
!/package.json
|
!/package.json
|
||||||
!/package-lock.json
|
!/package-lock.json
|
||||||
!/postcss.config.js
|
!/postcss.config.js
|
||||||
!/requirements.dev.txt
|
!/pyproject.toml
|
||||||
!/requirements.txt
|
|
||||||
!/rollup.config.mjs
|
!/rollup.config.mjs
|
||||||
!/supervisord.conf
|
!/supervisord.conf
|
||||||
|
!/uv.lock
|
||||||
!/uwsgi.ini
|
!/uwsgi.ini
|
||||||
!/version.txt
|
!/version.txt
|
||||||
|
|
||||||
|
|||||||
73
.github/workflows/build-test.yaml
vendored
Normal file
73
.github/workflows/build-test.yaml
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
name: build-test
|
||||||
|
|
||||||
|
on: workflow_dispatch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build latest
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/default.Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
tags: |
|
||||||
|
ghcr.io/sissbruecker/linkding:test
|
||||||
|
target: linkding
|
||||||
|
push: true
|
||||||
|
|
||||||
|
- name: Build latest-alpine
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/alpine.Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
tags: |
|
||||||
|
ghcr.io/sissbruecker/linkding:test-alpine
|
||||||
|
target: linkding
|
||||||
|
push: true
|
||||||
|
|
||||||
|
- name: Build latest-plus
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/default.Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
tags: |
|
||||||
|
ghcr.io/sissbruecker/linkding:test-plus
|
||||||
|
target: linkding-plus
|
||||||
|
push: true
|
||||||
|
|
||||||
|
- name: Build latest-plus-alpine
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/alpine.Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
tags: |
|
||||||
|
ghcr.io/sissbruecker/linkding:test-plus-alpine
|
||||||
|
target: linkding-plus
|
||||||
|
push: true
|
||||||
20
.github/workflows/main.yaml
vendored
20
.github/workflows/main.yaml
vendored
@@ -15,7 +15,9 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.13"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -25,10 +27,10 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Setup Python environment
|
- name: Setup Python environment
|
||||||
run: |
|
run: |
|
||||||
pip install -r requirements.txt -r requirements.dev.txt
|
uv sync
|
||||||
mkdir data
|
mkdir data
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: python manage.py test bookmarks.tests
|
run: uv run manage.py test bookmarks.tests
|
||||||
e2e_tests:
|
e2e_tests:
|
||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -37,7 +39,9 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.13"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -47,12 +51,12 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Setup Python environment
|
- name: Setup Python environment
|
||||||
run: |
|
run: |
|
||||||
pip install -r requirements.txt -r requirements.dev.txt
|
uv sync
|
||||||
playwright install chromium
|
uv run playwright install chromium
|
||||||
mkdir data
|
mkdir data
|
||||||
- name: Run build
|
- name: Run build
|
||||||
run: |
|
run: |
|
||||||
npm run build
|
npm run build
|
||||||
python manage.py collectstatic
|
uv run manage.py collectstatic
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: python manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"
|
run: uv run manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -196,3 +196,7 @@ typings/
|
|||||||
/chromium-profile
|
/chromium-profile
|
||||||
# direnv
|
# direnv
|
||||||
/.direnv
|
/.direnv
|
||||||
|
|
||||||
|
# Test setups
|
||||||
|
/scripts/unsecure-test-setups/authelia-oidc/authelia/db.sqlite3
|
||||||
|
/scripts/unsecure-test-setups/authelia-oidc/traefik/certs
|
||||||
|
|||||||
88
CHANGELOG.md
88
CHANGELOG.md
@@ -1,5 +1,93 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.41.0 (19/06/2025)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add bundles for organizing bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1097
|
||||||
|
* Add REST API for bookmark bundles by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1100
|
||||||
|
* Add date filters for REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1080
|
||||||
|
* Fix side panel not being hidden on smaller viewports by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1089
|
||||||
|
* Fix assets not using correct icon by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1098
|
||||||
|
* Add LinkBuddy to community section by @peterto in https://github.com/sissbruecker/linkding/pull/1088
|
||||||
|
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1084
|
||||||
|
* Bump django from 5.1.9 to 5.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/1086
|
||||||
|
* Bump requests from 2.32.3 to 2.32.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/1090
|
||||||
|
* Bump urllib3 from 2.2.3 to 2.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/1096
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @peterto made their first contribution in https://github.com/sissbruecker/linkding/pull/1088
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.40.0...v1.41.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.40.0 (17/05/2025)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add bulk and single bookmark metadata refresh by @Teknicallity in https://github.com/sissbruecker/linkding/pull/999
|
||||||
|
* Prefer local snapshot over web archive link in bookmark list links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1021
|
||||||
|
* Push Docker images to GHCR in addition to Docker Hub by @caycehouse in https://github.com/sissbruecker/linkding/pull/1024
|
||||||
|
* Allow auto tagging rules to match URL fragments by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1045
|
||||||
|
* Linkify plain URLs in notes by @sonicdoe in https://github.com/sissbruecker/linkding/pull/1051
|
||||||
|
* Add opensearch declaration by @jzorn in https://github.com/sissbruecker/linkding/pull/1058
|
||||||
|
* Allow pre-filling tags in new bookmark form by @dasrecht in https://github.com/sissbruecker/linkding/pull/1060
|
||||||
|
* Handle lowercase "true" in environment variables by @jose-elias-alvarez in https://github.com/sissbruecker/linkding/pull/1020
|
||||||
|
* Accessibility improvements in page structure by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1014
|
||||||
|
* Improve announcements after navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1015
|
||||||
|
* Fix OIDC login link by @cite in https://github.com/sissbruecker/linkding/pull/1019
|
||||||
|
* Fix bookmark asset download endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1033
|
||||||
|
* Add docs for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1009
|
||||||
|
* Fix typo in index.mdx tagline by @cenviity in https://github.com/sissbruecker/linkding/pull/1052
|
||||||
|
* Add how-to for using linkding PWA in native Android share sheet by @kzshantonu in https://github.com/sissbruecker/linkding/pull/1055
|
||||||
|
* Adding linktiles to community projects by @haondt in https://github.com/sissbruecker/linkding/pull/1025
|
||||||
|
* Bump django from 5.1.5 to 5.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/1007
|
||||||
|
* Bump django from 5.1.7 to 5.1.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/1030
|
||||||
|
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1028
|
||||||
|
* Bump prismjs from 1.29.0 to 1.30.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1034
|
||||||
|
* Bump @babel/helpers from 7.26.7 to 7.27.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1035
|
||||||
|
* Bump vite from 5.4.14 to 5.4.17 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1036
|
||||||
|
* Bump esbuild, @astrojs/starlight and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1037
|
||||||
|
* Bump django from 5.1.8 to 5.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/1059
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @cite made their first contribution in https://github.com/sissbruecker/linkding/pull/1019
|
||||||
|
* @jose-elias-alvarez made their first contribution in https://github.com/sissbruecker/linkding/pull/1020
|
||||||
|
* @Teknicallity made their first contribution in https://github.com/sissbruecker/linkding/pull/999
|
||||||
|
* @haondt made their first contribution in https://github.com/sissbruecker/linkding/pull/1025
|
||||||
|
* @caycehouse made their first contribution in https://github.com/sissbruecker/linkding/pull/1024
|
||||||
|
* @cenviity made their first contribution in https://github.com/sissbruecker/linkding/pull/1052
|
||||||
|
* @sonicdoe made their first contribution in https://github.com/sissbruecker/linkding/pull/1051
|
||||||
|
* @jzorn made their first contribution in https://github.com/sissbruecker/linkding/pull/1058
|
||||||
|
* @dasrecht made their first contribution in https://github.com/sissbruecker/linkding/pull/1060
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.39.1...v1.40.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.39.1 (06/03/2025)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Due to changes in the release process the `1.39.0` Docker image accidentally runs the application in debug mode. Please upgrade to `1.39.1` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.39.0 (06/03/2025)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add REST endpoint for uploading snapshots from the Singlefile extension by @sissbruecker in https://github.com/sissbruecker/linkding/pull/996
|
||||||
|
* Add bookmark assets API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1003
|
||||||
|
* Allow providing REST API authentication token with Bearer keyword by @sissbruecker in https://github.com/sissbruecker/linkding/pull/995
|
||||||
|
* Add Telegram bot to community section by @marb08 in https://github.com/sissbruecker/linkding/pull/1001
|
||||||
|
* Adding linklater to community projects by @nsartor in https://github.com/sissbruecker/linkding/pull/1002
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @marb08 made their first contribution in https://github.com/sissbruecker/linkding/pull/1001
|
||||||
|
* @nsartor made their first contribution in https://github.com/sissbruecker/linkding/pull/1002
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.1...v1.39.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.38.1 (22/02/2025)
|
## v1.38.1 (22/02/2025)
|
||||||
|
|
||||||
### What's Changed
|
### What's Changed
|
||||||
|
|||||||
16
Makefile
16
Makefile
@@ -1,15 +1,23 @@
|
|||||||
.PHONY: serve
|
.PHONY: serve
|
||||||
|
|
||||||
|
init:
|
||||||
|
uv sync
|
||||||
|
uv run manage.py migrate
|
||||||
|
npm install
|
||||||
|
|
||||||
serve:
|
serve:
|
||||||
python manage.py runserver
|
uv run manage.py runserver
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
python manage.py run_huey
|
uv run manage.py run_huey
|
||||||
|
|
||||||
test:
|
test:
|
||||||
pytest -n auto
|
uv run pytest -n auto
|
||||||
|
|
||||||
format:
|
format:
|
||||||
black bookmarks
|
uv run black bookmarks
|
||||||
npx prettier bookmarks/frontend --write
|
npx prettier bookmarks/frontend --write
|
||||||
npx prettier bookmarks/styles --write
|
npx prettier bookmarks/styles --write
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
npm run dev
|
||||||
43
README.md
43
README.md
@@ -61,43 +61,31 @@ Small improvements, bugfixes and documentation improvements are always welcome.
|
|||||||
The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
|
The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Python 3.12
|
- Python 3.13
|
||||||
|
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||||
- Node.js
|
- Node.js
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
Create a virtual environment for the application (https://docs.python.org/3/tutorial/venv.html):
|
Initialize the development environment with:
|
||||||
```
|
```
|
||||||
python3 -m venv ~/environments/linkding
|
make init
|
||||||
```
|
|
||||||
Activate the environment for your shell:
|
|
||||||
```
|
|
||||||
source ~/environments/linkding/bin/activate[.csh|.fish]
|
|
||||||
```
|
|
||||||
Within the active environment install the application dependencies from the application folder:
|
|
||||||
```
|
|
||||||
pip3 install -r requirements.txt -r requirements.dev.txt
|
|
||||||
```
|
|
||||||
Install frontend dependencies:
|
|
||||||
```
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
Initialize database:
|
|
||||||
```
|
|
||||||
mkdir -p data
|
|
||||||
python3 manage.py migrate
|
|
||||||
```
|
```
|
||||||
|
This sets up a virtual environment using uv, installs NPM dependencies and runs migrations to create the initial database.
|
||||||
|
|
||||||
Create a user for the frontend:
|
Create a user for the frontend:
|
||||||
```
|
```
|
||||||
python3 manage.py createsuperuser --username=joe --email=joe@example.com
|
uv run manage.py createsuperuser --username=joe --email=joe@example.com
|
||||||
```
|
```
|
||||||
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
|
|
||||||
|
Run the frontend build for bundling frontend components with:
|
||||||
```
|
```
|
||||||
npm run dev
|
make frontend
|
||||||
```
|
```
|
||||||
Start the Django development server with:
|
|
||||||
|
Then start the Django development server with:
|
||||||
```
|
```
|
||||||
python3 manage.py runserver
|
make serve
|
||||||
```
|
```
|
||||||
The frontend is now available under http://localhost:8000
|
The frontend is now available under http://localhost:8000
|
||||||
|
|
||||||
@@ -117,6 +105,11 @@ make format
|
|||||||
|
|
||||||
### DevContainers
|
### DevContainers
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> The dev container setup is currently broken after switching to uv.
|
||||||
|
> Feel free to contribute a PR if you want to fix it.
|
||||||
|
> The instructions below are outdated until then.
|
||||||
|
|
||||||
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/sissbruecker/linkding.git)
|
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/sissbruecker/linkding.git)
|
||||||
|
|
||||||
Once checked out, only the following commands are required to get started:
|
Once checked out, only the following commands are required to get started:
|
||||||
|
|||||||
@@ -11,7 +11,15 @@ from huey.contrib.djhuey import HUEY as huey
|
|||||||
from rest_framework.authtoken.admin import TokenAdmin
|
from rest_framework.authtoken.admin import TokenAdmin
|
||||||
from rest_framework.authtoken.models import TokenProxy
|
from rest_framework.authtoken.models import TokenProxy
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
|
from bookmarks.models import (
|
||||||
|
Bookmark,
|
||||||
|
BookmarkAsset,
|
||||||
|
BookmarkBundle,
|
||||||
|
Tag,
|
||||||
|
UserProfile,
|
||||||
|
Toast,
|
||||||
|
FeedToken,
|
||||||
|
)
|
||||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +214,7 @@ class AdminBookmarkAsset(admin.ModelAdmin):
|
|||||||
|
|
||||||
list_display = ("custom_display_name", "date_created", "status")
|
list_display = ("custom_display_name", "date_created", "status")
|
||||||
search_fields = (
|
search_fields = (
|
||||||
"custom_display_name",
|
"display_name",
|
||||||
"file",
|
"file",
|
||||||
)
|
)
|
||||||
list_filter = ("status",)
|
list_filter = ("status",)
|
||||||
@@ -256,6 +264,21 @@ class AdminTag(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminBookmarkBundle(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"name",
|
||||||
|
"owner",
|
||||||
|
"order",
|
||||||
|
"search",
|
||||||
|
"any_tags",
|
||||||
|
"all_tags",
|
||||||
|
"excluded_tags",
|
||||||
|
"date_created",
|
||||||
|
)
|
||||||
|
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||||
|
list_filter = ("owner__username",)
|
||||||
|
|
||||||
|
|
||||||
class AdminUserProfileInline(admin.StackedInline):
|
class AdminUserProfileInline(admin.StackedInline):
|
||||||
model = UserProfile
|
model = UserProfile
|
||||||
can_delete = False
|
can_delete = False
|
||||||
@@ -289,6 +312,7 @@ linkding_admin_site = LinkdingAdminSite()
|
|||||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||||
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
|
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
|
||||||
linkding_admin_site.register(Tag, AdminTag)
|
linkding_admin_site.register(Tag, AdminTag)
|
||||||
|
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
|
||||||
linkding_admin_site.register(User, AdminCustomUser)
|
linkding_admin_site.register(User, AdminCustomUser)
|
||||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||||
linkding_admin_site.register(Toast, AdminToast)
|
linkding_admin_site.register(Toast, AdminToast)
|
||||||
|
|||||||
@@ -16,9 +16,18 @@ from bookmarks.api.serializers import (
|
|||||||
BookmarkAssetSerializer,
|
BookmarkAssetSerializer,
|
||||||
TagSerializer,
|
TagSerializer,
|
||||||
UserProfileSerializer,
|
UserProfileSerializer,
|
||||||
|
BookmarkBundleSerializer,
|
||||||
)
|
)
|
||||||
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
|
from bookmarks.models import (
|
||||||
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
|
Bookmark,
|
||||||
|
BookmarkAsset,
|
||||||
|
BookmarkSearch,
|
||||||
|
Tag,
|
||||||
|
User,
|
||||||
|
BookmarkBundle,
|
||||||
|
)
|
||||||
|
from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
|
||||||
|
from bookmarks.utils import normalize_url
|
||||||
from bookmarks.type_defs import HttpRequest
|
from bookmarks.type_defs import HttpRequest
|
||||||
from bookmarks.views import access
|
from bookmarks.views import access
|
||||||
|
|
||||||
@@ -50,7 +59,7 @@ class BookmarkViewSet(
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# Provide filtered queryset for list actions
|
# Provide filtered queryset for list actions
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
search = BookmarkSearch.from_request(self.request.GET)
|
search = BookmarkSearch.from_request(self.request, self.request.GET)
|
||||||
if self.action == "list":
|
if self.action == "list":
|
||||||
return queries.query_bookmarks(user, user.profile, search)
|
return queries.query_bookmarks(user, user.profile, search)
|
||||||
elif self.action == "archived":
|
elif self.action == "archived":
|
||||||
@@ -99,7 +108,10 @@ class BookmarkViewSet(
|
|||||||
def check(self, request: HttpRequest):
|
def check(self, request: HttpRequest):
|
||||||
url = request.GET.get("url")
|
url = request.GET.get("url")
|
||||||
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
|
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
|
||||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
normalized_url = normalize_url(url)
|
||||||
|
bookmark = Bookmark.objects.filter(
|
||||||
|
owner=request.user, url_normalized=normalized_url
|
||||||
|
).first()
|
||||||
existing_bookmark_data = (
|
existing_bookmark_data = (
|
||||||
self.get_serializer(bookmark).data if bookmark else None
|
self.get_serializer(bookmark).data if bookmark else None
|
||||||
)
|
)
|
||||||
@@ -143,7 +155,10 @@ class BookmarkViewSet(
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
normalized_url = normalize_url(url)
|
||||||
|
bookmark = Bookmark.objects.filter(
|
||||||
|
owner=request.user, url_normalized=normalized_url
|
||||||
|
).first()
|
||||||
|
|
||||||
if not bookmark:
|
if not bookmark:
|
||||||
bookmark = Bookmark(url=url)
|
bookmark = Bookmark(url=url)
|
||||||
@@ -191,13 +206,10 @@ class BookmarkAssetViewSet(
|
|||||||
if asset.gzip
|
if asset.gzip
|
||||||
else open(file_path, "rb")
|
else open(file_path, "rb")
|
||||||
)
|
)
|
||||||
file_name = (
|
|
||||||
f"{asset.display_name}.html"
|
|
||||||
if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
|
|
||||||
else asset.display_name
|
|
||||||
)
|
|
||||||
response = StreamingHttpResponse(file_stream, content_type=content_type)
|
response = StreamingHttpResponse(file_stream, content_type=content_type)
|
||||||
response["Content-Disposition"] = f'attachment; filename="{file_name}"'
|
response["Content-Disposition"] = (
|
||||||
|
f'attachment; filename="{asset.download_name}"'
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise Http404("Asset file does not exist")
|
raise Http404("Asset file does not exist")
|
||||||
@@ -264,6 +276,28 @@ class UserViewSet(viewsets.GenericViewSet):
|
|||||||
return Response(UserProfileSerializer(request.user.profile).data)
|
return Response(UserProfileSerializer(request.user.profile).data)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkBundleViewSet(
|
||||||
|
viewsets.GenericViewSet,
|
||||||
|
mixins.ListModelMixin,
|
||||||
|
mixins.RetrieveModelMixin,
|
||||||
|
mixins.CreateModelMixin,
|
||||||
|
mixins.UpdateModelMixin,
|
||||||
|
mixins.DestroyModelMixin,
|
||||||
|
):
|
||||||
|
request: HttpRequest
|
||||||
|
serializer_class = BookmarkBundleSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
return BookmarkBundle.objects.filter(owner=user).order_by("order")
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
return {"user": self.request.user}
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
bundles.delete_bundle(instance)
|
||||||
|
|
||||||
|
|
||||||
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
|
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
|
||||||
# Instead create separate routers for each view set and manually register them in urls.py
|
# Instead create separate routers for each view set and manually register them in urls.py
|
||||||
# The default router is only used to allow reversing a URL for the API root
|
# The default router is only used to allow reversing a URL for the API root
|
||||||
@@ -278,5 +312,8 @@ tag_router.register("", TagViewSet, basename="tag")
|
|||||||
user_router = SimpleRouter()
|
user_router = SimpleRouter()
|
||||||
user_router.register("", UserViewSet, basename="user")
|
user_router.register("", UserViewSet, basename="user")
|
||||||
|
|
||||||
|
bundle_router = SimpleRouter()
|
||||||
|
bundle_router.register("", BookmarkBundleViewSet, basename="bundle")
|
||||||
|
|
||||||
bookmark_asset_router = SimpleRouter()
|
bookmark_asset_router = SimpleRouter()
|
||||||
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
|
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
from django.db.models import prefetch_related_objects
|
from django.db.models import Max, prefetch_related_objects
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.serializers import ListSerializer
|
from rest_framework.serializers import ListSerializer
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
|
from bookmarks.models import (
|
||||||
from bookmarks.services import bookmarks
|
Bookmark,
|
||||||
|
BookmarkAsset,
|
||||||
|
Tag,
|
||||||
|
build_tag_string,
|
||||||
|
UserProfile,
|
||||||
|
BookmarkBundle,
|
||||||
|
)
|
||||||
|
from bookmarks.services import bookmarks, bundles
|
||||||
from bookmarks.services.tags import get_or_create_tag
|
from bookmarks.services.tags import get_or_create_tag
|
||||||
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
||||||
from bookmarks.utils import app_version
|
from bookmarks.utils import app_version
|
||||||
@@ -27,6 +34,32 @@ class EmtpyField(serializers.ReadOnlyField):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkBundleSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = BookmarkBundle
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"search",
|
||||||
|
"any_tags",
|
||||||
|
"all_tags",
|
||||||
|
"excluded_tags",
|
||||||
|
"order",
|
||||||
|
"date_created",
|
||||||
|
"date_modified",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"date_created",
|
||||||
|
"date_modified",
|
||||||
|
]
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
bundle = BookmarkBundle(**validated_data)
|
||||||
|
bundle.order = validated_data["order"] if "order" in validated_data else None
|
||||||
|
return bundles.create_bundle(bundle, self.context["user"])
|
||||||
|
|
||||||
|
|
||||||
class BookmarkSerializer(serializers.ModelSerializer):
|
class BookmarkSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Bookmark
|
model = Bookmark
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||||
|
from bookmarks.views import access
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -30,10 +31,16 @@ def sanitize(text: str):
|
|||||||
class BaseBookmarksFeed(Feed):
|
class BaseBookmarksFeed(Feed):
|
||||||
def get_object(self, request, feed_key: str | None):
|
def get_object(self, request, feed_key: str | None):
|
||||||
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
|
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
|
||||||
|
bundle = None
|
||||||
|
bundle_id = request.GET.get("bundle")
|
||||||
|
if bundle_id:
|
||||||
|
bundle = access.bundle_read(request, bundle_id)
|
||||||
|
|
||||||
search = BookmarkSearch(
|
search = BookmarkSearch(
|
||||||
q=request.GET.get("q", ""),
|
q=request.GET.get("q", ""),
|
||||||
unread=request.GET.get("unread", ""),
|
unread=request.GET.get("unread", ""),
|
||||||
shared=request.GET.get("shared", ""),
|
shared=request.GET.get("shared", ""),
|
||||||
|
bundle=bundle,
|
||||||
)
|
)
|
||||||
query_set = self.get_query_set(feed_token, search)
|
query_set = self.get_query_set(feed_token, search)
|
||||||
return FeedContext(request, feed_token, query_set)
|
return FeedContext(request, feed_token, query_set)
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.forms.utils import ErrorList
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, build_tag_string
|
from bookmarks.models import (
|
||||||
from bookmarks.validators import BookmarkURLValidator
|
Bookmark,
|
||||||
from bookmarks.type_defs import HttpRequest
|
Tag,
|
||||||
|
build_tag_string,
|
||||||
|
parse_tag_string,
|
||||||
|
sanitize_tag_name,
|
||||||
|
)
|
||||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||||
|
from bookmarks.type_defs import HttpRequest
|
||||||
|
from bookmarks.utils import normalize_url
|
||||||
|
from bookmarks.validators import BookmarkURLValidator
|
||||||
|
|
||||||
|
|
||||||
|
class CustomErrorList(ErrorList):
|
||||||
|
template_name = "shared/error_list.html"
|
||||||
|
|
||||||
|
|
||||||
class BookmarkForm(forms.ModelForm):
|
class BookmarkForm(forms.ModelForm):
|
||||||
@@ -44,11 +57,14 @@ class BookmarkForm(forms.ModelForm):
|
|||||||
"tag_string": request.GET.get("tags"),
|
"tag_string": request.GET.get("tags"),
|
||||||
"auto_close": "auto_close" in request.GET,
|
"auto_close": "auto_close" in request.GET,
|
||||||
"unread": request.user_profile.default_mark_unread,
|
"unread": request.user_profile.default_mark_unread,
|
||||||
|
"shared": request.user_profile.default_mark_shared,
|
||||||
}
|
}
|
||||||
if instance is not None and request.method == "GET":
|
if instance is not None and request.method == "GET":
|
||||||
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
|
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
|
||||||
data = request.POST if request.method == "POST" else None
|
data = request.POST if request.method == "POST" else None
|
||||||
super().__init__(data, instance=instance, initial=initial)
|
super().__init__(
|
||||||
|
data, instance=instance, initial=initial, error_class=CustomErrorList
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_auto_close(self):
|
def is_auto_close(self):
|
||||||
@@ -78,8 +94,11 @@ class BookmarkForm(forms.ModelForm):
|
|||||||
# raise a validation error in that case.
|
# raise a validation error in that case.
|
||||||
url = self.cleaned_data["url"]
|
url = self.cleaned_data["url"]
|
||||||
if self.instance.pk:
|
if self.instance.pk:
|
||||||
|
normalized_url = normalize_url(url)
|
||||||
is_duplicate = (
|
is_duplicate = (
|
||||||
Bookmark.objects.filter(owner=self.instance.owner, url=url)
|
Bookmark.objects.filter(
|
||||||
|
owner=self.instance.owner, url_normalized=normalized_url
|
||||||
|
)
|
||||||
.exclude(pk=self.instance.pk)
|
.exclude(pk=self.instance.pk)
|
||||||
.exists()
|
.exists()
|
||||||
)
|
)
|
||||||
@@ -93,3 +112,88 @@ def convert_tag_string(tag_string: str):
|
|||||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||||
# strings
|
# strings
|
||||||
return tag_string.replace(" ", ",")
|
return tag_string.replace(" ", ",")
|
||||||
|
|
||||||
|
|
||||||
|
class TagForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Tag
|
||||||
|
fields = ["name"]
|
||||||
|
|
||||||
|
def __init__(self, user, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs, error_class=CustomErrorList)
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
def clean_name(self):
|
||||||
|
name = self.cleaned_data.get("name", "").strip()
|
||||||
|
|
||||||
|
name = sanitize_tag_name(name)
|
||||||
|
|
||||||
|
queryset = Tag.objects.filter(name__iexact=name, owner=self.user)
|
||||||
|
if self.instance.pk:
|
||||||
|
queryset = queryset.exclude(pk=self.instance.pk)
|
||||||
|
|
||||||
|
if queryset.exists():
|
||||||
|
raise forms.ValidationError(f'Tag "{name}" already exists.')
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
tag = super().save(commit=False)
|
||||||
|
if not self.instance.pk:
|
||||||
|
tag.owner = self.user
|
||||||
|
tag.date_added = timezone.now()
|
||||||
|
else:
|
||||||
|
tag.date_modified = timezone.now()
|
||||||
|
if commit:
|
||||||
|
tag.save()
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
class TagMergeForm(forms.Form):
|
||||||
|
target_tag = forms.CharField()
|
||||||
|
merge_tags = forms.CharField()
|
||||||
|
|
||||||
|
def __init__(self, user, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs, error_class=CustomErrorList)
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
def clean_target_tag(self):
|
||||||
|
target_tag_name = self.cleaned_data.get("target_tag", "")
|
||||||
|
|
||||||
|
target_tag_names = parse_tag_string(target_tag_name, " ")
|
||||||
|
if len(target_tag_names) != 1:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
"Please enter only one tag name for the target tag."
|
||||||
|
)
|
||||||
|
|
||||||
|
target_tag_name = target_tag_names[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_tag = Tag.objects.get(name__iexact=target_tag_name, owner=self.user)
|
||||||
|
except Tag.DoesNotExist:
|
||||||
|
raise forms.ValidationError(f'Tag "{target_tag_name}" does not exist.')
|
||||||
|
|
||||||
|
return target_tag
|
||||||
|
|
||||||
|
def clean_merge_tags(self):
|
||||||
|
merge_tags_string = self.cleaned_data.get("merge_tags", "")
|
||||||
|
|
||||||
|
merge_tag_names = parse_tag_string(merge_tags_string, " ")
|
||||||
|
if not merge_tag_names:
|
||||||
|
raise forms.ValidationError("Please enter at least one tag to merge.")
|
||||||
|
|
||||||
|
merge_tags = []
|
||||||
|
for tag_name in merge_tag_names:
|
||||||
|
try:
|
||||||
|
tag = Tag.objects.get(name__iexact=tag_name, owner=self.user)
|
||||||
|
merge_tags.append(tag)
|
||||||
|
except Tag.DoesNotExist:
|
||||||
|
raise forms.ValidationError(f'Tag "{tag_name}" does not exist.')
|
||||||
|
|
||||||
|
target_tag = self.cleaned_data.get("target_tag")
|
||||||
|
if target_tag and target_tag in merge_tags:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
"The target tag cannot be selected for merging."
|
||||||
|
)
|
||||||
|
|
||||||
|
return merge_tags
|
||||||
|
|||||||
@@ -1,79 +1,173 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
import { FocusTrapController, isKeyboardActive } from "./focus-utils";
|
||||||
|
|
||||||
|
let confirmId = 0;
|
||||||
|
|
||||||
|
function nextConfirmId() {
|
||||||
|
return `confirm-${confirmId++}`;
|
||||||
|
}
|
||||||
|
|
||||||
class ConfirmButtonBehavior extends Behavior {
|
class ConfirmButtonBehavior extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
|
|
||||||
this.onClick = this.onClick.bind(this);
|
this.onClick = this.onClick.bind(this);
|
||||||
element.addEventListener("click", this.onClick);
|
this.element.addEventListener("click", this.onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.reset();
|
if (this.opened) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
this.element.removeEventListener("click", this.onClick);
|
this.element.removeEventListener("click", this.onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
onClick(event) {
|
onClick(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
Behavior.interacting = true;
|
|
||||||
|
|
||||||
const container = document.createElement("span");
|
if (this.opened) {
|
||||||
container.className = "confirmation";
|
this.close();
|
||||||
|
} else {
|
||||||
const icon = this.element.getAttribute("ld-confirm-icon");
|
this.open();
|
||||||
if (icon) {
|
|
||||||
const iconElement = document.createElementNS(
|
|
||||||
"http://www.w3.org/2000/svg",
|
|
||||||
"svg",
|
|
||||||
);
|
|
||||||
iconElement.style.width = "16px";
|
|
||||||
iconElement.style.height = "16px";
|
|
||||||
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
|
|
||||||
container.append(iconElement);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const question = this.element.getAttribute("ld-confirm-question");
|
|
||||||
if (question) {
|
|
||||||
const questionElement = document.createElement("span");
|
|
||||||
questionElement.innerText = question;
|
|
||||||
container.append(question);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttonClasses = Array.from(this.element.classList.values())
|
|
||||||
.filter((cls) => cls.startsWith("btn"))
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
const cancelButton = document.createElement(this.element.nodeName);
|
|
||||||
cancelButton.type = "button";
|
|
||||||
cancelButton.innerText = question ? "No" : "Cancel";
|
|
||||||
cancelButton.className = `${buttonClasses} mr-1`;
|
|
||||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
|
||||||
|
|
||||||
const confirmButton = document.createElement(this.element.nodeName);
|
|
||||||
confirmButton.type = this.element.type;
|
|
||||||
confirmButton.name = this.element.name;
|
|
||||||
confirmButton.value = this.element.value;
|
|
||||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
|
||||||
confirmButton.className = buttonClasses;
|
|
||||||
confirmButton.addEventListener("click", this.reset.bind(this));
|
|
||||||
|
|
||||||
container.append(cancelButton, confirmButton);
|
|
||||||
this.container = container;
|
|
||||||
|
|
||||||
this.element.before(container);
|
|
||||||
this.element.classList.add("d-none");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
open() {
|
||||||
setTimeout(() => {
|
const dropdown = document.createElement("div");
|
||||||
Behavior.interacting = false;
|
dropdown.className = "dropdown confirm-dropdown active";
|
||||||
if (this.container) {
|
|
||||||
this.container.remove();
|
const confirmId = nextConfirmId();
|
||||||
this.container = null;
|
const questionId = `${confirmId}-question`;
|
||||||
}
|
|
||||||
this.element.classList.remove("d-none");
|
const menu = document.createElement("div");
|
||||||
|
menu.className = "menu with-arrow";
|
||||||
|
menu.role = "alertdialog";
|
||||||
|
menu.setAttribute("aria-modal", "true");
|
||||||
|
menu.setAttribute("aria-labelledby", questionId);
|
||||||
|
menu.addEventListener("keydown", this.onMenuKeyDown.bind(this));
|
||||||
|
|
||||||
|
const question = document.createElement("span");
|
||||||
|
question.id = questionId;
|
||||||
|
question.textContent =
|
||||||
|
this.element.getAttribute("ld-confirm-question") || "Are you sure?";
|
||||||
|
question.style.fontWeight = "bold";
|
||||||
|
|
||||||
|
const cancelButton = document.createElement("button");
|
||||||
|
cancelButton.textContent = "Cancel";
|
||||||
|
cancelButton.type = "button";
|
||||||
|
cancelButton.className = "btn";
|
||||||
|
cancelButton.tabIndex = 0;
|
||||||
|
cancelButton.addEventListener("click", () => this.close());
|
||||||
|
|
||||||
|
const confirmButton = document.createElement("button");
|
||||||
|
confirmButton.textContent = "Confirm";
|
||||||
|
confirmButton.type = "submit";
|
||||||
|
confirmButton.name = this.element.name;
|
||||||
|
confirmButton.value = this.element.value;
|
||||||
|
confirmButton.className = "btn btn-error";
|
||||||
|
confirmButton.addEventListener("click", () => this.confirm());
|
||||||
|
|
||||||
|
const arrow = document.createElement("div");
|
||||||
|
arrow.className = "menu-arrow";
|
||||||
|
|
||||||
|
menu.append(question, cancelButton, confirmButton, arrow);
|
||||||
|
dropdown.append(menu);
|
||||||
|
document.body.append(dropdown);
|
||||||
|
|
||||||
|
this.positionController = new AnchorPositionController(this.element, menu);
|
||||||
|
this.focusTrap = new FocusTrapController(menu);
|
||||||
|
this.dropdown = dropdown;
|
||||||
|
this.opened = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMenuKeyDown(event) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
this.element.closest("form").requestSubmit(this.element);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (!this.opened) return;
|
||||||
|
this.positionController.destroy();
|
||||||
|
this.focusTrap.destroy();
|
||||||
|
this.dropdown.remove();
|
||||||
|
this.element.focus({ focusVisible: isKeyboardActive() });
|
||||||
|
this.opened = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnchorPositionController {
|
||||||
|
constructor(anchor, overlay) {
|
||||||
|
this.anchor = anchor;
|
||||||
|
this.overlay = overlay;
|
||||||
|
|
||||||
|
this.handleScroll = this.handleScroll.bind(this);
|
||||||
|
window.addEventListener("scroll", this.handleScroll, { capture: true });
|
||||||
|
|
||||||
|
this.updatePosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScroll() {
|
||||||
|
if (this.debounce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debounce = true;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.updatePosition();
|
||||||
|
this.debounce = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePosition() {
|
||||||
|
const anchorRect = this.anchor.getBoundingClientRect();
|
||||||
|
const overlayRect = this.overlay.getBoundingClientRect();
|
||||||
|
const bufferX = 10;
|
||||||
|
const bufferY = 30;
|
||||||
|
|
||||||
|
let left = anchorRect.left - (overlayRect.width - anchorRect.width) / 2;
|
||||||
|
const initialLeft = left;
|
||||||
|
const overflowLeft = left < bufferX;
|
||||||
|
const overflowRight =
|
||||||
|
left + overlayRect.width > window.innerWidth - bufferX;
|
||||||
|
|
||||||
|
if (overflowLeft) {
|
||||||
|
left = bufferX;
|
||||||
|
} else if (overflowRight) {
|
||||||
|
left = window.innerWidth - overlayRect.width - bufferX;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = initialLeft - left;
|
||||||
|
this.overlay.style.setProperty("--arrow-offset", `${delta}px`);
|
||||||
|
|
||||||
|
let top = anchorRect.bottom;
|
||||||
|
const overflowBottom =
|
||||||
|
top + overlayRect.height > window.innerHeight - bufferY;
|
||||||
|
|
||||||
|
if (overflowBottom) {
|
||||||
|
top = anchorRect.top - overlayRect.height;
|
||||||
|
this.overlay.classList.remove("top-aligned");
|
||||||
|
this.overlay.classList.add("bottom-aligned");
|
||||||
|
} else {
|
||||||
|
this.overlay.classList.remove("bottom-aligned");
|
||||||
|
this.overlay.classList.add("top-aligned");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overlay.style.left = `${left}px`;
|
||||||
|
this.overlay.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
window.removeEventListener("scroll", this.handleScroll, { capture: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);
|
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class FilterDrawerTriggerBehavior extends Behavior {
|
|||||||
<div class="modal-container" role="dialog" aria-modal="true">
|
<div class="modal-container" role="dialog" aria-modal="true">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>Filters</h2>
|
<h2>Filters</h2>
|
||||||
<button class="close" aria-label="Close dialog">
|
<button class="btn btn-noborder close" aria-label="Close dialog">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ document.addEventListener("turbo:load", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ignore if there is a modal dialog, which should handle its own focus
|
||||||
|
const modal = document.querySelector("[aria-modal='true']");
|
||||||
|
if (modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if there is an explicit focus target for the next page load
|
// Check if there is an explicit focus target for the next page load
|
||||||
for (const target of afterPageLoadFocusTarget) {
|
for (const target of afterPageLoadFocusTarget) {
|
||||||
const element = document.querySelector(target);
|
const element = document.querySelector(target);
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
import { Behavior, registerBehavior } from "./index";
|
||||||
|
|
||||||
|
class FormSubmit extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
this.element.addEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.element.removeEventListener("keydown", this.onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
// Check for Ctrl/Cmd + Enter combination
|
||||||
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.element.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AutoSubmitBehavior extends Behavior {
|
class AutoSubmitBehavior extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
@@ -17,6 +39,36 @@ class AutoSubmitBehavior extends Behavior {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resets form controls to their initial values before Turbo caches the DOM.
|
||||||
|
// Useful for filter forms where navigating back would otherwise still show
|
||||||
|
// values from after the form submission, which means the filters would be out
|
||||||
|
// of sync with the URL.
|
||||||
|
class FormResetBehavior extends Behavior {
|
||||||
|
constructor(element) {
|
||||||
|
super(element);
|
||||||
|
|
||||||
|
this.controls = this.element.querySelectorAll("input, select");
|
||||||
|
this.controls.forEach((control) => {
|
||||||
|
if (control.type === "checkbox" || control.type === "radio") {
|
||||||
|
control.__initialValue = control.checked;
|
||||||
|
} else {
|
||||||
|
control.__initialValue = control.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.controls.forEach((control) => {
|
||||||
|
if (control.type === "checkbox" || control.type === "radio") {
|
||||||
|
control.checked = control.__initialValue;
|
||||||
|
} else {
|
||||||
|
control.value = control.__initialValue;
|
||||||
|
}
|
||||||
|
delete control.__initialValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class UploadButton extends Behavior {
|
class UploadButton extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
super(element);
|
super(element);
|
||||||
@@ -51,5 +103,7 @@ class UploadButton extends Behavior {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-form-submit", FormSubmit);
|
||||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||||
|
registerBehavior("ld-form-reset", FormResetBehavior);
|
||||||
registerBehavior("ld-upload-button", UploadButton);
|
registerBehavior("ld-upload-button", UploadButton);
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ export class Behavior {
|
|||||||
destroy() {}
|
destroy() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Behavior.interacting = false;
|
|
||||||
|
|
||||||
export function registerBehavior(name, behavior) {
|
export function registerBehavior(name, behavior) {
|
||||||
behaviorRegistry[name] = behavior;
|
behaviorRegistry[name] = behavior;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,32 +23,22 @@ export class ModalBehavior extends Behavior {
|
|||||||
this.closeButton.removeEventListener("click", this.onClose);
|
this.closeButton.removeEventListener("click", this.onClose);
|
||||||
document.removeEventListener("keydown", this.onKeyDown);
|
document.removeEventListener("keydown", this.onKeyDown);
|
||||||
|
|
||||||
this.clearInert();
|
this.removeScrollLock();
|
||||||
this.focusTrap.destroy();
|
this.focusTrap.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.setupInert();
|
this.setupScrollLock();
|
||||||
this.focusTrap = new FocusTrapController(
|
this.focusTrap = new FocusTrapController(
|
||||||
this.element.querySelector(".modal-container"),
|
this.element.querySelector(".modal-container"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupInert() {
|
setupScrollLock() {
|
||||||
// Inert all other elements on the page
|
|
||||||
document
|
|
||||||
.querySelectorAll("body > *:not(.modals)")
|
|
||||||
.forEach((el) => el.setAttribute("inert", ""));
|
|
||||||
// Lock scroll on the body
|
|
||||||
document.body.classList.add("scroll-lock");
|
document.body.classList.add("scroll-lock");
|
||||||
}
|
}
|
||||||
|
|
||||||
clearInert() {
|
removeScrollLock() {
|
||||||
// Clear inert attribute from all elements to allow focus outside the modal again
|
|
||||||
document
|
|
||||||
.querySelectorAll("body > *")
|
|
||||||
.forEach((el) => el.removeAttribute("inert"));
|
|
||||||
// Remove scroll lock from the body
|
|
||||||
document.body.classList.remove("scroll-lock");
|
document.body.classList.remove("scroll-lock");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +75,7 @@ export class ModalBehavior extends Behavior {
|
|||||||
|
|
||||||
doClose() {
|
doClose() {
|
||||||
this.element.remove();
|
this.element.remove();
|
||||||
this.clearInert();
|
this.removeScrollLock();
|
||||||
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
import { Behavior, registerBehavior } from "./index";
|
||||||
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte";
|
import "../components/SearchAutocomplete.js";
|
||||||
|
|
||||||
class SearchAutocomplete extends Behavior {
|
class SearchAutocomplete extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
@@ -10,26 +10,20 @@ class SearchAutocomplete extends Behavior {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = document.createElement("div");
|
const autocomplete = document.createElement("ld-search-autocomplete");
|
||||||
|
autocomplete.name = "q";
|
||||||
new SearchAutoCompleteComponent({
|
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
||||||
target: container,
|
autocomplete.value = input.value;
|
||||||
props: {
|
autocomplete.linkTarget = input.dataset.linkTarget || "_blank";
|
||||||
name: "q",
|
autocomplete.mode = input.dataset.mode || "";
|
||||||
placeholder: input.getAttribute("placeholder") || "",
|
autocomplete.search = {
|
||||||
value: input.value,
|
user: input.dataset.user,
|
||||||
linkTarget: input.dataset.linkTarget,
|
shared: input.dataset.shared,
|
||||||
mode: input.dataset.mode,
|
unread: input.dataset.unread,
|
||||||
search: {
|
};
|
||||||
user: input.dataset.user,
|
|
||||||
shared: input.dataset.shared,
|
|
||||||
unread: input.dataset.unread,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.input = input;
|
this.input = input;
|
||||||
this.autocomplete = container.firstElementChild;
|
this.autocomplete = autocomplete;
|
||||||
input.replaceWith(this.autocomplete);
|
input.replaceWith(this.autocomplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Behavior, registerBehavior } from "./index";
|
import { Behavior, registerBehavior } from "./index";
|
||||||
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
import "../components/TagAutocomplete.js";
|
||||||
|
|
||||||
class TagAutocomplete extends Behavior {
|
class TagAutocomplete extends Behavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
@@ -10,21 +10,16 @@ class TagAutocomplete extends Behavior {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = document.createElement("div");
|
const autocomplete = document.createElement("ld-tag-autocomplete");
|
||||||
|
autocomplete.id = input.id;
|
||||||
new TagAutoCompleteComponent({
|
autocomplete.name = input.name;
|
||||||
target: container,
|
autocomplete.value = input.value;
|
||||||
props: {
|
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
||||||
id: input.id,
|
autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || "";
|
||||||
name: input.name,
|
autocomplete.variant = input.getAttribute("variant") || "default";
|
||||||
value: input.value,
|
|
||||||
placeholder: input.getAttribute("placeholder") || "",
|
|
||||||
variant: input.getAttribute("variant"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.input = input;
|
this.input = input;
|
||||||
this.autocomplete = container.firstElementChild;
|
this.autocomplete = autocomplete;
|
||||||
input.replaceWith(this.autocomplete);
|
input.replaceWith(this.autocomplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
<script>
|
|
||||||
import {SearchHistory} from "./SearchHistory";
|
|
||||||
import {api} from "../api";
|
|
||||||
import {cache} from "../cache";
|
|
||||||
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
|
|
||||||
|
|
||||||
const searchHistory = new SearchHistory()
|
|
||||||
|
|
||||||
export let name;
|
|
||||||
export let placeholder;
|
|
||||||
export let value;
|
|
||||||
export let mode = '';
|
|
||||||
export let search;
|
|
||||||
export let linkTarget = '_blank';
|
|
||||||
|
|
||||||
let isFocus = false;
|
|
||||||
let isOpen = false;
|
|
||||||
let suggestions = []
|
|
||||||
let selectedIndex = undefined;
|
|
||||||
let input = null;
|
|
||||||
|
|
||||||
// Track current search query after loading the page
|
|
||||||
searchHistory.pushCurrent()
|
|
||||||
updateSuggestions()
|
|
||||||
|
|
||||||
function handleFocus() {
|
|
||||||
isFocus = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
isFocus = false;
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(e) {
|
|
||||||
value = e.target.value
|
|
||||||
debouncedLoadSuggestions()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e) {
|
|
||||||
// Enter
|
|
||||||
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
|
|
||||||
const suggestion = suggestions.total[selectedIndex];
|
|
||||||
if (suggestion) completeSuggestion(suggestion);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
// Escape
|
|
||||||
if (e.keyCode === 27) {
|
|
||||||
close();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
// Up arrow
|
|
||||||
if (e.keyCode === 38) {
|
|
||||||
updateSelection(-1);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
// Down arrow
|
|
||||||
if (e.keyCode === 40) {
|
|
||||||
if (!isOpen) {
|
|
||||||
loadSuggestions()
|
|
||||||
} else {
|
|
||||||
updateSelection(1);
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
isOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
isOpen = false;
|
|
||||||
updateSuggestions()
|
|
||||||
selectedIndex = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSuggestions() {
|
|
||||||
return suggestions.total.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSuggestions() {
|
|
||||||
|
|
||||||
let suggestionIndex = 0
|
|
||||||
|
|
||||||
function nextIndex() {
|
|
||||||
return suggestionIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tag suggestions
|
|
||||||
const tags = await cache.getTags();
|
|
||||||
let tagSuggestions = []
|
|
||||||
const currentWord = getCurrentWord(input)
|
|
||||||
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
|
||||||
const searchTag = currentWord.substring(1, currentWord.length)
|
|
||||||
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(tag => ({
|
|
||||||
type: 'tag',
|
|
||||||
index: nextIndex(),
|
|
||||||
label: `#${tag.name}`,
|
|
||||||
tagName: tag.name
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recent search suggestions
|
|
||||||
const recentSearches = searchHistory.getRecentSearches(value, 5).map(value => ({
|
|
||||||
type: 'search',
|
|
||||||
index: nextIndex(),
|
|
||||||
label: value,
|
|
||||||
value
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Bookmark suggestions
|
|
||||||
let bookmarks = []
|
|
||||||
|
|
||||||
if (value && value.length >= 3) {
|
|
||||||
const path = mode ? `/${mode}` : ''
|
|
||||||
const suggestionSearch = {
|
|
||||||
...search,
|
|
||||||
q: value
|
|
||||||
}
|
|
||||||
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
|
||||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
|
||||||
const fullLabel = bookmark.title || bookmark.url
|
|
||||||
const label = clampText(fullLabel, 60)
|
|
||||||
return {
|
|
||||||
type: 'bookmark',
|
|
||||||
index: nextIndex(),
|
|
||||||
label,
|
|
||||||
bookmark
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSuggestions(recentSearches, bookmarks, tagSuggestions)
|
|
||||||
|
|
||||||
if (hasSuggestions()) {
|
|
||||||
open()
|
|
||||||
} else {
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const debouncedLoadSuggestions = debounce(loadSuggestions)
|
|
||||||
|
|
||||||
function updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
|
|
||||||
recentSearches = recentSearches || []
|
|
||||||
bookmarks = bookmarks || []
|
|
||||||
tagSuggestions = tagSuggestions || []
|
|
||||||
suggestions = {
|
|
||||||
recentSearches,
|
|
||||||
bookmarks,
|
|
||||||
tags: tagSuggestions,
|
|
||||||
total: [
|
|
||||||
...tagSuggestions,
|
|
||||||
...recentSearches,
|
|
||||||
...bookmarks,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function completeSuggestion(suggestion) {
|
|
||||||
if (suggestion.type === 'search') {
|
|
||||||
value = suggestion.value
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
if (suggestion.type === 'bookmark') {
|
|
||||||
window.open(suggestion.bookmark.url, linkTarget)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
if (suggestion.type === 'tag') {
|
|
||||||
const bounds = getCurrentWordBounds(input);
|
|
||||||
const inputValue = input.value;
|
|
||||||
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelection(dir) {
|
|
||||||
|
|
||||||
const length = suggestions.total.length;
|
|
||||||
|
|
||||||
if (length === 0) return
|
|
||||||
|
|
||||||
if (selectedIndex === undefined) {
|
|
||||||
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let newIndex = selectedIndex + dir;
|
|
||||||
|
|
||||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
|
||||||
if (newIndex >= length) newIndex = 0;
|
|
||||||
|
|
||||||
selectedIndex = newIndex;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="form-autocomplete">
|
|
||||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
|
||||||
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
|
|
||||||
bind:this={input}
|
|
||||||
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="menu" class:open={isOpen}>
|
|
||||||
{#if suggestions.tags.length > 0}
|
|
||||||
<li class="menu-item group-item">Tags</li>
|
|
||||||
{/if}
|
|
||||||
{#each suggestions.tags as suggestion}
|
|
||||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
|
||||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
|
||||||
{suggestion.label}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if suggestions.recentSearches.length > 0}
|
|
||||||
<li class="menu-item group-item">Recent Searches</li>
|
|
||||||
{/if}
|
|
||||||
{#each suggestions.recentSearches as suggestion}
|
|
||||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
|
||||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
|
||||||
{suggestion.label}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if suggestions.bookmarks.length > 0}
|
|
||||||
<li class="menu-item group-item">Bookmarks</li>
|
|
||||||
{/if}
|
|
||||||
{#each suggestions.bookmarks as suggestion}
|
|
||||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
|
||||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
|
||||||
{suggestion.label}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.menu {
|
|
||||||
display: none;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu.open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-autocomplete-input {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-autocomplete-input.is-focused {
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
304
bookmarks/frontend/components/SearchAutocomplete.js
Normal file
304
bookmarks/frontend/components/SearchAutocomplete.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { LitElement, html } from "lit";
|
||||||
|
import { SearchHistory } from "./SearchHistory.js";
|
||||||
|
import { api } from "../api.js";
|
||||||
|
import { cache } from "../cache.js";
|
||||||
|
import {
|
||||||
|
clampText,
|
||||||
|
debounce,
|
||||||
|
getCurrentWord,
|
||||||
|
getCurrentWordBounds,
|
||||||
|
} from "../util.js";
|
||||||
|
|
||||||
|
export class SearchAutocomplete extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
name: { type: String },
|
||||||
|
placeholder: { type: String },
|
||||||
|
value: { type: String },
|
||||||
|
mode: { type: String },
|
||||||
|
search: { type: Object },
|
||||||
|
linkTarget: { type: String },
|
||||||
|
isFocus: { state: true },
|
||||||
|
isOpen: { state: true },
|
||||||
|
suggestions: { state: true },
|
||||||
|
selectedIndex: { state: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.name = "";
|
||||||
|
this.placeholder = "";
|
||||||
|
this.value = "";
|
||||||
|
this.mode = "";
|
||||||
|
this.search = {};
|
||||||
|
this.linkTarget = "_blank";
|
||||||
|
this.isFocus = false;
|
||||||
|
this.isOpen = false;
|
||||||
|
this.suggestions = {
|
||||||
|
recentSearches: [],
|
||||||
|
bookmarks: [],
|
||||||
|
tags: [],
|
||||||
|
total: [],
|
||||||
|
};
|
||||||
|
this.selectedIndex = undefined;
|
||||||
|
this.input = null;
|
||||||
|
this.searchHistory = new SearchHistory();
|
||||||
|
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
|
||||||
|
}
|
||||||
|
|
||||||
|
createRenderRoot() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUpdated() {
|
||||||
|
this.style.setProperty("--menu-max-height", "400px");
|
||||||
|
this.input = this.querySelector("input");
|
||||||
|
// Track current search query after loading the page
|
||||||
|
this.searchHistory.pushCurrent();
|
||||||
|
this.updateSuggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFocus() {
|
||||||
|
this.isFocus = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBlur() {
|
||||||
|
this.isFocus = false;
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput(e) {
|
||||||
|
this.value = e.target.value;
|
||||||
|
this.debouncedLoadSuggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDown(e) {
|
||||||
|
// Enter
|
||||||
|
if (
|
||||||
|
this.isOpen &&
|
||||||
|
this.selectedIndex !== undefined &&
|
||||||
|
(e.keyCode === 13 || e.keyCode === 9)
|
||||||
|
) {
|
||||||
|
const suggestion = this.suggestions.total[this.selectedIndex];
|
||||||
|
if (suggestion) this.completeSuggestion(suggestion);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
// Escape
|
||||||
|
if (e.keyCode === 27) {
|
||||||
|
this.close();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
// Up arrow
|
||||||
|
if (e.keyCode === 38) {
|
||||||
|
this.updateSelection(-1);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
// Down arrow
|
||||||
|
if (e.keyCode === 40) {
|
||||||
|
if (!this.isOpen) {
|
||||||
|
this.loadSuggestions();
|
||||||
|
} else {
|
||||||
|
this.updateSelection(1);
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.isOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.isOpen = false;
|
||||||
|
this.updateSuggestions();
|
||||||
|
this.selectedIndex = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSuggestions() {
|
||||||
|
return this.suggestions.total.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSuggestions() {
|
||||||
|
let suggestionIndex = 0;
|
||||||
|
|
||||||
|
function nextIndex() {
|
||||||
|
return suggestionIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag suggestions
|
||||||
|
const tags = await cache.getTags();
|
||||||
|
let tagSuggestions = [];
|
||||||
|
const currentWord = getCurrentWord(this.input);
|
||||||
|
if (currentWord && currentWord.length > 1 && currentWord[0] === "#") {
|
||||||
|
const searchTag = currentWord.substring(1, currentWord.length);
|
||||||
|
tagSuggestions = (tags || [])
|
||||||
|
.filter(
|
||||||
|
(tag) =>
|
||||||
|
tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0,
|
||||||
|
)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((tag) => ({
|
||||||
|
type: "tag",
|
||||||
|
index: nextIndex(),
|
||||||
|
label: `#${tag.name}`,
|
||||||
|
tagName: tag.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent search suggestions
|
||||||
|
const recentSearches = this.searchHistory
|
||||||
|
.getRecentSearches(this.value, 5)
|
||||||
|
.map((value) => ({
|
||||||
|
type: "search",
|
||||||
|
index: nextIndex(),
|
||||||
|
label: value,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Bookmark suggestions
|
||||||
|
let bookmarks = [];
|
||||||
|
|
||||||
|
if (this.value && this.value.length >= 3) {
|
||||||
|
const path = this.mode ? `/${this.mode}` : "";
|
||||||
|
const suggestionSearch = {
|
||||||
|
...this.search,
|
||||||
|
q: this.value,
|
||||||
|
};
|
||||||
|
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {
|
||||||
|
limit: 5,
|
||||||
|
offset: 0,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
bookmarks = fetchedBookmarks.map((bookmark) => {
|
||||||
|
const fullLabel = bookmark.title || bookmark.url;
|
||||||
|
const label = clampText(fullLabel, 60);
|
||||||
|
return {
|
||||||
|
type: "bookmark",
|
||||||
|
index: nextIndex(),
|
||||||
|
label,
|
||||||
|
bookmark,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateSuggestions(recentSearches, bookmarks, tagSuggestions);
|
||||||
|
|
||||||
|
if (this.hasSuggestions()) {
|
||||||
|
this.open();
|
||||||
|
} else {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
|
||||||
|
recentSearches = recentSearches || [];
|
||||||
|
bookmarks = bookmarks || [];
|
||||||
|
tagSuggestions = tagSuggestions || [];
|
||||||
|
this.suggestions = {
|
||||||
|
recentSearches,
|
||||||
|
bookmarks,
|
||||||
|
tags: tagSuggestions,
|
||||||
|
total: [...tagSuggestions, ...recentSearches, ...bookmarks],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
completeSuggestion(suggestion) {
|
||||||
|
if (suggestion.type === "search") {
|
||||||
|
this.value = suggestion.value;
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
if (suggestion.type === "bookmark") {
|
||||||
|
window.open(suggestion.bookmark.url, this.linkTarget);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
if (suggestion.type === "tag") {
|
||||||
|
const bounds = getCurrentWordBounds(this.input);
|
||||||
|
const inputValue = this.input.value;
|
||||||
|
this.input.value =
|
||||||
|
inputValue.substring(0, bounds.start) +
|
||||||
|
`#${suggestion.tagName} ` +
|
||||||
|
inputValue.substring(bounds.end);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelection(dir) {
|
||||||
|
const length = this.suggestions.total.length;
|
||||||
|
|
||||||
|
if (length === 0) return;
|
||||||
|
|
||||||
|
if (this.selectedIndex === undefined) {
|
||||||
|
this.selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newIndex = this.selectedIndex + dir;
|
||||||
|
|
||||||
|
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||||
|
if (newIndex >= length) newIndex = 0;
|
||||||
|
|
||||||
|
this.selectedIndex = newIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSuggestions(suggestions, title) {
|
||||||
|
if (suggestions.length === 0) return "";
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<li class="menu-item group-item">${title}</li>
|
||||||
|
${suggestions.map(
|
||||||
|
(suggestion) => html`
|
||||||
|
<li
|
||||||
|
class="menu-item ${this.selectedIndex === suggestion.index
|
||||||
|
? "selected"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
@mousedown=${(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.completeSuggestion(suggestion);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${suggestion.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<div class="form-autocomplete">
|
||||||
|
<div
|
||||||
|
class="form-autocomplete-input form-input ${this.isFocus
|
||||||
|
? "is-focused"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="form-input"
|
||||||
|
name="${this.name}"
|
||||||
|
placeholder="${this.placeholder}"
|
||||||
|
autocomplete="off"
|
||||||
|
.value="${this.value}"
|
||||||
|
@input=${this.handleInput}
|
||||||
|
@keydown=${this.handleKeyDown}
|
||||||
|
@focus=${this.handleFocus}
|
||||||
|
@blur=${this.handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="menu ${this.isOpen ? "open" : ""}">
|
||||||
|
${this.renderSuggestions(this.suggestions.tags, "Tags")}
|
||||||
|
${this.renderSuggestions(
|
||||||
|
this.suggestions.recentSearches,
|
||||||
|
"Recent Searches",
|
||||||
|
)}
|
||||||
|
${this.renderSuggestions(this.suggestions.bookmarks, "Bookmarks")}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-search-autocomplete", SearchAutocomplete);
|
||||||
194
bookmarks/frontend/components/TagAutocomplete.js
Normal file
194
bookmarks/frontend/components/TagAutocomplete.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { LitElement, html } from "lit";
|
||||||
|
import { cache } from "../cache.js";
|
||||||
|
import { getCurrentWord, getCurrentWordBounds } from "../util.js";
|
||||||
|
|
||||||
|
export class TagAutocomplete extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
id: { type: String },
|
||||||
|
name: { type: String },
|
||||||
|
value: { type: String },
|
||||||
|
placeholder: { type: String },
|
||||||
|
ariaDescribedBy: { type: String, attribute: "aria-described-by" },
|
||||||
|
variant: { type: String },
|
||||||
|
isFocus: { state: true },
|
||||||
|
isOpen: { state: true },
|
||||||
|
suggestions: { state: true },
|
||||||
|
selectedIndex: { state: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.id = "";
|
||||||
|
this.name = "";
|
||||||
|
this.value = "";
|
||||||
|
this.placeholder = "";
|
||||||
|
this.ariaDescribedBy = "";
|
||||||
|
this.variant = "default";
|
||||||
|
this.isFocus = false;
|
||||||
|
this.isOpen = false;
|
||||||
|
this.suggestions = [];
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
this.input = null;
|
||||||
|
this.suggestionList = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRenderRoot() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUpdated() {
|
||||||
|
this.input = this.querySelector("input");
|
||||||
|
this.suggestionList = this.querySelector(".menu");
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFocus() {
|
||||||
|
this.isFocus = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBlur() {
|
||||||
|
this.isFocus = false;
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleInput(e) {
|
||||||
|
this.input = e.target;
|
||||||
|
|
||||||
|
const tags = await cache.getTags();
|
||||||
|
const word = getCurrentWord(this.input);
|
||||||
|
|
||||||
|
this.suggestions = word
|
||||||
|
? tags.filter(
|
||||||
|
(tag) => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (word && this.suggestions.length > 0) {
|
||||||
|
this.open();
|
||||||
|
} else {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDown(e) {
|
||||||
|
if (this.isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
|
||||||
|
const suggestion = this.suggestions[this.selectedIndex];
|
||||||
|
this.complete(suggestion);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
if (e.keyCode === 27) {
|
||||||
|
this.close();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
if (e.keyCode === 38) {
|
||||||
|
this.updateSelection(-1);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
if (e.keyCode === 40) {
|
||||||
|
this.updateSelection(1);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.isOpen = true;
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.isOpen = false;
|
||||||
|
this.suggestions = [];
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(suggestion) {
|
||||||
|
const bounds = getCurrentWordBounds(this.input);
|
||||||
|
const value = this.input.value;
|
||||||
|
this.input.value =
|
||||||
|
value.substring(0, bounds.start) +
|
||||||
|
suggestion.name +
|
||||||
|
" " +
|
||||||
|
value.substring(bounds.end);
|
||||||
|
this.input.dispatchEvent(new CustomEvent("change", { bubbles: true }));
|
||||||
|
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelection(dir) {
|
||||||
|
const length = this.suggestions.length;
|
||||||
|
let newIndex = this.selectedIndex + dir;
|
||||||
|
|
||||||
|
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||||
|
if (newIndex >= length) newIndex = 0;
|
||||||
|
|
||||||
|
this.selectedIndex = newIndex;
|
||||||
|
|
||||||
|
// Scroll to selected list item
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.suggestionList) {
|
||||||
|
const selectedListItem =
|
||||||
|
this.suggestionList.querySelector("li.selected");
|
||||||
|
if (selectedListItem) {
|
||||||
|
selectedListItem.scrollIntoView({ block: "center" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<div class="form-autocomplete ${this.variant === "small" ? "small" : ""}">
|
||||||
|
<!-- autocomplete input container -->
|
||||||
|
<div
|
||||||
|
class="form-autocomplete-input form-input ${this.isFocus
|
||||||
|
? "is-focused"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
<!-- autocomplete real input box -->
|
||||||
|
<input
|
||||||
|
id="${this.id}"
|
||||||
|
name="${this.name}"
|
||||||
|
.value="${this.value || ""}"
|
||||||
|
placeholder="${this.placeholder || " "}"
|
||||||
|
class="form-input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-describedby="${this.ariaDescribedBy}"
|
||||||
|
@input=${this.handleInput}
|
||||||
|
@keydown=${this.handleKeyDown}
|
||||||
|
@focus=${this.handleFocus}
|
||||||
|
@blur=${this.handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- autocomplete suggestion list -->
|
||||||
|
<ul
|
||||||
|
class="menu ${this.isOpen && this.suggestions.length > 0
|
||||||
|
? "open"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
<!-- menu list items -->
|
||||||
|
${this.suggestions.map(
|
||||||
|
(tag, i) => html`
|
||||||
|
<li
|
||||||
|
class="menu-item ${this.selectedIndex === i ? "selected" : ""}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
@mousedown=${(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.complete(tag);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${tag.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ld-tag-autocomplete", TagAutocomplete);
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
<script>
|
|
||||||
import {cache} from "../cache";
|
|
||||||
import {getCurrentWord, getCurrentWordBounds} from "../util";
|
|
||||||
|
|
||||||
export let id;
|
|
||||||
export let name;
|
|
||||||
export let value;
|
|
||||||
export let placeholder;
|
|
||||||
export let variant = 'default';
|
|
||||||
|
|
||||||
let isFocus = false;
|
|
||||||
let isOpen = false;
|
|
||||||
let input = null;
|
|
||||||
let suggestionList = null;
|
|
||||||
|
|
||||||
let suggestions = [];
|
|
||||||
let selectedIndex = 0;
|
|
||||||
|
|
||||||
function handleFocus() {
|
|
||||||
isFocus = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
isFocus = false;
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleInput(e) {
|
|
||||||
input = e.target;
|
|
||||||
|
|
||||||
const tags = await cache.getTags();
|
|
||||||
const word = getCurrentWord(input);
|
|
||||||
|
|
||||||
suggestions = word
|
|
||||||
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (word && suggestions.length > 0) {
|
|
||||||
open();
|
|
||||||
} else {
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e) {
|
|
||||||
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
|
|
||||||
const suggestion = suggestions[selectedIndex];
|
|
||||||
complete(suggestion);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
if (e.keyCode === 27) {
|
|
||||||
close();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
if (e.keyCode === 38) {
|
|
||||||
updateSelection(-1);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
if (e.keyCode === 40) {
|
|
||||||
updateSelection(1);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
isOpen = true;
|
|
||||||
selectedIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
isOpen = false;
|
|
||||||
suggestions = [];
|
|
||||||
selectedIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function complete(suggestion) {
|
|
||||||
const bounds = getCurrentWordBounds(input);
|
|
||||||
const value = input.value;
|
|
||||||
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
|
||||||
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelection(dir) {
|
|
||||||
|
|
||||||
const length = suggestions.length;
|
|
||||||
let newIndex = selectedIndex + dir;
|
|
||||||
|
|
||||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
|
||||||
if (newIndex >= length) newIndex = 0;
|
|
||||||
|
|
||||||
selectedIndex = newIndex;
|
|
||||||
|
|
||||||
// Scroll to selected list item
|
|
||||||
setTimeout(() => {
|
|
||||||
if (suggestionList) {
|
|
||||||
const selectedListItem = suggestionList.querySelector('li.selected');
|
|
||||||
if (selectedListItem) {
|
|
||||||
selectedListItem.scrollIntoView({block: 'center'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="form-autocomplete" class:small={variant === 'small'}>
|
|
||||||
<!-- autocomplete input container -->
|
|
||||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
|
||||||
<!-- autocomplete real input box -->
|
|
||||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
|
|
||||||
class="form-input" type="text" autocomplete="off" autocapitalize="off"
|
|
||||||
on:input={handleInput} on:keydown={handleKeyDown}
|
|
||||||
on:focus={handleFocus} on:blur={handleBlur}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- autocomplete suggestion list -->
|
|
||||||
<ul class="menu" class:open={isOpen && suggestions.length > 0}
|
|
||||||
bind:this={suggestionList}>
|
|
||||||
<!-- menu list items -->
|
|
||||||
{#each suggestions as tag,i}
|
|
||||||
<li class="menu-item" class:selected={selectedIndex === i}>
|
|
||||||
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
|
||||||
{tag.name}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.menu {
|
|
||||||
display: none;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu.open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-autocomplete-input {
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: var(--control-size);
|
|
||||||
min-height: var(--control-size);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-autocomplete-input input {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-autocomplete.small .form-autocomplete-input {
|
|
||||||
height: var(--control-size-sm);
|
|
||||||
min-height: var(--control-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-autocomplete.small .form-autocomplete-input input {
|
|
||||||
padding: 0.05rem 0.3rem;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-autocomplete.small .menu .menu-item {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -11,7 +11,5 @@ import "./behaviors/global-shortcuts";
|
|||||||
import "./behaviors/search-autocomplete";
|
import "./behaviors/search-autocomplete";
|
||||||
import "./behaviors/tag-autocomplete";
|
import "./behaviors/tag-autocomplete";
|
||||||
|
|
||||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
|
||||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
|
||||||
export { api } from "./api";
|
export { api } from "./api";
|
||||||
export { cache } from "./cache";
|
export { cache } from "./cache";
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ export function debounce(callback, delay = 250) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function preventDefault(fn) {
|
||||||
|
return function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
fn.call(this, event);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function clampText(text, maxChars = 30) {
|
export function clampText(text, maxChars = 30) {
|
||||||
if (!text || text.length <= 30) return text;
|
if (!text || text.length <= 30) return text;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 5.1.9 on 2025-06-19 08:48
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0044_bookmark_latest_snapshot"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="hide_bundles",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BookmarkBundle",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=256)),
|
||||||
|
("search", models.CharField(blank=True, max_length=256)),
|
||||||
|
("any_tags", models.CharField(blank=True, max_length=1024)),
|
||||||
|
("all_tags", models.CharField(blank=True, max_length=1024)),
|
||||||
|
("excluded_tags", models.CharField(blank=True, max_length=1024)),
|
||||||
|
("order", models.IntegerField(default=0)),
|
||||||
|
("date_created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("date_modified", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"owner",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
18
bookmarks/migrations/0046_add_url_normalized_field.py
Normal file
18
bookmarks/migrations/0046_add_url_normalized_field.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-08-22 08:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0045_userprofile_hide_bundles_bookmarkbundle"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="url_normalized",
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=2048),
|
||||||
|
),
|
||||||
|
]
|
||||||
38
bookmarks/migrations/0047_populate_url_normalized_field.py
Normal file
38
bookmarks/migrations/0047_populate_url_normalized_field.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-08-22 08:28
|
||||||
|
|
||||||
|
from django.db import migrations, transaction
|
||||||
|
from bookmarks.utils import normalize_url
|
||||||
|
|
||||||
|
|
||||||
|
def populate_url_normalized(apps, schema_editor):
|
||||||
|
Bookmark = apps.get_model("bookmarks", "Bookmark")
|
||||||
|
|
||||||
|
batch_size = 500
|
||||||
|
with transaction.atomic():
|
||||||
|
qs = Bookmark.objects.all()
|
||||||
|
for start in range(0, qs.count(), batch_size):
|
||||||
|
batch = list(qs[start : start + batch_size])
|
||||||
|
for bookmark in batch:
|
||||||
|
bookmark.url_normalized = normalize_url(bookmark.url)
|
||||||
|
Bookmark.objects.bulk_update(
|
||||||
|
batch, ["url_normalized"], batch_size=batch_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_populate_url_normalized(apps, schema_editor):
|
||||||
|
Bookmark = apps.get_model("bookmarks", "Bookmark")
|
||||||
|
Bookmark.objects.all().update(url_normalized="")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0046_add_url_normalized_field"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
populate_url_normalized,
|
||||||
|
reverse_populate_url_normalized,
|
||||||
|
),
|
||||||
|
]
|
||||||
18
bookmarks/migrations/0048_userprofile_default_mark_shared.py
Normal file
18
bookmarks/migrations/0048_userprofile_default_mark_shared.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-08-22 17:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0047_populate_url_normalized_field"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="default_mark_shared",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,6 +2,7 @@ import binascii
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from functools import cached_property
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
@@ -13,7 +14,7 @@ from django.db.models.signals import post_save, post_delete
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
|
|
||||||
from bookmarks.utils import unique
|
from bookmarks.utils import unique, normalize_url
|
||||||
from bookmarks.validators import BookmarkURLValidator
|
from bookmarks.validators import BookmarkURLValidator
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -39,7 +40,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ","):
|
|||||||
return []
|
return []
|
||||||
names = tag_string.strip().split(delimiter)
|
names = tag_string.strip().split(delimiter)
|
||||||
# remove empty names, sanitize remaining names
|
# remove empty names, sanitize remaining names
|
||||||
names = [sanitize_tag_name(name) for name in names if name]
|
names = [sanitize_tag_name(name) for name in names if name.strip()]
|
||||||
# remove duplicates
|
# remove duplicates
|
||||||
names = unique(names, str.lower)
|
names = unique(names, str.lower)
|
||||||
names.sort(key=str.lower)
|
names.sort(key=str.lower)
|
||||||
@@ -53,6 +54,7 @@ def build_tag_string(tag_names: List[str], delimiter: str = ","):
|
|||||||
|
|
||||||
class Bookmark(models.Model):
|
class Bookmark(models.Model):
|
||||||
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
|
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
|
||||||
|
url_normalized = models.CharField(max_length=2048, blank=True, db_index=True)
|
||||||
title = models.CharField(max_length=512, blank=True)
|
title = models.CharField(max_length=512, blank=True)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
notes = models.TextField(blank=True)
|
notes = models.TextField(blank=True)
|
||||||
@@ -95,6 +97,10 @@ class Bookmark(models.Model):
|
|||||||
names = [tag.name for tag in self.tags.all()]
|
names = [tag.name for tag in self.tags.all()]
|
||||||
return sorted(names)
|
return sorted(names)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.url_normalized = normalize_url(self.url)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||||
|
|
||||||
@@ -132,6 +138,14 @@ class BookmarkAsset(models.Model):
|
|||||||
status = models.CharField(max_length=64, blank=False, null=False)
|
status = models.CharField(max_length=64, blank=False, null=False)
|
||||||
gzip = models.BooleanField(default=False, null=False)
|
gzip = models.BooleanField(default=False, null=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def download_name(self):
|
||||||
|
return (
|
||||||
|
f"{self.display_name}.html"
|
||||||
|
if self.asset_type == BookmarkAsset.TYPE_SNAPSHOT
|
||||||
|
else self.display_name
|
||||||
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.file:
|
if self.file:
|
||||||
try:
|
try:
|
||||||
@@ -157,6 +171,27 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
|
|||||||
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
|
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkBundle(models.Model):
|
||||||
|
name = models.CharField(max_length=256, blank=False)
|
||||||
|
search = models.CharField(max_length=256, blank=True)
|
||||||
|
any_tags = models.CharField(max_length=1024, blank=True)
|
||||||
|
all_tags = models.CharField(max_length=1024, blank=True)
|
||||||
|
excluded_tags = models.CharField(max_length=1024, blank=True)
|
||||||
|
order = models.IntegerField(null=False, default=0)
|
||||||
|
date_created = models.DateTimeField(auto_now_add=True, null=False)
|
||||||
|
date_modified = models.DateTimeField(auto_now=True, null=False)
|
||||||
|
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkBundleForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = BookmarkBundle
|
||||||
|
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||||
|
|
||||||
|
|
||||||
class BookmarkSearch:
|
class BookmarkSearch:
|
||||||
SORT_ADDED_ASC = "added_asc"
|
SORT_ADDED_ASC = "added_asc"
|
||||||
SORT_ADDED_DESC = "added_desc"
|
SORT_ADDED_DESC = "added_desc"
|
||||||
@@ -171,34 +206,54 @@ class BookmarkSearch:
|
|||||||
FILTER_UNREAD_YES = "yes"
|
FILTER_UNREAD_YES = "yes"
|
||||||
FILTER_UNREAD_NO = "no"
|
FILTER_UNREAD_NO = "no"
|
||||||
|
|
||||||
params = ["q", "user", "sort", "shared", "unread"]
|
params = [
|
||||||
|
"q",
|
||||||
|
"user",
|
||||||
|
"bundle",
|
||||||
|
"sort",
|
||||||
|
"shared",
|
||||||
|
"unread",
|
||||||
|
"modified_since",
|
||||||
|
"added_since",
|
||||||
|
]
|
||||||
preferences = ["sort", "shared", "unread"]
|
preferences = ["sort", "shared", "unread"]
|
||||||
defaults = {
|
defaults = {
|
||||||
"q": "",
|
"q": "",
|
||||||
"user": "",
|
"user": "",
|
||||||
|
"bundle": None,
|
||||||
"sort": SORT_ADDED_DESC,
|
"sort": SORT_ADDED_DESC,
|
||||||
"shared": FILTER_SHARED_OFF,
|
"shared": FILTER_SHARED_OFF,
|
||||||
"unread": FILTER_UNREAD_OFF,
|
"unread": FILTER_UNREAD_OFF,
|
||||||
|
"modified_since": None,
|
||||||
|
"added_since": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
q: str = None,
|
q: str = None,
|
||||||
user: str = None,
|
user: str = None,
|
||||||
|
bundle: BookmarkBundle = None,
|
||||||
sort: str = None,
|
sort: str = None,
|
||||||
shared: str = None,
|
shared: str = None,
|
||||||
unread: str = None,
|
unread: str = None,
|
||||||
|
modified_since: str = None,
|
||||||
|
added_since: str = None,
|
||||||
preferences: dict = None,
|
preferences: dict = None,
|
||||||
|
request: any = None,
|
||||||
):
|
):
|
||||||
if not preferences:
|
if not preferences:
|
||||||
preferences = {}
|
preferences = {}
|
||||||
self.defaults = {**BookmarkSearch.defaults, **preferences}
|
self.defaults = {**BookmarkSearch.defaults, **preferences}
|
||||||
|
self.request = request
|
||||||
|
|
||||||
self.q = q or self.defaults["q"]
|
self.q = q or self.defaults["q"]
|
||||||
self.user = user or self.defaults["user"]
|
self.user = user or self.defaults["user"]
|
||||||
|
self.bundle = bundle or self.defaults["bundle"]
|
||||||
self.sort = sort or self.defaults["sort"]
|
self.sort = sort or self.defaults["sort"]
|
||||||
self.shared = shared or self.defaults["shared"]
|
self.shared = shared or self.defaults["shared"]
|
||||||
self.unread = unread or self.defaults["unread"]
|
self.unread = unread or self.defaults["unread"]
|
||||||
|
self.modified_since = modified_since or self.defaults["modified_since"]
|
||||||
|
self.added_since = added_since or self.defaults["added_since"]
|
||||||
|
|
||||||
def is_modified(self, param):
|
def is_modified(self, param):
|
||||||
value = self.__dict__[param]
|
value = self.__dict__[param]
|
||||||
@@ -226,7 +281,14 @@ class BookmarkSearch:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def query_params(self):
|
def query_params(self):
|
||||||
return {param: self.__dict__[param] for param in self.modified_params}
|
query_params = {}
|
||||||
|
for param in self.modified_params:
|
||||||
|
value = self.__dict__[param]
|
||||||
|
if isinstance(value, models.Model):
|
||||||
|
query_params[param] = value.id
|
||||||
|
else:
|
||||||
|
query_params[param] = value
|
||||||
|
return query_params
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def preferences_dict(self):
|
def preferences_dict(self):
|
||||||
@@ -235,14 +297,21 @@ class BookmarkSearch:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_request(query_dict: QueryDict, preferences: dict = None):
|
def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
|
||||||
initial_values = {}
|
initial_values = {}
|
||||||
for param in BookmarkSearch.params:
|
for param in BookmarkSearch.params:
|
||||||
value = query_dict.get(param)
|
value = query_dict.get(param)
|
||||||
if value:
|
if value:
|
||||||
initial_values[param] = value
|
if param == "bundle":
|
||||||
|
initial_values[param] = BookmarkBundle.objects.filter(
|
||||||
|
owner=request.user, pk=value
|
||||||
|
).first()
|
||||||
|
else:
|
||||||
|
initial_values[param] = value
|
||||||
|
|
||||||
return BookmarkSearch(**initial_values, preferences=preferences)
|
return BookmarkSearch(
|
||||||
|
**initial_values, preferences=preferences, request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkSearchForm(forms.Form):
|
class BookmarkSearchForm(forms.Form):
|
||||||
@@ -265,9 +334,12 @@ class BookmarkSearchForm(forms.Form):
|
|||||||
|
|
||||||
q = forms.CharField()
|
q = forms.CharField()
|
||||||
user = forms.ChoiceField(required=False)
|
user = forms.ChoiceField(required=False)
|
||||||
|
bundle = forms.CharField(required=False)
|
||||||
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||||
|
modified_since = forms.CharField(required=False)
|
||||||
|
added_since = forms.CharField(required=False)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -287,7 +359,11 @@ class BookmarkSearchForm(forms.Form):
|
|||||||
|
|
||||||
for param in search.params:
|
for param in search.params:
|
||||||
# set initial values for modified params
|
# set initial values for modified params
|
||||||
self.fields[param].initial = search.__dict__[param]
|
value = search.__dict__.get(param)
|
||||||
|
if isinstance(value, models.Model):
|
||||||
|
self.fields[param].initial = value.id
|
||||||
|
else:
|
||||||
|
self.fields[param].initial = value
|
||||||
|
|
||||||
# Mark non-editable modified fields as hidden. That way, templates
|
# Mark non-editable modified fields as hidden. That way, templates
|
||||||
# rendering a form can just loop over hidden_fields to ensure that
|
# rendering a form can just loop over hidden_fields to ensure that
|
||||||
@@ -403,11 +479,13 @@ class UserProfile(models.Model):
|
|||||||
search_preferences = models.JSONField(default=dict, null=False)
|
search_preferences = models.JSONField(default=dict, null=False)
|
||||||
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||||
default_mark_unread = models.BooleanField(default=False, null=False)
|
default_mark_unread = models.BooleanField(default=False, null=False)
|
||||||
|
default_mark_shared = models.BooleanField(default=False, null=False)
|
||||||
items_per_page = models.IntegerField(
|
items_per_page = models.IntegerField(
|
||||||
null=False, default=30, validators=[MinValueValidator(10)]
|
null=False, default=30, validators=[MinValueValidator(10)]
|
||||||
)
|
)
|
||||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||||
collapse_side_panel = models.BooleanField(default=False, null=False)
|
collapse_side_panel = models.BooleanField(default=False, null=False)
|
||||||
|
hide_bundles = models.BooleanField(default=False, null=False)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.custom_css:
|
if self.custom_css:
|
||||||
@@ -443,11 +521,13 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"display_remove_bookmark_action",
|
"display_remove_bookmark_action",
|
||||||
"permanent_notes",
|
"permanent_notes",
|
||||||
"default_mark_unread",
|
"default_mark_unread",
|
||||||
|
"default_mark_shared",
|
||||||
"custom_css",
|
"custom_css",
|
||||||
"auto_tagging_rules",
|
"auto_tagging_rules",
|
||||||
"items_per_page",
|
"items_per_page",
|
||||||
"sticky_pagination",
|
"sticky_pagination",
|
||||||
"collapse_side_panel",
|
"collapse_side_panel",
|
||||||
|
"hide_bundles",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,26 @@ from typing import Optional
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
|
from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
|
||||||
from django.db.models.expressions import RawSQL
|
from django.db.models.expressions import RawSQL
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
from bookmarks.models import (
|
||||||
|
Bookmark,
|
||||||
|
BookmarkBundle,
|
||||||
|
BookmarkSearch,
|
||||||
|
Tag,
|
||||||
|
UserProfile,
|
||||||
|
parse_tag_string,
|
||||||
|
)
|
||||||
from bookmarks.utils import unique
|
from bookmarks.utils import unique
|
||||||
|
|
||||||
|
|
||||||
def query_bookmarks(
|
def query_bookmarks(
|
||||||
user: User, profile: UserProfile, search: BookmarkSearch
|
user: User,
|
||||||
|
profile: UserProfile,
|
||||||
|
search: BookmarkSearch,
|
||||||
) -> QuerySet:
|
) -> QuerySet:
|
||||||
return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
|
return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
|
||||||
|
|
||||||
@@ -35,8 +45,51 @@ def query_shared_bookmarks(
|
|||||||
return _base_bookmarks_query(user, profile, search).filter(conditions)
|
return _base_bookmarks_query(user, profile, search).filter(conditions)
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
|
||||||
|
# Search terms
|
||||||
|
search_terms = parse_query_string(bundle.search)["search_terms"]
|
||||||
|
for term in search_terms:
|
||||||
|
conditions = (
|
||||||
|
Q(title__icontains=term)
|
||||||
|
| Q(description__icontains=term)
|
||||||
|
| Q(notes__icontains=term)
|
||||||
|
| Q(url__icontains=term)
|
||||||
|
)
|
||||||
|
query_set = query_set.filter(conditions)
|
||||||
|
|
||||||
|
# Any tags - at least one tag must match
|
||||||
|
any_tags = parse_tag_string(bundle.any_tags, " ")
|
||||||
|
if len(any_tags) > 0:
|
||||||
|
tag_conditions = Q()
|
||||||
|
for tag in any_tags:
|
||||||
|
tag_conditions |= Q(tags__name__iexact=tag)
|
||||||
|
|
||||||
|
query_set = query_set.filter(
|
||||||
|
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
|
||||||
|
)
|
||||||
|
|
||||||
|
# All tags - all tags must match
|
||||||
|
all_tags = parse_tag_string(bundle.all_tags, " ")
|
||||||
|
for tag in all_tags:
|
||||||
|
query_set = query_set.filter(tags__name__iexact=tag)
|
||||||
|
|
||||||
|
# Excluded tags - no tags must match
|
||||||
|
exclude_tags = parse_tag_string(bundle.excluded_tags, " ")
|
||||||
|
if len(exclude_tags) > 0:
|
||||||
|
tag_conditions = Q()
|
||||||
|
for tag in exclude_tags:
|
||||||
|
tag_conditions |= Q(tags__name__iexact=tag)
|
||||||
|
query_set = query_set.exclude(
|
||||||
|
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
|
||||||
|
)
|
||||||
|
|
||||||
|
return query_set
|
||||||
|
|
||||||
|
|
||||||
def _base_bookmarks_query(
|
def _base_bookmarks_query(
|
||||||
user: Optional[User], profile: UserProfile, search: BookmarkSearch
|
user: Optional[User],
|
||||||
|
profile: UserProfile,
|
||||||
|
search: BookmarkSearch,
|
||||||
) -> QuerySet:
|
) -> QuerySet:
|
||||||
query_set = Bookmark.objects
|
query_set = Bookmark.objects
|
||||||
|
|
||||||
@@ -44,6 +97,22 @@ def _base_bookmarks_query(
|
|||||||
if user:
|
if user:
|
||||||
query_set = query_set.filter(owner=user)
|
query_set = query_set.filter(owner=user)
|
||||||
|
|
||||||
|
# Filter by modified_since if provided
|
||||||
|
if search.modified_since:
|
||||||
|
try:
|
||||||
|
query_set = query_set.filter(date_modified__gt=search.modified_since)
|
||||||
|
except ValidationError:
|
||||||
|
# If the date format is invalid, ignore the filter
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Filter by added_since if provided
|
||||||
|
if search.added_since:
|
||||||
|
try:
|
||||||
|
query_set = query_set.filter(date_added__gt=search.added_since)
|
||||||
|
except ValidationError:
|
||||||
|
# If the date format is invalid, ignore the filter
|
||||||
|
pass
|
||||||
|
|
||||||
# Split query into search terms and tags
|
# Split query into search terms and tags
|
||||||
query = parse_query_string(search.q)
|
query = parse_query_string(search.q)
|
||||||
|
|
||||||
@@ -85,6 +154,10 @@ def _base_bookmarks_query(
|
|||||||
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
|
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
|
||||||
query_set = query_set.filter(shared=False)
|
query_set = query_set.filter(shared=False)
|
||||||
|
|
||||||
|
# Filter by bundle
|
||||||
|
if search.bundle:
|
||||||
|
query_set = _filter_bundle(query_set, search.bundle)
|
||||||
|
|
||||||
# Sort
|
# Sort
|
||||||
if (
|
if (
|
||||||
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ def create_snapshot(asset: BookmarkAsset):
|
|||||||
# Store as gzip in asset folder
|
# Store as gzip in asset folder
|
||||||
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
|
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
|
||||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||||
with open(temp_filepath, "rb") as temp_file, gzip.open(
|
with (
|
||||||
filepath, "wb"
|
open(temp_filepath, "rb") as temp_file,
|
||||||
) as gz_file:
|
gzip.open(filepath, "wb") as gz_file,
|
||||||
|
):
|
||||||
shutil.copyfileobj(temp_file, gz_file)
|
shutil.copyfileobj(temp_file, gz_file)
|
||||||
|
|
||||||
# Remove temporary file
|
# Remove temporary file
|
||||||
@@ -53,6 +54,7 @@ def create_snapshot(asset: BookmarkAsset):
|
|||||||
asset.save()
|
asset.save()
|
||||||
|
|
||||||
asset.bookmark.latest_snapshot = asset
|
asset.bookmark.latest_snapshot = asset
|
||||||
|
asset.bookmark.date_modified = timezone.now()
|
||||||
asset.bookmark.save()
|
asset.bookmark.save()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||||
@@ -75,6 +77,7 @@ def upload_snapshot(bookmark: Bookmark, html: bytes):
|
|||||||
asset.save()
|
asset.save()
|
||||||
|
|
||||||
asset.bookmark.latest_snapshot = asset
|
asset.bookmark.latest_snapshot = asset
|
||||||
|
asset.bookmark.date_modified = timezone.now()
|
||||||
asset.bookmark.save()
|
asset.bookmark.save()
|
||||||
|
|
||||||
return asset
|
return asset
|
||||||
@@ -92,14 +95,33 @@ def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
|
|||||||
gzip=False,
|
gzip=False,
|
||||||
)
|
)
|
||||||
name, extension = os.path.splitext(upload_file.name)
|
name, extension = os.path.splitext(upload_file.name)
|
||||||
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
|
|
||||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
# automatically gzip the file if it is not already gzipped
|
||||||
with open(filepath, "wb") as f:
|
if upload_file.content_type != "application/gzip":
|
||||||
for chunk in upload_file.chunks():
|
filename = _generate_asset_filename(
|
||||||
f.write(chunk)
|
asset, name, extension.lstrip(".") + ".gz"
|
||||||
asset.file = filename
|
)
|
||||||
asset.file_size = upload_file.size
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||||
|
with gzip.open(filepath, "wb", compresslevel=9) as f:
|
||||||
|
for chunk in upload_file.chunks():
|
||||||
|
f.write(chunk)
|
||||||
|
asset.gzip = True
|
||||||
|
asset.file = filename
|
||||||
|
asset.file_size = os.path.getsize(filepath)
|
||||||
|
else:
|
||||||
|
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
|
||||||
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
for chunk in upload_file.chunks():
|
||||||
|
f.write(chunk)
|
||||||
|
asset.file = filename
|
||||||
|
asset.file_size = upload_file.size
|
||||||
|
|
||||||
asset.save()
|
asset.save()
|
||||||
|
|
||||||
|
asset.bookmark.date_modified = timezone.now()
|
||||||
|
asset.bookmark.save()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
||||||
)
|
)
|
||||||
@@ -128,9 +150,10 @@ def remove_asset(asset: BookmarkAsset):
|
|||||||
)
|
)
|
||||||
|
|
||||||
bookmark.latest_snapshot = latest
|
bookmark.latest_snapshot = latest
|
||||||
bookmark.save()
|
|
||||||
|
|
||||||
asset.delete()
|
asset.delete()
|
||||||
|
bookmark.date_modified = timezone.now()
|
||||||
|
bookmark.save()
|
||||||
|
|
||||||
|
|
||||||
def _generate_asset_filename(
|
def _generate_asset_filename(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Union
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, User, parse_tag_string
|
from bookmarks.models import Bookmark, User, parse_tag_string
|
||||||
|
from bookmarks.utils import normalize_url
|
||||||
from bookmarks.services import auto_tagging
|
from bookmarks.services import auto_tagging
|
||||||
from bookmarks.services import tasks
|
from bookmarks.services import tasks
|
||||||
from bookmarks.services import website_loader
|
from bookmarks.services import website_loader
|
||||||
@@ -19,8 +20,9 @@ def create_bookmark(
|
|||||||
disable_html_snapshot: bool = False,
|
disable_html_snapshot: bool = False,
|
||||||
):
|
):
|
||||||
# If URL is already bookmarked, then update it
|
# If URL is already bookmarked, then update it
|
||||||
|
normalized_url = normalize_url(bookmark.url)
|
||||||
existing_bookmark: Bookmark = Bookmark.objects.filter(
|
existing_bookmark: Bookmark = Bookmark.objects.filter(
|
||||||
owner=current_user, url=bookmark.url
|
owner=current_user, url_normalized=normalized_url
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing_bookmark is not None:
|
if existing_bookmark is not None:
|
||||||
@@ -208,6 +210,15 @@ def refresh_bookmarks_metadata(bookmark_ids: [Union[int, str]], current_user: Us
|
|||||||
tasks.load_preview_image(current_user, bookmark)
|
tasks.load_preview_image(current_user, bookmark)
|
||||||
|
|
||||||
|
|
||||||
|
def create_html_snapshots(bookmark_ids: list[Union[int, str]], current_user: User):
|
||||||
|
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||||
|
owned_bookmarks = Bookmark.objects.filter(
|
||||||
|
owner=current_user, id__in=sanitized_bookmark_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.create_html_snapshots(owned_bookmarks)
|
||||||
|
|
||||||
|
|
||||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
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
|
||||||
|
|||||||
37
bookmarks/services/bundles.py
Normal file
37
bookmarks/services/bundles.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from django.db.models import Max
|
||||||
|
|
||||||
|
from bookmarks.models import BookmarkBundle, User
|
||||||
|
|
||||||
|
|
||||||
|
def create_bundle(bundle: BookmarkBundle, current_user: User):
|
||||||
|
bundle.owner = current_user
|
||||||
|
if bundle.order is None:
|
||||||
|
max_order_result = BookmarkBundle.objects.filter(owner=current_user).aggregate(
|
||||||
|
Max("order", default=-1)
|
||||||
|
)
|
||||||
|
bundle.order = max_order_result["order__max"] + 1
|
||||||
|
bundle.save()
|
||||||
|
return bundle
|
||||||
|
|
||||||
|
|
||||||
|
def move_bundle(bundle_to_move: BookmarkBundle, new_order: int):
|
||||||
|
user_bundles = list(
|
||||||
|
BookmarkBundle.objects.filter(owner=bundle_to_move.owner).order_by("order")
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_order != user_bundles.index(bundle_to_move):
|
||||||
|
user_bundles.remove(bundle_to_move)
|
||||||
|
user_bundles.insert(new_order, bundle_to_move)
|
||||||
|
for bundle_index, bundle in enumerate(user_bundles):
|
||||||
|
bundle.order = bundle_index
|
||||||
|
|
||||||
|
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
|
||||||
|
|
||||||
|
|
||||||
|
def delete_bundle(bundle: BookmarkBundle):
|
||||||
|
bundle.delete()
|
||||||
|
|
||||||
|
user_bundles = BookmarkBundle.objects.filter(owner=bundle.owner).order_by("order")
|
||||||
|
for index, user_bundle in enumerate(user_bundles):
|
||||||
|
user_bundle.order = index
|
||||||
|
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
|
||||||
@@ -96,6 +96,13 @@ def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User)
|
|||||||
|
|
||||||
for netscape_bookmark in netscape_bookmarks:
|
for netscape_bookmark in netscape_bookmarks:
|
||||||
for tag_name in netscape_bookmark.tag_names:
|
for tag_name in netscape_bookmark.tag_names:
|
||||||
|
# Skip tag names that exceed the maximum allowed length
|
||||||
|
if len(tag_name) > 64:
|
||||||
|
logger.warning(
|
||||||
|
f"Ignoring tag '{tag_name}' (length {len(tag_name)}) as it exceeds maximum length of 64 characters"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
tag = tag_cache.get(tag_name)
|
tag = tag_cache.get(tag_name)
|
||||||
if not tag:
|
if not tag:
|
||||||
tag = Tag(name=tag_name, owner=user)
|
tag = Tag(name=tag_name, owner=user)
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ def create_snapshot(url: str, filepath: str):
|
|||||||
command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}"
|
command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}"
|
||||||
subprocess.run(command, check=True, shell=True)
|
subprocess.run(command, check=True, shell=True)
|
||||||
|
|
||||||
with open(temp_filepath, "rb") as raw_file, gzip.open(
|
with (
|
||||||
filepath, "wb"
|
open(temp_filepath, "rb") as raw_file,
|
||||||
) as gz_file:
|
gzip.open(filepath, "wb") as gz_file,
|
||||||
|
):
|
||||||
shutil.copyfileobj(raw_file, gz_file)
|
shutil.copyfileobj(raw_file, gz_file)
|
||||||
|
|
||||||
os.remove(temp_filepath)
|
os.remove(temp_filepath)
|
||||||
|
|||||||
@@ -116,8 +116,6 @@ TIME_ZONE = os.getenv("TZ", "UTC")
|
|||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
USE_L10N = True
|
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
/* Common styles */
|
/* Common styles */
|
||||||
.bookmark-details {
|
.bookmark-details {
|
||||||
|
.title {
|
||||||
|
word-break: break-word;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
& .weblinks {
|
& .weblinks {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -49,50 +57,9 @@
|
|||||||
& .assets {
|
& .assets {
|
||||||
margin-top: var(--unit-2);
|
margin-top: var(--unit-2);
|
||||||
|
|
||||||
& .asset {
|
& .filesize {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--unit-2);
|
|
||||||
padding: var(--unit-2) 0;
|
|
||||||
border-top: var(--unit-o) solid var(--secondary-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .asset:last-child {
|
|
||||||
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .asset-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .asset-text {
|
|
||||||
flex: 1 1 0;
|
|
||||||
gap: var(--unit-2);
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .asset-text .truncate {
|
|
||||||
flex-shrink: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .asset-text .filesize {
|
|
||||||
color: var(--tertiary-text-color);
|
color: var(--tertiary-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .asset-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--unit-4);
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& .btn.btn-link {
|
|
||||||
height: unset;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& .assets-actions {
|
& .assets-actions {
|
||||||
|
|||||||
@@ -346,12 +346,6 @@ li[ld-bookmark-item] {
|
|||||||
.bookmark-pagination {
|
.bookmark-pagination {
|
||||||
margin-top: var(--unit-4);
|
margin-top: var(--unit-4);
|
||||||
|
|
||||||
/* Remove left padding from first pagination link */
|
|
||||||
|
|
||||||
& .page-item:first-child a {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.sticky {
|
&.sticky {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -365,7 +359,8 @@ li[ld-bookmark-item] {
|
|||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: calc(
|
left: calc(
|
||||||
-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
|
-1 *
|
||||||
|
calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
|
||||||
);
|
);
|
||||||
width: calc(
|
width: calc(
|
||||||
var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)
|
var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)
|
||||||
@@ -379,6 +374,26 @@ li[ld-bookmark-item] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bundle-menu {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0 0 var(--unit-6);
|
||||||
|
|
||||||
|
.bundle-menu-item {
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bundle-menu-item a {
|
||||||
|
padding: var(--unit-1) var(--unit-2);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bundle-menu-item.selected a {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: var(--contrast-text-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tag-cloud {
|
.tag-cloud {
|
||||||
/* Increase line-height for better separation within / between items */
|
/* Increase line-height for better separation within / between items */
|
||||||
line-height: 1.1rem;
|
line-height: 1.1rem;
|
||||||
|
|||||||
29
bookmarks/styles/bundles.css
Normal file
29
bookmarks/styles/bundles.css
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
.bundles-page {
|
||||||
|
.crud-table {
|
||||||
|
svg {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.drag-start {
|
||||||
|
--secondary-border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.dragging > * {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bundles-editor-page {
|
||||||
|
&.grid {
|
||||||
|
gap: var(--unit-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
border-top: solid 1px var(--secondary-border-color);
|
||||||
|
background: var(--body-color);
|
||||||
|
padding: var(--unit-3) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,28 +25,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.section-header {
|
.section-header:not(.no-wrap) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Confirm button component */
|
/* Confirm button component */
|
||||||
span.confirmation {
|
.confirm-dropdown.active {
|
||||||
display: flex;
|
position: fixed;
|
||||||
align-items: baseline;
|
z-index: 500;
|
||||||
gap: var(--unit-1);
|
|
||||||
color: var(--error-color) !important;
|
|
||||||
|
|
||||||
svg {
|
& .menu {
|
||||||
align-self: center;
|
position: fixed;
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
.btn.btn-link {
|
box-sizing: border-box;
|
||||||
color: var(--error-color) !important;
|
gap: var(--unit-2);
|
||||||
|
padding: var(--unit-2);
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,3 +55,60 @@ span.confirmation {
|
|||||||
.turbo-progress-bar {
|
.turbo-progress-bar {
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.message-list {
|
||||||
|
margin: var(--unit-4) 0;
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
margin-bottom: var(--unit-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast a.btn-clear:visited {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item list */
|
||||||
|
.item-list {
|
||||||
|
& .list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
padding: var(--unit-2) 0;
|
||||||
|
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .list-item:last-child {
|
||||||
|
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .list-item-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .list-item-text {
|
||||||
|
flex: 1 1 0;
|
||||||
|
gap: var(--unit-2);
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .list-item-text .truncate {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .list-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--unit-4);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& .btn.btn-link {
|
||||||
|
height: unset;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
65
bookmarks/styles/crud.css
Normal file
65
bookmarks/styles/crud.css
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
.crud-page {
|
||||||
|
.crud-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--unit-6);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.crud-filters {
|
||||||
|
background: var(--body-color-contrast);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: solid 1px var(--secondary-border-color);
|
||||||
|
padding: var(--unit-3);
|
||||||
|
margin-bottom: var(--unit-4);
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--unit-4);
|
||||||
|
|
||||||
|
& .form-group {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.form-input,
|
||||||
|
&.form-select {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .form-group:has(.form-checkbox) {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.crud-table {
|
||||||
|
.btn.btn-link {
|
||||||
|
padding: 0;
|
||||||
|
height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
max-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.actions,
|
||||||
|
td.actions {
|
||||||
|
width: 1%;
|
||||||
|
max-width: 150px;
|
||||||
|
|
||||||
|
*:not(:last-child) {
|
||||||
|
margin-right: var(--unit-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,15 +27,3 @@ header {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header .toasts {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast a.btn-clear:visited {
|
|
||||||
color: currentColor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
6
bookmarks/styles/tags.css
Normal file
6
bookmarks/styles/tags.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.tags-editor-page {
|
||||||
|
main {
|
||||||
|
max-width: 550px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,12 @@
|
|||||||
@import "responsive.css";
|
@import "responsive.css";
|
||||||
@import "layout.css";
|
@import "layout.css";
|
||||||
@import "components.css";
|
@import "components.css";
|
||||||
|
@import "crud.css";
|
||||||
@import "bookmark-details.css";
|
@import "bookmark-details.css";
|
||||||
@import "bookmark-form.css";
|
@import "bookmark-form.css";
|
||||||
@import "bookmark-page.css";
|
@import "bookmark-page.css";
|
||||||
@import "markdown.css";
|
@import "markdown.css";
|
||||||
@import "reader-mode.css";
|
@import "reader-mode.css";
|
||||||
@import "settings.css";
|
@import "settings.css";
|
||||||
|
@import "bundles.css";
|
||||||
|
@import "tags.css";
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
& .form-autocomplete-input {
|
& .form-autocomplete-input {
|
||||||
|
box-sizing: border-box;
|
||||||
align-content: flex-start;
|
align-content: flex-start;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
height: auto;
|
|
||||||
min-height: var(--unit-8);
|
|
||||||
padding: var(--unit-h);
|
|
||||||
background: var(--input-bg-color);
|
background: var(--input-bg-color);
|
||||||
|
height: var(--control-size);
|
||||||
|
min-height: var(--control-size);
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
&.is-focused {
|
&.is-focused {
|
||||||
outline: var(--focus-outline);
|
outline: var(--focus-outline);
|
||||||
@@ -22,10 +23,11 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
height: var(--unit-6);
|
|
||||||
line-height: var(--unit-4);
|
line-height: var(--unit-4);
|
||||||
margin: var(--unit-h);
|
width: 100%;
|
||||||
width: auto;
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -33,11 +35,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
.form-autocomplete-input {
|
||||||
|
height: var(--control-size-sm);
|
||||||
|
min-height: var(--control-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-autocomplete-input input {
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu .menu-item {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
& .menu {
|
& .menu {
|
||||||
|
display: none;
|
||||||
left: 0;
|
left: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-height: var(--menu-max-height, 200px);
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
& .menu-item.selected > a,
|
& .menu-item.selected > a,
|
||||||
& .menu-item > a:hover {
|
& .menu-item > a:hover {
|
||||||
@@ -54,4 +75,8 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .menu.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Button no border */
|
||||||
|
&.btn-noborder {
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Button Link */
|
/* Button Link */
|
||||||
|
|
||||||
&.btn-link {
|
&.btn-link {
|
||||||
|
|||||||
@@ -224,12 +224,13 @@ textarea.form-input {
|
|||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
clip: rect(0, 0, 0, 0);
|
opacity: 0;
|
||||||
height: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
|
||||||
|
left: 0;
|
||||||
|
height: var(--control-icon-size);
|
||||||
|
width: var(--control-icon-size);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:focus-visible + .form-icon {
|
&:focus-visible + .form-icon {
|
||||||
outline: var(--focus-outline);
|
outline: var(--focus-outline);
|
||||||
@@ -243,9 +244,9 @@ textarea.form-input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-icon {
|
.form-icon {
|
||||||
|
pointer-events: none;
|
||||||
border: var(--border-width) solid var(--checkbox-border-color);
|
border: var(--border-width) solid var(--checkbox-border-color);
|
||||||
box-shadow: var(--input-box-shadow);
|
box-shadow: var(--input-box-shadow);
|
||||||
cursor: pointer;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transition:
|
transition:
|
||||||
@@ -429,13 +430,21 @@ textarea.form-input {
|
|||||||
/* Form element: Input groups */
|
/* Form element: Input groups */
|
||||||
.input-group {
|
.input-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--input-box-shadow);
|
||||||
|
|
||||||
|
> * {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.input-group-addon {
|
.input-group-addon {
|
||||||
background: var(--body-color);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--input-bg-color);
|
||||||
border: var(--border-width) solid var(--input-border-color);
|
border: var(--border-width) solid var(--input-border-color);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
line-height: var(--line-height);
|
line-height: var(--line-height);
|
||||||
padding: var(--control-padding-y) var(--control-padding-x);
|
padding: 0 var(--control-padding-x);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&.addon-sm {
|
&.addon-sm {
|
||||||
|
|||||||
@@ -87,4 +87,43 @@
|
|||||||
border-bottom: solid 1px var(--secondary-border-color);
|
border-bottom: solid 1px var(--secondary-border-color);
|
||||||
margin: var(--unit-2) 0;
|
margin: var(--unit-2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.with-arrow {
|
||||||
|
overflow: visible;
|
||||||
|
--arrow-size: 16px;
|
||||||
|
--arrow-offset: 0px;
|
||||||
|
|
||||||
|
.menu-arrow {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: calc(50% + var(--arrow-offset));
|
||||||
|
top: 0;
|
||||||
|
width: var(--arrow-size);
|
||||||
|
height: var(--arrow-size);
|
||||||
|
translate: -50% -50%;
|
||||||
|
rotate: 45deg;
|
||||||
|
background: inherit;
|
||||||
|
border: inherit;
|
||||||
|
clip-path: polygon(0 0, 0 100%, 100% 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top-aligned {
|
||||||
|
transform: translateY(
|
||||||
|
calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bottom-aligned {
|
||||||
|
transform: translateY(
|
||||||
|
calc(calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm)) * -1)
|
||||||
|
);
|
||||||
|
|
||||||
|
.menu-arrow {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
rotate: 225deg;
|
||||||
|
translate: -50% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,17 +80,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
& .close {
|
& .close {
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
line-height: 0;
|
height: auto;
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.85;
|
|
||||||
color: var(--secondary-text-color);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +97,6 @@
|
|||||||
& .modal-footer {
|
& .modal-footer {
|
||||||
padding: var(--unit-6);
|
padding: var(--unit-6);
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:first-child a {
|
||||||
|
/* Remove left padding from first pagination link */
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
& a {
|
& a {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
|
|||||||
@@ -5,22 +5,19 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
/* Scrollable tables */
|
td,
|
||||||
|
th {
|
||||||
&.table-scroll {
|
border-bottom: var(--border-width) solid var(--secondary-border-color);
|
||||||
display: block;
|
padding: var(--unit-2) var(--unit-2);
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& td,
|
th {
|
||||||
& th {
|
font-weight: 500;
|
||||||
border-bottom: var(--border-width) solid var(--border-color);
|
border-bottom-color: var(--border-color);
|
||||||
padding: var(--unit-3) var(--unit-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& th {
|
th:first-child,
|
||||||
border-bottom-width: var(--border-width-lg);
|
td:first-child {
|
||||||
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,44 @@
|
|||||||
margin-top: var(--unit-4) !important;
|
margin-top: var(--unit-4) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m-6 {
|
||||||
|
margin: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-6 {
|
||||||
|
margin-bottom: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-6 {
|
||||||
|
margin-left: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-6 {
|
||||||
|
margin-right: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-6 {
|
||||||
|
margin-top: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-6 {
|
||||||
|
margin-left: var(--unit-6) !important;
|
||||||
|
margin-right: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-6 {
|
||||||
|
margin-bottom: var(--unit-6) !important;
|
||||||
|
margin-top: var(--unit-6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-auto {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-auto {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
@@ -283,6 +321,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Flex */
|
/* Flex */
|
||||||
|
.flex-column {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.align-baseline {
|
.align-baseline {
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
@@ -294,3 +336,7 @@
|
|||||||
.justify-between {
|
.justify-between {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gap-2 {
|
||||||
|
gap: var(--unit-2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,20 +49,22 @@
|
|||||||
--body-color-contrast: var(--gray-100);
|
--body-color-contrast: var(--gray-100);
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI",
|
--base-font-family:
|
||||||
Roboto;
|
-apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto;
|
||||||
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier,
|
--mono-font-family:
|
||||||
monospace;
|
"SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace;
|
||||||
--fallback-font-family: "Helvetica Neue", sans-serif;
|
--fallback-font-family: "Helvetica Neue", sans-serif;
|
||||||
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC",
|
--cjk-zh-hans-font-family:
|
||||||
"Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
|
var(--base-font-family), "PingFang SC", "Hiragino Sans GB",
|
||||||
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC",
|
"Microsoft YaHei", var(--fallback-font-family);
|
||||||
"Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
|
--cjk-zh-hant-font-family:
|
||||||
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans",
|
var(--base-font-family), "PingFang TC", "Hiragino Sans CNS",
|
||||||
"Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo,
|
"Microsoft JhengHei", var(--fallback-font-family);
|
||||||
var(--fallback-font-family);
|
--cjk-jp-font-family:
|
||||||
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic",
|
var(--base-font-family), "Hiragino Sans", "Hiragino Kaku Gothic Pro",
|
||||||
var(--fallback-font-family);
|
"Yu Gothic", YuGothic, Meiryo, var(--fallback-font-family);
|
||||||
|
--cjk-ko-font-family:
|
||||||
|
var(--base-font-family), "Malgun Gothic", var(--fallback-font-family);
|
||||||
--body-font-family: var(--base-font-family), var(--fallback-font-family);
|
--body-font-family: var(--base-font-family), var(--fallback-font-family);
|
||||||
|
|
||||||
/* Unit sizes */
|
/* Unit sizes */
|
||||||
@@ -145,6 +147,6 @@
|
|||||||
/* Shadows */
|
/* Shadows */
|
||||||
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
||||||
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
--box-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
--box-shadow-lg:
|
||||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,16 +30,10 @@
|
|||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{# Tag cloud #}
|
{# Filters #}
|
||||||
<div class="side-panel col-1">
|
<div class="side-panel col-1 hide-md">
|
||||||
<section aria-labelledby="tags-heading">
|
{% include 'bookmarks/bundle_section.html' %}
|
||||||
<div class="section-header">
|
{% include 'bookmarks/tag_section.html' %}
|
||||||
<h2 id="tags-heading">Tags</h2>
|
|
||||||
</div>
|
|
||||||
<div id="tag-cloud-container">
|
|
||||||
{% include 'bookmarks/tag_cloud.html' %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -77,72 +77,76 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span>{{ bookmark_item.display_date }}</span>
|
<span>{{ bookmark_item.display_date }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span>|</span>
|
{% if not bookmark_list.is_preview %}
|
||||||
{% endif %}
|
<span>|</span>
|
||||||
{# View link is visible for both owned and shared bookmarks #}
|
|
||||||
{% if bookmark_list.show_view_action %}
|
|
||||||
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
|
||||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if bookmark_item.is_editable %}
|
|
||||||
{# Bookmark owner actions #}
|
|
||||||
{% if bookmark_list.show_edit_action %}
|
|
||||||
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_list.show_archive_action %}
|
|
||||||
{% if bookmark_item.is_archived %}
|
|
||||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
|
||||||
class="btn btn-link btn-sm">Unarchive
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
|
||||||
class="btn btn-link btn-sm">Archive
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% if bookmark_list.show_remove_action %}
|
|
||||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
|
||||||
class="btn btn-link btn-sm">Remove
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
{# Shared bookmark actions #}
|
|
||||||
<span>Shared by
|
|
||||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.has_extra_actions %}
|
{% if not bookmark_list.is_preview %}
|
||||||
<div class="extra-actions">
|
{# View link is visible for both owned and shared bookmarks #}
|
||||||
<span class="hide-sm">|</span>
|
{% if bookmark_list.show_view_action %}
|
||||||
{% if bookmark_item.show_mark_as_read %}
|
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||||
class="btn btn-link btn-sm btn-icon"
|
{% endif %}
|
||||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
{% if bookmark_item.is_editable %}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
{# Bookmark owner actions #}
|
||||||
<use xlink:href="#ld-icon-unread"></use>
|
{% if bookmark_list.show_edit_action %}
|
||||||
</svg>
|
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||||
Unread
|
{% endif %}
|
||||||
|
{% if bookmark_list.show_archive_action %}
|
||||||
|
{% if bookmark_item.is_archived %}
|
||||||
|
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||||
|
class="btn btn-link btn-sm">Unarchive
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||||
|
class="btn btn-link btn-sm">Archive
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark_list.show_remove_action %}
|
||||||
|
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||||
|
class="btn btn-link btn-sm">Remove
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.show_unshare %}
|
{% else %}
|
||||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
{# Shared bookmark actions #}
|
||||||
class="btn btn-link btn-sm btn-icon"
|
<span>Shared by
|
||||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
</span>
|
||||||
<use xlink:href="#ld-icon-share"></use>
|
{% endif %}
|
||||||
</svg>
|
{% if bookmark_item.has_extra_actions %}
|
||||||
Shared
|
<div class="extra-actions">
|
||||||
</button>
|
<span class="hide-sm">|</span>
|
||||||
{% endif %}
|
{% if bookmark_item.show_mark_as_read %}
|
||||||
{% if bookmark_item.show_notes_button %}
|
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
class="btn btn-link btn-sm btn-icon"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
ld-confirm-button ld-confirm-question="Mark as read?">
|
||||||
<use xlink:href="#ld-icon-note"></use>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
</svg>
|
<use xlink:href="#ld-icon-unread"></use>
|
||||||
Notes
|
</svg>
|
||||||
</button>
|
Unread
|
||||||
{% endif %}
|
</button>
|
||||||
</div>
|
{% endif %}
|
||||||
|
{% if bookmark_item.show_unshare %}
|
||||||
|
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||||
|
class="btn btn-link btn-sm btn-icon"
|
||||||
|
ld-confirm-button ld-confirm-question="Unshare?">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<use xlink:href="#ld-icon-share"></use>
|
||||||
|
</svg>
|
||||||
|
Shared
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark_item.show_notes_button %}
|
||||||
|
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||||
|
<use xlink:href="#ld-icon-note"></use>
|
||||||
|
</svg>
|
||||||
|
Notes
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var bookmarkUrl = window.location;
|
const bookmarkUrl = window.location;
|
||||||
var applicationUrl = '{{ application_url }}';
|
|
||||||
|
|
||||||
|
let applicationUrl = '{{ application_url }}';
|
||||||
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
|
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
|
||||||
applicationUrl += '&auto_close';
|
applicationUrl += '&auto_close';
|
||||||
|
|
||||||
|
|||||||
25
bookmarks/templates/bookmarks/bookmarklet_clientside.js
Normal file
25
bookmarks/templates/bookmarks/bookmarklet_clientside.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
(function () {
|
||||||
|
const bookmarkUrl = window.location;
|
||||||
|
const title =
|
||||||
|
document.querySelector('title')?.textContent ||
|
||||||
|
document
|
||||||
|
.querySelector(`meta[property='og:title']`)
|
||||||
|
?.getAttribute('content') ||
|
||||||
|
'';
|
||||||
|
const description =
|
||||||
|
document
|
||||||
|
.querySelector(`meta[name='description']`)
|
||||||
|
?.getAttribute('content') ||
|
||||||
|
document
|
||||||
|
.querySelector(`meta[property='og:description']`)
|
||||||
|
?.getAttribute(`content`) ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
let applicationUrl = '{{ application_url }}';
|
||||||
|
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
|
||||||
|
applicationUrl += '&title=' + encodeURIComponent(title);
|
||||||
|
applicationUrl += '&description=' + encodeURIComponent(description);
|
||||||
|
applicationUrl += '&auto_close';
|
||||||
|
|
||||||
|
window.open(applicationUrl);
|
||||||
|
})();
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
<option value="bulk_unshare">Unshare</option>
|
<option value="bulk_unshare">Unshare</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<option value="bulk_refresh">Refresh from website</option>
|
<option value="bulk_refresh">Refresh from website</option>
|
||||||
|
{% if bookmark_list.snapshot_feature_enabled %}
|
||||||
|
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||||
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
<div class="tag-autocomplete d-none" ld-tag-autocomplete>
|
<div class="tag-autocomplete d-none" ld-tag-autocomplete>
|
||||||
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">
|
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">
|
||||||
|
|||||||
36
bookmarks/templates/bookmarks/bundle_section.html
Normal file
36
bookmarks/templates/bookmarks/bundle_section.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{% if not request.user_profile.hide_bundles %}
|
||||||
|
<section aria-labelledby="bundles-heading">
|
||||||
|
<div class="section-header no-wrap">
|
||||||
|
<h2 id="bundles-heading">Bundles</h2>
|
||||||
|
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||||
|
<button class="btn btn-noborder dropdown-toggle" aria-label="Bundles menu">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
|
<path d="M4 6l16 0"/>
|
||||||
|
<path d="M4 12l16 0"/>
|
||||||
|
<path d="M4 18l16 0"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul class="menu" role="list" tabindex="-1">
|
||||||
|
<li class="menu-item">
|
||||||
|
<a href="{% url 'linkding:bundles.index' %}" class="menu-link">Manage bundles</a>
|
||||||
|
</li>
|
||||||
|
{% if bookmark_list.search.q %}
|
||||||
|
<li class="menu-item">
|
||||||
|
<a href="{% url 'linkding:bundles.new' %}?q={{ bookmark_list.search.q|urlencode }}" class="menu-link">Create
|
||||||
|
bundle from search</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="bundle-menu">
|
||||||
|
{% for bundle in bundles.bundles %}
|
||||||
|
<li class="bundle-menu-item {% if bundle.id == bundles.selected_bundle.id %}selected{% endif %}">
|
||||||
|
<a href="?bundle={{ bundle.id }}">{{ bundle.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<div>
|
<div>
|
||||||
{% if details.assets %}
|
{% if details.assets %}
|
||||||
<div class="assets">
|
<div class="item-list assets">
|
||||||
{% for asset in details.assets %}
|
{% for asset in details.assets %}
|
||||||
<div class="asset" data-asset-id="{{ asset.id }}">
|
<div class="list-item" data-asset-id="{{ asset.id }}">
|
||||||
<div class="asset-icon {{ asset.icon_classes }}">
|
<div class="list-item-icon {{ asset.icon_classes }}">
|
||||||
{% include 'bookmarks/details/asset_icon.html' %}
|
{% include 'bookmarks/details/asset_icon.html' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="asset-text {{ asset.text_classes }}">
|
<div class="list-item-text {{ asset.text_classes }}">
|
||||||
<span class="truncate">
|
<span class="truncate">
|
||||||
{{ asset.display_name }}
|
{{ asset.display_name }}
|
||||||
{% if asset.status == 'pending' %}(queued){% endif %}
|
{% if asset.status == 'pending' %}(queued){% endif %}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
|
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="asset-actions">
|
<div class="list-item-actions">
|
||||||
{% if asset.file %}
|
{% if asset.file %}
|
||||||
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
|
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<div class="modal-overlay"></div>
|
<div class="modal-overlay"></div>
|
||||||
<div class="modal-container" role="dialog" aria-modal="true">
|
<div class="modal-container" role="dialog" aria-modal="true">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>{{ details.bookmark.resolved_title }}</h2>
|
<h2 class="title">{{ details.bookmark.resolved_title }}</h2>
|
||||||
<button class="close" aria-label="Close dialog">
|
<button class="btn btn-noborder close" aria-label="Close dialog">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<input type="hidden" name="disable_turbo" value="true">
|
<input type="hidden" name="disable_turbo" value="true">
|
||||||
<button ld-confirm-button class="btn btn-error btn-wide"
|
<button ld-confirm-button class="btn btn-error btn-wide"
|
||||||
type="submit" name="remove" value="{{ details.bookmark.id }}">
|
type="submit" name="remove" value="{{ details.bookmark.id }}">
|
||||||
Delete...
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<h1 id="main-heading">Edit bookmark</h1>
|
<h1 id="main-heading">Edit bookmark</h1>
|
||||||
</div>
|
</div>
|
||||||
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||||
novalidate>
|
novalidate ld-form-submit>
|
||||||
{% include 'bookmarks/form.html' %}
|
{% include 'bookmarks/form.html' %}
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% load widget_tweaks %}
|
{% load widget_tweaks %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load shared %}
|
||||||
|
|
||||||
<div class="bookmarks-form">
|
<div class="bookmarks-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -7,7 +8,7 @@
|
|||||||
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
|
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
|
||||||
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
|
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
|
||||||
<div class="has-icon-right">
|
<div class="has-icon-right">
|
||||||
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
|
{{ form.url|form_field:"validation"|add_class:"form-input"|attr:"autofocus" }}
|
||||||
<i class="form-icon loading"></i>
|
<i class="form-icon loading"></i>
|
||||||
</div>
|
</div>
|
||||||
{% if form.url.errors %}
|
{% if form.url.errors %}
|
||||||
@@ -22,8 +23,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group" ld-tag-autocomplete>
|
<div class="form-group" ld-tag-autocomplete>
|
||||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
<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" }}
|
{{ form.tag_string|form_field:"help"|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||||
<div class="form-input-hint">
|
<div id="{{ form.tag_string.auto_id }}_help" class="form-input-hint">
|
||||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#).
|
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.
|
If a tag does not exist it will be automatically created.
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +36,8 @@
|
|||||||
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
|
<button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
|
||||||
<button ld-clear-button data-for="{{ form.title.id_for_label }}" class="ml-2 btn btn-link suffix-button clear-button"
|
<button ld-clear-button data-for="{{ form.title.id_for_label }}"
|
||||||
|
class="ml-2 btn btn-link suffix-button clear-button"
|
||||||
type="button">Clear
|
type="button">Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,31 +62,31 @@
|
|||||||
<span class="form-label d-inline-block">Notes</span>
|
<span class="form-label d-inline-block">Notes</span>
|
||||||
</summary>
|
</summary>
|
||||||
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
|
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
|
||||||
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
|
{{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }}
|
||||||
<div class="form-input-hint">
|
<div id="{{ form.notes.auto_id }}_help" class="form-input-hint">
|
||||||
Additional notes, supports Markdown.
|
Additional notes, supports Markdown.
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{{ form.notes.errors }}
|
{{ form.notes.errors }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
|
<div class="form-checkbox">
|
||||||
{{ form.unread }}
|
{{ form.unread|form_field:"help" }}
|
||||||
<i class="form-icon"></i>
|
<i class="form-icon"></i>
|
||||||
<span>Mark as unread</span>
|
<label for="{{ form.unread.id_for_label }}">Mark as unread</label>
|
||||||
</label>
|
</div>
|
||||||
<div class="form-input-hint">
|
<div id="{{ form.unread.auto_id }}_help" class="form-input-hint">
|
||||||
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
|
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if request.user_profile.enable_sharing %}
|
{% if request.user_profile.enable_sharing %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
|
<div class="form-checkbox">
|
||||||
{{ form.shared }}
|
{{ form.shared|form_field:"help" }}
|
||||||
<i class="form-icon"></i>
|
<i class="form-icon"></i>
|
||||||
<span>Share</span>
|
<label for="{{ form.shared.id_for_label }}">Share</label>
|
||||||
</label>
|
</div>
|
||||||
<div class="form-input-hint">
|
<div id="{{ form.shared.auto_id }}_help" class="form-input-hint">
|
||||||
{% if request.user_profile.enable_public_sharing %}
|
{% if request.user_profile.enable_public_sharing %}
|
||||||
Share this bookmark with other registered users and anonymous users.
|
Share this bookmark with other registered users and anonymous users.
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
|
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ return_url }}" class="btn">Nevermind</a>
|
<a href="{{ return_url }}" class="btn">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
/**
|
/**
|
||||||
@@ -227,6 +229,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshButton.addEventListener('click', refreshMetadata);
|
refreshButton.addEventListener('click', refreshMetadata);
|
||||||
|
|
||||||
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
|
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
|
||||||
|
|||||||
@@ -32,16 +32,10 @@
|
|||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{# Tag cloud #}
|
{# Filters #}
|
||||||
<div class="side-panel col-1">
|
<div class="side-panel col-1 hide-md">
|
||||||
<section aria-labelledby="tags-heading">
|
{% include 'bookmarks/bundle_section.html' %}
|
||||||
<div class="section-header">
|
{% include 'bookmarks/tag_section.html' %}
|
||||||
<h2 id="tags-heading">Tags</h2>
|
|
||||||
</div>
|
|
||||||
<div id="tag-cloud-container">
|
|
||||||
{% include 'bookmarks/tag_cloud.html' %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -18,18 +18,6 @@
|
|||||||
<path d="M21 6l0 13"></path>
|
<path d="M21 6l0 13"></path>
|
||||||
</symbol>
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="ld-icon-read" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
||||||
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 5.899 -1.096"></path>
|
|
||||||
<path d="M3 6a9 9 0 0 1 2.114 -.884m3.8 -.21c1.07 .17 2.116 .534 3.086 1.094a9 9 0 0 1 9 0"></path>
|
|
||||||
<path d="M3 6v13"></path>
|
|
||||||
<path d="M12 6v2m0 4v7"></path>
|
|
||||||
<path d="M21 6v11"></path>
|
|
||||||
<path d="M3 3l18 18"></path>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -41,18 +29,6 @@
|
|||||||
<path d="M8.7 13.3l6.6 3.4"></path>
|
<path d="M8.7 13.3l6.6 3.4"></path>
|
||||||
</symbol>
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="ld-icon-unshare" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
||||||
<path d="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
|
||||||
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
|
||||||
<path d="M15.861 15.896a3 3 0 0 0 4.265 4.22m.578 -3.417a3.012 3.012 0 0 0 -1.507 -1.45"></path>
|
|
||||||
<path d="M8.7 10.7l1.336 -.688m2.624 -1.352l2.64 -1.36"></path>
|
|
||||||
<path d="M8.7 13.3l6.6 3.4"></path>
|
|
||||||
<path d="M3 3l18 18"></path>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -67,7 +43,7 @@
|
|||||||
|
|
||||||
<header class="container">
|
<header class="container">
|
||||||
{% if has_toasts %}
|
{% if has_toasts %}
|
||||||
<div class="toasts">
|
<div class="message-list">
|
||||||
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% for toast in toast_messages %}
|
{% for toast in toast_messages %}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h1 id="main-heading">New bookmark</h1>
|
<h1 id="main-heading">New bookmark</h1>
|
||||||
</div>
|
</div>
|
||||||
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate>
|
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate ld-form-submit>
|
||||||
{% include 'bookmarks/form.html' %}
|
{% include 'bookmarks/form.html' %}
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
{% if prev_link %}
|
{% if prev_link %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="?{{ prev_link }}" tabindex="-1">Previous</a>
|
<a href="{{ prev_link }}" tabindex="-1">Previous</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled">
|
<li class="page-item disabled">
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
{% for page_link in page_links %}
|
{% for page_link in page_links %}
|
||||||
{% if page_link %}
|
{% if page_link %}
|
||||||
<li class="page-item {% if page_link.active %}active{% endif %}">
|
<li class="page-item {% if page_link.active %}active{% endif %}">
|
||||||
<a href="?{{ page_link.link }}">{{ page_link.number }}</a>
|
<a href="{{ page_link.link }}">{{ page_link.number }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
{% if next_link %}
|
{% if next_link %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="?{{ next_link }}" tabindex="-1">Next</a>
|
<a href="{{ next_link }}" tabindex="-1">Next</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled">
|
<li class="page-item disabled">
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if request.user_profile.custom_css %}
|
||||||
|
<link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
|
||||||
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<template id="content">{{ content|safe }}</template>
|
<template id="content">{{ content|safe }}</template>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{# Filters #}
|
{# Filters #}
|
||||||
<div class="side-panel col-1">
|
<div class="side-panel col-1 hide-md">
|
||||||
<section aria-labelledby="user-heading">
|
<section aria-labelledby="user-heading">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2 id="user-heading">User</h2>
|
<h2 id="user-heading">User</h2>
|
||||||
@@ -38,14 +38,7 @@
|
|||||||
<br>
|
<br>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section aria-labelledby="tags-heading">
|
{% include 'bookmarks/tag_section.html' %}
|
||||||
<div class="section-header">
|
|
||||||
<h2 id="tags-heading">Tags</h2>
|
|
||||||
</div>
|
|
||||||
<div id="tag-cloud-container">
|
|
||||||
{% include 'bookmarks/tag_cloud.html' %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
26
bookmarks/templates/bookmarks/tag_section.html
Normal file
26
bookmarks/templates/bookmarks/tag_section.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<section aria-labelledby="tags-heading">
|
||||||
|
<div class="section-header no-wrap">
|
||||||
|
<h2 id="tags-heading">Tags</h2>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||||
|
<button class="btn btn-noborder dropdown-toggle" aria-label="Tags menu">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
|
<path d="M4 6l16 0"/>
|
||||||
|
<path d="M4 12l16 0"/>
|
||||||
|
<path d="M4 18l16 0"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul class="menu" role="list" tabindex="-1">
|
||||||
|
<li class="menu-item">
|
||||||
|
<a href="{% url 'linkding:tags.index' %}" class="menu-link">Manage tags</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div id="tag-cloud-container">
|
||||||
|
{% include 'bookmarks/tag_cloud.html' %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
33
bookmarks/templates/bundles/edit.html
Normal file
33
bookmarks/templates/bundles/edit.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends 'bookmarks/layout.html' %}
|
||||||
|
{% load widget_tweaks %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% with page_title="Edit bundle - Linkding" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="bundles-editor-page grid columns-md-1">
|
||||||
|
<main aria-labelledby="main-heading">
|
||||||
|
<div class="section-header">
|
||||||
|
<h1 id="main-heading">Edit bundle</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'shared/messages.html' %}
|
||||||
|
|
||||||
|
<form id="bundle-form" action="{% url 'linkding:bundles.edit' bundle.id %}" method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include 'bundles/form.html' %}
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="col-2" aria-labelledby="preview-heading">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 id="preview-heading">Preview</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'bundles/preview.html' %}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
91
bookmarks/templates/bundles/form.html
Normal file
91
bookmarks/templates/bundles/form.html
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{% load widget_tweaks %}
|
||||||
|
|
||||||
|
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
|
||||||
|
<label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
|
||||||
|
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||||
|
{% if form.name.errors %}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
{{ form.name.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group {% if form.search.errors %}has-error{% endif %}">
|
||||||
|
<label for="{{ form.search.id_for_label }}" class="form-label">Search</label>
|
||||||
|
{{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||||
|
{% if form.search.errors %}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
{{ form.search.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Search terms to match bookmarks in this bundle.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" ld-tag-autocomplete>
|
||||||
|
<label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label>
|
||||||
|
{{ form.any_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
At least one of these tags must be present in a bookmark to match.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" ld-tag-autocomplete>
|
||||||
|
<label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label>
|
||||||
|
{{ form.all_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
All of these tags must be present in a bookmark to match.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" ld-tag-autocomplete>
|
||||||
|
<label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label>
|
||||||
|
{{ form.excluded_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
None of these tags must be present in a bookmark to match.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-footer d-flex mt-4">
|
||||||
|
<input type="submit" name="save" value="Save" class="btn btn-primary btn-wide">
|
||||||
|
<a href="{% url 'linkding:bundles.index' %}" class="btn btn-wide ml-auto">Cancel</a>
|
||||||
|
<a href="{% url 'linkding:bundles.preview' %}" data-turbo-frame="preview" class="d-none" id="preview-link"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function init() {
|
||||||
|
const bundleForm = document.getElementById('bundle-form');
|
||||||
|
const previewLink = document.getElementById('preview-link');
|
||||||
|
|
||||||
|
let pendingUpdate;
|
||||||
|
|
||||||
|
function scheduleUpdate() {
|
||||||
|
if (pendingUpdate) {
|
||||||
|
clearTimeout(pendingUpdate);
|
||||||
|
}
|
||||||
|
pendingUpdate = setTimeout(() => {
|
||||||
|
// Ignore if link has been removed (e.g. form submit or navigation)
|
||||||
|
if (!previewLink.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = previewLink.href.split('?')[0];
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const inputs = bundleForm.querySelectorAll('input[type="text"]:not([name="csrfmiddlewaretoken"]), textarea, select');
|
||||||
|
|
||||||
|
inputs.forEach(input => {
|
||||||
|
if (input.name && input.value.trim()) {
|
||||||
|
params.set(input.name, input.value.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
previewLink.href = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
|
||||||
|
previewLink.click();
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleForm.addEventListener('input', scheduleUpdate);
|
||||||
|
bundleForm.addEventListener('change', scheduleUpdate);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
134
bookmarks/templates/bundles/index.html
Normal file
134
bookmarks/templates/bundles/index.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% with page_title="Bundles - Linkding" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="bundles-page crud-page" aria-labelledby="main-heading">
|
||||||
|
<div class="crud-header">
|
||||||
|
<h1 id="main-heading">Bundles</h1>
|
||||||
|
<a href="{% url 'linkding:bundles.new' %}" class="btn">Add bundle</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'shared/messages.html' %}
|
||||||
|
|
||||||
|
{% if bundles %}
|
||||||
|
<form action="{% url 'linkding:bundles.action' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<table class="table crud-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th class="actions">
|
||||||
|
<span class="text-assistive">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for bundle in bundles %}
|
||||||
|
<tr data-bundle-id="{{ bundle.id }}" draggable="true">
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
|
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||||
|
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||||
|
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||||
|
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||||
|
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||||
|
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ bundle.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
|
||||||
|
<button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}"
|
||||||
|
class="btn btn-link">Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<input type="submit" name="move_bundle" value="" class="d-none">
|
||||||
|
<input type="hidden" name="move_position" value="">
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">
|
||||||
|
<p class="empty-title h5">You have no bundles yet</p>
|
||||||
|
<p class="empty-subtitle">Create your first bundle to get started</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function init() {
|
||||||
|
const tableBody = document.querySelector(".crud-table tbody");
|
||||||
|
if (!tableBody) return;
|
||||||
|
|
||||||
|
let draggedElement = null;
|
||||||
|
|
||||||
|
const rows = tableBody.querySelectorAll('tr');
|
||||||
|
rows.forEach((item) => {
|
||||||
|
item.addEventListener('dragstart', handleDragStart);
|
||||||
|
item.addEventListener('dragend', handleDragEnd);
|
||||||
|
item.addEventListener('dragover', handleDragOver);
|
||||||
|
item.addEventListener('dragenter', handleDragEnter);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleDragStart(e) {
|
||||||
|
draggedElement = this;
|
||||||
|
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
this.classList.add('drag-start');
|
||||||
|
setTimeout(() => {
|
||||||
|
this.classList.remove('drag-start');
|
||||||
|
this.classList.add('dragging');
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
this.classList.remove('dragging');
|
||||||
|
|
||||||
|
const moveBundleInput = document.querySelector('input[name="move_bundle"]');
|
||||||
|
const movePositionInput = document.querySelector('input[name="move_position"]');
|
||||||
|
moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
|
||||||
|
movePositionInput.value = Array.from(tableBody.children).indexOf(draggedElement);
|
||||||
|
|
||||||
|
const form = this.closest('form');
|
||||||
|
form.requestSubmit(moveBundleInput);
|
||||||
|
|
||||||
|
draggedElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
if (e.preventDefault) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnter() {
|
||||||
|
if (this !== draggedElement) {
|
||||||
|
const listItems = Array.from(tableBody.children);
|
||||||
|
const draggedIndex = listItems.indexOf(draggedElement);
|
||||||
|
const currentIndex = listItems.indexOf(this);
|
||||||
|
|
||||||
|
if (draggedIndex < currentIndex) {
|
||||||
|
this.insertAdjacentElement('afterend', draggedElement);
|
||||||
|
} else {
|
||||||
|
this.insertAdjacentElement('beforebegin', draggedElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
33
bookmarks/templates/bundles/new.html
Normal file
33
bookmarks/templates/bundles/new.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends 'bookmarks/layout.html' %}
|
||||||
|
{% load widget_tweaks %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% with page_title="New bundle - Linkding" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="bundles-editor-page grid columns-md-1">
|
||||||
|
<main aria-labelledby="main-heading">
|
||||||
|
<div class="section-header">
|
||||||
|
<h1 id="main-heading">New bundle</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'shared/messages.html' %}
|
||||||
|
|
||||||
|
<form id="bundle-form" action="{% url 'linkding:bundles.new' %}" method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include 'bundles/form.html' %}
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="col-2" aria-labelledby="preview-heading">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 id="preview-heading">Preview</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'bundles/preview.html' %}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
12
bookmarks/templates/bundles/preview.html
Normal file
12
bookmarks/templates/bundles/preview.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<turbo-frame id="preview">
|
||||||
|
{% if bookmark_list.is_empty %}
|
||||||
|
<div>
|
||||||
|
No bookmarks match the current bundle.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mb-4">
|
||||||
|
Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.
|
||||||
|
</div>
|
||||||
|
{% include 'bookmarks/bookmark_list.html' %}
|
||||||
|
{% endif %}
|
||||||
|
</turbo-frame>
|
||||||
@@ -139,6 +139,15 @@
|
|||||||
Instead, the tags are shown in an expandable drawer.
|
Instead, the tags are shown in an expandable drawer.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.hide_bundles.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ form.hide_bundles }}
|
||||||
|
<i class="form-icon"></i> Hide bundles
|
||||||
|
</label>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Allows to hide the bundles in the side panel if you don't intend to use them.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
||||||
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
|
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
|
||||||
@@ -261,6 +270,17 @@ reddit.com/r/Music music reddit</pre>
|
|||||||
This can be overridden when creating each new bookmark.
|
This can be overridden when creating each new bookmark.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.default_mark_shared.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ form.default_mark_shared }}
|
||||||
|
<i class="form-icon"></i> Create bookmarks as shared by default
|
||||||
|
</label>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Sets the default state for the "Share" option when creating a new bookmark.
|
||||||
|
Setting this option will make all new bookmarks default to shared.
|
||||||
|
This can be overridden when creating each new bookmark.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<details {% if form.custom_css.value %}open{% endif %}>
|
<details {% if form.custom_css.value %}open{% endif %}>
|
||||||
<summary>
|
<summary>
|
||||||
@@ -374,17 +394,17 @@ reddit.com/r/Music music reddit</pre>
|
|||||||
<td>{{ version_info }}</td>
|
<td>{{ version_info }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="3" style="vertical-align: top">Links</td>
|
<td style="vertical-align: top">Links</td>
|
||||||
<td><a href="https://github.com/sissbruecker/linkding/"
|
<td>
|
||||||
target="_blank">GitHub</a></td>
|
<div class="d-flex flex-column gap-2">
|
||||||
</tr>
|
<a href="https://github.com/sissbruecker/linkding/"
|
||||||
<tr>
|
target="_blank">GitHub</a>
|
||||||
<td><a href="https://linkding.link/"
|
<a href="https://linkding.link/"
|
||||||
target="_blank">Documentation</a></td>
|
target="_blank">Documentation</a>
|
||||||
</tr>
|
<a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
|
||||||
<tr>
|
target="_blank">Changelog</a>
|
||||||
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
|
</div>
|
||||||
target="_blank">Changelog</a></td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -395,21 +415,25 @@ reddit.com/r/Music music reddit</pre>
|
|||||||
(function init() {
|
(function init() {
|
||||||
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
|
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
|
||||||
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
|
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
|
||||||
|
const defaultMarkShared = document.getElementById("{{ form.default_mark_shared.id_for_label }}");
|
||||||
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
|
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
|
||||||
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
|
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
|
||||||
|
|
||||||
// Automatically disable public bookmark sharing if bookmark sharing is disabled
|
// Automatically disable public bookmark sharing and default shared option if bookmark sharing is disabled
|
||||||
function updatePublicSharing() {
|
function updateSharingOptions() {
|
||||||
if (enableSharing.checked) {
|
if (enableSharing.checked) {
|
||||||
enablePublicSharing.disabled = false;
|
enablePublicSharing.disabled = false;
|
||||||
|
defaultMarkShared.disabled = false;
|
||||||
} else {
|
} else {
|
||||||
enablePublicSharing.disabled = true;
|
enablePublicSharing.disabled = true;
|
||||||
enablePublicSharing.checked = false;
|
enablePublicSharing.checked = false;
|
||||||
|
defaultMarkShared.disabled = true;
|
||||||
|
defaultMarkShared.checked = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePublicSharing();
|
updateSharingOptions();
|
||||||
enableSharing.addEventListener("change", updatePublicSharing);
|
enableSharing.addEventListener("change", updateSharingOptions);
|
||||||
|
|
||||||
// Automatically hide the bookmark description max lines input if the description display is set to inline
|
// Automatically hide the bookmark description max lines input if the description display is set to inline
|
||||||
function updateBookmarkDescriptionMaxLines() {
|
function updateBookmarkDescriptionMaxLines() {
|
||||||
|
|||||||
@@ -25,15 +25,33 @@
|
|||||||
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
|
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
|
||||||
application first. Here's how it works:</p>
|
application first. Here's how it works:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
<li>Choose your preferred method for detecting website titles and descriptions below (<a href="https://linkding.link/troubleshooting/#automatically-detected-title-and-description-are-incorrect" target="_blank">Help</a>)</li>
|
||||||
|
<li>Drag the bookmarklet below into your browser's bookmark bar / toolbar</li>
|
||||||
<li>Open the website that you want to bookmark</li>
|
<li>Open the website that you want to bookmark</li>
|
||||||
<li>Click the bookmarklet in your browsers toolbar</li>
|
<li>Click the bookmarklet in your browser's toolbar</li>
|
||||||
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
||||||
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
<li>After saving the bookmark, the linkding window closes, and you are back on your website</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Drag the following bookmarklet to your browser's toolbar:</p>
|
|
||||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
|
<div class="form-group radio-group" role="radiogroup" aria-labelledby="detection-method-label">
|
||||||
class="btn btn-primary">📎 Add bookmark</a>
|
<p id="detection-method-label">Choose your preferred bookmarklet:</p>
|
||||||
|
<label for="detection-method-server" class="form-radio">
|
||||||
|
<input id="detection-method-server" type="radio" name="bookmarklet-type" value="server" checked>
|
||||||
|
<i class="form-icon"></i>
|
||||||
|
Detect title and description on the server
|
||||||
|
</label>
|
||||||
|
<label for="detection-method-client" class="form-radio">
|
||||||
|
<input id="detection-method-client" type="radio" name="bookmarklet-type" value="client">
|
||||||
|
<i class="form-icon"></i>
|
||||||
|
Detect title and description in the browser
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="bookmarklet-container">
|
||||||
|
<a id="bookmarklet-server" href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
|
||||||
|
class="btn btn-primary">📎 Add bookmark</a>
|
||||||
|
<a id="bookmarklet-client" href="javascript: {% include 'bookmarks/bookmarklet_clientside.js' %}" data-turbo="false"
|
||||||
|
class="btn btn-primary" style="display: none;">📎 Add bookmark</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="rest-api-heading">
|
<section aria-labelledby="rest-api-heading">
|
||||||
@@ -90,4 +108,28 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function init() {
|
||||||
|
const radioButtons = document.querySelectorAll('input[name="bookmarklet-type"]');
|
||||||
|
const serverBookmarklet = document.getElementById('bookmarklet-server');
|
||||||
|
const clientBookmarklet = document.getElementById('bookmarklet-client');
|
||||||
|
|
||||||
|
function toggleBookmarklet() {
|
||||||
|
const selectedValue = document.querySelector('input[name="bookmarklet-type"]:checked').value;
|
||||||
|
if (selectedValue === 'server') {
|
||||||
|
serverBookmarklet.style.display = 'inline-block';
|
||||||
|
clientBookmarklet.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
serverBookmarklet.style.display = 'none';
|
||||||
|
clientBookmarklet.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleBookmarklet();
|
||||||
|
radioButtons.forEach(function(radio) {
|
||||||
|
radio.addEventListener('change', toggleBookmarklet);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
6
bookmarks/templates/shared/error_list.html
Normal file
6
bookmarks/templates/shared/error_list.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{# Force rendering validation errors in English language to align with the rest of the app #}
|
||||||
|
{% language 'en-us' %}
|
||||||
|
{% if errors %}<ul class="{{ error_class }}"{% if errors.field_id %} id="{{ errors.field_id }}_error"{% endif %}>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
|
||||||
|
{% endlanguage %}
|
||||||
9
bookmarks/templates/shared/messages.html
Normal file
9
bookmarks/templates/shared/messages.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{% if messages %}
|
||||||
|
<div class="message-list">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="toast toast-{{ message.tags }}" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
23
bookmarks/templates/tags/edit.html
Normal file
23
bookmarks/templates/tags/edit.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
{% load shared %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% with page_title="Edit tag - Linkding" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tags-editor-page">
|
||||||
|
<main aria-labelledby="main-heading">
|
||||||
|
<div class="section-header">
|
||||||
|
<h1 id="main-heading">Edit tag</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include 'tags/form.html' %}
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
19
bookmarks/templates/tags/form.html
Normal file
19
bookmarks/templates/tags/form.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% load widget_tweaks %}
|
||||||
|
|
||||||
|
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
|
||||||
|
<label class="form-label" for="{{ form.name.id_for_label }}">Name</label>
|
||||||
|
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||||
|
<div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).</div>
|
||||||
|
{% if form.name.errors %}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
{{ form.name.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="form-group d-flex justify-between">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
|
||||||
|
</div>
|
||||||
125
bookmarks/templates/tags/index.html
Normal file
125
bookmarks/templates/tags/index.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
{% load shared %}
|
||||||
|
{% load pagination %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% with page_title="Tags - Linkding" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tags-page crud-page">
|
||||||
|
<main aria-labelledby="main-heading">
|
||||||
|
<div class="crud-header">
|
||||||
|
<h1 id="main-heading">Tags</h1>
|
||||||
|
<div class="d-flex gap-2 ml-auto">
|
||||||
|
<a href="{% url 'linkding:tags.new' %}" class="btn">Add Tag</a>
|
||||||
|
<a href="{% url 'linkding:tags.merge' %}" class="btn">Merge Tags</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'shared/messages.html' %}
|
||||||
|
|
||||||
|
{# Filters #}
|
||||||
|
<div class="crud-filters">
|
||||||
|
<form method="get" class="mb-2" ld-form-reset>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label text-assistive" for="search">Search tags</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Search tags..."
|
||||||
|
class="form-input">
|
||||||
|
<button type="submit" class="btn input-group-btn">Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label text-assistive" for="sort">Sort by</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-addon text-secondary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path
|
||||||
|
stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 9l4 -4l4 4m-4 -4v14"/><path
|
||||||
|
d="M21 15l-4 4l-4 -4m4 4v-14"/></svg>
|
||||||
|
</span>
|
||||||
|
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||||
|
<option value="name-asc" {% if sort == "name-asc" %}selected{% endif %}>Name A-Z</option>
|
||||||
|
<option value="name-desc" {% if sort == "name-desc" %}selected{% endif %}>Name Z-A</option>
|
||||||
|
<option value="count-asc" {% if sort == "count-asc" %}selected{% endif %}>Fewest bookmarks</option>
|
||||||
|
<option value="count-desc" {% if sort == "count-desc" %}selected{% endif %}>Most bookmarks</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-checkbox">
|
||||||
|
<input type="checkbox" name="unused" value="true" {% if unused_only %}checked{% endif %} ld-auto-submit>
|
||||||
|
<i class="form-icon"></i> Show only unused tags
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Tags count #}
|
||||||
|
<p class="text-secondary text-small m-0">
|
||||||
|
{% if search or unused_only %}
|
||||||
|
Showing {{ page.paginator.count }} of {{ total_tags }} tags
|
||||||
|
{% else %}
|
||||||
|
{{ total_tags }} tags total
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Tags List #}
|
||||||
|
{% if page.object_list %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<table class="table crud-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th style="width: 25%">Bookmarks</th>
|
||||||
|
<th class="actions">
|
||||||
|
<span class="text-assistive">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tag in page.object_list %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ tag.name }}
|
||||||
|
</td>
|
||||||
|
<td style="width: 25%">
|
||||||
|
<a class="btn btn-link" href="{% url 'linkding:bookmarks.index' %}?q=%23{{ tag.name|urlencode }}">
|
||||||
|
{{ tag.bookmark_count }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a class="btn btn-link" href="{% url 'linkding:tags.edit' tag.id %}">Edit</a>
|
||||||
|
<button type="submit" name="delete_tag" value="{{ tag.id }}" class="btn btn-link text-error"
|
||||||
|
ld-confirm-button>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% pagination page %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">
|
||||||
|
{% if search or unused_only %}
|
||||||
|
<p class="empty-title h5">No tags found</p>
|
||||||
|
<p class="empty-subtitle">Try adjusting your search or filters</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-title h5">You have no tags yet</p>
|
||||||
|
<p class="empty-subtitle">Tags will appear here when you add bookmarks with tags</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
68
bookmarks/templates/tags/merge.html
Normal file
68
bookmarks/templates/tags/merge.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
{% load shared %}
|
||||||
|
{% load widget_tweaks %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% with page_title="Merge tags - Linkding" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tags-editor-page">
|
||||||
|
<main aria-labelledby="main-heading">
|
||||||
|
<div class="section-header">
|
||||||
|
<h1 id="main-heading">Merge tags</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mb-4">
|
||||||
|
<summary>
|
||||||
|
<span class="text-bold mb-1">How to merge tags</span>
|
||||||
|
</summary>
|
||||||
|
<ol>
|
||||||
|
<li>Enter the name of the tag you want to keep</li>
|
||||||
|
<li>Enter the names of tags to merge into the target tag</li>
|
||||||
|
<li>The target tag is added to all bookmarks that have any of the merge tags</li>
|
||||||
|
<li>The merged tags are deleted</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="form-group {% if form.target_tag.errors %}has-error{% endif %}" ld-tag-autocomplete>
|
||||||
|
<label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
|
||||||
|
{{ form.target_tag|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
|
||||||
|
</div>
|
||||||
|
{% if form.target_tag.errors %}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
{{ form.target_tag.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}" ld-tag-autocomplete>
|
||||||
|
<label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
|
||||||
|
{{ form.merge_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
|
||||||
|
<div class="form-input-hint">Enter the names of tags to merge into the target tag, separated by spaces. These
|
||||||
|
tags will be deleted after merging.
|
||||||
|
</div>
|
||||||
|
{% if form.merge_tags.errors %}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
{{ form.merge_tags.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="form-group d-flex justify-between">
|
||||||
|
<button type="submit" class="btn btn-primary">Merge Tags</button>
|
||||||
|
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
23
bookmarks/templates/tags/new.html
Normal file
23
bookmarks/templates/tags/new.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
{% load shared %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% with page_title="Add tag - Linkding" %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tags-editor-page">
|
||||||
|
<main aria-labelledby="main-heading">
|
||||||
|
<div class="section-header">
|
||||||
|
<h1 id="main-heading">New tag</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include 'tags/form.html' %}
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -13,18 +13,21 @@ register = template.Library()
|
|||||||
"bookmarks/pagination.html", name="pagination", takes_context=True
|
"bookmarks/pagination.html", name="pagination", takes_context=True
|
||||||
)
|
)
|
||||||
def pagination(context, page: Page):
|
def pagination(context, page: Page):
|
||||||
|
request = context["request"]
|
||||||
|
base_url = request.build_absolute_uri(request.path)
|
||||||
|
|
||||||
# remove page number and details from query parameters
|
# remove page number and details from query parameters
|
||||||
query_params = context["request"].GET.copy()
|
query_params = request.GET.copy()
|
||||||
query_params.pop("page", None)
|
query_params.pop("page", None)
|
||||||
query_params.pop("details", None)
|
query_params.pop("details", None)
|
||||||
|
|
||||||
prev_link = (
|
prev_link = (
|
||||||
_generate_link(query_params, page.previous_page_number())
|
_generate_link(base_url, query_params, page.previous_page_number())
|
||||||
if page.has_previous()
|
if page.has_previous()
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
next_link = (
|
next_link = (
|
||||||
_generate_link(query_params, page.next_page_number())
|
_generate_link(base_url, query_params, page.next_page_number())
|
||||||
if page.has_next()
|
if page.has_next()
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -37,7 +40,7 @@ def pagination(context, page: Page):
|
|||||||
if page_number == -1:
|
if page_number == -1:
|
||||||
page_links.append(None)
|
page_links.append(None)
|
||||||
else:
|
else:
|
||||||
link = _generate_link(query_params, page_number)
|
link = _generate_link(base_url, query_params, page_number)
|
||||||
page_links.append(
|
page_links.append(
|
||||||
{
|
{
|
||||||
"active": page_number == page.number,
|
"active": page_number == page.number,
|
||||||
@@ -92,6 +95,7 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
|||||||
return reduce(append_page, visible_pages, [])
|
return reduce(append_page, visible_pages, [])
|
||||||
|
|
||||||
|
|
||||||
def _generate_link(query_params: QueryDict, page_number: int) -> str:
|
def _generate_link(base_url: str, query_params: QueryDict, page_number: int) -> str:
|
||||||
|
query_params = query_params.copy()
|
||||||
query_params["page"] = page_number
|
query_params["page"] = page_number
|
||||||
return query_params.urlencode()
|
return f"{base_url}?{query_params.urlencode()}"
|
||||||
|
|||||||
@@ -145,3 +145,30 @@ def render_markdown(context, markdown_text):
|
|||||||
linkified_html = bleach.linkify(sanitized_html)
|
linkified_html = bleach.linkify(sanitized_html)
|
||||||
|
|
||||||
return mark_safe(linkified_html)
|
return mark_safe(linkified_html)
|
||||||
|
|
||||||
|
|
||||||
|
def append_attr(widget, attr, value):
|
||||||
|
attrs = widget.attrs
|
||||||
|
if attrs.get(attr):
|
||||||
|
attrs[attr] += " " + value
|
||||||
|
else:
|
||||||
|
attrs[attr] = value
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter("form_field")
|
||||||
|
def form_field(field, modifier_string):
|
||||||
|
modifiers = modifier_string.split(",")
|
||||||
|
has_errors = hasattr(field, "errors") and field.errors
|
||||||
|
|
||||||
|
if "validation" in modifiers and has_errors:
|
||||||
|
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_error")
|
||||||
|
if "help" in modifiers:
|
||||||
|
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_help")
|
||||||
|
|
||||||
|
# Some assistive technologies announce a field as invalid when it has the
|
||||||
|
# required attribute, even if the user has not interacted with the field
|
||||||
|
# yet. Set aria-invalid false to prevent this behavior.
|
||||||
|
if field.field.required and not has_errors:
|
||||||
|
append_attr(field.field.widget, "aria-invalid", "false")
|
||||||
|
|
||||||
|
return field
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from rest_framework import status
|
|||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, User
|
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Tag, User
|
||||||
|
|
||||||
|
|
||||||
class BookmarkFactoryMixin:
|
class BookmarkFactoryMixin:
|
||||||
@@ -166,6 +166,33 @@ class BookmarkFactoryMixin:
|
|||||||
def get_numbered_bookmark(self, title: str):
|
def get_numbered_bookmark(self, title: str):
|
||||||
return Bookmark.objects.get(title=title)
|
return Bookmark.objects.get(title=title)
|
||||||
|
|
||||||
|
def setup_bundle(
|
||||||
|
self,
|
||||||
|
user: User = None,
|
||||||
|
name: str = None,
|
||||||
|
search: str = "",
|
||||||
|
any_tags: str = "",
|
||||||
|
all_tags: str = "",
|
||||||
|
excluded_tags: str = "",
|
||||||
|
order: int = 0,
|
||||||
|
):
|
||||||
|
if user is None:
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
if not name:
|
||||||
|
name = get_random_string(length=32)
|
||||||
|
bundle = BookmarkBundle(
|
||||||
|
name=name,
|
||||||
|
owner=user,
|
||||||
|
date_created=timezone.now(),
|
||||||
|
search=search,
|
||||||
|
any_tags=any_tags,
|
||||||
|
all_tags=all_tags,
|
||||||
|
excluded_tags=excluded_tags,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
bundle.save()
|
||||||
|
return bundle
|
||||||
|
|
||||||
def setup_asset(
|
def setup_asset(
|
||||||
self,
|
self,
|
||||||
bookmark: Bookmark,
|
bookmark: Bookmark,
|
||||||
@@ -209,8 +236,17 @@ class BookmarkFactoryMixin:
|
|||||||
|
|
||||||
def read_asset_file(self, asset: BookmarkAsset):
|
def read_asset_file(self, asset: BookmarkAsset):
|
||||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||||
with open(filepath, "rb") as f:
|
|
||||||
return f.read()
|
if asset.gzip:
|
||||||
|
with gzip.open(filepath, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
else:
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def get_asset_filesize(self, asset: BookmarkAsset):
|
||||||
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||||
|
return os.path.getsize(filepath) if os.path.exists(filepath) else 0
|
||||||
|
|
||||||
def has_asset_file(self, asset: BookmarkAsset):
|
def has_asset_file(self, asset: BookmarkAsset):
|
||||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||||
@@ -239,7 +275,7 @@ class BookmarkFactoryMixin:
|
|||||||
user.profile.save()
|
user.profile.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
|
def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):
|
||||||
all_tags = []
|
all_tags = []
|
||||||
for bookmark in bookmarks:
|
for bookmark in bookmarks:
|
||||||
all_tags = all_tags + list(bookmark.tags.all())
|
all_tags = all_tags + list(bookmark.tags.all())
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertIsNone(asset.id)
|
self.assertIsNone(asset.id)
|
||||||
|
|
||||||
def test_create_snapshot(self):
|
def test_create_snapshot(self):
|
||||||
bookmark = self.setup_bookmark(url="https://example.com")
|
initial_modified = timezone.datetime(
|
||||||
|
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
url="https://example.com", modified=initial_modified
|
||||||
|
)
|
||||||
asset = assets.create_snapshot_asset(bookmark)
|
asset = assets.create_snapshot_asset(bookmark)
|
||||||
asset.save()
|
asset.save()
|
||||||
asset.date_created = timezone.datetime(
|
asset.date_created = timezone.datetime(
|
||||||
@@ -91,6 +96,9 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(asset.file, expected_filename)
|
self.assertEqual(asset.file, expected_filename)
|
||||||
self.assertTrue(asset.gzip)
|
self.assertTrue(asset.gzip)
|
||||||
|
|
||||||
|
# should update bookmark modified date
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
|
||||||
def test_create_snapshot_failure(self):
|
def test_create_snapshot_failure(self):
|
||||||
bookmark = self.setup_bookmark(url="https://example.com")
|
bookmark = self.setup_bookmark(url="https://example.com")
|
||||||
asset = assets.create_snapshot_asset(bookmark)
|
asset = assets.create_snapshot_asset(bookmark)
|
||||||
@@ -120,7 +128,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
|
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
|
||||||
|
|
||||||
def test_upload_snapshot(self):
|
def test_upload_snapshot(self):
|
||||||
bookmark = self.setup_bookmark(url="https://example.com")
|
initial_modified = timezone.datetime(
|
||||||
|
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
url="https://example.com", modified=initial_modified
|
||||||
|
)
|
||||||
asset = assets.upload_snapshot(bookmark, self.html_content.encode())
|
asset = assets.upload_snapshot(bookmark, self.html_content.encode())
|
||||||
|
|
||||||
# should create gzip file in asset folder
|
# should create gzip file in asset folder
|
||||||
@@ -145,6 +158,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(asset.file, saved_file_name)
|
self.assertEqual(asset.file, saved_file_name)
|
||||||
self.assertTrue(asset.gzip)
|
self.assertTrue(asset.gzip)
|
||||||
|
|
||||||
|
# should update bookmark modified date
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||||
|
|
||||||
def test_upload_snapshot_failure(self):
|
def test_upload_snapshot_failure(self):
|
||||||
bookmark = self.setup_bookmark(url="https://example.com")
|
bookmark = self.setup_bookmark(url="https://example.com")
|
||||||
|
|
||||||
@@ -173,7 +190,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
@disable_logging
|
@disable_logging
|
||||||
def test_upload_asset(self):
|
def test_upload_asset(self):
|
||||||
bookmark = self.setup_bookmark()
|
initial_modified = timezone.datetime(
|
||||||
|
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
bookmark = self.setup_bookmark(modified=initial_modified)
|
||||||
file_content = b"test content"
|
file_content = b"test content"
|
||||||
upload_file = SimpleUploadedFile(
|
upload_file = SimpleUploadedFile(
|
||||||
"test_file.txt", file_content, content_type="text/plain"
|
"test_file.txt", file_content, content_type="text/plain"
|
||||||
@@ -187,11 +207,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
# verify file name
|
# verify file name
|
||||||
self.assertTrue(saved_file_name.startswith("upload_"))
|
self.assertTrue(saved_file_name.startswith("upload_"))
|
||||||
self.assertTrue(saved_file_name.endswith("_test_file.txt"))
|
self.assertTrue(saved_file_name.endswith("_test_file.txt.gz"))
|
||||||
|
|
||||||
# file should contain the correct content
|
# file should contain the correct content
|
||||||
with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file:
|
self.assertEqual(self.read_asset_file(asset), file_content)
|
||||||
self.assertEqual(file.read(), file_content)
|
|
||||||
|
|
||||||
# should create asset
|
# should create asset
|
||||||
self.assertIsNotNone(asset.id)
|
self.assertIsNotNone(asset.id)
|
||||||
@@ -201,9 +220,52 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(asset.display_name, upload_file.name)
|
self.assertEqual(asset.display_name, upload_file.name)
|
||||||
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
|
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
|
||||||
self.assertEqual(asset.file, saved_file_name)
|
self.assertEqual(asset.file, saved_file_name)
|
||||||
|
self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
|
||||||
|
self.assertTrue(asset.gzip)
|
||||||
|
|
||||||
|
# should update bookmark modified date
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||||
|
|
||||||
|
@disable_logging
|
||||||
|
def test_upload_gzip_asset(self):
|
||||||
|
initial_modified = timezone.datetime(
|
||||||
|
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
bookmark = self.setup_bookmark(modified=initial_modified)
|
||||||
|
file_content = gzip.compress(b"<html>test content</html>")
|
||||||
|
upload_file = SimpleUploadedFile(
|
||||||
|
"test_file.html.gz", file_content, content_type="application/gzip"
|
||||||
|
)
|
||||||
|
|
||||||
|
asset = assets.upload_asset(bookmark, upload_file)
|
||||||
|
|
||||||
|
# should create file in asset folder
|
||||||
|
saved_file_name = self.get_saved_snapshot_file()
|
||||||
|
self.assertIsNotNone(upload_file)
|
||||||
|
|
||||||
|
# verify file name
|
||||||
|
self.assertTrue(saved_file_name.startswith("upload_"))
|
||||||
|
self.assertTrue(saved_file_name.endswith("_test_file.html.gz"))
|
||||||
|
|
||||||
|
# file should contain the correct content
|
||||||
|
self.assertEqual(self.read_asset_file(asset), file_content)
|
||||||
|
|
||||||
|
# should create asset
|
||||||
|
self.assertIsNotNone(asset.id)
|
||||||
|
self.assertEqual(asset.bookmark, bookmark)
|
||||||
|
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
|
||||||
|
self.assertEqual(asset.content_type, "application/gzip")
|
||||||
|
self.assertEqual(asset.display_name, upload_file.name)
|
||||||
|
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
|
||||||
|
self.assertEqual(asset.file, saved_file_name)
|
||||||
self.assertEqual(asset.file_size, len(file_content))
|
self.assertEqual(asset.file_size, len(file_content))
|
||||||
self.assertFalse(asset.gzip)
|
self.assertFalse(asset.gzip)
|
||||||
|
|
||||||
|
# should update bookmark modified date
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||||
|
|
||||||
@disable_logging
|
@disable_logging
|
||||||
def test_upload_asset_truncates_asset_file_name(self):
|
def test_upload_asset_truncates_asset_file_name(self):
|
||||||
# Create a bookmark with a very long URL
|
# Create a bookmark with a very long URL
|
||||||
@@ -221,7 +283,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
self.assertEqual(192, len(saved_file))
|
self.assertEqual(192, len(saved_file))
|
||||||
self.assertTrue(saved_file.startswith("upload_"))
|
self.assertTrue(saved_file.startswith("upload_"))
|
||||||
self.assertTrue(saved_file.endswith("aaaa.txt"))
|
self.assertTrue(saved_file.endswith("aaaa.txt.gz"))
|
||||||
|
|
||||||
@disable_logging
|
@disable_logging
|
||||||
def test_upload_asset_failure(self):
|
def test_upload_asset_failure(self):
|
||||||
@@ -409,3 +471,36 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
# Verify that latest_snapshot hasn't changed
|
# Verify that latest_snapshot hasn't changed
|
||||||
self.assertEqual(bookmark.latest_snapshot, latest_asset)
|
self.assertEqual(bookmark.latest_snapshot, latest_asset)
|
||||||
|
|
||||||
|
@disable_logging
|
||||||
|
def test_remove_asset(self):
|
||||||
|
initial_modified = timezone.datetime(
|
||||||
|
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
bookmark = self.setup_bookmark(modified=initial_modified)
|
||||||
|
file_content = b"test content for removal"
|
||||||
|
upload_file = SimpleUploadedFile(
|
||||||
|
"test_remove_file.txt", file_content, content_type="text/plain"
|
||||||
|
)
|
||||||
|
|
||||||
|
asset = assets.upload_asset(bookmark, upload_file)
|
||||||
|
asset_filepath = os.path.join(self.assets_dir, asset.file)
|
||||||
|
|
||||||
|
# Verify asset and file exist
|
||||||
|
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||||
|
self.assertTrue(os.path.exists(asset_filepath))
|
||||||
|
|
||||||
|
bookmark.date_modified = initial_modified
|
||||||
|
bookmark.save()
|
||||||
|
|
||||||
|
# Remove the asset
|
||||||
|
assets.remove_asset(asset)
|
||||||
|
|
||||||
|
# Verify asset is removed from DB
|
||||||
|
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||||
|
# Verify file is removed from disk
|
||||||
|
self.assertFalse(os.path.exists(asset_filepath))
|
||||||
|
|
||||||
|
# Verify bookmark modified date is updated
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||||
|
|||||||
@@ -844,6 +844,26 @@ class BookmarkActionViewTestCase(
|
|||||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||||
|
|
||||||
|
def test_index_action_bulk_select_across_respects_bundle(self):
|
||||||
|
self.setup_numbered_bookmarks(3, prefix="foo")
|
||||||
|
self.setup_numbered_bookmarks(3, prefix="bar")
|
||||||
|
|
||||||
|
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||||
|
|
||||||
|
bundle = self.setup_bundle(search="foo")
|
||||||
|
|
||||||
|
self.client.post(
|
||||||
|
reverse("linkding:bookmarks.index.action") + f"?bundle={bundle.id}",
|
||||||
|
{
|
||||||
|
"bulk_action": ["bulk_delete"],
|
||||||
|
"bulk_execute": [""],
|
||||||
|
"bulk_select_across": ["on"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||||
|
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||||
|
|
||||||
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
|
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
|
||||||
self.setup_bulk_edit_scope_test_data()
|
self.setup_bulk_edit_scope_test_data()
|
||||||
|
|
||||||
@@ -889,6 +909,26 @@ class BookmarkActionViewTestCase(
|
|||||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||||
|
|
||||||
|
def test_archived_action_bulk_select_across_respects_bundle(self):
|
||||||
|
self.setup_numbered_bookmarks(3, prefix="foo", archived=True)
|
||||||
|
self.setup_numbered_bookmarks(3, prefix="bar", archived=True)
|
||||||
|
|
||||||
|
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||||
|
|
||||||
|
bundle = self.setup_bundle(search="foo")
|
||||||
|
|
||||||
|
self.client.post(
|
||||||
|
reverse("linkding:bookmarks.archived.action") + f"?bundle={bundle.id}",
|
||||||
|
{
|
||||||
|
"bulk_action": ["bulk_delete"],
|
||||||
|
"bulk_execute": [""],
|
||||||
|
"bulk_select_across": ["on"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||||
|
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||||
|
|
||||||
def test_shared_action_bulk_select_across_not_supported(self):
|
def test_shared_action_bulk_select_across_not_supported(self):
|
||||||
self.setup_bulk_edit_scope_test_data()
|
self.setup_bulk_edit_scope_test_data()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import BookmarkSearch, UserProfile
|
from bookmarks.models import BookmarkSearch, UserProfile
|
||||||
@@ -9,7 +9,6 @@ from bookmarks.tests.helpers import (
|
|||||||
BookmarkFactoryMixin,
|
BookmarkFactoryMixin,
|
||||||
BookmarkListTestMixin,
|
BookmarkListTestMixin,
|
||||||
TagCloudTestMixin,
|
TagCloudTestMixin,
|
||||||
collapse_whitespace,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -60,7 +59,23 @@ class BookmarkArchivedViewTestCase(
|
|||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
|
response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
|
||||||
html = collapse_whitespace(response.content.decode())
|
|
||||||
|
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||||
|
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||||
|
|
||||||
|
def test_should_list_bookmarks_matching_bundle(self):
|
||||||
|
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||||
|
3, prefix="foo", archived=True
|
||||||
|
)
|
||||||
|
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||||
|
3, prefix="bar", archived=True
|
||||||
|
)
|
||||||
|
|
||||||
|
bundle = self.setup_bundle(search="foo")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
|
||||||
|
)
|
||||||
|
|
||||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||||
@@ -105,6 +120,26 @@ class BookmarkArchivedViewTestCase(
|
|||||||
self.assertVisibleTags(response, visible_tags)
|
self.assertVisibleTags(response, visible_tags)
|
||||||
self.assertInvisibleTags(response, invisible_tags)
|
self.assertInvisibleTags(response, invisible_tags)
|
||||||
|
|
||||||
|
def test_should_list_tags_for_bookmarks_matching_bundle(self):
|
||||||
|
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||||
|
3, with_tags=True, archived=True, prefix="foo", tag_prefix="foo"
|
||||||
|
)
|
||||||
|
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||||
|
3, with_tags=True, archived=True, prefix="bar", tag_prefix="bar"
|
||||||
|
)
|
||||||
|
|
||||||
|
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||||
|
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||||
|
|
||||||
|
bundle = self.setup_bundle(search="foo")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertVisibleTags(response, visible_tags)
|
||||||
|
self.assertInvisibleTags(response, invisible_tags)
|
||||||
|
|
||||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||||
user_profile = self.user.profile
|
user_profile = self.user.profile
|
||||||
user_profile.search_preferences = {
|
user_profile.search_preferences = {
|
||||||
@@ -284,6 +319,28 @@ class BookmarkArchivedViewTestCase(
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_allowed_bulk_actions_with_html_snapshot_enabled(self):
|
||||||
|
url = reverse("linkding:bookmarks.archived")
|
||||||
|
response = self.client.get(url)
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
f"""
|
||||||
|
<select name="bulk_action" class="form-select select-sm">
|
||||||
|
<option value="bulk_unarchive">Unarchive</option>
|
||||||
|
<option value="bulk_delete">Delete</option>
|
||||||
|
<option value="bulk_tag">Add tags</option>
|
||||||
|
<option value="bulk_untag">Remove tags</option>
|
||||||
|
<option value="bulk_read">Mark as read</option>
|
||||||
|
<option value="bulk_unread">Mark as unread</option>
|
||||||
|
<option value="bulk_refresh">Refresh from website</option>
|
||||||
|
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||||
|
</select>
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
def test_allowed_bulk_actions_with_sharing_enabled(self):
|
def test_allowed_bulk_actions_with_sharing_enabled(self):
|
||||||
user_profile = self.user.profile
|
user_profile = self.user.profile
|
||||||
user_profile.enable_sharing = True
|
user_profile.enable_sharing = True
|
||||||
@@ -310,6 +367,34 @@ class BookmarkArchivedViewTestCase(
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_allowed_bulk_actions_with_sharing_and_html_snapshot_enabled(self):
|
||||||
|
user_profile = self.user.profile
|
||||||
|
user_profile.enable_sharing = True
|
||||||
|
user_profile.save()
|
||||||
|
|
||||||
|
url = reverse("linkding:bookmarks.archived")
|
||||||
|
response = self.client.get(url)
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
f"""
|
||||||
|
<select name="bulk_action" class="form-select select-sm">
|
||||||
|
<option value="bulk_unarchive">Unarchive</option>
|
||||||
|
<option value="bulk_delete">Delete</option>
|
||||||
|
<option value="bulk_tag">Add tags</option>
|
||||||
|
<option value="bulk_untag">Remove tags</option>
|
||||||
|
<option value="bulk_read">Mark as read</option>
|
||||||
|
<option value="bulk_unread">Mark as unread</option>
|
||||||
|
<option value="bulk_share">Share</option>
|
||||||
|
<option value="bulk_unshare">Unshare</option>
|
||||||
|
<option value="bulk_refresh">Refresh from website</option>
|
||||||
|
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||||
|
</select>
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
def test_apply_search_preferences(self):
|
def test_apply_search_preferences(self):
|
||||||
# no params
|
# no params
|
||||||
response = self.client.post(reverse("linkding:bookmarks.archived"))
|
response = self.client.post(reverse("linkding:bookmarks.archived"))
|
||||||
@@ -515,3 +600,20 @@ class BookmarkArchivedViewTestCase(
|
|||||||
|
|
||||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||||
self.assertIsNone(feed)
|
self.assertIsNone(feed)
|
||||||
|
|
||||||
|
def test_hide_bundles_when_enabled_in_profile(self):
|
||||||
|
# visible by default
|
||||||
|
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
|
||||||
|
|
||||||
|
# hidden when disabled in profile
|
||||||
|
user_profile = self.get_or_create_test_user().profile
|
||||||
|
user_profile.hide_bundles = True
|
||||||
|
user_profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ from django.conf import settings
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.tests.helpers import (
|
from bookmarks.models import BookmarkAsset
|
||||||
BookmarkFactoryMixin,
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
@@ -23,7 +22,21 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
def setup_asset_with_file(self, bookmark):
|
def setup_asset_with_file(self, bookmark):
|
||||||
filename = f"temp_{bookmark.id}.html.gzip"
|
filename = f"temp_{bookmark.id}.html.gzip"
|
||||||
self.setup_asset_file(filename)
|
self.setup_asset_file(filename)
|
||||||
asset = self.setup_asset(bookmark=bookmark, file=filename)
|
asset = self.setup_asset(
|
||||||
|
bookmark=bookmark, file=filename, display_name=f"Snapshot {bookmark.id}"
|
||||||
|
)
|
||||||
|
return asset
|
||||||
|
|
||||||
|
def setup_asset_with_uploaded_file(self, bookmark):
|
||||||
|
filename = f"temp_{bookmark.id}.png.gzip"
|
||||||
|
self.setup_asset_file(filename)
|
||||||
|
asset = self.setup_asset(
|
||||||
|
bookmark=bookmark,
|
||||||
|
file=filename,
|
||||||
|
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||||
|
content_type="image/png",
|
||||||
|
display_name=f"Uploaded file {bookmark.id}.png",
|
||||||
|
)
|
||||||
return asset
|
return asset
|
||||||
|
|
||||||
def view_access_test(self, view_name: str):
|
def view_access_test(self, view_name: str):
|
||||||
@@ -127,3 +140,25 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
def test_reader_view_access_guest_user(self):
|
def test_reader_view_access_guest_user(self):
|
||||||
self.view_access_guest_user_test("linkding:assets.read")
|
self.view_access_guest_user_test("linkding:assets.read")
|
||||||
|
|
||||||
|
def test_snapshot_download_name(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
|
||||||
|
|
||||||
|
self.assertEqual(response["Content-Type"], asset.content_type)
|
||||||
|
self.assertEqual(
|
||||||
|
response["Content-Disposition"],
|
||||||
|
f'inline; filename="{asset.display_name}.html"',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_uploaded_file_download_name(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
asset = self.setup_asset_with_uploaded_file(bookmark)
|
||||||
|
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
|
||||||
|
|
||||||
|
self.assertEqual(response["Content-Type"], asset.content_type)
|
||||||
|
self.assertEqual(
|
||||||
|
response["Content-Disposition"],
|
||||||
|
f'inline; filename="{asset.display_name}"',
|
||||||
|
)
|
||||||
|
|||||||
@@ -253,8 +253,8 @@ class BookmarkAssetsApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(asset.display_name, file_name)
|
self.assertEqual(asset.display_name, file_name)
|
||||||
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
|
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
|
||||||
self.assertEqual(asset.content_type, "text/plain")
|
self.assertEqual(asset.content_type, "text/plain")
|
||||||
self.assertEqual(asset.file_size, len(file_content))
|
self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
|
||||||
self.assertFalse(asset.gzip)
|
self.assertTrue(asset.gzip)
|
||||||
|
|
||||||
content = self.read_asset_file(asset)
|
content = self.read_asset_file(asset)
|
||||||
self.assertEqual(content, file_content)
|
self.assertEqual(content, file_content)
|
||||||
|
|||||||
@@ -501,7 +501,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
modal = self.get_index_details_modal(bookmark)
|
modal = self.get_index_details_modal(bookmark)
|
||||||
delete_button = modal.find("button", {"type": "submit", "name": "remove"})
|
delete_button = modal.find("button", {"type": "submit", "name": "remove"})
|
||||||
self.assertIsNotNone(delete_button)
|
self.assertIsNotNone(delete_button)
|
||||||
self.assertEqual("Delete...", delete_button.text.strip())
|
self.assertEqual("Delete", delete_button.text.strip())
|
||||||
self.assertEqual(str(bookmark.id), delete_button["value"])
|
self.assertEqual(str(bookmark.id), delete_button["value"])
|
||||||
|
|
||||||
form = delete_button.find_parent("form")
|
form = delete_button.find_parent("form")
|
||||||
@@ -585,10 +585,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
asset_item = self.find_asset(asset_list, asset)
|
asset_item = self.find_asset(asset_list, asset)
|
||||||
self.assertIsNotNone(asset_item)
|
self.assertIsNotNone(asset_item)
|
||||||
|
|
||||||
asset_icon = asset_item.select_one(".asset-icon svg")
|
asset_icon = asset_item.select_one(".list-item-icon svg")
|
||||||
self.assertIsNotNone(asset_icon)
|
self.assertIsNotNone(asset_icon)
|
||||||
|
|
||||||
asset_text = asset_item.select_one(".asset-text span")
|
asset_text = asset_item.select_one(".list-item-text span")
|
||||||
self.assertIsNotNone(asset_text)
|
self.assertIsNotNone(asset_text)
|
||||||
self.assertIn(asset.display_name, asset_text.text)
|
self.assertIn(asset.display_name, asset_text.text)
|
||||||
|
|
||||||
@@ -687,11 +687,11 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, pending_asset)
|
asset_item = self.find_asset(soup, pending_asset)
|
||||||
asset_text = asset_item.select_one(".asset-text span")
|
asset_text = asset_item.select_one(".list-item-text span")
|
||||||
self.assertIn("(queued)", asset_text.text)
|
self.assertIn("(queued)", asset_text.text)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, failed_asset)
|
asset_item = self.find_asset(soup, failed_asset)
|
||||||
asset_text = asset_item.select_one(".asset-text span")
|
asset_text = asset_item.select_one(".list-item-text span")
|
||||||
self.assertIn("(failed)", asset_text.text)
|
self.assertIn("(failed)", asset_text.text)
|
||||||
|
|
||||||
def test_asset_file_size(self):
|
def test_asset_file_size(self):
|
||||||
@@ -703,15 +703,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
soup = self.get_index_details_modal(bookmark)
|
soup = self.get_index_details_modal(bookmark)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, asset1)
|
asset_item = self.find_asset(soup, asset1)
|
||||||
asset_text = asset_item.select_one(".asset-text")
|
asset_text = asset_item.select_one(".list-item-text")
|
||||||
self.assertEqual(asset_text.text.strip(), asset1.display_name)
|
self.assertEqual(asset_text.text.strip(), asset1.display_name)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, asset2)
|
asset_item = self.find_asset(soup, asset2)
|
||||||
asset_text = asset_item.select_one(".asset-text")
|
asset_text = asset_item.select_one(".list-item-text")
|
||||||
self.assertIn("53.4\xa0KB", asset_text.text)
|
self.assertIn("53.4\xa0KB", asset_text.text)
|
||||||
|
|
||||||
asset_item = self.find_asset(soup, asset3)
|
asset_item = self.find_asset(soup, asset3)
|
||||||
asset_text = asset_item.select_one(".asset-text")
|
asset_text = asset_item.select_one(".list-item-text")
|
||||||
self.assertIn("11.0\xa0MB", asset_text.text)
|
self.assertIn("11.0\xa0MB", asset_text.text)
|
||||||
|
|
||||||
def test_asset_actions_visibility(self):
|
def test_asset_actions_visibility(self):
|
||||||
|
|||||||
@@ -114,9 +114,8 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<input type="text" name="url" value="{bookmark.url}" placeholder=" "
|
<input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="{bookmark.url}">
|
||||||
autofocus class="form-input" required id="id_url">
|
""",
|
||||||
""",
|
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,7 +123,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<input type="text" name="tag_string" value="{tag_string}"
|
<input type="text" name="tag_string" value="{tag_string}"
|
||||||
autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string">
|
autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string" aria-describedby="id_tag_string_help">
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
@@ -148,7 +147,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">
|
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes" aria-describedby="id_notes_help">
|
||||||
{bookmark.notes}
|
{bookmark.notes}
|
||||||
</textarea>
|
</textarea>
|
||||||
""",
|
""",
|
||||||
@@ -189,6 +188,25 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
edited_bookmark.refresh_from_db()
|
edited_bookmark.refresh_from_db()
|
||||||
self.assertNotEqual(edited_bookmark.url, existing_bookmark.url)
|
self.assertNotEqual(edited_bookmark.url, existing_bookmark.url)
|
||||||
|
|
||||||
|
def test_should_prevent_duplicate_normalized_urls(self):
|
||||||
|
self.setup_bookmark(url="https://EXAMPLE.COM/path/?z=1&a=2")
|
||||||
|
|
||||||
|
edited_bookmark = self.setup_bookmark(url="http://different.com")
|
||||||
|
|
||||||
|
form_data = self.create_form_data({"url": "https://example.com/path?a=2&z=1"})
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("linkding:bookmarks.edit", args=[edited_bookmark.id]), form_data
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
self.assertInHTML(
|
||||||
|
"<li>A bookmark with this URL already exists.</li>",
|
||||||
|
response.content.decode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
edited_bookmark.refresh_from_db()
|
||||||
|
self.assertEqual(edited_bookmark.url, "http://different.com")
|
||||||
|
|
||||||
def test_should_redirect_to_return_url(self):
|
def test_should_redirect_to_return_url(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
form_data = self.create_form_data()
|
form_data = self.create_form_data()
|
||||||
@@ -259,12 +277,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<label for="id_shared" class="form-checkbox">
|
<div class="form-checkbox">
|
||||||
<input type="checkbox" name="shared" id="id_shared">
|
<input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
|
||||||
<i class="form-icon"></i>
|
<i class="form-icon"></i>
|
||||||
<span>Share</span>
|
<label for="id_shared">Share</label>
|
||||||
</label>
|
</div>
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
count=0,
|
count=0,
|
||||||
)
|
)
|
||||||
@@ -278,12 +296,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<label for="id_shared" class="form-checkbox">
|
<div class="form-checkbox">
|
||||||
<input type="checkbox" name="shared" id="id_shared">
|
<input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
|
||||||
<i class="form-icon"></i>
|
<i class="form-icon"></i>
|
||||||
<span>Share</span>
|
<label for="id_shared">Share</label>
|
||||||
</label>
|
</div>
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
count=1,
|
count=1,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import BookmarkSearch, UserProfile
|
from bookmarks.models import BookmarkSearch, UserProfile
|
||||||
@@ -34,6 +34,21 @@ class BookmarkIndexViewTestCase(
|
|||||||
self.assertIsNotNone(form)
|
self.assertIsNotNone(form)
|
||||||
self.assertEqual(form.attrs["action"], url)
|
self.assertEqual(form.attrs["action"], url)
|
||||||
|
|
||||||
|
def assertVisibleBundles(self, soup, bundles):
|
||||||
|
bundle_list = soup.select_one("ul.bundle-menu")
|
||||||
|
self.assertIsNotNone(bundle_list)
|
||||||
|
|
||||||
|
list_items = bundle_list.select("li.bundle-menu-item")
|
||||||
|
self.assertEqual(len(list_items), len(bundles))
|
||||||
|
|
||||||
|
for index, list_item in enumerate(list_items):
|
||||||
|
bundle = bundles[index]
|
||||||
|
link = list_item.select_one("a")
|
||||||
|
href = link.attrs["href"]
|
||||||
|
|
||||||
|
self.assertEqual(bundle.name, list_item.text.strip())
|
||||||
|
self.assertEqual(f"?bundle={bundle.id}", href)
|
||||||
|
|
||||||
def test_should_list_unarchived_and_user_owned_bookmarks(self):
|
def test_should_list_unarchived_and_user_owned_bookmarks(self):
|
||||||
other_user = User.objects.create_user(
|
other_user = User.objects.create_user(
|
||||||
"otheruser", "otheruser@example.com", "password123"
|
"otheruser", "otheruser@example.com", "password123"
|
||||||
@@ -58,6 +73,19 @@ class BookmarkIndexViewTestCase(
|
|||||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||||
|
|
||||||
|
def test_should_list_bookmarks_matching_bundle(self):
|
||||||
|
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
|
||||||
|
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
|
||||||
|
|
||||||
|
bundle = self.setup_bundle(search="foo")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||||
|
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||||
|
|
||||||
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
|
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
|
||||||
other_user = User.objects.create_user(
|
other_user = User.objects.create_user(
|
||||||
"otheruser", "otheruser@example.com", "password123"
|
"otheruser", "otheruser@example.com", "password123"
|
||||||
@@ -96,6 +124,26 @@ class BookmarkIndexViewTestCase(
|
|||||||
self.assertVisibleTags(response, visible_tags)
|
self.assertVisibleTags(response, visible_tags)
|
||||||
self.assertInvisibleTags(response, invisible_tags)
|
self.assertInvisibleTags(response, invisible_tags)
|
||||||
|
|
||||||
|
def test_should_list_tags_for_bookmarks_matching_bundle(self):
|
||||||
|
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||||
|
3, with_tags=True, prefix="foo", tag_prefix="foo"
|
||||||
|
)
|
||||||
|
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||||
|
3, with_tags=True, prefix="bar", tag_prefix="bar"
|
||||||
|
)
|
||||||
|
|
||||||
|
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||||
|
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||||
|
|
||||||
|
bundle = self.setup_bundle(search="foo")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertVisibleTags(response, visible_tags)
|
||||||
|
self.assertInvisibleTags(response, invisible_tags)
|
||||||
|
|
||||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||||
user_profile = self.user.profile
|
user_profile = self.user.profile
|
||||||
user_profile.search_preferences = {
|
user_profile.search_preferences = {
|
||||||
@@ -265,6 +313,28 @@ class BookmarkIndexViewTestCase(
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_allowed_bulk_actions_with_html_snapshot_enabled(self):
|
||||||
|
url = reverse("linkding:bookmarks.index")
|
||||||
|
response = self.client.get(url)
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
f"""
|
||||||
|
<select name="bulk_action" class="form-select select-sm">
|
||||||
|
<option value="bulk_archive">Archive</option>
|
||||||
|
<option value="bulk_delete">Delete</option>
|
||||||
|
<option value="bulk_tag">Add tags</option>
|
||||||
|
<option value="bulk_untag">Remove tags</option>
|
||||||
|
<option value="bulk_read">Mark as read</option>
|
||||||
|
<option value="bulk_unread">Mark as unread</option>
|
||||||
|
<option value="bulk_refresh">Refresh from website</option>
|
||||||
|
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||||
|
</select>
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
def test_allowed_bulk_actions_with_sharing_enabled(self):
|
def test_allowed_bulk_actions_with_sharing_enabled(self):
|
||||||
user_profile = self.user.profile
|
user_profile = self.user.profile
|
||||||
user_profile.enable_sharing = True
|
user_profile.enable_sharing = True
|
||||||
@@ -291,6 +361,34 @@ class BookmarkIndexViewTestCase(
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_allowed_bulk_actions_with_sharing_and_html_snapshot_enabled(self):
|
||||||
|
user_profile = self.user.profile
|
||||||
|
user_profile.enable_sharing = True
|
||||||
|
user_profile.save()
|
||||||
|
|
||||||
|
url = reverse("linkding:bookmarks.index")
|
||||||
|
response = self.client.get(url)
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
f"""
|
||||||
|
<select name="bulk_action" class="form-select select-sm">
|
||||||
|
<option value="bulk_archive">Archive</option>
|
||||||
|
<option value="bulk_delete">Delete</option>
|
||||||
|
<option value="bulk_tag">Add tags</option>
|
||||||
|
<option value="bulk_untag">Remove tags</option>
|
||||||
|
<option value="bulk_read">Mark as read</option>
|
||||||
|
<option value="bulk_unread">Mark as unread</option>
|
||||||
|
<option value="bulk_share">Share</option>
|
||||||
|
<option value="bulk_unshare">Unshare</option>
|
||||||
|
<option value="bulk_refresh">Refresh from website</option>
|
||||||
|
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||||
|
</select>
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
def test_apply_search_preferences(self):
|
def test_apply_search_preferences(self):
|
||||||
# no params
|
# no params
|
||||||
response = self.client.post(reverse("linkding:bookmarks.index"))
|
response = self.client.post(reverse("linkding:bookmarks.index"))
|
||||||
@@ -494,3 +592,43 @@ class BookmarkIndexViewTestCase(
|
|||||||
|
|
||||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||||
self.assertIsNone(feed)
|
self.assertIsNone(feed)
|
||||||
|
|
||||||
|
def test_list_bundles(self):
|
||||||
|
books = self.setup_bundle(name="Books bundle", order=3)
|
||||||
|
music = self.setup_bundle(name="Music bundle", order=1)
|
||||||
|
tools = self.setup_bundle(name="Tools bundle", order=2)
|
||||||
|
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||||
|
html = response.content.decode()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
|
||||||
|
self.assertVisibleBundles(soup, [music, tools, books])
|
||||||
|
|
||||||
|
def test_list_bundles_only_shows_user_owned_bundles(self):
|
||||||
|
user_bundles = [self.setup_bundle(), self.setup_bundle(), self.setup_bundle()]
|
||||||
|
other_user = self.setup_user()
|
||||||
|
self.setup_bundle(user=other_user)
|
||||||
|
self.setup_bundle(user=other_user)
|
||||||
|
self.setup_bundle(user=other_user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||||
|
html = response.content.decode()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
|
||||||
|
self.assertVisibleBundles(soup, user_bundles)
|
||||||
|
|
||||||
|
def test_hide_bundles_when_enabled_in_profile(self):
|
||||||
|
# visible by default
|
||||||
|
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
|
||||||
|
|
||||||
|
# hidden when disabled in profile
|
||||||
|
user_profile = self.get_or_create_test_user().profile
|
||||||
|
user_profile.hide_bundles = True
|
||||||
|
user_profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
'<input type="text" name="url" value="http://example.com" '
|
"""
|
||||||
'placeholder=" " autofocus class="form-input" required '
|
<input type="text" name="url" aria-invalid="false" autofocus class="form-input" required id="id_url" value="http://example.com">
|
||||||
'id="id_url">',
|
""",
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,9 +117,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
'<input type="text" name="tag_string" value="tag1 tag2 tag3" '
|
"""
|
||||||
'class="form-input" autocomplete="off" autocapitalize="off" '
|
<input type="text" name="tag_string" value="tag1 tag2 tag3"
|
||||||
'id="id_tag_string">',
|
aria-describedby="id_tag_string_help" autocapitalize="off" autocomplete="off" class="form-input" id="id_tag_string">
|
||||||
|
""",
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,8 +138,8 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
<span class="form-label d-inline-block">Notes</span>
|
<span class="form-label d-inline-block">Notes</span>
|
||||||
</summary>
|
</summary>
|
||||||
<label for="id_notes" class="text-assistive">Notes</label>
|
<label for="id_notes" class="text-assistive">Notes</label>
|
||||||
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">**Find** more info [here](http://example.com)</textarea>
|
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes" aria-describedby="id_notes_help">**Find** more info [here](http://example.com)</textarea>
|
||||||
<div class="form-input-hint">
|
<div id="id_notes_help" class="form-input-hint">
|
||||||
Additional notes, supports Markdown.
|
Additional notes, supports Markdown.
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -196,12 +197,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<label for="id_shared" class="form-checkbox">
|
<div class="form-checkbox">
|
||||||
<input type="checkbox" name="shared" id="id_shared">
|
<input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
|
||||||
<i class="form-icon"></i>
|
<i class="form-icon"></i>
|
||||||
<span>Share</span>
|
<label for="id_shared">Share</label>
|
||||||
</label>
|
</div>
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
count=0,
|
count=0,
|
||||||
)
|
)
|
||||||
@@ -213,12 +214,12 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<label for="id_shared" class="form-checkbox">
|
<div class="form-checkbox">
|
||||||
<input type="checkbox" name="shared" id="id_shared">
|
<input type="checkbox" name="shared" aria-describedby="id_shared_help" id="id_shared">
|
||||||
<i class="form-icon"></i>
|
<i class="form-icon"></i>
|
||||||
<span>Share</span>
|
<label for="id_shared">Share</label>
|
||||||
</label>
|
</div>
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
count=1,
|
count=1,
|
||||||
)
|
)
|
||||||
@@ -231,10 +232,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<div class="form-input-hint">
|
<div id="id_shared_help" class="form-input-hint">
|
||||||
Share this bookmark with other registered users.
|
Share this bookmark with other registered users.
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -245,10 +246,10 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<div class="form-input-hint">
|
<div id="id_shared_help" class="form-input-hint">
|
||||||
Share this bookmark with other registered users and anonymous users.
|
Share this bookmark with other registered users and anonymous users.
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -265,7 +266,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
'<input type="checkbox" name="unread" id="id_unread">',
|
'<input type="checkbox" name="unread" id="id_unread" aria-describedby="id_unread_help">',
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -277,6 +278,31 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
'<input type="checkbox" name="unread" id="id_unread" checked="">',
|
'<input type="checkbox" name="unread" id="id_unread" checked="" aria-describedby="id_unread_help">',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_should_not_check_shared_by_default(self):
|
||||||
|
self.user.profile.enable_sharing = True
|
||||||
|
self.user.profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:bookmarks.new"))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
'<input type="checkbox" name="shared" id="id_shared" aria-describedby="id_shared_help">',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_should_check_shared_when_configured_in_profile(self):
|
||||||
|
self.user.profile.enable_sharing = True
|
||||||
|
self.user.profile.default_mark_shared = True
|
||||||
|
self.user.profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("linkding:bookmarks.new"))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
'<input type="checkbox" name="shared" id="id_shared" checked="" aria-describedby="id_shared_help">',
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,21 +11,25 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
|
|||||||
form = BookmarkSearchForm(search)
|
form = BookmarkSearchForm(search)
|
||||||
self.assertEqual(form["q"].initial, "")
|
self.assertEqual(form["q"].initial, "")
|
||||||
self.assertEqual(form["user"].initial, "")
|
self.assertEqual(form["user"].initial, "")
|
||||||
|
self.assertEqual(form["bundle"].initial, None)
|
||||||
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
|
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
|
||||||
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
|
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
|
||||||
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
|
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||||
|
|
||||||
# with params
|
# with params
|
||||||
|
bundle = self.setup_bundle()
|
||||||
search = BookmarkSearch(
|
search = BookmarkSearch(
|
||||||
q="search query",
|
q="search query",
|
||||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||||
user="user123",
|
user="user123",
|
||||||
|
bundle=bundle,
|
||||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
)
|
)
|
||||||
form = BookmarkSearchForm(search)
|
form = BookmarkSearchForm(search)
|
||||||
self.assertEqual(form["q"].initial, "search query")
|
self.assertEqual(form["q"].initial, "search query")
|
||||||
self.assertEqual(form["user"].initial, "user123")
|
self.assertEqual(form["user"].initial, "user123")
|
||||||
|
self.assertEqual(form["bundle"].initial, bundle.id)
|
||||||
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_ASC)
|
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_ASC)
|
||||||
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
|
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
|
||||||
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_YES)
|
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_YES)
|
||||||
@@ -61,17 +65,26 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertCountEqual(form.hidden_fields(), [form["q"], form["sort"]])
|
self.assertCountEqual(form.hidden_fields(), [form["q"], form["sort"]])
|
||||||
|
|
||||||
# all modified params
|
# all modified params
|
||||||
|
bundle = self.setup_bundle()
|
||||||
search = BookmarkSearch(
|
search = BookmarkSearch(
|
||||||
q="search query",
|
q="search query",
|
||||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||||
user="user123",
|
user="user123",
|
||||||
|
bundle=bundle,
|
||||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
)
|
)
|
||||||
form = BookmarkSearchForm(search)
|
form = BookmarkSearchForm(search)
|
||||||
self.assertCountEqual(
|
self.assertCountEqual(
|
||||||
form.hidden_fields(),
|
form.hidden_fields(),
|
||||||
[form["q"], form["sort"], form["user"], form["shared"], form["unread"]],
|
[
|
||||||
|
form["q"],
|
||||||
|
form["sort"],
|
||||||
|
form["user"],
|
||||||
|
form["bundle"],
|
||||||
|
form["shared"],
|
||||||
|
form["unread"],
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# some modified params are editable fields
|
# some modified params are editable fields
|
||||||
|
|||||||
@@ -2,16 +2,23 @@ from django.http import QueryDict
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from bookmarks.models import BookmarkSearch
|
from bookmarks.models import BookmarkSearch
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
class BookmarkSearchModelTest(TestCase):
|
class MockRequest:
|
||||||
|
def __init__(self, user):
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkSearchModelTest(TestCase, BookmarkFactoryMixin):
|
||||||
def test_from_request(self):
|
def test_from_request(self):
|
||||||
# no params
|
# no params
|
||||||
query_dict = QueryDict()
|
query_dict = QueryDict()
|
||||||
|
|
||||||
search = BookmarkSearch.from_request(query_dict)
|
search = BookmarkSearch.from_request(None, query_dict)
|
||||||
self.assertEqual(search.q, "")
|
self.assertEqual(search.q, "")
|
||||||
self.assertEqual(search.user, "")
|
self.assertEqual(search.user, "")
|
||||||
|
self.assertEqual(search.bundle, None)
|
||||||
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
||||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
|
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
|
||||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||||
@@ -19,7 +26,7 @@ class BookmarkSearchModelTest(TestCase):
|
|||||||
# some params
|
# some params
|
||||||
query_dict = QueryDict("q=search query&user=user123")
|
query_dict = QueryDict("q=search query&user=user123")
|
||||||
|
|
||||||
bookmark_search = BookmarkSearch.from_request(query_dict)
|
bookmark_search = BookmarkSearch.from_request(None, query_dict)
|
||||||
self.assertEqual(bookmark_search.q, "search query")
|
self.assertEqual(bookmark_search.q, "search query")
|
||||||
self.assertEqual(bookmark_search.user, "user123")
|
self.assertEqual(bookmark_search.user, "user123")
|
||||||
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
|
||||||
@@ -27,13 +34,16 @@ class BookmarkSearchModelTest(TestCase):
|
|||||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||||
|
|
||||||
# all params
|
# all params
|
||||||
|
bundle = self.setup_bundle()
|
||||||
|
request = MockRequest(self.get_or_create_test_user())
|
||||||
query_dict = QueryDict(
|
query_dict = QueryDict(
|
||||||
"q=search query&sort=title_asc&user=user123&shared=yes&unread=yes"
|
f"q=search query&sort=title_asc&user=user123&bundle={bundle.id}&shared=yes&unread=yes"
|
||||||
)
|
)
|
||||||
|
|
||||||
search = BookmarkSearch.from_request(query_dict)
|
search = BookmarkSearch.from_request(request, query_dict)
|
||||||
self.assertEqual(search.q, "search query")
|
self.assertEqual(search.q, "search query")
|
||||||
self.assertEqual(search.user, "user123")
|
self.assertEqual(search.user, "user123")
|
||||||
|
self.assertEqual(search.bundle, bundle)
|
||||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
|
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
|
||||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
|
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
|
||||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
|
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
|
||||||
@@ -45,7 +55,7 @@ class BookmarkSearchModelTest(TestCase):
|
|||||||
}
|
}
|
||||||
query_dict = QueryDict("q=search query")
|
query_dict = QueryDict("q=search query")
|
||||||
|
|
||||||
search = BookmarkSearch.from_request(query_dict, preferences)
|
search = BookmarkSearch.from_request(None, query_dict, preferences)
|
||||||
self.assertEqual(search.q, "search query")
|
self.assertEqual(search.q, "search query")
|
||||||
self.assertEqual(search.user, "")
|
self.assertEqual(search.user, "")
|
||||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
|
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
|
||||||
@@ -60,13 +70,110 @@ class BookmarkSearchModelTest(TestCase):
|
|||||||
}
|
}
|
||||||
query_dict = QueryDict("sort=title_desc&shared=no&unread=off")
|
query_dict = QueryDict("sort=title_desc&shared=no&unread=off")
|
||||||
|
|
||||||
search = BookmarkSearch.from_request(query_dict, preferences)
|
search = BookmarkSearch.from_request(None, query_dict, preferences)
|
||||||
self.assertEqual(search.q, "")
|
self.assertEqual(search.q, "")
|
||||||
self.assertEqual(search.user, "")
|
self.assertEqual(search.user, "")
|
||||||
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
|
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
|
||||||
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
|
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
|
||||||
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
|
||||||
|
|
||||||
|
def test_from_request_ignores_invalid_bundle_param(self):
|
||||||
|
self.setup_bundle()
|
||||||
|
|
||||||
|
# bundle does not exist
|
||||||
|
request = MockRequest(self.get_or_create_test_user())
|
||||||
|
query_dict = QueryDict("bundle=99999")
|
||||||
|
search = BookmarkSearch.from_request(request, query_dict)
|
||||||
|
self.assertIsNone(search.bundle)
|
||||||
|
|
||||||
|
# bundle belongs to another user
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bundle = self.setup_bundle(user=other_user)
|
||||||
|
query_dict = QueryDict(f"bundle={bundle.id}")
|
||||||
|
search = BookmarkSearch.from_request(request, query_dict)
|
||||||
|
self.assertIsNone(search.bundle)
|
||||||
|
|
||||||
|
def test_query_params(self):
|
||||||
|
# no params
|
||||||
|
search = BookmarkSearch()
|
||||||
|
self.assertEqual(search.query_params, {})
|
||||||
|
|
||||||
|
# params are default values
|
||||||
|
search = BookmarkSearch(
|
||||||
|
q="", sort=BookmarkSearch.SORT_ADDED_DESC, user="", bundle=None, shared=""
|
||||||
|
)
|
||||||
|
self.assertEqual(search.query_params, {})
|
||||||
|
|
||||||
|
# some modified params
|
||||||
|
search = BookmarkSearch(q="search query", sort=BookmarkSearch.SORT_ADDED_ASC)
|
||||||
|
self.assertEqual(
|
||||||
|
search.query_params,
|
||||||
|
{"q": "search query", "sort": BookmarkSearch.SORT_ADDED_ASC},
|
||||||
|
)
|
||||||
|
|
||||||
|
# all modified params
|
||||||
|
bundle = self.setup_bundle()
|
||||||
|
search = BookmarkSearch(
|
||||||
|
q="search query",
|
||||||
|
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||||
|
user="user123",
|
||||||
|
bundle=bundle,
|
||||||
|
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||||
|
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
search.query_params,
|
||||||
|
{
|
||||||
|
"q": "search query",
|
||||||
|
"sort": BookmarkSearch.SORT_ADDED_ASC,
|
||||||
|
"user": "user123",
|
||||||
|
"bundle": bundle.id,
|
||||||
|
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
|
||||||
|
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# preferences are not query params if they match default
|
||||||
|
preferences = {
|
||||||
|
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||||
|
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
|
}
|
||||||
|
search = BookmarkSearch(preferences=preferences)
|
||||||
|
self.assertEqual(search.query_params, {})
|
||||||
|
|
||||||
|
# param is not a query param if it matches the preference
|
||||||
|
preferences = {
|
||||||
|
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||||
|
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
|
}
|
||||||
|
search = BookmarkSearch(
|
||||||
|
sort=BookmarkSearch.SORT_TITLE_ASC,
|
||||||
|
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
|
preferences=preferences,
|
||||||
|
)
|
||||||
|
self.assertEqual(search.query_params, {})
|
||||||
|
|
||||||
|
# overriding preferences is a query param
|
||||||
|
preferences = {
|
||||||
|
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||||
|
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
|
||||||
|
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
|
}
|
||||||
|
search = BookmarkSearch(
|
||||||
|
sort=BookmarkSearch.SORT_TITLE_DESC,
|
||||||
|
shared=BookmarkSearch.FILTER_SHARED_UNSHARED,
|
||||||
|
unread=BookmarkSearch.FILTER_UNREAD_OFF,
|
||||||
|
preferences=preferences,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
search.query_params,
|
||||||
|
{
|
||||||
|
"sort": BookmarkSearch.SORT_TITLE_DESC,
|
||||||
|
"shared": BookmarkSearch.FILTER_SHARED_UNSHARED,
|
||||||
|
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_modified_params(self):
|
def test_modified_params(self):
|
||||||
# no params
|
# no params
|
||||||
bookmark_search = BookmarkSearch()
|
bookmark_search = BookmarkSearch()
|
||||||
@@ -88,16 +195,18 @@ class BookmarkSearchModelTest(TestCase):
|
|||||||
self.assertCountEqual(modified_params, ["q", "sort"])
|
self.assertCountEqual(modified_params, ["q", "sort"])
|
||||||
|
|
||||||
# all modified params
|
# all modified params
|
||||||
|
bundle = self.setup_bundle()
|
||||||
bookmark_search = BookmarkSearch(
|
bookmark_search = BookmarkSearch(
|
||||||
q="search query",
|
q="search query",
|
||||||
sort=BookmarkSearch.SORT_ADDED_ASC,
|
sort=BookmarkSearch.SORT_ADDED_ASC,
|
||||||
user="user123",
|
user="user123",
|
||||||
|
bundle=bundle,
|
||||||
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
shared=BookmarkSearch.FILTER_SHARED_SHARED,
|
||||||
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
unread=BookmarkSearch.FILTER_UNREAD_YES,
|
||||||
)
|
)
|
||||||
modified_params = bookmark_search.modified_params
|
modified_params = bookmark_search.modified_params
|
||||||
self.assertCountEqual(
|
self.assertCountEqual(
|
||||||
modified_params, ["q", "sort", "user", "shared", "unread"]
|
modified_params, ["q", "sort", "user", "bundle", "shared", "unread"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# preferences are not modified params
|
# preferences are not modified params
|
||||||
@@ -180,7 +289,10 @@ class BookmarkSearchModelTest(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# only returns preferences
|
# only returns preferences
|
||||||
bookmark_search = BookmarkSearch(q="search query", user="user123")
|
bundle = self.setup_bundle()
|
||||||
|
bookmark_search = BookmarkSearch(
|
||||||
|
q="search query", user="user123", bundle=bundle
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
bookmark_search.preferences_dict,
|
bookmark_search.preferences_dict,
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user