mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-03 00:13:13 +08:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
492de5618c | ||
|
|
c349ad7670 | ||
|
|
1c17e16655 | ||
|
|
9b70bc3b55 | ||
|
|
beba4f8b93 | ||
|
|
bb7af56dc1 | ||
|
|
e89fecbd10 | ||
|
|
70734ed273 | ||
|
|
dcb15f1942 | ||
|
|
3b6cdbdd84 | ||
|
|
344420ec4a | ||
|
|
eb99ece360 | ||
|
|
95529eccd4 | ||
|
|
a6b36750da | ||
|
|
8b98a335d4 | ||
|
|
6ac8ce6a7b | ||
|
|
a9f135552a | ||
|
|
f110eb35fe | ||
|
|
051bd39256 | ||
|
|
229d3b511f | ||
|
|
b9d6d91a91 | ||
|
|
a7a4dd5fff | ||
|
|
ecb34d2aea | ||
|
|
5495565fbd | ||
|
|
0c18b83a8e | ||
|
|
128e1afbce | ||
|
|
d33719dc7c | ||
|
|
357c2d1399 | ||
|
|
9cda5a54d3 | ||
|
|
67d5b17450 | ||
|
|
3ec6c0a7f8 | ||
|
|
86c2bdd138 | ||
|
|
82e5b7d9d5 | ||
|
|
d873342105 | ||
|
|
d519cb74eb | ||
|
|
ff0e6f0ff6 | ||
|
|
77c45c63f3 | ||
|
|
e45e63bfb1 | ||
|
|
004319adae | ||
|
|
d8358f1b12 | ||
|
|
b90ae1b202 | ||
|
|
6c874afff2 | ||
|
|
723b843c13 | ||
|
|
96176ba50e | ||
|
|
f6fb46e8ad | ||
|
|
3804640574 | ||
|
|
8f61fbd04a | ||
|
|
22bc713ed8 |
@@ -2,7 +2,7 @@
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "Python 3",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.12",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.13",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"forwardPorts": [8000],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.dev.txt && npm install && mkdir -p data && python3 manage.py migrate",
|
||||
"postCreateCommand": "pip install uv && uv sync --group dev && npm install && mkdir -p data && uv run manage.py migrate",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
!/package.json
|
||||
!/package-lock.json
|
||||
!/postcss.config.js
|
||||
!/requirements.dev.txt
|
||||
!/requirements.txt
|
||||
!/pyproject.toml
|
||||
!/rollup.config.mjs
|
||||
!/supervisord.conf
|
||||
!/uv.lock
|
||||
!/uwsgi.ini
|
||||
!/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
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -25,10 +27,10 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: |
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
uv sync
|
||||
mkdir data
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.tests
|
||||
run: uv run manage.py test bookmarks.tests
|
||||
e2e_tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -37,7 +39,9 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -47,12 +51,12 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: |
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
playwright install chromium
|
||||
uv sync
|
||||
uv run playwright install chromium
|
||||
mkdir data
|
||||
- name: Run build
|
||||
run: |
|
||||
npm run build
|
||||
python manage.py collectstatic
|
||||
uv run manage.py collectstatic
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.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
|
||||
# direnv
|
||||
/.direnv
|
||||
|
||||
# Test setups
|
||||
/scripts/unsecure-test-setups/authelia-oidc/authelia/db.sqlite3
|
||||
/scripts/unsecure-test-setups/authelia-oidc/traefik/certs
|
||||
|
||||
85
CHANGELOG.md
85
CHANGELOG.md
@@ -1,5 +1,90 @@
|
||||
# Changelog
|
||||
|
||||
## v1.44.1 (11/10/2025)
|
||||
|
||||
### What's Changed
|
||||
* Fix normalized URL not being generated in bookmark import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1202
|
||||
* Fix missing tags causing errors in import with Postgres by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1203
|
||||
* Check for dupes by exact URL if normalized URL is missing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1204
|
||||
* Attempt to fix botched normalized URL migration from 1.43.0 by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1205
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.44.0...v1.44.1
|
||||
|
||||
---
|
||||
|
||||
## v1.44.0 (05/10/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add new search engine that supports logical expressions (and, or, not) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1198
|
||||
* Fix pagination links to use relative URLs by @dunlor in https://github.com/sissbruecker/linkding/pull/1186
|
||||
* Fix queued tasks link when context path is used by @dunlor in https://github.com/sissbruecker/linkding/pull/1187
|
||||
* Fix bundle preview pagination resetting to first page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1194
|
||||
|
||||
### New Contributors
|
||||
* @dunlor made their first contribution in https://github.com/sissbruecker/linkding/pull/1186
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.43.0...v1.44.0
|
||||
|
||||
---
|
||||
|
||||
## v1.43.0 (28/09/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add basic tag management by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1175
|
||||
* Normalize URLs when checking for duplicates by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1169
|
||||
* Add option to mark bookmarks as shared by default by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1170
|
||||
* Use modal dialog for confirming actions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1168
|
||||
* Fix error when filtering bookmark assets in the admin UI by @proog in https://github.com/sissbruecker/linkding/pull/1162
|
||||
* Document API bundle filter by @proog in https://github.com/sissbruecker/linkding/pull/1161
|
||||
* Add alfred-linkding-bookmarks to community.md by @FireFingers21 in https://github.com/sissbruecker/linkding/pull/1160
|
||||
* Switch to uv by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1172
|
||||
* Replace Svelte components with Lit elements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1174
|
||||
* Bump versions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1173
|
||||
* Bump astro from 5.12.8 to 5.13.2 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1166
|
||||
* Bump vite from 6.3.5 to 6.3.6 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1184
|
||||
|
||||
### New Contributors
|
||||
* @FireFingers21 made their first contribution in https://github.com/sissbruecker/linkding/pull/1160
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.42.0...v1.43.0
|
||||
|
||||
---
|
||||
|
||||
## v1.42.0 (16/08/2025)
|
||||
|
||||
### What's Changed
|
||||
* Bulk create HTML snapshots by @Tql-ws1 in https://github.com/sissbruecker/linkding/pull/1132
|
||||
* Create bundle from current search query by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1154
|
||||
* Add alternative bookmarklet that uses browser metadata by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1159
|
||||
* Add date and time to HTML export filename by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1101
|
||||
* Automatically compress uploads with gzip by @hkclark in https://github.com/sissbruecker/linkding/pull/1087
|
||||
* Show bookmark bundles in admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1110
|
||||
* Allow filtering feeds by bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1152
|
||||
* Submit bookmark form with Ctrl/Cmd + Enter by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1158
|
||||
* Improve bookmark form accessibility by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1116
|
||||
* Fix custom CSS not being used in reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1102
|
||||
* Use filename when downloading asset through UI by @proog in https://github.com/sissbruecker/linkding/pull/1146
|
||||
* Update order when deleting bundle by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1114
|
||||
* Wrap long titles in bookmark details modal by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1150
|
||||
* Ignore tags with just whitespace by @pvl in https://github.com/sissbruecker/linkding/pull/1125
|
||||
* Ignore tags that exceed length limit during import by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1153
|
||||
* Add CloudBreak on Managed Hosting by @benjaminoakes in https://github.com/sissbruecker/linkding/pull/1079
|
||||
* Add Pocket migration to to community page by @hkclark in https://github.com/sissbruecker/linkding/pull/1112
|
||||
* Add linkding-media-archiver to community.md by @proog in https://github.com/sissbruecker/linkding/pull/1144
|
||||
* Bump astro from 5.7.13 to 5.12.8 in /docs by @dependabot[bot] in https://github.com/sissbruecker/linkding/pull/1147
|
||||
|
||||
### New Contributors
|
||||
* @hkclark made their first contribution in https://github.com/sissbruecker/linkding/pull/1087
|
||||
* @benjaminoakes made their first contribution in https://github.com/sissbruecker/linkding/pull/1079
|
||||
* @proog made their first contribution in https://github.com/sissbruecker/linkding/pull/1146
|
||||
* @pvl made their first contribution in https://github.com/sissbruecker/linkding/pull/1125
|
||||
* @Tql-ws1 made their first contribution in https://github.com/sissbruecker/linkding/pull/1132
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.41.0...v1.42.0
|
||||
|
||||
---
|
||||
|
||||
## v1.41.0 (19/06/2025)
|
||||
|
||||
### What's Changed
|
||||
|
||||
16
Makefile
16
Makefile
@@ -1,15 +1,23 @@
|
||||
.PHONY: serve
|
||||
|
||||
init:
|
||||
uv sync
|
||||
uv run manage.py migrate
|
||||
npm install
|
||||
|
||||
serve:
|
||||
python manage.py runserver
|
||||
uv run manage.py runserver
|
||||
|
||||
tasks:
|
||||
python manage.py run_huey
|
||||
uv run manage.py run_huey
|
||||
|
||||
test:
|
||||
pytest -n auto
|
||||
uv run pytest -n auto
|
||||
|
||||
format:
|
||||
black bookmarks
|
||||
uv run black bookmarks
|
||||
npx prettier bookmarks/frontend --write
|
||||
npx prettier bookmarks/styles --write
|
||||
|
||||
frontend:
|
||||
npm run dev
|
||||
44
README.md
44
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 🙂.
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.12
|
||||
- Python 3.13
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- Node.js
|
||||
|
||||
### Setup
|
||||
|
||||
Create a virtual environment for the application (https://docs.python.org/3/tutorial/venv.html):
|
||||
Initialize the development environment with:
|
||||
```
|
||||
python3 -m venv ~/environments/linkding
|
||||
```
|
||||
Activate the environment for your shell:
|
||||
```
|
||||
source ~/environments/linkding/bin/activate[.csh|.fish]
|
||||
```
|
||||
Within the active environment install the application dependencies from the application folder:
|
||||
```
|
||||
pip3 install -r requirements.txt -r requirements.dev.txt
|
||||
```
|
||||
Install frontend dependencies:
|
||||
```
|
||||
npm install
|
||||
```
|
||||
Initialize database:
|
||||
```
|
||||
mkdir -p data
|
||||
python3 manage.py migrate
|
||||
make init
|
||||
```
|
||||
This sets up a virtual environment using uv, installs NPM dependencies and runs migrations to create the initial database.
|
||||
|
||||
Create a user for the frontend:
|
||||
```
|
||||
python3 manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
uv run manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
|
||||
|
||||
Run the frontend build for bundling frontend components with:
|
||||
```
|
||||
npm run dev
|
||||
make frontend
|
||||
```
|
||||
Start the Django development server with:
|
||||
|
||||
Then start the Django development server with:
|
||||
```
|
||||
python3 manage.py runserver
|
||||
make serve
|
||||
```
|
||||
The frontend is now available under http://localhost:8000
|
||||
|
||||
@@ -123,14 +111,14 @@ Once checked out, only the following commands are required to get started:
|
||||
|
||||
Create a user for the frontend:
|
||||
```
|
||||
python3 manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
uv run manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
|
||||
```
|
||||
npm run dev
|
||||
make frontend
|
||||
```
|
||||
Start the Django development server with:
|
||||
```
|
||||
python3 manage.py runserver
|
||||
make serve
|
||||
```
|
||||
The frontend is now available under http://localhost:8000
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
@@ -83,6 +84,7 @@ class LinkdingAdminSite(AdminSite):
|
||||
|
||||
def get_app_list(self, request, app_label=None):
|
||||
app_list = super().get_app_list(request, app_label)
|
||||
context_path = os.getenv("LD_CONTEXT_PATH", "")
|
||||
app_list += [
|
||||
{
|
||||
"name": "Huey",
|
||||
@@ -91,7 +93,7 @@ class LinkdingAdminSite(AdminSite):
|
||||
{
|
||||
"name": "Queued tasks",
|
||||
"object_name": "background_tasks",
|
||||
"admin_url": "/admin/tasks/",
|
||||
"admin_url": f"/{context_path}admin/tasks/",
|
||||
"view_only": True,
|
||||
}
|
||||
],
|
||||
@@ -214,7 +216,7 @@ class AdminBookmarkAsset(admin.ModelAdmin):
|
||||
|
||||
list_display = ("custom_display_name", "date_created", "status")
|
||||
search_fields = (
|
||||
"custom_display_name",
|
||||
"display_name",
|
||||
"file",
|
||||
)
|
||||
list_filter = ("status",)
|
||||
|
||||
@@ -107,7 +107,7 @@ class BookmarkViewSet(
|
||||
def check(self, request: HttpRequest):
|
||||
url = request.GET.get("url")
|
||||
ignore_cache = request.GET.get("ignore_cache", False) in ["true"]
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
bookmark = Bookmark.query_existing(request.user, url).first()
|
||||
existing_bookmark_data = (
|
||||
self.get_serializer(bookmark).data if bookmark else None
|
||||
)
|
||||
@@ -151,7 +151,7 @@ class BookmarkViewSet(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
bookmark = Bookmark.query_existing(request.user, url).first()
|
||||
|
||||
if not bookmark:
|
||||
bookmark = Bookmark(url=url)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
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.validators import BookmarkURLValidator
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
Tag,
|
||||
build_tag_string,
|
||||
parse_tag_string,
|
||||
sanitize_tag_name,
|
||||
)
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
|
||||
|
||||
class CustomErrorList(ErrorList):
|
||||
@@ -49,6 +56,7 @@ class BookmarkForm(forms.ModelForm):
|
||||
"tag_string": request.GET.get("tags"),
|
||||
"auto_close": "auto_close" in request.GET,
|
||||
"unread": request.user_profile.default_mark_unread,
|
||||
"shared": request.user_profile.default_mark_shared,
|
||||
}
|
||||
if instance is not None and request.method == "GET":
|
||||
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
|
||||
@@ -86,7 +94,7 @@ class BookmarkForm(forms.ModelForm):
|
||||
url = self.cleaned_data["url"]
|
||||
if self.instance.pk:
|
||||
is_duplicate = (
|
||||
Bookmark.objects.filter(owner=self.instance.owner, url=url)
|
||||
Bookmark.query_existing(self.instance.owner, url)
|
||||
.exclude(pk=self.instance.pk)
|
||||
.exists()
|
||||
)
|
||||
@@ -100,3 +108,88 @@ def convert_tag_string(tag_string: str):
|
||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||
# strings
|
||||
return tag_string.replace(" ", ",")
|
||||
|
||||
|
||||
class TagForm(forms.ModelForm):
|
||||
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 { FocusTrapController, isKeyboardActive } from "./focus-utils";
|
||||
|
||||
let confirmId = 0;
|
||||
|
||||
function nextConfirmId() {
|
||||
return `confirm-${confirmId++}`;
|
||||
}
|
||||
|
||||
class ConfirmButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
element.addEventListener("click", this.onClick);
|
||||
this.element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.reset();
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
}
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
Behavior.interacting = true;
|
||||
|
||||
const container = document.createElement("span");
|
||||
container.className = "confirmation";
|
||||
|
||||
const icon = this.element.getAttribute("ld-confirm-icon");
|
||||
if (icon) {
|
||||
const iconElement = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg",
|
||||
);
|
||||
iconElement.style.width = "16px";
|
||||
iconElement.style.height = "16px";
|
||||
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
|
||||
container.append(iconElement);
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
|
||||
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() {
|
||||
setTimeout(() => {
|
||||
Behavior.interacting = false;
|
||||
if (this.container) {
|
||||
this.container.remove();
|
||||
this.container = null;
|
||||
}
|
||||
this.element.classList.remove("d-none");
|
||||
open() {
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "dropdown confirm-dropdown active";
|
||||
|
||||
const confirmId = nextConfirmId();
|
||||
const questionId = `${confirmId}-question`;
|
||||
|
||||
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);
|
||||
|
||||
@@ -24,7 +24,7 @@ class FilterDrawerTriggerBehavior extends Behavior {
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<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"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
|
||||
@@ -93,6 +93,12 @@ document.addEventListener("turbo:load", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore if there is a modal dialog, which should handle its own focus
|
||||
const modal = document.querySelector("[aria-modal='true']");
|
||||
if (modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there is an explicit focus target for the next page load
|
||||
for (const target of afterPageLoadFocusTarget) {
|
||||
const element = document.querySelector(target);
|
||||
|
||||
@@ -39,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 {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
@@ -75,4 +105,5 @@ class UploadButton extends Behavior {
|
||||
|
||||
registerBehavior("ld-form-submit", FormSubmit);
|
||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||
registerBehavior("ld-form-reset", FormResetBehavior);
|
||||
registerBehavior("ld-upload-button", UploadButton);
|
||||
|
||||
@@ -54,8 +54,6 @@ export class Behavior {
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
Behavior.interacting = false;
|
||||
|
||||
export function registerBehavior(name, behavior) {
|
||||
behaviorRegistry[name] = behavior;
|
||||
}
|
||||
|
||||
@@ -23,32 +23,22 @@ export class ModalBehavior extends Behavior {
|
||||
this.closeButton.removeEventListener("click", this.onClose);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.clearInert();
|
||||
this.removeScrollLock();
|
||||
this.focusTrap.destroy();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupInert();
|
||||
this.setupScrollLock();
|
||||
this.focusTrap = new FocusTrapController(
|
||||
this.element.querySelector(".modal-container"),
|
||||
);
|
||||
}
|
||||
|
||||
setupInert() {
|
||||
// Inert all other elements on the page
|
||||
document
|
||||
.querySelectorAll("body > *:not(.modals)")
|
||||
.forEach((el) => el.setAttribute("inert", ""));
|
||||
// Lock scroll on the body
|
||||
setupScrollLock() {
|
||||
document.body.classList.add("scroll-lock");
|
||||
}
|
||||
|
||||
clearInert() {
|
||||
// Clear inert attribute from all elements to allow focus outside the modal again
|
||||
document
|
||||
.querySelectorAll("body > *")
|
||||
.forEach((el) => el.removeAttribute("inert"));
|
||||
// Remove scroll lock from the body
|
||||
removeScrollLock() {
|
||||
document.body.classList.remove("scroll-lock");
|
||||
}
|
||||
|
||||
@@ -85,7 +75,7 @@ export class ModalBehavior extends Behavior {
|
||||
|
||||
doClose() {
|
||||
this.element.remove();
|
||||
this.clearInert();
|
||||
this.removeScrollLock();
|
||||
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte";
|
||||
import "../components/SearchAutocomplete.js";
|
||||
|
||||
class SearchAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
@@ -10,26 +10,20 @@ class SearchAutocomplete extends Behavior {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
|
||||
new SearchAutoCompleteComponent({
|
||||
target: container,
|
||||
props: {
|
||||
name: "q",
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
value: input.value,
|
||||
linkTarget: input.dataset.linkTarget,
|
||||
mode: input.dataset.mode,
|
||||
search: {
|
||||
user: input.dataset.user,
|
||||
shared: input.dataset.shared,
|
||||
unread: input.dataset.unread,
|
||||
},
|
||||
},
|
||||
});
|
||||
const autocomplete = document.createElement("ld-search-autocomplete");
|
||||
autocomplete.name = "q";
|
||||
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
||||
autocomplete.value = input.value;
|
||||
autocomplete.linkTarget = input.dataset.linkTarget || "_blank";
|
||||
autocomplete.mode = input.dataset.mode || "";
|
||||
autocomplete.search = {
|
||||
user: input.dataset.user,
|
||||
shared: input.dataset.shared,
|
||||
unread: input.dataset.unread,
|
||||
};
|
||||
|
||||
this.input = input;
|
||||
this.autocomplete = container.firstElementChild;
|
||||
this.autocomplete = autocomplete;
|
||||
input.replaceWith(this.autocomplete);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||
import "../components/TagAutocomplete.js";
|
||||
|
||||
class TagAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
@@ -10,22 +10,16 @@ class TagAutocomplete extends Behavior {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
|
||||
new TagAutoCompleteComponent({
|
||||
target: container,
|
||||
props: {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
ariaDescribedBy: input.getAttribute("aria-describedby") || "",
|
||||
variant: input.getAttribute("variant"),
|
||||
},
|
||||
});
|
||||
const autocomplete = document.createElement("ld-tag-autocomplete");
|
||||
autocomplete.id = input.id;
|
||||
autocomplete.name = input.name;
|
||||
autocomplete.value = input.value;
|
||||
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
||||
autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || "";
|
||||
autocomplete.variant = input.getAttribute("variant") || "default";
|
||||
|
||||
this.input = input;
|
||||
this.autocomplete = container.firstElementChild;
|
||||
this.autocomplete = 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.dispatchEvent(new CustomEvent("input", { bubbles: true }));
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
updateSelection(dir) {
|
||||
const length = this.suggestions.length;
|
||||
let newIndex = this.selectedIndex + dir;
|
||||
|
||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||
if (newIndex >= length) newIndex = 0;
|
||||
|
||||
this.selectedIndex = newIndex;
|
||||
|
||||
// Scroll to selected list item
|
||||
setTimeout(() => {
|
||||
if (this.suggestionList) {
|
||||
const selectedListItem =
|
||||
this.suggestionList.querySelector("li.selected");
|
||||
if (selectedListItem) {
|
||||
selectedListItem.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="form-autocomplete ${this.variant === "small" ? "small" : ""}">
|
||||
<!-- autocomplete input container -->
|
||||
<div
|
||||
class="form-autocomplete-input form-input ${this.isFocus
|
||||
? "is-focused"
|
||||
: ""}"
|
||||
>
|
||||
<!-- autocomplete real input box -->
|
||||
<input
|
||||
id="${this.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,171 +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 ariaDescribedBy;
|
||||
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);
|
||||
input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
|
||||
|
||||
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"
|
||||
aria-describedby="{ariaDescribedBy}"
|
||||
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/tag-autocomplete";
|
||||
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
export { api } from "./api";
|
||||
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) {
|
||||
if (!text || text.length <= 30) return text;
|
||||
|
||||
|
||||
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),
|
||||
),
|
||||
]
|
||||
18
bookmarks/migrations/0049_userprofile_legacy_search.py
Normal file
18
bookmarks/migrations/0049_userprofile_legacy_search.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-05 09:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0048_userprofile_default_mark_shared"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="legacy_search",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
34
bookmarks/migrations/0050_new_search_toast.py
Normal file
34
bookmarks/migrations/0050_new_search_toast.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-05 10:01
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import migrations
|
||||
|
||||
from bookmarks.models import Toast
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
|
||||
for user in User.objects.all():
|
||||
toast = Toast(
|
||||
key="new_search_toast",
|
||||
message="This version replaces the search engine with a new implementation that supports logical operators (and, or, not). If you run into any issues with the new search, you can switch back to the old one by enabling legacy search in the settings.",
|
||||
owner=user,
|
||||
)
|
||||
toast.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
Toast.objects.filter(key="new_search_toast").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0049_userprofile_legacy_search"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, reverse),
|
||||
]
|
||||
34
bookmarks/migrations/0051_fix_normalized_url.py
Normal file
34
bookmarks/migrations/0051_fix_normalized_url.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-11 08:46
|
||||
|
||||
from django.db import migrations
|
||||
from bookmarks.utils import normalize_url
|
||||
|
||||
|
||||
def fix_url_normalized(apps, schema_editor):
|
||||
Bookmark = apps.get_model("bookmarks", "Bookmark")
|
||||
|
||||
batch_size = 200
|
||||
qs = Bookmark.objects.filter(url_normalized="").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"])
|
||||
|
||||
|
||||
def reverse_fix_url_normalized(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0050_new_search_toast"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
fix_url_normalized,
|
||||
reverse_fix_url_normalized,
|
||||
),
|
||||
]
|
||||
@@ -1,20 +1,20 @@
|
||||
import binascii
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from functools import cached_property
|
||||
from typing import List
|
||||
|
||||
import binascii
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http import QueryDict
|
||||
|
||||
from bookmarks.utils import unique
|
||||
from bookmarks.utils import unique, normalize_url
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -54,6 +54,7 @@ def build_tag_string(tag_names: List[str], delimiter: str = ","):
|
||||
|
||||
class Bookmark(models.Model):
|
||||
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)
|
||||
description = models.TextField(blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
@@ -96,9 +97,23 @@ class Bookmark(models.Model):
|
||||
names = [tag.name for tag in self.tags.all()]
|
||||
return sorted(names)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.url_normalized = normalize_url(self.url)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||
|
||||
@staticmethod
|
||||
def query_existing(owner: User, url: str) -> models.QuerySet:
|
||||
# Find existing bookmark by normalized URL, or fall back to exact URL if
|
||||
# normalized URL was not generated for whatever reason
|
||||
normalized_url = normalize_url(url)
|
||||
q = Q(owner=owner) & (
|
||||
Q(url_normalized=normalized_url) | Q(url_normalized="", url=url)
|
||||
)
|
||||
return Bookmark.objects.filter(q)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Bookmark)
|
||||
def bookmark_deleted(sender, instance, **kwargs):
|
||||
@@ -474,12 +489,14 @@ class UserProfile(models.Model):
|
||||
search_preferences = models.JSONField(default=dict, null=False)
|
||||
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||
default_mark_unread = models.BooleanField(default=False, null=False)
|
||||
default_mark_shared = models.BooleanField(default=False, null=False)
|
||||
items_per_page = models.IntegerField(
|
||||
null=False, default=30, validators=[MinValueValidator(10)]
|
||||
)
|
||||
sticky_pagination = models.BooleanField(default=False, null=False)
|
||||
collapse_side_panel = models.BooleanField(default=False, null=False)
|
||||
hide_bundles = models.BooleanField(default=False, null=False)
|
||||
legacy_search = models.BooleanField(default=False, null=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.custom_css:
|
||||
@@ -515,12 +532,14 @@ class UserProfileForm(forms.ModelForm):
|
||||
"display_remove_bookmark_action",
|
||||
"permanent_notes",
|
||||
"default_mark_unread",
|
||||
"default_mark_shared",
|
||||
"custom_css",
|
||||
"auto_tagging_rules",
|
||||
"items_per_page",
|
||||
"sticky_pagination",
|
||||
"collapse_side_panel",
|
||||
"hide_bundles",
|
||||
"legacy_search",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,18 @@ from bookmarks.models import (
|
||||
UserProfile,
|
||||
parse_tag_string,
|
||||
)
|
||||
from bookmarks.services.search_query_parser import (
|
||||
parse_search_query,
|
||||
SearchExpression,
|
||||
TermExpression,
|
||||
TagExpression,
|
||||
SpecialKeywordExpression,
|
||||
AndExpression,
|
||||
OrExpression,
|
||||
NotExpression,
|
||||
SearchQueryParseError,
|
||||
extract_tag_names_from_query,
|
||||
)
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
@@ -45,6 +57,122 @@ def query_shared_bookmarks(
|
||||
return _base_bookmarks_query(user, profile, search).filter(conditions)
|
||||
|
||||
|
||||
def _convert_ast_to_q_object(ast_node: SearchExpression, profile: UserProfile) -> Q:
|
||||
if isinstance(ast_node, TermExpression):
|
||||
# Search across title, description, notes, URL
|
||||
conditions = (
|
||||
Q(title__icontains=ast_node.term)
|
||||
| Q(description__icontains=ast_node.term)
|
||||
| Q(notes__icontains=ast_node.term)
|
||||
| Q(url__icontains=ast_node.term)
|
||||
)
|
||||
|
||||
# In lax mode, also search in tag names
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
conditions = conditions | Exists(
|
||||
Bookmark.objects.filter(
|
||||
id=OuterRef("id"), tags__name__iexact=ast_node.term
|
||||
)
|
||||
)
|
||||
|
||||
return conditions
|
||||
|
||||
elif isinstance(ast_node, TagExpression):
|
||||
# Use Exists() to avoid reusing the same join when combining multiple tag expressions with and
|
||||
return Q(
|
||||
Exists(
|
||||
Bookmark.objects.filter(
|
||||
id=OuterRef("id"), tags__name__iexact=ast_node.tag
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
elif isinstance(ast_node, SpecialKeywordExpression):
|
||||
# Handle special keywords
|
||||
if ast_node.keyword.lower() == "unread":
|
||||
return Q(unread=True)
|
||||
elif ast_node.keyword.lower() == "untagged":
|
||||
return Q(tags=None)
|
||||
else:
|
||||
# Unknown keyword, return empty Q object (matches all)
|
||||
return Q()
|
||||
|
||||
elif isinstance(ast_node, AndExpression):
|
||||
# Combine left and right with AND
|
||||
left_q = _convert_ast_to_q_object(ast_node.left, profile)
|
||||
right_q = _convert_ast_to_q_object(ast_node.right, profile)
|
||||
return left_q & right_q
|
||||
|
||||
elif isinstance(ast_node, OrExpression):
|
||||
# Combine left and right with OR
|
||||
left_q = _convert_ast_to_q_object(ast_node.left, profile)
|
||||
right_q = _convert_ast_to_q_object(ast_node.right, profile)
|
||||
return left_q | right_q
|
||||
|
||||
elif isinstance(ast_node, NotExpression):
|
||||
# Negate the operand
|
||||
operand_q = _convert_ast_to_q_object(ast_node.operand, profile)
|
||||
return ~operand_q
|
||||
|
||||
else:
|
||||
# Fallback for unknown node types
|
||||
return Q()
|
||||
|
||||
|
||||
def _filter_search_query(
|
||||
query_set: QuerySet, query_string: str, profile: UserProfile
|
||||
) -> QuerySet:
|
||||
"""New search filtering logic using logical expressions."""
|
||||
|
||||
try:
|
||||
ast = parse_search_query(query_string)
|
||||
if ast:
|
||||
search_query = _convert_ast_to_q_object(ast, profile)
|
||||
query_set = query_set.filter(search_query)
|
||||
except SearchQueryParseError:
|
||||
# If the query cannot be parsed, return zero results
|
||||
return query_set.none()
|
||||
|
||||
return query_set
|
||||
|
||||
|
||||
def _filter_search_query_legacy(
|
||||
query_set: QuerySet, query_string: str, profile: UserProfile
|
||||
) -> QuerySet:
|
||||
"""Legacy search filtering logic where everything is just combined with AND."""
|
||||
|
||||
# Split query into search terms and tags
|
||||
query = parse_query_string(query_string)
|
||||
|
||||
# Filter for search terms and tags
|
||||
for term in query["search_terms"]:
|
||||
conditions = (
|
||||
Q(title__icontains=term)
|
||||
| Q(description__icontains=term)
|
||||
| Q(notes__icontains=term)
|
||||
| Q(url__icontains=term)
|
||||
)
|
||||
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
conditions = conditions | Exists(
|
||||
Bookmark.objects.filter(id=OuterRef("id"), tags__name__iexact=term)
|
||||
)
|
||||
|
||||
query_set = query_set.filter(conditions)
|
||||
|
||||
for tag_name in query["tag_names"]:
|
||||
query_set = query_set.filter(tags__name__iexact=tag_name)
|
||||
|
||||
# Untagged bookmarks
|
||||
if query["untagged"]:
|
||||
query_set = query_set.filter(tags=None)
|
||||
# Legacy unread bookmarks filter from query
|
||||
if query["unread"]:
|
||||
query_set = query_set.filter(unread=True)
|
||||
|
||||
return query_set
|
||||
|
||||
|
||||
def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
|
||||
# Search terms
|
||||
search_terms = parse_query_string(bundle.search)["search_terms"]
|
||||
@@ -113,34 +241,11 @@ def _base_bookmarks_query(
|
||||
# If the date format is invalid, ignore the filter
|
||||
pass
|
||||
|
||||
# Split query into search terms and tags
|
||||
query = parse_query_string(search.q)
|
||||
|
||||
# Filter for search terms and tags
|
||||
for term in query["search_terms"]:
|
||||
conditions = (
|
||||
Q(title__icontains=term)
|
||||
| Q(description__icontains=term)
|
||||
| Q(notes__icontains=term)
|
||||
| Q(url__icontains=term)
|
||||
)
|
||||
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
conditions = conditions | Exists(
|
||||
Bookmark.objects.filter(id=OuterRef("id"), tags__name__iexact=term)
|
||||
)
|
||||
|
||||
query_set = query_set.filter(conditions)
|
||||
|
||||
for tag_name in query["tag_names"]:
|
||||
query_set = query_set.filter(tags__name__iexact=tag_name)
|
||||
|
||||
# Untagged bookmarks
|
||||
if query["untagged"]:
|
||||
query_set = query_set.filter(tags=None)
|
||||
# Legacy unread bookmarks filter from query
|
||||
if query["unread"]:
|
||||
query_set = query_set.filter(unread=True)
|
||||
# Filter by search query
|
||||
if profile.legacy_search:
|
||||
query_set = _filter_search_query_legacy(query_set, search.q, profile)
|
||||
else:
|
||||
query_set = _filter_search_query(query_set, search.q, profile)
|
||||
|
||||
# Unread filter from bookmark search
|
||||
if search.unread == BookmarkSearch.FILTER_UNREAD_YES:
|
||||
@@ -241,6 +346,45 @@ def get_user_tags(user: User):
|
||||
return Tag.objects.filter(owner=user).all()
|
||||
|
||||
|
||||
def get_tags_for_query(user: User, profile: UserProfile, query: str) -> QuerySet:
|
||||
tag_names = extract_tag_names_from_query(query, profile)
|
||||
|
||||
if not tag_names:
|
||||
return Tag.objects.none()
|
||||
|
||||
tag_conditions = Q()
|
||||
for tag_name in tag_names:
|
||||
tag_conditions |= Q(name__iexact=tag_name)
|
||||
|
||||
return Tag.objects.filter(owner=user).filter(tag_conditions).distinct()
|
||||
|
||||
|
||||
def get_shared_tags_for_query(
|
||||
user: Optional[User], profile: UserProfile, query: str, public_only: bool
|
||||
) -> QuerySet:
|
||||
tag_names = extract_tag_names_from_query(query, profile)
|
||||
|
||||
if not tag_names:
|
||||
return Tag.objects.none()
|
||||
|
||||
# Build conditions similar to query_shared_bookmarks
|
||||
conditions = Q(bookmark__shared=True) & Q(
|
||||
bookmark__owner__profile__enable_sharing=True
|
||||
)
|
||||
if public_only:
|
||||
conditions = conditions & Q(
|
||||
bookmark__owner__profile__enable_public_sharing=True
|
||||
)
|
||||
if user is not None:
|
||||
conditions = conditions & Q(bookmark__owner=user)
|
||||
|
||||
tag_conditions = Q()
|
||||
for tag_name in tag_names:
|
||||
tag_conditions |= Q(name__iexact=tag_name)
|
||||
|
||||
return Tag.objects.filter(conditions).filter(tag_conditions).distinct()
|
||||
|
||||
|
||||
def parse_query_string(query_string):
|
||||
# Sanitize query params
|
||||
if not query_string:
|
||||
|
||||
@@ -39,9 +39,10 @@ def create_snapshot(asset: BookmarkAsset):
|
||||
# Store as gzip in asset folder
|
||||
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(temp_filepath, "rb") as temp_file, gzip.open(
|
||||
filepath, "wb"
|
||||
) as gz_file:
|
||||
with (
|
||||
open(temp_filepath, "rb") as temp_file,
|
||||
gzip.open(filepath, "wb") as gz_file,
|
||||
):
|
||||
shutil.copyfileobj(temp_file, gz_file)
|
||||
|
||||
# Remove temporary file
|
||||
|
||||
@@ -19,8 +19,8 @@ def create_bookmark(
|
||||
disable_html_snapshot: bool = False,
|
||||
):
|
||||
# If URL is already bookmarked, then update it
|
||||
existing_bookmark: Bookmark = Bookmark.objects.filter(
|
||||
owner=current_user, url=bookmark.url
|
||||
existing_bookmark: Bookmark = Bookmark.query_existing(
|
||||
current_user, bookmark.url
|
||||
).first()
|
||||
|
||||
if existing_bookmark is not None:
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.utils import timezone
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services.parser import parse, NetscapeBookmark
|
||||
from bookmarks.utils import parse_timestamp
|
||||
from bookmarks.utils import normalize_url, parse_timestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,8 +45,9 @@ class TagCache:
|
||||
result = []
|
||||
for tag_name in tag_names:
|
||||
tag = self.get(tag_name)
|
||||
# Tag may not have been created if tag name exceeded maximum length
|
||||
# Prevent returning duplicates
|
||||
if not (tag in result):
|
||||
if tag and not (tag in result):
|
||||
result.append(tag)
|
||||
|
||||
return result
|
||||
@@ -181,6 +182,7 @@ def _import_batch(
|
||||
bookmarks_to_update,
|
||||
[
|
||||
"url",
|
||||
"url_normalized",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
"unread",
|
||||
@@ -234,6 +236,7 @@ def _copy_bookmark_data(
|
||||
netscape_bookmark: NetscapeBookmark, bookmark: Bookmark, options: ImportOptions
|
||||
):
|
||||
bookmark.url = netscape_bookmark.href
|
||||
bookmark.url_normalized = normalize_url(bookmark.url)
|
||||
if netscape_bookmark.date_added:
|
||||
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
||||
else:
|
||||
|
||||
@@ -22,9 +22,10 @@ def create_snapshot(url: str, filepath: str):
|
||||
command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}"
|
||||
subprocess.run(command, check=True, shell=True)
|
||||
|
||||
with open(temp_filepath, "rb") as raw_file, gzip.open(
|
||||
filepath, "wb"
|
||||
) as gz_file:
|
||||
with (
|
||||
open(temp_filepath, "rb") as raw_file,
|
||||
gzip.open(filepath, "wb") as gz_file,
|
||||
):
|
||||
shutil.copyfileobj(raw_file, gz_file)
|
||||
|
||||
os.remove(temp_filepath)
|
||||
|
||||
575
bookmarks/services/search_query_parser.py
Normal file
575
bookmarks/services/search_query_parser.py
Normal file
@@ -0,0 +1,575 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from bookmarks.models import UserProfile
|
||||
|
||||
|
||||
class TokenType(Enum):
|
||||
TERM = "TERM"
|
||||
TAG = "TAG"
|
||||
SPECIAL_KEYWORD = "SPECIAL_KEYWORD"
|
||||
AND = "AND"
|
||||
OR = "OR"
|
||||
NOT = "NOT"
|
||||
LPAREN = "LPAREN"
|
||||
RPAREN = "RPAREN"
|
||||
EOF = "EOF"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Token:
|
||||
type: TokenType
|
||||
value: str
|
||||
position: int
|
||||
|
||||
|
||||
class SearchQueryTokenizer:
|
||||
def __init__(self, query: str):
|
||||
self.query = query.strip()
|
||||
self.position = 0
|
||||
self.current_char = self.query[0] if self.query else None
|
||||
|
||||
def advance(self):
|
||||
"""Move to the next character in the query."""
|
||||
self.position += 1
|
||||
if self.position >= len(self.query):
|
||||
self.current_char = None
|
||||
else:
|
||||
self.current_char = self.query[self.position]
|
||||
|
||||
def skip_whitespace(self):
|
||||
"""Skip whitespace characters."""
|
||||
while self.current_char and self.current_char.isspace():
|
||||
self.advance()
|
||||
|
||||
def read_term(self) -> str:
|
||||
"""Read a search term (sequence of non-whitespace, non-special characters)."""
|
||||
term = ""
|
||||
|
||||
while (
|
||||
self.current_char
|
||||
and not self.current_char.isspace()
|
||||
and self.current_char not in "()\"'#!"
|
||||
):
|
||||
term += self.current_char
|
||||
self.advance()
|
||||
|
||||
return term
|
||||
|
||||
def read_quoted_string(self, quote_char: str) -> str:
|
||||
"""Read a quoted string, handling escaped quotes."""
|
||||
content = ""
|
||||
self.advance() # skip opening quote
|
||||
|
||||
while self.current_char and self.current_char != quote_char:
|
||||
if self.current_char == "\\":
|
||||
# Handle escaped characters
|
||||
self.advance()
|
||||
if self.current_char:
|
||||
if self.current_char == "n":
|
||||
content += "\n"
|
||||
elif self.current_char == "t":
|
||||
content += "\t"
|
||||
elif self.current_char == "r":
|
||||
content += "\r"
|
||||
elif self.current_char == "\\":
|
||||
content += "\\"
|
||||
elif self.current_char == quote_char:
|
||||
content += quote_char
|
||||
else:
|
||||
# For any other escaped character, just include it as-is
|
||||
content += self.current_char
|
||||
self.advance()
|
||||
else:
|
||||
content += self.current_char
|
||||
self.advance()
|
||||
|
||||
if self.current_char == quote_char:
|
||||
self.advance() # skip closing quote
|
||||
else:
|
||||
# Unclosed quote - we could raise an error here, but let's be lenient
|
||||
# and treat it as if the quote was closed at the end
|
||||
pass
|
||||
|
||||
return content
|
||||
|
||||
def read_tag(self) -> str:
|
||||
"""Read a tag (starts with # and continues until whitespace or special chars)."""
|
||||
tag = ""
|
||||
self.advance() # skip the # character
|
||||
|
||||
while (
|
||||
self.current_char
|
||||
and not self.current_char.isspace()
|
||||
and self.current_char not in "()\"'"
|
||||
):
|
||||
tag += self.current_char
|
||||
self.advance()
|
||||
|
||||
return tag
|
||||
|
||||
def read_special_keyword(self) -> str:
|
||||
"""Read a special keyword (starts with ! and continues until whitespace or special chars)."""
|
||||
keyword = ""
|
||||
self.advance() # skip the ! character
|
||||
|
||||
while (
|
||||
self.current_char
|
||||
and not self.current_char.isspace()
|
||||
and self.current_char not in "()\"'"
|
||||
):
|
||||
keyword += self.current_char
|
||||
self.advance()
|
||||
|
||||
return keyword
|
||||
|
||||
def tokenize(self) -> List[Token]:
|
||||
"""Convert the query string into a list of tokens."""
|
||||
tokens = []
|
||||
|
||||
while self.current_char:
|
||||
self.skip_whitespace()
|
||||
|
||||
if not self.current_char:
|
||||
break
|
||||
|
||||
start_pos = self.position
|
||||
|
||||
if self.current_char == "(":
|
||||
tokens.append(Token(TokenType.LPAREN, "(", start_pos))
|
||||
self.advance()
|
||||
elif self.current_char == ")":
|
||||
tokens.append(Token(TokenType.RPAREN, ")", start_pos))
|
||||
self.advance()
|
||||
elif self.current_char in "\"'":
|
||||
# Read a quoted string - always treated as a term
|
||||
quote_char = self.current_char
|
||||
term = self.read_quoted_string(quote_char)
|
||||
tokens.append(Token(TokenType.TERM, term, start_pos))
|
||||
elif self.current_char == "#":
|
||||
# Read a tag
|
||||
tag = self.read_tag()
|
||||
# Only add the tag token if it has content
|
||||
if tag:
|
||||
tokens.append(Token(TokenType.TAG, tag, start_pos))
|
||||
elif self.current_char == "!":
|
||||
# Read a special keyword
|
||||
keyword = self.read_special_keyword()
|
||||
# Only add the keyword token if it has content
|
||||
if keyword:
|
||||
tokens.append(Token(TokenType.SPECIAL_KEYWORD, keyword, start_pos))
|
||||
else:
|
||||
# Read a term and check if it's an operator
|
||||
term = self.read_term()
|
||||
term_lower = term.lower()
|
||||
|
||||
if term_lower == "and":
|
||||
tokens.append(Token(TokenType.AND, term, start_pos))
|
||||
elif term_lower == "or":
|
||||
tokens.append(Token(TokenType.OR, term, start_pos))
|
||||
elif term_lower == "not":
|
||||
tokens.append(Token(TokenType.NOT, term, start_pos))
|
||||
else:
|
||||
tokens.append(Token(TokenType.TERM, term, start_pos))
|
||||
|
||||
tokens.append(Token(TokenType.EOF, "", len(self.query)))
|
||||
return tokens
|
||||
|
||||
|
||||
class SearchExpression:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TermExpression(SearchExpression):
|
||||
term: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagExpression(SearchExpression):
|
||||
tag: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpecialKeywordExpression(SearchExpression):
|
||||
keyword: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AndExpression(SearchExpression):
|
||||
left: SearchExpression
|
||||
right: SearchExpression
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrExpression(SearchExpression):
|
||||
left: SearchExpression
|
||||
right: SearchExpression
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotExpression(SearchExpression):
|
||||
operand: SearchExpression
|
||||
|
||||
|
||||
class SearchQueryParseError(Exception):
|
||||
def __init__(self, message: str, position: int):
|
||||
self.message = message
|
||||
self.position = position
|
||||
super().__init__(f"{message} at position {position}")
|
||||
|
||||
|
||||
class SearchQueryParser:
|
||||
def __init__(self, tokens: List[Token]):
|
||||
self.tokens = tokens
|
||||
self.position = 0
|
||||
self.current_token = tokens[0] if tokens else Token(TokenType.EOF, "", 0)
|
||||
|
||||
def advance(self):
|
||||
"""Move to the next token."""
|
||||
if self.position < len(self.tokens) - 1:
|
||||
self.position += 1
|
||||
self.current_token = self.tokens[self.position]
|
||||
|
||||
def consume(self, expected_type: TokenType) -> Token:
|
||||
"""Consume a token of the expected type or raise an error."""
|
||||
if self.current_token.type == expected_type:
|
||||
token = self.current_token
|
||||
self.advance()
|
||||
return token
|
||||
else:
|
||||
raise SearchQueryParseError(
|
||||
f"Expected {expected_type.value}, got {self.current_token.type.value}",
|
||||
self.current_token.position,
|
||||
)
|
||||
|
||||
def parse(self) -> Optional[SearchExpression]:
|
||||
"""Parse the tokens into an AST."""
|
||||
if not self.tokens or (
|
||||
len(self.tokens) == 1 and self.tokens[0].type == TokenType.EOF
|
||||
):
|
||||
return None
|
||||
|
||||
expr = self.parse_or_expression()
|
||||
|
||||
if self.current_token.type != TokenType.EOF:
|
||||
raise SearchQueryParseError(
|
||||
f"Unexpected token {self.current_token.type.value}",
|
||||
self.current_token.position,
|
||||
)
|
||||
|
||||
return expr
|
||||
|
||||
def parse_or_expression(self) -> SearchExpression:
|
||||
"""Parse OR expressions (lowest precedence)."""
|
||||
left = self.parse_and_expression()
|
||||
|
||||
while self.current_token.type == TokenType.OR:
|
||||
self.advance() # consume OR
|
||||
right = self.parse_and_expression()
|
||||
left = OrExpression(left, right)
|
||||
|
||||
return left
|
||||
|
||||
def parse_and_expression(self) -> SearchExpression:
|
||||
"""Parse AND expressions (medium precedence), including implicit AND."""
|
||||
left = self.parse_not_expression()
|
||||
|
||||
while self.current_token.type == TokenType.AND or self.current_token.type in [
|
||||
TokenType.TERM,
|
||||
TokenType.TAG,
|
||||
TokenType.SPECIAL_KEYWORD,
|
||||
TokenType.LPAREN,
|
||||
TokenType.NOT,
|
||||
]:
|
||||
|
||||
if self.current_token.type == TokenType.AND:
|
||||
self.advance() # consume explicit AND
|
||||
# else: implicit AND (don't advance token)
|
||||
|
||||
right = self.parse_not_expression()
|
||||
left = AndExpression(left, right)
|
||||
|
||||
return left
|
||||
|
||||
def parse_not_expression(self) -> SearchExpression:
|
||||
"""Parse NOT expressions (high precedence)."""
|
||||
if self.current_token.type == TokenType.NOT:
|
||||
self.advance() # consume NOT
|
||||
operand = self.parse_not_expression() # right associative
|
||||
return NotExpression(operand)
|
||||
|
||||
return self.parse_primary_expression()
|
||||
|
||||
def parse_primary_expression(self) -> SearchExpression:
|
||||
"""Parse primary expressions (terms, tags, special keywords, and parenthesized expressions)."""
|
||||
if self.current_token.type == TokenType.TERM:
|
||||
term = self.current_token.value
|
||||
self.advance()
|
||||
return TermExpression(term)
|
||||
elif self.current_token.type == TokenType.TAG:
|
||||
tag = self.current_token.value
|
||||
self.advance()
|
||||
return TagExpression(tag)
|
||||
elif self.current_token.type == TokenType.SPECIAL_KEYWORD:
|
||||
keyword = self.current_token.value
|
||||
self.advance()
|
||||
return SpecialKeywordExpression(keyword)
|
||||
elif self.current_token.type == TokenType.LPAREN:
|
||||
self.advance() # consume (
|
||||
expr = self.parse_or_expression()
|
||||
self.consume(TokenType.RPAREN) # consume )
|
||||
return expr
|
||||
else:
|
||||
raise SearchQueryParseError(
|
||||
f"Unexpected token {self.current_token.type.value}",
|
||||
self.current_token.position,
|
||||
)
|
||||
|
||||
|
||||
def parse_search_query(query: str) -> Optional[SearchExpression]:
|
||||
if not query or not query.strip():
|
||||
return None
|
||||
|
||||
tokenizer = SearchQueryTokenizer(query)
|
||||
tokens = tokenizer.tokenize()
|
||||
parser = SearchQueryParser(tokens)
|
||||
return parser.parse()
|
||||
|
||||
|
||||
def _needs_parentheses(expr: SearchExpression, parent_type: type) -> bool:
|
||||
if isinstance(expr, OrExpression) and parent_type == AndExpression:
|
||||
return True
|
||||
# AndExpression or OrExpression needs parentheses when inside NotExpression
|
||||
if isinstance(expr, (AndExpression, OrExpression)) and parent_type == NotExpression:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_simple_expression(expr: SearchExpression) -> bool:
|
||||
"""Check if an expression is simple (term, tag, or keyword)."""
|
||||
return isinstance(expr, (TermExpression, TagExpression, SpecialKeywordExpression))
|
||||
|
||||
|
||||
def _expression_to_string(expr: SearchExpression, parent_type: type = None) -> str:
|
||||
if isinstance(expr, TermExpression):
|
||||
# Quote terms if they contain spaces or special characters
|
||||
if " " in expr.term or any(c in expr.term for c in ["(", ")", '"', "'"]):
|
||||
# Escape any quotes in the term
|
||||
escaped = expr.term.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
return expr.term
|
||||
|
||||
elif isinstance(expr, TagExpression):
|
||||
return f"#{expr.tag}"
|
||||
|
||||
elif isinstance(expr, SpecialKeywordExpression):
|
||||
return f"!{expr.keyword}"
|
||||
|
||||
elif isinstance(expr, NotExpression):
|
||||
# Don't pass parent type to children
|
||||
operand_str = _expression_to_string(expr.operand, None)
|
||||
# Add parentheses if the operand is a binary operation
|
||||
if isinstance(expr.operand, (AndExpression, OrExpression)):
|
||||
return f"not ({operand_str})"
|
||||
return f"not {operand_str}"
|
||||
|
||||
elif isinstance(expr, AndExpression):
|
||||
# Don't pass parent type to children - they'll add their own parens only if needed
|
||||
left_str = _expression_to_string(expr.left, None)
|
||||
right_str = _expression_to_string(expr.right, None)
|
||||
|
||||
# Add parentheses to children if needed for precedence
|
||||
if _needs_parentheses(expr.left, AndExpression):
|
||||
left_str = f"({left_str})"
|
||||
if _needs_parentheses(expr.right, AndExpression):
|
||||
right_str = f"({right_str})"
|
||||
|
||||
result = f"{left_str} {right_str}"
|
||||
|
||||
# Add outer parentheses if needed based on parent context
|
||||
if parent_type and _needs_parentheses(expr, parent_type):
|
||||
result = f"({result})"
|
||||
|
||||
return result
|
||||
|
||||
elif isinstance(expr, OrExpression):
|
||||
# Don't pass parent type to children
|
||||
left_str = _expression_to_string(expr.left, None)
|
||||
right_str = _expression_to_string(expr.right, None)
|
||||
|
||||
# OrExpression children don't need parentheses unless they're also OR (handled by recursion)
|
||||
result = f"{left_str} or {right_str}"
|
||||
|
||||
# Add outer parentheses if needed based on parent context
|
||||
if parent_type and _needs_parentheses(expr, parent_type):
|
||||
result = f"({result})"
|
||||
|
||||
return result
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown expression type: {type(expr)}")
|
||||
|
||||
|
||||
def expression_to_string(expr: Optional[SearchExpression]) -> str:
|
||||
if expr is None:
|
||||
return ""
|
||||
return _expression_to_string(expr)
|
||||
|
||||
|
||||
def _strip_tag_from_expression(
|
||||
expr: Optional[SearchExpression], tag_name: str, enable_lax_search: bool = False
|
||||
) -> Optional[SearchExpression]:
|
||||
if expr is None:
|
||||
return None
|
||||
|
||||
if isinstance(expr, TagExpression):
|
||||
# Remove this tag if it matches
|
||||
if expr.tag.lower() == tag_name.lower():
|
||||
return None
|
||||
return expr
|
||||
|
||||
elif isinstance(expr, TermExpression):
|
||||
# In lax search mode, also remove terms that match the tag name
|
||||
if enable_lax_search and expr.term.lower() == tag_name.lower():
|
||||
return None
|
||||
return expr
|
||||
|
||||
elif isinstance(expr, SpecialKeywordExpression):
|
||||
# Keep special keywords as-is
|
||||
return expr
|
||||
|
||||
elif isinstance(expr, NotExpression):
|
||||
# Recursively filter the operand
|
||||
filtered_operand = _strip_tag_from_expression(
|
||||
expr.operand, tag_name, enable_lax_search
|
||||
)
|
||||
if filtered_operand is None:
|
||||
# If the operand is removed, the whole NOT expression should be removed
|
||||
return None
|
||||
return NotExpression(filtered_operand)
|
||||
|
||||
elif isinstance(expr, AndExpression):
|
||||
# Recursively filter both sides
|
||||
left = _strip_tag_from_expression(expr.left, tag_name, enable_lax_search)
|
||||
right = _strip_tag_from_expression(expr.right, tag_name, enable_lax_search)
|
||||
|
||||
# If both sides are removed, remove the AND expression
|
||||
if left is None and right is None:
|
||||
return None
|
||||
# If one side is removed, return the other side
|
||||
elif left is None:
|
||||
return right
|
||||
elif right is None:
|
||||
return left
|
||||
else:
|
||||
return AndExpression(left, right)
|
||||
|
||||
elif isinstance(expr, OrExpression):
|
||||
# Recursively filter both sides
|
||||
left = _strip_tag_from_expression(expr.left, tag_name, enable_lax_search)
|
||||
right = _strip_tag_from_expression(expr.right, tag_name, enable_lax_search)
|
||||
|
||||
# If both sides are removed, remove the OR expression
|
||||
if left is None and right is None:
|
||||
return None
|
||||
# If one side is removed, return the other side
|
||||
elif left is None:
|
||||
return right
|
||||
elif right is None:
|
||||
return left
|
||||
else:
|
||||
return OrExpression(left, right)
|
||||
|
||||
else:
|
||||
# Unknown expression type, return as-is
|
||||
return expr
|
||||
|
||||
|
||||
def strip_tag_from_query(
|
||||
query: str, tag_name: str, user_profile: UserProfile | None = None
|
||||
) -> str:
|
||||
try:
|
||||
ast = parse_search_query(query)
|
||||
except SearchQueryParseError:
|
||||
return query
|
||||
|
||||
if ast is None:
|
||||
return ""
|
||||
|
||||
# Determine if lax search is enabled
|
||||
enable_lax_search = False
|
||||
if user_profile is not None:
|
||||
enable_lax_search = user_profile.tag_search == UserProfile.TAG_SEARCH_LAX
|
||||
|
||||
# Strip the tag from the AST
|
||||
filtered_ast = _strip_tag_from_expression(ast, tag_name, enable_lax_search)
|
||||
|
||||
# Convert back to a query string
|
||||
return expression_to_string(filtered_ast)
|
||||
|
||||
|
||||
def _extract_tag_names_from_expression(
|
||||
expr: Optional[SearchExpression], enable_lax_search: bool = False
|
||||
) -> List[str]:
|
||||
if expr is None:
|
||||
return []
|
||||
|
||||
if isinstance(expr, TagExpression):
|
||||
return [expr.tag]
|
||||
|
||||
elif isinstance(expr, TermExpression):
|
||||
# In lax search mode, terms are also considered tags
|
||||
if enable_lax_search:
|
||||
return [expr.term]
|
||||
return []
|
||||
|
||||
elif isinstance(expr, SpecialKeywordExpression):
|
||||
# Special keywords are not tags
|
||||
return []
|
||||
|
||||
elif isinstance(expr, NotExpression):
|
||||
# Recursively extract from the operand
|
||||
return _extract_tag_names_from_expression(expr.operand, enable_lax_search)
|
||||
|
||||
elif isinstance(expr, (AndExpression, OrExpression)):
|
||||
# Recursively extract from both sides and combine
|
||||
left_tags = _extract_tag_names_from_expression(expr.left, enable_lax_search)
|
||||
right_tags = _extract_tag_names_from_expression(expr.right, enable_lax_search)
|
||||
return left_tags + right_tags
|
||||
|
||||
else:
|
||||
# Unknown expression type
|
||||
return []
|
||||
|
||||
|
||||
def extract_tag_names_from_query(
|
||||
query: str, user_profile: UserProfile | None = None
|
||||
) -> List[str]:
|
||||
try:
|
||||
ast = parse_search_query(query)
|
||||
except SearchQueryParseError:
|
||||
return []
|
||||
|
||||
if ast is None:
|
||||
return []
|
||||
|
||||
# Determine if lax search is enabled
|
||||
enable_lax_search = False
|
||||
if user_profile is not None:
|
||||
enable_lax_search = user_profile.tag_search == UserProfile.TAG_SEARCH_LAX
|
||||
|
||||
# Extract tag names from the AST
|
||||
tag_names = _extract_tag_names_from_expression(ast, enable_lax_search)
|
||||
|
||||
# Deduplicate (case-insensitive) and sort
|
||||
seen = set()
|
||||
unique_tags = []
|
||||
for tag in tag_names:
|
||||
tag_lower = tag.lower()
|
||||
if tag_lower not in seen:
|
||||
seen.add(tag_lower)
|
||||
unique_tags.append(tag_lower)
|
||||
|
||||
return sorted(unique_tags)
|
||||
@@ -346,12 +346,6 @@ li[ld-bookmark-item] {
|
||||
.bookmark-pagination {
|
||||
margin-top: var(--unit-4);
|
||||
|
||||
/* Remove left padding from first pagination link */
|
||||
|
||||
& .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.sticky {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
@@ -365,7 +359,8 @@ li[ld-bookmark-item] {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
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(
|
||||
var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
.bundles-page {
|
||||
h1 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
.item-list {
|
||||
.list-item .list-item-icon {
|
||||
.crud-table {
|
||||
svg {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.list-item.drag-start {
|
||||
tr.drag-start {
|
||||
--secondary-border-color: transparent;
|
||||
}
|
||||
|
||||
.list-item.dragging > * {
|
||||
visibility: hidden;
|
||||
tr.dragging > * {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,22 +31,17 @@
|
||||
}
|
||||
|
||||
/* Confirm button component */
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--unit-1);
|
||||
color: var(--error-color) !important;
|
||||
.confirm-dropdown.active {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: var(--error-color) !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
& .menu {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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,6 +22,7 @@
|
||||
@import "responsive.css";
|
||||
@import "layout.css";
|
||||
@import "components.css";
|
||||
@import "crud.css";
|
||||
@import "bookmark-details.css";
|
||||
@import "bookmark-form.css";
|
||||
@import "bookmark-page.css";
|
||||
@@ -29,3 +30,4 @@
|
||||
@import "reader-mode.css";
|
||||
@import "settings.css";
|
||||
@import "bundles.css";
|
||||
@import "tags.css";
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
position: relative;
|
||||
|
||||
& .form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
align-content: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
min-height: var(--unit-8);
|
||||
padding: var(--unit-h);
|
||||
background: var(--input-bg-color);
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
|
||||
&.is-focused {
|
||||
outline: var(--focus-outline);
|
||||
@@ -22,10 +23,11 @@
|
||||
box-shadow: none;
|
||||
display: inline-block;
|
||||
flex: 1 0 auto;
|
||||
height: var(--unit-6);
|
||||
line-height: var(--unit-4);
|
||||
margin: var(--unit-h);
|
||||
width: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
|
||||
&:focus {
|
||||
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 {
|
||||
display: none;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
max-height: var(--menu-max-height, 200px);
|
||||
overflow: auto;
|
||||
|
||||
& .menu-item.selected > a,
|
||||
& .menu-item > a:hover {
|
||||
@@ -54,4 +75,8 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
& .menu.open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,12 +113,19 @@
|
||||
&.btn-error {
|
||||
--btn-border-color: var(--error-color);
|
||||
--btn-text-color: var(--error-color);
|
||||
--btn-icon-color: var(--error-color);
|
||||
|
||||
&:hover {
|
||||
--btn-hover-bg-color: var(--error-color-shade);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button no border */
|
||||
&.btn-noborder {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Button Link */
|
||||
|
||||
&.btn-link {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
padding: var(--unit-16) var(--unit-8);
|
||||
padding: var(--unit-8) var(--unit-8);
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: var(--layout-spacing-lg);
|
||||
|
||||
@@ -430,13 +430,21 @@ textarea.form-input {
|
||||
/* Form element: Input groups */
|
||||
.input-group {
|
||||
display: flex;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
|
||||
> * {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.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-radius: var(--border-radius);
|
||||
line-height: var(--line-height);
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
padding: 0 var(--control-padding-x);
|
||||
white-space: nowrap;
|
||||
|
||||
&.addon-sm {
|
||||
|
||||
@@ -87,4 +87,43 @@
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
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 {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +97,6 @@
|
||||
& .modal-footer {
|
||||
padding: var(--unit-6);
|
||||
padding-top: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child a {
|
||||
/* Remove left padding from first pagination link */
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
& a {
|
||||
background: var(--primary-color);
|
||||
|
||||
@@ -5,22 +5,19 @@
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
/* Scrollable tables */
|
||||
|
||||
&.table-scroll {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.75rem;
|
||||
white-space: nowrap;
|
||||
td,
|
||||
th {
|
||||
border-bottom: var(--border-width) solid var(--secondary-border-color);
|
||||
padding: var(--unit-2) var(--unit-2);
|
||||
}
|
||||
|
||||
& td,
|
||||
& th {
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
padding: var(--unit-3) var(--unit-2);
|
||||
th {
|
||||
font-weight: 500;
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
|
||||
& th {
|
||||
border-bottom-width: var(--border-width-lg);
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,36 @@
|
||||
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;
|
||||
}
|
||||
@@ -291,6 +321,10 @@
|
||||
}
|
||||
|
||||
/* Flex */
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
@@ -302,3 +336,7 @@
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
@@ -49,20 +49,22 @@
|
||||
--body-color-contrast: var(--gray-100);
|
||||
|
||||
/* Fonts */
|
||||
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto;
|
||||
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier,
|
||||
monospace;
|
||||
--base-font-family:
|
||||
-apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto;
|
||||
--mono-font-family:
|
||||
"SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace;
|
||||
--fallback-font-family: "Helvetica Neue", sans-serif;
|
||||
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
|
||||
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC",
|
||||
"Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
|
||||
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans",
|
||||
"Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo,
|
||||
var(--fallback-font-family);
|
||||
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic",
|
||||
var(--fallback-font-family);
|
||||
--cjk-zh-hans-font-family:
|
||||
var(--base-font-family), "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei", var(--fallback-font-family);
|
||||
--cjk-zh-hant-font-family:
|
||||
var(--base-font-family), "PingFang TC", "Hiragino Sans CNS",
|
||||
"Microsoft JhengHei", var(--fallback-font-family);
|
||||
--cjk-jp-font-family:
|
||||
var(--base-font-family), "Hiragino Sans", "Hiragino Kaku Gothic Pro",
|
||||
"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);
|
||||
|
||||
/* Unit sizes */
|
||||
@@ -145,6 +147,6 @@
|
||||
/* Shadows */
|
||||
--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-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--box-shadow-lg:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
@@ -36,14 +36,14 @@
|
||||
{% endif %}
|
||||
{% if bookmark_list.description_display == 'inline' %}
|
||||
<div class="description inline truncate">
|
||||
{% if bookmark_item.tag_names %}
|
||||
{% if bookmark_item.tags %}
|
||||
<span class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% for tag in bookmark_item.tags %}
|
||||
<a href="?{{ tag.query_string }}">#{{ tag.name }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.tags and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% endif %}
|
||||
@@ -52,10 +52,10 @@
|
||||
{% if bookmark_item.description %}
|
||||
<div class="description separate">{{ bookmark_item.description }}</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
{% if bookmark_item.tags %}
|
||||
<div class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% for tag in bookmark_item.tags %}
|
||||
<a href="?{{ tag.query_string }}">#{{ tag.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -120,7 +120,7 @@
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
ld-confirm-button ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
@@ -130,7 +130,7 @@
|
||||
{% 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-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="section-header no-wrap">
|
||||
<h2 id="bundles-heading">Bundles</h2>
|
||||
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||
<button class="btn dropdown-toggle" aria-label="Bundles menu">
|
||||
<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"/>
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
<section class="tags col-1">
|
||||
<h3 id="details-modal-tags-title">Tags</h3>
|
||||
<div>
|
||||
{% for tag_name in details.bookmark.tag_names %}
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% for tag in details.tags %}
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?{{ tag.query_string }}">#{{ tag.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<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"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
@@ -32,7 +32,7 @@
|
||||
<input type="hidden" name="disable_turbo" value="true">
|
||||
<button ld-confirm-button class="btn btn-error btn-wide"
|
||||
type="submit" name="remove" value="{{ details.bookmark.id }}">
|
||||
Delete...
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<div class="empty">
|
||||
<p class="empty-title h5">You have no bookmarks yet</p>
|
||||
<p class="empty-subtitle">
|
||||
You can get started by <a href="{% url 'linkding:bookmarks.new' %}">adding</a> bookmarks,
|
||||
<a href="{% url 'linkding:settings.general' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'linkding:settings.integrations' %}">browser extension</a> or the <a
|
||||
href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>.
|
||||
</p>
|
||||
{% if not bookmark_list.query_is_valid %}
|
||||
<p class="empty-title h5">Invalid search query</p>
|
||||
<p class="empty-subtitle">
|
||||
The search query you entered is not valid. Common reasons are unclosed parentheses or a logical operator (AND, OR,
|
||||
NOT) without operands. The error message from the parser is: "{{ bookmark_list.query_error_message }}".
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="empty-title h5">You have no bookmarks yet</p>
|
||||
<p class="empty-subtitle">
|
||||
You can get started by <a href="{% url 'linkding:bookmarks.new' %}">adding</a> bookmarks,
|
||||
<a href="{% url 'linkding:settings.general' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'linkding:settings.integrations' %}">browser extension</a> or the <a
|
||||
href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -18,18 +18,6 @@
|
||||
<path d="M21 6l0 13"></path>
|
||||
</symbol>
|
||||
</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">
|
||||
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -41,18 +29,6 @@
|
||||
<path d="M8.7 13.3l6.6 3.4"></path>
|
||||
</symbol>
|
||||
</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">
|
||||
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% if tag_cloud.has_selected_tags %}
|
||||
<p class="selected-tags">
|
||||
{% for tag in tag_cloud.selected_tags %}
|
||||
<a href="?{% remove_tag_from_query tag.name %}"
|
||||
<a href="?{{ tag.query_string }}"
|
||||
class="text-bold mr-2">
|
||||
<span>-{{ tag.name }}</span>
|
||||
</a>
|
||||
@@ -15,16 +15,16 @@
|
||||
{% for group in tag_cloud.groups %}
|
||||
<p class="group">
|
||||
{% for tag in group.tags %}
|
||||
{# Highlight first char of first tag in group #}
|
||||
{% if forloop.counter == 1 %}
|
||||
<a href="?{% add_tag_to_query tag.name %}"
|
||||
{# Highlight first char of first tag in group if grouping is enabled #}
|
||||
{% if group.highlight_first_char and forloop.counter == 1 %}
|
||||
<a href="?{{ tag.query_string }}"
|
||||
class="mr-2" data-is-tag-item>
|
||||
<span
|
||||
class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
{# Render remaining tags normally #}
|
||||
<a href="?{% add_tag_to_query tag.name %}"
|
||||
{# Render tags normally #}
|
||||
<a href="?{{ tag.query_string }}"
|
||||
class="mr-2" data-is-tag-item>
|
||||
<span>{{ tag.name }}</span>
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<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' %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group {% if form.search.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.search.id_for_label }}" class="form-label">Search</label>
|
||||
<label for="{{ form.search.id_for_label }}" class="form-label">Search terms</label>
|
||||
{{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||
{% if form.search.errors %}
|
||||
<div class="form-input-hint">
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-input-hint">
|
||||
Search terms to match bookmarks in this bundle.
|
||||
All of these search terms must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,6 +86,5 @@
|
||||
}
|
||||
|
||||
bundleForm.addEventListener('input', scheduleUpdate);
|
||||
bundleForm.addEventListener('change', scheduleUpdate);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -7,41 +7,55 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="bundles-page" aria-labelledby="main-heading">
|
||||
<h1 id="main-heading">Bundles</h1>
|
||||
<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 %}
|
||||
<div class="item-list bundles">
|
||||
<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 %}
|
||||
<div class="list-item" data-bundle-id="{{ bundle.id }}" draggable="true">
|
||||
<div class="list-item-icon text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" 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>
|
||||
</div>
|
||||
<div class="list-item-text">
|
||||
<span class="truncate">{{ bundle.name }}</span>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<input type="submit" name="move_bundle" value="" class="d-none">
|
||||
<input type="hidden" name="move_position" value="">
|
||||
</form>
|
||||
@@ -51,21 +65,17 @@
|
||||
<p class="empty-subtitle">Create your first bundle to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'linkding:bundles.new' %}" class="btn btn-primary">Add new bundle</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function init() {
|
||||
const bundlesList = document.querySelector(".item-list.bundles");
|
||||
if (!bundlesList) return;
|
||||
const tableBody = document.querySelector(".crud-table tbody");
|
||||
if (!tableBody) return;
|
||||
|
||||
let draggedElement = null;
|
||||
|
||||
const listItems = bundlesList.querySelectorAll('.list-item');
|
||||
listItems.forEach((item) => {
|
||||
const rows = tableBody.querySelectorAll('tr');
|
||||
rows.forEach((item) => {
|
||||
item.addEventListener('dragstart', handleDragStart);
|
||||
item.addEventListener('dragend', handleDragEnd);
|
||||
item.addEventListener('dragover', handleDragOver);
|
||||
@@ -91,7 +101,7 @@
|
||||
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(bundlesList.children).indexOf(draggedElement);
|
||||
movePositionInput.value = Array.from(tableBody.children).indexOf(draggedElement);
|
||||
|
||||
const form = this.closest('form');
|
||||
form.requestSubmit(moveBundleInput);
|
||||
@@ -108,7 +118,7 @@
|
||||
|
||||
function handleDragEnter() {
|
||||
if (this !== draggedElement) {
|
||||
const listItems = Array.from(bundlesList.children);
|
||||
const listItems = Array.from(tableBody.children);
|
||||
const draggedIndex = listItems.indexOf(draggedElement);
|
||||
const currentIndex = listItems.indexOf(this);
|
||||
|
||||
|
||||
@@ -158,6 +158,18 @@
|
||||
result will also include bookmarks where a search term matches otherwise.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.legacy_search.id_for_label }}" class="form-checkbox">
|
||||
{{ form.legacy_search }}
|
||||
<i class="form-icon"></i> Enable legacy search
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Since version 1.44.0, linkding has a new search engine that supports logical expressions (and, or, not).
|
||||
If you run into any issues with the new search, you can enable this option to temporarily switch back to the old search.
|
||||
Please report any issues you encounter with the new search on <a href="https://github.com/sissbruecker/linkding/issues" target="_blank">GitHub</a> so they can be addressed.
|
||||
This option will be removed in a future version.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_grouping.id_for_label }}" class="form-label">Tag grouping</label>
|
||||
{{ form.tag_grouping|add_class:"form-select width-25 width-sm-100" }}
|
||||
@@ -270,6 +282,17 @@ reddit.com/r/Music music reddit</pre>
|
||||
This can be overridden when creating each new bookmark.
|
||||
</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">
|
||||
<details {% if form.custom_css.value %}open{% endif %}>
|
||||
<summary>
|
||||
@@ -383,17 +406,17 @@ reddit.com/r/Music music reddit</pre>
|
||||
<td>{{ version_info }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3" style="vertical-align: top">Links</td>
|
||||
<td><a href="https://github.com/sissbruecker/linkding/"
|
||||
target="_blank">GitHub</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://linkding.link/"
|
||||
target="_blank">Documentation</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
|
||||
target="_blank">Changelog</a></td>
|
||||
<td style="vertical-align: top">Links</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<a href="https://github.com/sissbruecker/linkding/"
|
||||
target="_blank">GitHub</a>
|
||||
<a href="https://linkding.link/"
|
||||
target="_blank">Documentation</a>
|
||||
<a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
|
||||
target="_blank">Changelog</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -404,21 +427,25 @@ reddit.com/r/Music music reddit</pre>
|
||||
(function init() {
|
||||
const enableSharing = document.getElementById("{{ form.enable_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 bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
|
||||
|
||||
// Automatically disable public bookmark sharing if bookmark sharing is disabled
|
||||
function updatePublicSharing() {
|
||||
// Automatically disable public bookmark sharing and default shared option if bookmark sharing is disabled
|
||||
function updateSharingOptions() {
|
||||
if (enableSharing.checked) {
|
||||
enablePublicSharing.disabled = false;
|
||||
defaultMarkShared.disabled = false;
|
||||
} else {
|
||||
enablePublicSharing.disabled = true;
|
||||
enablePublicSharing.checked = false;
|
||||
defaultMarkShared.disabled = true;
|
||||
defaultMarkShared.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
updatePublicSharing();
|
||||
enableSharing.addEventListener("change", updatePublicSharing);
|
||||
updateSharingOptions();
|
||||
enableSharing.addEventListener("change", updateSharingOptions);
|
||||
|
||||
// Automatically hide the bookmark description max lines input if the description display is set to inline
|
||||
function updateBookmarkDescriptionMaxLines() {
|
||||
|
||||
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 %}
|
||||
@@ -14,7 +14,7 @@ register = template.Library()
|
||||
)
|
||||
def pagination(context, page: Page):
|
||||
request = context["request"]
|
||||
base_url = request.build_absolute_uri(request.path)
|
||||
base_url = request.path
|
||||
|
||||
# remove page number and details from query parameters
|
||||
query_params = request.GET.copy()
|
||||
|
||||
@@ -23,53 +23,6 @@ def update_query_string(context, **kwargs):
|
||||
return query.urlencode()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def add_tag_to_query(context, tag_name: str):
|
||||
params = context.request.GET.copy()
|
||||
|
||||
# Append to or create query string
|
||||
query_string = params.get("q", "")
|
||||
query_string = (query_string + " #" + tag_name).strip()
|
||||
params.setlist("q", [query_string])
|
||||
|
||||
# Remove details ID and page number
|
||||
params.pop("details", None)
|
||||
params.pop("page", None)
|
||||
|
||||
return params.urlencode()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def remove_tag_from_query(context, tag_name: str):
|
||||
params = context.request.GET.copy()
|
||||
if params.__contains__("q"):
|
||||
# Split query string into parts
|
||||
query_string = params.__getitem__("q")
|
||||
query_parts = query_string.split()
|
||||
# Remove tag with hash
|
||||
tag_name_with_hash = "#" + tag_name
|
||||
query_parts = [
|
||||
part
|
||||
for part in query_parts
|
||||
if str.lower(part) != str.lower(tag_name_with_hash)
|
||||
]
|
||||
# When using lax tag search, also remove tag without hash
|
||||
profile = context.request.user_profile
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
query_parts = [
|
||||
part for part in query_parts if str.lower(part) != str.lower(tag_name)
|
||||
]
|
||||
# Rebuild query string
|
||||
query_string = " ".join(query_parts)
|
||||
params.__setitem__("q", query_string)
|
||||
|
||||
# Remove details ID and page number
|
||||
params.pop("details", None)
|
||||
params.pop("page", None)
|
||||
|
||||
return params.urlencode()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def replace_query_param(context, **kwargs):
|
||||
query = context.request.GET.copy()
|
||||
@@ -82,11 +35,6 @@ def replace_query_param(context, **kwargs):
|
||||
return query.urlencode()
|
||||
|
||||
|
||||
@register.filter(name="hash_tag")
|
||||
def hash_tag(tag_name):
|
||||
return "#" + tag_name
|
||||
|
||||
|
||||
@register.filter(name="first_char")
|
||||
def first_char(text):
|
||||
return text[0]
|
||||
|
||||
@@ -141,7 +141,7 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_reader_view_access_guest_user(self):
|
||||
self.view_access_guest_user_test("linkding:assets.read")
|
||||
|
||||
def test_snapshot_download_name(self):
|
||||
def test_snapshot_download_headers(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset_with_file(bookmark)
|
||||
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
|
||||
@@ -151,8 +151,9 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response["Content-Disposition"],
|
||||
f'inline; filename="{asset.display_name}.html"',
|
||||
)
|
||||
self.assertEqual(response["Content-Security-Policy"], "sandbox")
|
||||
|
||||
def test_uploaded_file_download_name(self):
|
||||
def test_uploaded_file_download_headers(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset_with_uploaded_file(bookmark)
|
||||
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
|
||||
@@ -162,3 +163,4 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response["Content-Disposition"],
|
||||
f'inline; filename="{asset.display_name}"',
|
||||
)
|
||||
self.assertEqual(response["Content-Security-Policy"], "sandbox")
|
||||
|
||||
@@ -501,7 +501,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
modal = self.get_index_details_modal(bookmark)
|
||||
delete_button = modal.find("button", {"type": "submit", "name": "remove"})
|
||||
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"])
|
||||
|
||||
form = delete_button.find_parent("form")
|
||||
|
||||
@@ -188,6 +188,25 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
edited_bookmark.refresh_from_db()
|
||||
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):
|
||||
bookmark = self.setup_bookmark()
|
||||
form_data = self.create_form_data()
|
||||
|
||||
@@ -281,3 +281,28 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
'<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,
|
||||
)
|
||||
|
||||
@@ -660,3 +660,21 @@ class BookmarkSharedViewTestCase(
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNotNone(feed)
|
||||
self.assertEqual(feed.attrs["href"], reverse("linkding:feeds.public_shared"))
|
||||
|
||||
def test_tag_menu_visible_for_authenticated_user(self):
|
||||
self.authenticate()
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
html = response.content.decode()
|
||||
|
||||
soup = self.make_soup(html)
|
||||
tag_menu = soup.find(attrs={"aria-label": "Tags menu"})
|
||||
self.assertIsNotNone(tag_menu)
|
||||
|
||||
def test_tag_menu_not_visible_for_unauthenticated_user(self):
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
html = response.content.decode()
|
||||
|
||||
soup = self.make_soup(html)
|
||||
tag_menu = soup.find(attrs={"aria-label": "Tags menu"})
|
||||
self.assertIsNone(tag_menu)
|
||||
|
||||
@@ -1047,6 +1047,29 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(expected_metadata.description, metadata["description"])
|
||||
self.assertEqual(expected_metadata.preview_image, metadata["preview_image"])
|
||||
|
||||
def test_check_returns_bookmark_using_normalized_url(self):
|
||||
self.authenticate()
|
||||
|
||||
# Create bookmark with one URL variant
|
||||
bookmark = self.setup_bookmark(
|
||||
url="https://EXAMPLE.COM/path/?z=1&a=2",
|
||||
title="Example title",
|
||||
description="Example description",
|
||||
)
|
||||
|
||||
# Check with different URL variant that should normalize to the same URL
|
||||
url = reverse("linkding:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com/path?a=2&z=1")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
bookmark_data = response.data["bookmark"]
|
||||
|
||||
# Should find the existing bookmark despite URL differences
|
||||
self.assertIsNotNone(bookmark_data)
|
||||
self.assertEqual(bookmark.id, bookmark_data["id"])
|
||||
self.assertEqual(bookmark.title, bookmark_data["title"])
|
||||
|
||||
def test_check_returns_no_auto_tags_if_none_configured(self):
|
||||
self.authenticate()
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
f"""
|
||||
<button type="submit" name="unshare" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
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>
|
||||
@@ -247,7 +247,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
f"""
|
||||
<button type="submit" name="mark_as_read" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
ld-confirm-button ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
@@ -476,6 +476,27 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
self.assertEqual(tag_links[1].text, "#tag2")
|
||||
self.assertEqual(tag_links[2].text, "#tag3")
|
||||
|
||||
def test_bookmark_tag_query_string(self):
|
||||
# appends tag to existing query string
|
||||
bookmark = self.setup_bookmark(title="term1 term2")
|
||||
tag1 = self.setup_tag(name="tag1")
|
||||
bookmark.tags.add(tag1)
|
||||
|
||||
html = self.render_template(url="/bookmarks?q=term1 and term2")
|
||||
soup = self.make_soup(html)
|
||||
tags = soup.select_one(".tags")
|
||||
tag_links = tags.find_all("a")
|
||||
self.assertEqual(len(tag_links), 1)
|
||||
self.assertEqual(tag_links[0]["href"], "?q=term1+and+term2+%23tag1")
|
||||
|
||||
# wraps or expression in parentheses
|
||||
html = self.render_template(url="/bookmarks?q=term1 or term2")
|
||||
soup = self.make_soup(html)
|
||||
tags = soup.select_one(".tags")
|
||||
tag_links = tags.find_all("a")
|
||||
self.assertEqual(len(tag_links), 1)
|
||||
self.assertEqual(tag_links[0]["href"], "?q=%28term1+or+term2%29+%23tag1")
|
||||
|
||||
def test_should_render_web_archive_link_with_absolute_date_setting(self):
|
||||
bookmark = self.setup_date_format_test(
|
||||
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,
|
||||
@@ -1017,6 +1038,34 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
'<p class="empty-title h5">You have no bookmarks yet</p>', html
|
||||
)
|
||||
|
||||
def test_empty_state_with_valid_query_no_results(self):
|
||||
self.setup_bookmark(title="Test Bookmark")
|
||||
html = self.render_template(url="/bookmarks?q=nonexistent")
|
||||
|
||||
self.assertInHTML(
|
||||
'<p class="empty-title h5">You have no bookmarks yet</p>', html
|
||||
)
|
||||
|
||||
def test_empty_state_with_invalid_query(self):
|
||||
self.setup_bookmark()
|
||||
html = self.render_template(url="/bookmarks?q=(test")
|
||||
|
||||
self.assertInHTML('<p class="empty-title h5">Invalid search query</p>', html)
|
||||
self.assertIn("Expected RPAREN", html)
|
||||
|
||||
def test_empty_state_with_legacy_search(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.legacy_search = True
|
||||
profile.save()
|
||||
|
||||
self.setup_bookmark()
|
||||
html = self.render_template(url="/bookmarks?q=(test")
|
||||
|
||||
# With legacy search, search queries are not validated
|
||||
self.assertInHTML(
|
||||
'<p class="empty-title h5">You have no bookmarks yet</p>', html
|
||||
)
|
||||
|
||||
def test_pagination_is_not_sticky_by_default(self):
|
||||
self.setup_bookmark()
|
||||
html = self.render_template()
|
||||
|
||||
@@ -95,6 +95,82 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
# Saving a duplicate bookmark should not modify archive flag - right?
|
||||
self.assertFalse(updated_bookmark.is_archived)
|
||||
|
||||
def test_create_should_update_existing_bookmark_with_normalized_url(
|
||||
self,
|
||||
):
|
||||
original_bookmark = self.setup_bookmark(
|
||||
url="https://EXAMPLE.com/path/?a=1&z=2", unread=False, shared=False
|
||||
)
|
||||
bookmark_data = Bookmark(
|
||||
url="HTTPS://example.com/path?z=2&a=1",
|
||||
title="Updated Title",
|
||||
description="Updated description",
|
||||
)
|
||||
updated_bookmark = create_bookmark(
|
||||
bookmark_data, "", self.get_or_create_test_user()
|
||||
)
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
self.assertEqual(updated_bookmark.id, original_bookmark.id)
|
||||
self.assertEqual(updated_bookmark.title, bookmark_data.title)
|
||||
|
||||
def test_create_should_update_existing_bookmark_when_normalized_url_is_empty(
|
||||
self,
|
||||
):
|
||||
# Test behavior when url_normalized is empty for whatever reason
|
||||
# In this case should at least match the URL directly
|
||||
original_bookmark = self.setup_bookmark(url="https://example.com")
|
||||
Bookmark.objects.update(url_normalized="")
|
||||
bookmark_data = Bookmark(
|
||||
url="https://example.com",
|
||||
title="Updated Title",
|
||||
description="Updated description",
|
||||
)
|
||||
updated_bookmark = create_bookmark(
|
||||
bookmark_data, "", self.get_or_create_test_user()
|
||||
)
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
self.assertEqual(updated_bookmark.id, original_bookmark.id)
|
||||
self.assertEqual(updated_bookmark.title, bookmark_data.title)
|
||||
|
||||
def test_create_should_update_first_existing_bookmark_for_multiple_duplicates(
|
||||
self,
|
||||
):
|
||||
first_dupe = self.setup_bookmark(url="https://example.com")
|
||||
second_dupe = self.setup_bookmark(url="https://example.com/")
|
||||
|
||||
bookmark_data = Bookmark(
|
||||
url="https://example.com",
|
||||
title="Updated Title",
|
||||
description="Updated description",
|
||||
)
|
||||
create_bookmark(bookmark_data, "", self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 2)
|
||||
|
||||
first_dupe.refresh_from_db()
|
||||
self.assertEqual(first_dupe.title, bookmark_data.title)
|
||||
|
||||
second_dupe.refresh_from_db()
|
||||
self.assertNotEqual(second_dupe.title, bookmark_data.title)
|
||||
|
||||
def test_create_should_populate_url_normalized_field(self):
|
||||
bookmark_data = Bookmark(
|
||||
url="https://EXAMPLE.COM/path/?z=1&a=2",
|
||||
title="Test Title",
|
||||
description="Test description",
|
||||
)
|
||||
created_bookmark = create_bookmark(
|
||||
bookmark_data, "", self.get_or_create_test_user()
|
||||
)
|
||||
|
||||
created_bookmark.refresh_from_db()
|
||||
self.assertEqual(created_bookmark.url, "https://EXAMPLE.COM/path/?z=1&a=2")
|
||||
self.assertEqual(
|
||||
created_bookmark.url_normalized, "https://example.com/path?a=2&z=1"
|
||||
)
|
||||
|
||||
def test_create_should_create_web_archive_snapshot(self):
|
||||
with patch.object(
|
||||
tasks, "create_web_archive_snapshot"
|
||||
|
||||
@@ -25,27 +25,27 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
for bundle in bundles:
|
||||
expected_list_item = f"""
|
||||
<div class="list-item" data-bundle-id="{bundle.id}" draggable="true">
|
||||
<div class="list-item-icon text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" 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>
|
||||
</div>
|
||||
<div class="list-item-text">
|
||||
<span class="truncate">{bundle.name}</span>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<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="{reverse("linkding:bundles.edit", args=[bundle.id])}">Edit</a>
|
||||
<button ld-confirm-button type="submit" name="remove_bundle" value="{bundle.id}" class="btn btn-link">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
self.assertInHTML(expected_list_item, html)
|
||||
@@ -61,7 +61,7 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'<span class="truncate">{user_bundle.name}</span>', html)
|
||||
self.assertInHTML(f"<span>{user_bundle.name}</span>", html)
|
||||
self.assertNotIn(other_user_bundle.name, html)
|
||||
|
||||
def test_empty_state(self):
|
||||
@@ -83,7 +83,7 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
f'<a href="{reverse("linkding:bundles.new")}" class="btn btn-primary">Add new bundle</a>',
|
||||
f'<a href="{reverse("linkding:bundles.new")}" class="btn">Add bundle</a>',
|
||||
html,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import datetime
|
||||
import email
|
||||
import unittest
|
||||
import urllib.parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.models import FeedToken, User
|
||||
from bookmarks.feeds import sanitize
|
||||
from bookmarks.models import FeedToken, User
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
def rfc2822_date(date):
|
||||
@@ -343,6 +345,10 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "<item>", count=5)
|
||||
|
||||
@unittest.skipIf(
|
||||
settings.LD_DB_ENGINE == "postgres",
|
||||
"Postgres does not allow NUL in text columns",
|
||||
)
|
||||
def test_strip_control_characters(self):
|
||||
self.setup_bookmark(
|
||||
title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description"
|
||||
|
||||
@@ -409,6 +409,21 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
self.assertEqual(import_result.success, 0)
|
||||
self.assertEqual(import_result.failed, 2)
|
||||
|
||||
def test_generate_normalized_url(self):
|
||||
html_tags = [
|
||||
BookmarkHtmlTag(href="https://example.com/?z=1&a=2#"),
|
||||
BookmarkHtmlTag(
|
||||
href="foo.bar"
|
||||
), # invalid URL, should be skipped without error
|
||||
]
|
||||
import_html = self.render_html(tags=html_tags)
|
||||
import_netscape_html(import_html, self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
self.assertEqual(
|
||||
Bookmark.objects.all()[0].url_normalized, "https://example.com?a=2&z=1"
|
||||
)
|
||||
|
||||
def test_private_flag(self):
|
||||
# does not map private flag if not enabled in options
|
||||
test_html = self.render_html(
|
||||
|
||||
@@ -32,7 +32,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
def assertPrevLink(self, html: str, page_number: int, href: str = None):
|
||||
href = href if href else "http://testserver/test?page={0}".format(page_number)
|
||||
href = href if href else "/test?page={0}".format(page_number)
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<li class="page-item">
|
||||
@@ -55,7 +55,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
def assertNextLink(self, html: str, page_number: int, href: str = None):
|
||||
href = href if href else "http://testserver/test?page={0}".format(page_number)
|
||||
href = href if href else "/test?page={0}".format(page_number)
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<li class="page-item">
|
||||
@@ -76,7 +76,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||
href: str = None,
|
||||
):
|
||||
active_class = "active" if active else ""
|
||||
href = href if href else "http://testserver/test?page={0}".format(page_number)
|
||||
href = href if href else "/test?page={0}".format(page_number)
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<li class="page-item {1}">
|
||||
@@ -167,35 +167,31 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||
self.assertPrevLink(
|
||||
rendered_template,
|
||||
1,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=1",
|
||||
href="/test?q=cake&sort=title_asc&page=1",
|
||||
)
|
||||
self.assertPageLink(
|
||||
rendered_template,
|
||||
1,
|
||||
False,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=1",
|
||||
href="/test?q=cake&sort=title_asc&page=1",
|
||||
)
|
||||
self.assertPageLink(
|
||||
rendered_template,
|
||||
2,
|
||||
True,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=2",
|
||||
href="/test?q=cake&sort=title_asc&page=2",
|
||||
)
|
||||
self.assertNextLink(
|
||||
rendered_template,
|
||||
3,
|
||||
href="http://testserver/test?q=cake&sort=title_asc&page=3",
|
||||
href="/test?q=cake&sort=title_asc&page=3",
|
||||
)
|
||||
|
||||
def test_removes_details_parameter(self):
|
||||
rendered_template = self.render_template(
|
||||
100, 10, 2, url="/test?details=1&page=2"
|
||||
)
|
||||
self.assertPrevLink(rendered_template, 1, href="http://testserver/test?page=1")
|
||||
self.assertPageLink(
|
||||
rendered_template, 1, False, href="http://testserver/test?page=1"
|
||||
)
|
||||
self.assertPageLink(
|
||||
rendered_template, 2, True, href="http://testserver/test?page=2"
|
||||
)
|
||||
self.assertNextLink(rendered_template, 3, href="http://testserver/test?page=3")
|
||||
self.assertPrevLink(rendered_template, 1, href="/test?page=1")
|
||||
self.assertPageLink(rendered_template, 1, False, href="/test?page=1")
|
||||
self.assertPageLink(rendered_template, 2, True, href="/test?page=2")
|
||||
self.assertNextLink(rendered_template, 3, href="/test?page=3")
|
||||
|
||||
@@ -324,3 +324,14 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
||||
self.assertEqual(
|
||||
bookmarks[0].notes, "Interesting notes about the <style> HTML element."
|
||||
)
|
||||
|
||||
def test_unescape_href_attribute(self):
|
||||
html = self.render_html(
|
||||
tags_html="""
|
||||
<DT><A HREF="https://example.com¢er=123" ADD_DATE="1">Imported bookmark</A>
|
||||
<DD>Imported bookmark description
|
||||
"""
|
||||
)
|
||||
|
||||
bookmarks = parse(html)
|
||||
self.assertEqual(bookmarks[0].href, "https://example.com¢er=123")
|
||||
|
||||
@@ -11,7 +11,7 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
class QueriesBasicTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self):
|
||||
self.profile = self.get_or_create_test_user().profile
|
||||
|
||||
@@ -1199,7 +1199,11 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
sorted_bookmarks = sorted(bookmarks, key=lambda b: b.resolved_title.lower())
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertEqual(list(query), sorted_bookmarks)
|
||||
|
||||
# Use resolved title for comparison as Postgres returns bookmarks with same resolved title in random order
|
||||
expected_effective_titles = [b.resolved_title for b in sorted_bookmarks]
|
||||
actual_effective_titles = [b.resolved_title for b in query]
|
||||
self.assertEqual(expected_effective_titles, actual_effective_titles)
|
||||
|
||||
def test_sort_by_title_desc(self):
|
||||
search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_DESC)
|
||||
@@ -1210,7 +1214,11 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertEqual(list(query), sorted_bookmarks)
|
||||
|
||||
# Use resolved title for comparison as Postgres returns bookmarks with same resolved title in random order
|
||||
expected_effective_titles = [b.resolved_title for b in sorted_bookmarks]
|
||||
actual_effective_titles = [b.resolved_title for b in query]
|
||||
self.assertEqual(expected_effective_titles, actual_effective_titles)
|
||||
|
||||
def test_query_bookmarks_filter_modified_since(self):
|
||||
# Create bookmarks with different modification dates
|
||||
@@ -1551,3 +1559,324 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
None, self.profile, BookmarkSearch(q="", bundle=bundle), False
|
||||
)
|
||||
self.assertQueryResult(query, [matching_bookmarks])
|
||||
|
||||
|
||||
# Legacy search should be covered by basic test suite which was effectively the
|
||||
# full test suite before advanced search was introduced.
|
||||
class QueriesLegacySearchTestCase(QueriesBasicTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.profile.legacy_search = True
|
||||
self.profile.save()
|
||||
|
||||
|
||||
class QueriesAdvancedSearchTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self):
|
||||
self.user = self.get_or_create_test_user()
|
||||
self.profile = self.user.profile
|
||||
|
||||
self.python_bookmark = self.setup_bookmark(
|
||||
title="Python Tutorial",
|
||||
tags=[self.setup_tag(name="python"), self.setup_tag(name="tutorial")],
|
||||
)
|
||||
self.java_bookmark = self.setup_bookmark(
|
||||
title="Java Guide",
|
||||
tags=[self.setup_tag(name="java"), self.setup_tag(name="programming")],
|
||||
)
|
||||
self.deprecated_python_bookmark = self.setup_bookmark(
|
||||
title="Old Python Guide",
|
||||
tags=[self.setup_tag(name="python"), self.setup_tag(name="deprecated")],
|
||||
)
|
||||
self.javascript_tutorial = self.setup_bookmark(
|
||||
title="JavaScript Basics",
|
||||
tags=[self.setup_tag(name="javascript"), self.setup_tag(name="tutorial")],
|
||||
)
|
||||
self.web_development = self.setup_bookmark(
|
||||
title="Web Development with React",
|
||||
description="Modern web development",
|
||||
tags=[self.setup_tag(name="react"), self.setup_tag(name="web")],
|
||||
)
|
||||
|
||||
def test_explicit_and_operator(self):
|
||||
search = BookmarkSearch(q="python AND tutorial")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [self.python_bookmark])
|
||||
|
||||
def test_or_operator(self):
|
||||
search = BookmarkSearch(q="#python OR #java")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(
|
||||
list(query),
|
||||
[self.python_bookmark, self.java_bookmark, self.deprecated_python_bookmark],
|
||||
)
|
||||
|
||||
def test_not_operator(self):
|
||||
search = BookmarkSearch(q="#python AND NOT #deprecated")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [self.python_bookmark])
|
||||
|
||||
def test_implicit_and_between_terms(self):
|
||||
search = BookmarkSearch(q="web development")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [self.web_development])
|
||||
|
||||
search = BookmarkSearch(q="python tutorial")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [self.python_bookmark])
|
||||
|
||||
def test_implicit_and_between_tags(self):
|
||||
search = BookmarkSearch(q="#python #tutorial")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [self.python_bookmark])
|
||||
|
||||
def test_nested_and_expression(self):
|
||||
search = BookmarkSearch(q="nonexistingterm OR (#python AND #tutorial)")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [self.python_bookmark])
|
||||
|
||||
search = BookmarkSearch(
|
||||
q="(#javascript AND #tutorial) OR (#python AND #tutorial)"
|
||||
)
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(
|
||||
list(query), [self.javascript_tutorial, self.python_bookmark]
|
||||
)
|
||||
|
||||
def test_mixed_terms_and_tags_with_operators(self):
|
||||
# Set lax mode to allow term matching against tags
|
||||
self.profile.tag_search = self.profile.TAG_SEARCH_LAX
|
||||
self.profile.save()
|
||||
|
||||
search = BookmarkSearch(q="(tutorial OR guide) AND #python")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(
|
||||
list(query), [self.python_bookmark, self.deprecated_python_bookmark]
|
||||
)
|
||||
|
||||
def test_parentheses(self):
|
||||
# Set lax mode to allow term matching against tags
|
||||
self.profile.tag_search = self.profile.TAG_SEARCH_LAX
|
||||
self.profile.save()
|
||||
|
||||
# Without parentheses
|
||||
search = BookmarkSearch(q="python AND tutorial OR javascript AND tutorial")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(
|
||||
list(query), [self.python_bookmark, self.javascript_tutorial]
|
||||
)
|
||||
|
||||
# With parentheses
|
||||
search = BookmarkSearch(q="(python OR javascript) AND tutorial")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(
|
||||
list(query), [self.python_bookmark, self.javascript_tutorial]
|
||||
)
|
||||
|
||||
def test_complex_query_with_all_operators(self):
|
||||
# Set lax mode to allow term matching against tags
|
||||
self.profile.tag_search = self.profile.TAG_SEARCH_LAX
|
||||
self.profile.save()
|
||||
|
||||
search = BookmarkSearch(
|
||||
q="(#python OR #javascript) AND tutorial AND NOT #deprecated"
|
||||
)
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(
|
||||
list(query), [self.python_bookmark, self.javascript_tutorial]
|
||||
)
|
||||
|
||||
def test_quoted_strings_with_operators(self):
|
||||
# Set lax mode to allow term matching against tags
|
||||
self.profile.tag_search = self.profile.TAG_SEARCH_LAX
|
||||
self.profile.save()
|
||||
|
||||
search = BookmarkSearch(q='"Web Development" OR tutorial')
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(
|
||||
list(query),
|
||||
[self.web_development, self.python_bookmark, self.javascript_tutorial],
|
||||
)
|
||||
|
||||
def test_implicit_and_with_quoted_strings(self):
|
||||
search = BookmarkSearch(q='"Web Development" react')
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [self.web_development])
|
||||
|
||||
def test_empty_query(self):
|
||||
# empty query returns all bookmarks
|
||||
search = BookmarkSearch(q="")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
expected = [
|
||||
self.python_bookmark,
|
||||
self.java_bookmark,
|
||||
self.deprecated_python_bookmark,
|
||||
self.javascript_tutorial,
|
||||
self.web_development,
|
||||
]
|
||||
self.assertCountEqual(list(query), expected)
|
||||
|
||||
def test_unparseable_query_returns_no_results(self):
|
||||
# Use a query that causes a parse error (unclosed parenthesis)
|
||||
search = BookmarkSearch(q="(python AND tutorial")
|
||||
query = queries.query_bookmarks(self.user, self.profile, search)
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
|
||||
class GetTagsForQueryTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self):
|
||||
self.user = self.get_or_create_test_user()
|
||||
self.profile = self.user.profile
|
||||
|
||||
def test_returns_tags_matching_query(self):
|
||||
python_tag = self.setup_tag(name="python")
|
||||
django_tag = self.setup_tag(name="django")
|
||||
self.setup_tag(name="unused")
|
||||
|
||||
result = queries.get_tags_for_query(
|
||||
self.user, self.profile, "#python and #django"
|
||||
)
|
||||
self.assertCountEqual(list(result), [python_tag, django_tag])
|
||||
|
||||
def test_case_insensitive_matching(self):
|
||||
python_tag = self.setup_tag(name="Python")
|
||||
|
||||
result = queries.get_tags_for_query(self.user, self.profile, "#python")
|
||||
self.assertCountEqual(list(result), [python_tag])
|
||||
|
||||
# having two tags with the same name returns both for now
|
||||
other_python_tag = self.setup_tag(name="python")
|
||||
|
||||
result = queries.get_tags_for_query(self.user, self.profile, "#python")
|
||||
self.assertCountEqual(list(result), [python_tag, other_python_tag])
|
||||
|
||||
def test_lax_mode_includes_terms(self):
|
||||
python_tag = self.setup_tag(name="python")
|
||||
django_tag = self.setup_tag(name="django")
|
||||
|
||||
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
self.profile.save()
|
||||
|
||||
result = queries.get_tags_for_query(
|
||||
self.user, self.profile, "#python and django"
|
||||
)
|
||||
self.assertCountEqual(list(result), [python_tag, django_tag])
|
||||
|
||||
def test_strict_mode_excludes_terms(self):
|
||||
python_tag = self.setup_tag(name="python")
|
||||
self.setup_tag(name="django")
|
||||
|
||||
result = queries.get_tags_for_query(
|
||||
self.user, self.profile, "#python and django"
|
||||
)
|
||||
self.assertCountEqual(list(result), [python_tag])
|
||||
|
||||
def test_only_returns_user_tags(self):
|
||||
python_tag = self.setup_tag(name="python")
|
||||
|
||||
other_user = self.setup_user()
|
||||
other_python = self.setup_tag(name="python", user=other_user)
|
||||
other_django = self.setup_tag(name="django", user=other_user)
|
||||
|
||||
result = queries.get_tags_for_query(
|
||||
self.user, self.profile, "#python and #django"
|
||||
)
|
||||
self.assertCountEqual(list(result), [python_tag])
|
||||
self.assertNotIn(other_python, list(result))
|
||||
self.assertNotIn(other_django, list(result))
|
||||
|
||||
def test_empty_query_returns_no_tags(self):
|
||||
self.setup_tag(name="python")
|
||||
|
||||
result = queries.get_tags_for_query(self.user, self.profile, "")
|
||||
self.assertCountEqual(list(result), [])
|
||||
|
||||
def test_query_with_no_tags_returns_empty(self):
|
||||
self.setup_tag(name="python")
|
||||
|
||||
result = queries.get_tags_for_query(self.user, self.profile, "!unread")
|
||||
self.assertCountEqual(list(result), [])
|
||||
|
||||
def test_nonexistent_tag_returns_empty(self):
|
||||
self.setup_tag(name="python")
|
||||
|
||||
result = queries.get_tags_for_query(self.user, self.profile, "#ruby")
|
||||
self.assertCountEqual(list(result), [])
|
||||
|
||||
|
||||
class GetSharedTagsForQueryTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self):
|
||||
self.user = self.get_or_create_test_user()
|
||||
self.profile = self.user.profile
|
||||
self.profile.enable_sharing = True
|
||||
self.profile.save()
|
||||
|
||||
def test_returns_tags_from_shared_bookmarks(self):
|
||||
python_tag = self.setup_tag(name="python")
|
||||
self.setup_tag(name="django")
|
||||
self.setup_bookmark(shared=True, tags=[python_tag])
|
||||
|
||||
result = queries.get_shared_tags_for_query(
|
||||
None, self.profile, "#python and #django", public_only=False
|
||||
)
|
||||
self.assertCountEqual(list(result), [python_tag])
|
||||
|
||||
def test_excludes_tags_from_non_shared_bookmarks(self):
|
||||
python_tag = self.setup_tag(name="python")
|
||||
self.setup_tag(name="django")
|
||||
self.setup_bookmark(shared=False, tags=[python_tag])
|
||||
|
||||
result = queries.get_shared_tags_for_query(
|
||||
None, self.profile, "#python and #django", public_only=False
|
||||
)
|
||||
self.assertCountEqual(list(result), [])
|
||||
|
||||
def test_respects_sharing_enabled_setting(self):
|
||||
self.profile.enable_sharing = False
|
||||
self.profile.save()
|
||||
|
||||
python_tag = self.setup_tag(name="python")
|
||||
self.setup_tag(name="django")
|
||||
self.setup_bookmark(shared=True, tags=[python_tag])
|
||||
|
||||
result = queries.get_shared_tags_for_query(
|
||||
None, self.profile, "#python and #django", public_only=False
|
||||
)
|
||||
self.assertCountEqual(list(result), [])
|
||||
|
||||
def test_public_only_flag(self):
|
||||
# public sharing disabled
|
||||
python_tag = self.setup_tag(name="python")
|
||||
self.setup_tag(name="django")
|
||||
self.setup_bookmark(shared=True, tags=[python_tag])
|
||||
|
||||
result = queries.get_shared_tags_for_query(
|
||||
None, self.profile, "#python and #django", public_only=True
|
||||
)
|
||||
self.assertCountEqual(list(result), [])
|
||||
|
||||
# public sharing enabled
|
||||
self.profile.enable_public_sharing = True
|
||||
self.profile.save()
|
||||
|
||||
result = queries.get_shared_tags_for_query(
|
||||
None, self.profile, "#python and #django", public_only=True
|
||||
)
|
||||
self.assertCountEqual(list(result), [python_tag])
|
||||
|
||||
def test_filters_by_user(self):
|
||||
python_tag = self.setup_tag(name="python")
|
||||
self.setup_tag(name="django")
|
||||
self.setup_bookmark(shared=True, tags=[python_tag])
|
||||
|
||||
other_user = self.setup_user()
|
||||
other_user.profile.enable_sharing = True
|
||||
other_user.profile.save()
|
||||
other_tag = self.setup_tag(name="python", user=other_user)
|
||||
self.setup_bookmark(shared=True, tags=[other_tag], user=other_user)
|
||||
|
||||
result = queries.get_shared_tags_for_query(
|
||||
self.user, self.profile, "#python and #django", public_only=False
|
||||
)
|
||||
self.assertCountEqual(list(result), [python_tag])
|
||||
self.assertNotIn(other_tag, list(result))
|
||||
|
||||
1277
bookmarks/tests/test_search_query_parser.py
Normal file
1277
bookmarks/tests/test_search_query_parser.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"sticky_pagination": False,
|
||||
"collapse_side_panel": False,
|
||||
"hide_bundles": False,
|
||||
"legacy_search": False,
|
||||
}
|
||||
|
||||
return {**form_data, **overrides}
|
||||
@@ -115,12 +116,14 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"display_remove_bookmark_action": False,
|
||||
"permanent_notes": True,
|
||||
"default_mark_unread": True,
|
||||
"default_mark_shared": True,
|
||||
"custom_css": "body { background-color: #000; }",
|
||||
"auto_tagging_rules": "example.com tag",
|
||||
"items_per_page": "10",
|
||||
"sticky_pagination": True,
|
||||
"collapse_side_panel": True,
|
||||
"hide_bundles": True,
|
||||
"legacy_search": True,
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse("linkding:settings.update"), form_data, follow=True
|
||||
@@ -188,6 +191,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(
|
||||
self.user.profile.default_mark_unread, form_data["default_mark_unread"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.user.profile.default_mark_shared, form_data["default_mark_shared"]
|
||||
)
|
||||
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
|
||||
self.assertEqual(
|
||||
self.user.profile.auto_tagging_rules, form_data["auto_tagging_rules"]
|
||||
@@ -202,6 +208,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
|
||||
)
|
||||
self.assertEqual(self.user.profile.hide_bundles, form_data["hide_bundles"])
|
||||
self.assertEqual(self.user.profile.legacy_search, form_data["legacy_search"])
|
||||
|
||||
self.assertSuccessMessage(html, "Profile updated")
|
||||
|
||||
|
||||
@@ -32,7 +32,12 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
template_to_render = Template("{% include 'bookmarks/tag_cloud.html' %}")
|
||||
return template_to_render.render(context)
|
||||
|
||||
def assertTagGroups(self, rendered_template: str, groups: List[List[str]]):
|
||||
def assertTagGroups(
|
||||
self,
|
||||
rendered_template: str,
|
||||
groups: List[List[str]],
|
||||
highlight_first_char: bool = True,
|
||||
):
|
||||
soup = self.make_soup(rendered_template)
|
||||
group_elements = soup.select("p.group")
|
||||
|
||||
@@ -48,6 +53,18 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
link_element = link_elements[tag_index]
|
||||
self.assertEqual(link_element.text.strip(), tag)
|
||||
|
||||
if tag_index == 0:
|
||||
if highlight_first_char:
|
||||
self.assertIn(
|
||||
f'<span class="highlight-char">{tag[0]}</span>',
|
||||
str(link_element),
|
||||
)
|
||||
else:
|
||||
self.assertNotIn(
|
||||
f'<span class="highlight-char">{tag[0]}</span>',
|
||||
str(link_element),
|
||||
)
|
||||
|
||||
def assertNumSelectedTags(self, rendered_template: str, count: int):
|
||||
soup = self.make_soup(rendered_template)
|
||||
link_elements = soup.select("p.selected-tags a")
|
||||
@@ -178,6 +195,7 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
"Coyote",
|
||||
],
|
||||
],
|
||||
False,
|
||||
)
|
||||
|
||||
def test_no_duplicate_tag_names(self):
|
||||
@@ -234,6 +252,21 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
rendered_template,
|
||||
)
|
||||
|
||||
def test_tag_url_wraps_or_expression_in_parenthesis(self):
|
||||
tag = self.setup_tag(name="tag1")
|
||||
self.setup_bookmark(tags=[tag], title="term1")
|
||||
|
||||
rendered_template = self.render_template(url="/test?q=term1 or term2")
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<a href="?q=%28term1+or+term2%29+%23tag1" class="mr-2" data-is-tag-item>
|
||||
<span class="highlight-char">t</span><span>ag1</span>
|
||||
</a>
|
||||
""",
|
||||
rendered_template,
|
||||
)
|
||||
|
||||
def test_selected_tags(self):
|
||||
tags = [
|
||||
self.setup_tag(name="tag1"),
|
||||
@@ -265,6 +298,63 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
rendered_template,
|
||||
)
|
||||
|
||||
def test_selected_tags_complex_queries(self):
|
||||
tags = [
|
||||
self.setup_tag(name="tag1"),
|
||||
self.setup_tag(name="tag2"),
|
||||
]
|
||||
self.setup_bookmark(tags=tags)
|
||||
|
||||
rendered_template = self.render_template(url="/test?q=%23tag1 or not %23tag2")
|
||||
|
||||
self.assertNumSelectedTags(rendered_template, 2)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<a href="?q=not+%23tag2"
|
||||
class="text-bold mr-2">
|
||||
<span>-tag1</span>
|
||||
</a>
|
||||
""",
|
||||
rendered_template,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<a href="?q=%23tag1"
|
||||
class="text-bold mr-2">
|
||||
<span>-tag2</span>
|
||||
</a>
|
||||
""",
|
||||
rendered_template,
|
||||
)
|
||||
|
||||
rendered_template = self.render_template(
|
||||
url="/test?q=%23tag1 and not (%23tag2 or term)"
|
||||
)
|
||||
|
||||
self.assertNumSelectedTags(rendered_template, 2)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<a href="?q=not+%28%23tag2+or+term%29"
|
||||
class="text-bold mr-2">
|
||||
<span>-tag1</span>
|
||||
</a>
|
||||
""",
|
||||
rendered_template,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<a href="?q=%23tag1+not+term"
|
||||
class="text-bold mr-2">
|
||||
<span>-tag2</span>
|
||||
</a>
|
||||
""",
|
||||
rendered_template,
|
||||
)
|
||||
|
||||
def test_selected_tags_with_lax_tag_search(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
@@ -410,6 +500,12 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
self.assertTagGroups(rendered_template, [["tag3", "tag4", "tag5"]])
|
||||
|
||||
rendered_template = self.render_template(
|
||||
url="/test?q=%23tag1 or (%23tag2 or not term)"
|
||||
)
|
||||
|
||||
self.assertTagGroups(rendered_template, [["tag3", "tag4", "tag5"]])
|
||||
|
||||
def test_with_anonymous_user(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
|
||||
113
bookmarks/tests/test_tags_edit_view.py
Normal file
113
bookmarks/tests/test_tags_edit_view.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self) -> None:
|
||||
self.user = self.get_or_create_test_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_update_tag(self):
|
||||
tag = self.setup_tag(name="old_name")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag.id]), {"name": "new_name"}
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
|
||||
tag.refresh_from_db()
|
||||
self.assertEqual(tag.name, "new_name")
|
||||
|
||||
def test_allow_case_changes(self):
|
||||
tag = self.setup_tag(name="tag")
|
||||
|
||||
self.client.post(reverse("linkding:tags.edit", args=[tag.id]), {"name": "TAG"})
|
||||
|
||||
tag.refresh_from_db()
|
||||
self.assertEqual(tag.name, "TAG")
|
||||
|
||||
def test_can_only_edit_own_tags(self):
|
||||
other_user = self.setup_user()
|
||||
tag = self.setup_tag(user=other_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag.id]), {"name": "new_name"}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
tag.refresh_from_db()
|
||||
self.assertNotEqual(tag.name, "new_name")
|
||||
|
||||
def test_show_error_for_empty_name(self):
|
||||
tag = self.setup_tag(name="tag1")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag.id]), {"name": ""}
|
||||
)
|
||||
|
||||
self.assertContains(response, "This field is required", status_code=422)
|
||||
tag.refresh_from_db()
|
||||
self.assertEqual(tag.name, "tag1")
|
||||
|
||||
def test_show_error_for_duplicate_name(self):
|
||||
tag1 = self.setup_tag(name="tag1")
|
||||
self.setup_tag(name="tag2")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag1.id]), {"name": "tag2"}
|
||||
)
|
||||
|
||||
self.assertContains(
|
||||
response, "Tag "tag2" already exists", status_code=422
|
||||
)
|
||||
tag1.refresh_from_db()
|
||||
self.assertEqual(tag1.name, "tag1")
|
||||
|
||||
def test_show_error_for_duplicate_name_different_casing(self):
|
||||
tag1 = self.setup_tag(name="tag1")
|
||||
self.setup_tag(name="tag2")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag1.id]), {"name": "TAG2"}
|
||||
)
|
||||
|
||||
self.assertContains(
|
||||
response, "Tag "TAG2" already exists", status_code=422
|
||||
)
|
||||
tag1.refresh_from_db()
|
||||
self.assertEqual(tag1.name, "tag1")
|
||||
|
||||
def test_no_error_for_duplicate_name_different_user(self):
|
||||
other_user = self.setup_user()
|
||||
self.setup_tag(name="tag1", user=other_user)
|
||||
|
||||
tag2 = self.setup_tag(name="tag2")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag2.id]), {"name": "tag1"}
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
tag2.refresh_from_db()
|
||||
self.assertEqual(tag2.name, "tag1")
|
||||
|
||||
def test_update_shows_success_message(self):
|
||||
tag = self.setup_tag(name="old_name")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag.id]),
|
||||
{"name": "new_name"},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<div class="toast toast-success" role="alert">
|
||||
Tag "new_name" updated successfully.
|
||||
</div>
|
||||
""",
|
||||
response.content.decode(),
|
||||
)
|
||||
281
bookmarks/tests/test_tags_index_view.py
Normal file
281
bookmarks/tests/test_tags_index_view.py
Normal file
@@ -0,0 +1,281 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Tag
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
def setUp(self) -> None:
|
||||
self.user = self.get_or_create_test_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def get_rows(self, response):
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
return soup.select(".crud-table tbody tr")
|
||||
|
||||
def find_row(self, rows, tag):
|
||||
for row in rows:
|
||||
if tag.name in row.get_text():
|
||||
return row
|
||||
return None
|
||||
|
||||
def assertRows(self, response, tags):
|
||||
rows = self.get_rows(response)
|
||||
self.assertEqual(len(rows), len(tags))
|
||||
for tag in tags:
|
||||
row = self.find_row(rows, tag)
|
||||
self.assertIsNotNone(row, f"Tag '{tag.name}' not found in table")
|
||||
|
||||
def assertOrderedRows(self, response, tags):
|
||||
rows = self.get_rows(response)
|
||||
self.assertEqual(len(rows), len(tags))
|
||||
for index, tag in enumerate(tags):
|
||||
row = rows[index]
|
||||
self.assertIn(
|
||||
tag.name,
|
||||
row.get_text(),
|
||||
f"Tag '{tag.name}' not found at index {index}",
|
||||
)
|
||||
|
||||
def test_list_tags(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
tag3 = self.setup_tag()
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertRows(response, [tag1, tag2, tag3])
|
||||
|
||||
def test_show_user_owned_tags(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
tag3 = self.setup_tag()
|
||||
|
||||
other_user = self.setup_user()
|
||||
self.setup_tag(user=other_user)
|
||||
self.setup_tag(user=other_user)
|
||||
self.setup_tag(user=other_user)
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
|
||||
self.assertRows(response, [tag1, tag2, tag3])
|
||||
|
||||
def test_search_tags(self):
|
||||
tag1 = self.setup_tag(name="programming")
|
||||
self.setup_tag(name="python")
|
||||
self.setup_tag(name="django")
|
||||
self.setup_tag(name="design")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?search=prog")
|
||||
|
||||
self.assertRows(response, [tag1])
|
||||
|
||||
def test_filter_unused_tags(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
tag3 = self.setup_tag()
|
||||
|
||||
self.setup_bookmark(tags=[tag1])
|
||||
self.setup_bookmark(tags=[tag3])
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?unused=true")
|
||||
|
||||
self.assertRows(response, [tag2])
|
||||
|
||||
def test_rows_have_links_to_filtered_bookmarks(self):
|
||||
tag1 = self.setup_tag(name="python")
|
||||
tag2 = self.setup_tag(name="django-framework")
|
||||
|
||||
self.setup_bookmark(tags=[tag1])
|
||||
self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
|
||||
rows = self.get_rows(response)
|
||||
|
||||
tag1_row = self.find_row(rows, tag1)
|
||||
view_link = tag1_row.find("a", string=lambda s: s and s.strip() == "2")
|
||||
expected_url = reverse("linkding:bookmarks.index") + "?q=%23python"
|
||||
self.assertEqual(view_link["href"], expected_url)
|
||||
|
||||
tag2_row = self.find_row(rows, tag2)
|
||||
view_link = tag2_row.find("a", string=lambda s: s and s.strip() == "1")
|
||||
expected_url = reverse("linkding:bookmarks.index") + "?q=%23django-framework"
|
||||
self.assertEqual(view_link["href"], expected_url)
|
||||
|
||||
def test_shows_tag_total(self):
|
||||
tag1 = self.setup_tag(name="python")
|
||||
tag2 = self.setup_tag(name="javascript")
|
||||
tag3 = self.setup_tag(name="design")
|
||||
self.setup_tag(name="unused-tag")
|
||||
|
||||
self.setup_bookmark(tags=[tag1])
|
||||
self.setup_bookmark(tags=[tag2])
|
||||
self.setup_bookmark(tags=[tag3])
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
self.assertContains(response, "4 tags total")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?search=python")
|
||||
self.assertContains(response, "Showing 1 of 4 tags")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?unused=true")
|
||||
self.assertContains(response, "Showing 1 of 4 tags")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:tags.index") + "?search=nonexistent"
|
||||
)
|
||||
self.assertContains(response, "Showing 0 of 4 tags")
|
||||
|
||||
def test_pagination(self):
|
||||
tags = []
|
||||
for i in range(75):
|
||||
tags.append(self.setup_tag())
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
rows = self.get_rows(response)
|
||||
self.assertEqual(len(rows), 50)
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?page=2")
|
||||
rows = self.get_rows(response)
|
||||
self.assertEqual(len(rows), 25)
|
||||
|
||||
def test_delete_action(self):
|
||||
tag = self.setup_tag(name="tag_to_delete")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.index"), {"delete_tag": tag.id}
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
self.assertFalse(Tag.objects.filter(id=tag.id).exists())
|
||||
|
||||
def test_tag_delete_action_shows_success_message(self):
|
||||
tag = self.setup_tag(name="tag_to_delete")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.index"), {"delete_tag": tag.id}, follow=True
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<div class="toast toast-success" role="alert">
|
||||
Tag "tag_to_delete" deleted successfully.
|
||||
</div>
|
||||
""",
|
||||
response.content.decode(),
|
||||
)
|
||||
|
||||
def test_tag_delete_action_preserves_query_parameters(self):
|
||||
tag = self.setup_tag(name="search_tag")
|
||||
|
||||
url = (
|
||||
reverse("linkding:tags.index")
|
||||
+ "?search=search&unused=true&page=2&sort=name-desc"
|
||||
)
|
||||
response = self.client.post(url, {"delete_tag": tag.id})
|
||||
|
||||
self.assertRedirects(response, url)
|
||||
|
||||
def test_tag_delete_action_only_deletes_own_tags(self):
|
||||
other_user = self.setup_user()
|
||||
other_tag = self.setup_tag(user=other_user, name="other_user_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.index"), {"delete_tag": other_tag.id}, follow=True
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_sort_by_name_ascending(self):
|
||||
tag_c = self.setup_tag(name="c_tag")
|
||||
tag_a = self.setup_tag(name="a_tag")
|
||||
tag_b = self.setup_tag(name="b_tag")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-asc")
|
||||
|
||||
self.assertOrderedRows(response, [tag_a, tag_b, tag_c])
|
||||
|
||||
def test_sort_by_name_descending(self):
|
||||
tag_c = self.setup_tag(name="c_tag")
|
||||
tag_a = self.setup_tag(name="a_tag")
|
||||
tag_b = self.setup_tag(name="b_tag")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-desc")
|
||||
|
||||
self.assertOrderedRows(response, [tag_c, tag_b, tag_a])
|
||||
|
||||
def test_sort_by_bookmark_count_ascending(self):
|
||||
tag_few = self.setup_tag(name="few_bookmarks")
|
||||
tag_many = self.setup_tag(name="many_bookmarks")
|
||||
tag_none = self.setup_tag(name="no_bookmarks")
|
||||
|
||||
self.setup_bookmark(tags=[tag_few])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?sort=count-asc")
|
||||
|
||||
self.assertOrderedRows(response, [tag_none, tag_few, tag_many])
|
||||
|
||||
def test_sort_by_bookmark_count_descending(self):
|
||||
tag_few = self.setup_tag(name="few_bookmarks")
|
||||
tag_many = self.setup_tag(name="many_bookmarks")
|
||||
tag_none = self.setup_tag(name="no_bookmarks")
|
||||
|
||||
self.setup_bookmark(tags=[tag_few])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?sort=count-desc")
|
||||
|
||||
self.assertOrderedRows(response, [tag_many, tag_few, tag_none])
|
||||
|
||||
def test_default_sort_is_name_ascending(self):
|
||||
tag_c = self.setup_tag(name="c_tag")
|
||||
tag_a = self.setup_tag(name="a_tag")
|
||||
tag_b = self.setup_tag(name="b_tag")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
|
||||
self.assertOrderedRows(response, [tag_a, tag_b, tag_c])
|
||||
|
||||
def test_sort_select_has_correct_options_and_selection(self):
|
||||
self.setup_tag()
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||
<option value="name-asc" selected>Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
<option value="count-asc">Fewest bookmarks</option>
|
||||
<option value="count-desc">Most bookmarks</option>
|
||||
</select>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-desc")
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc" selected>Name Z-A</option>
|
||||
<option value="count-asc">Fewest bookmarks</option>
|
||||
<option value="count-desc">Most bookmarks</option>
|
||||
</select>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
219
bookmarks/tests/test_tags_merge_view.py
Normal file
219
bookmarks/tests/test_tags_merge_view.py
Normal file
@@ -0,0 +1,219 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
def setUp(self) -> None:
|
||||
self.user = self.get_or_create_test_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def get_form_group(self, response, input_name):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
input_element = soup.find("input", {"name": input_name})
|
||||
if input_element:
|
||||
return input_element.find_parent("div", class_="form-group")
|
||||
return None
|
||||
|
||||
def test_merge_tags(self):
|
||||
target_tag = self.setup_tag(name="target_tag")
|
||||
merge_tag1 = self.setup_tag(name="merge_tag1")
|
||||
merge_tag2 = self.setup_tag(name="merge_tag2")
|
||||
|
||||
bookmark1 = self.setup_bookmark(tags=[merge_tag1])
|
||||
bookmark2 = self.setup_bookmark(tags=[merge_tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[target_tag])
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
|
||||
self.assertEqual(Tag.objects.count(), 1)
|
||||
self.assertFalse(Tag.objects.filter(id=merge_tag1.id).exists())
|
||||
self.assertFalse(Tag.objects.filter(id=merge_tag2.id).exists())
|
||||
|
||||
self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])
|
||||
|
||||
def test_merge_tags_complex(self):
|
||||
target_tag = self.setup_tag(name="target_tag")
|
||||
merge_tag1 = self.setup_tag(name="merge_tag1")
|
||||
merge_tag2 = self.setup_tag(name="merge_tag2")
|
||||
other_tag = self.setup_tag(name="other_tag")
|
||||
|
||||
bookmark1 = self.setup_bookmark(tags=[merge_tag1])
|
||||
bookmark2 = self.setup_bookmark(tags=[merge_tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[target_tag])
|
||||
bookmark4 = self.setup_bookmark(
|
||||
tags=[merge_tag1, merge_tag2]
|
||||
) # both merge tags
|
||||
bookmark5 = self.setup_bookmark(
|
||||
tags=[merge_tag2, target_tag]
|
||||
) # already has target tag
|
||||
bookmark6 = self.setup_bookmark(
|
||||
tags=[merge_tag1, merge_tag2, target_tag]
|
||||
) # both merge tags and target
|
||||
bookmark7 = self.setup_bookmark(tags=[other_tag]) # unrelated tag
|
||||
bookmark8 = self.setup_bookmark(
|
||||
tags=[other_tag, merge_tag1]
|
||||
) # merge and unrelated tag
|
||||
bookmark9 = self.setup_bookmark(
|
||||
tags=[other_tag, target_tag]
|
||||
) # merge and target tag
|
||||
bookmark10 = self.setup_bookmark(tags=[]) # no tags
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 10)
|
||||
self.assertEqual(Tag.objects.count(), 2)
|
||||
self.assertEqual(Bookmark.tags.through.objects.count(), 11)
|
||||
|
||||
self.assertCountEqual(list(Tag.objects.all()), [target_tag, other_tag])
|
||||
|
||||
self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark4.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark5.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark6.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark7.tags.all()), [other_tag])
|
||||
self.assertCountEqual(list(bookmark8.tags.all()), [other_tag, target_tag])
|
||||
self.assertCountEqual(list(bookmark9.tags.all()), [other_tag, target_tag])
|
||||
self.assertCountEqual(list(bookmark10.tags.all()), [])
|
||||
|
||||
def test_can_only_merge_own_tags(self):
|
||||
other_user = self.setup_user()
|
||||
self.setup_tag(name="target_tag", user=other_user)
|
||||
self.setup_tag(name="merge_tag", user=other_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "merge_tag"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
target_tag_group = self.get_form_group(response, "target_tag")
|
||||
self.assertIn('Tag "target_tag" does not exist', target_tag_group.get_text())
|
||||
|
||||
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||
self.assertIn('Tag "merge_tag" does not exist', merge_tags_group.get_text())
|
||||
|
||||
def test_validate_missing_target_tag(self):
|
||||
merge_tag = self.setup_tag(name="merge_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "", "merge_tags": "merge_tag"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
target_tag_group = self.get_form_group(response, "target_tag")
|
||||
self.assertIn("This field is required", target_tag_group.get_text())
|
||||
self.assertTrue(Tag.objects.filter(id=merge_tag.id).exists())
|
||||
|
||||
def test_validate_missing_merge_tags(self):
|
||||
self.setup_tag(name="target_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": ""},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||
self.assertIn("This field is required", merge_tags_group.get_text())
|
||||
|
||||
def test_validate_nonexistent_target_tag(self):
|
||||
merge_tag = self.setup_tag(name="merge_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "nonexistent_tag", "merge_tags": "merge_tag"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
target_tag_group = self.get_form_group(response, "target_tag")
|
||||
self.assertIn(
|
||||
'Tag "nonexistent_tag" does not exist', target_tag_group.get_text()
|
||||
)
|
||||
|
||||
def test_validate_nonexistent_merge_tag(self):
|
||||
self.setup_tag(name="target_tag")
|
||||
self.setup_tag(name="merge_tag1")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "merge_tag1 nonexistent_tag"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||
self.assertIn(
|
||||
'Tag "nonexistent_tag" does not exist', merge_tags_group.get_text()
|
||||
)
|
||||
|
||||
def test_validate_multiple_target_tags(self):
|
||||
self.setup_tag(name="target_tag1")
|
||||
self.setup_tag(name="target_tag2")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag1 target_tag2", "merge_tags": "some_tag"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
target_tag_group = self.get_form_group(response, "target_tag")
|
||||
self.assertIn(
|
||||
"Please enter only one tag name for the target tag",
|
||||
target_tag_group.get_text(),
|
||||
)
|
||||
|
||||
def test_validate_target_tag_in_merge_list(self):
|
||||
self.setup_tag(name="target_tag")
|
||||
self.setup_tag(name="merge_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "target_tag merge_tag"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||
self.assertIn(
|
||||
"The target tag cannot be selected for merging", merge_tags_group.get_text()
|
||||
)
|
||||
|
||||
def test_merge_shows_success_message(self):
|
||||
self.setup_tag(name="target_tag")
|
||||
self.setup_tag(name="merge_tag1")
|
||||
self.setup_tag(name="merge_tag2")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<div class="toast toast-success" role="alert">
|
||||
Successfully merged 2 tags (merge_tag1, merge_tag2) into "target_tag".
|
||||
</div>
|
||||
""",
|
||||
response.content.decode(),
|
||||
)
|
||||
79
bookmarks/tests/test_tags_new_view.py
Normal file
79
bookmarks/tests/test_tags_new_view.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Tag
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class TagsNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self) -> None:
|
||||
self.user = self.get_or_create_test_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_create_tag(self):
|
||||
response = self.client.post(reverse("linkding:tags.new"), {"name": "new_tag"})
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
self.assertEqual(Tag.objects.count(), 1)
|
||||
self.assertTrue(Tag.objects.filter(name="new_tag", owner=self.user).exists())
|
||||
|
||||
def test_show_error_for_empty_name(self):
|
||||
response = self.client.post(reverse("linkding:tags.new"), {"name": ""})
|
||||
|
||||
self.assertContains(response, "This field is required", status_code=422)
|
||||
self.assertEqual(Tag.objects.count(), 0)
|
||||
|
||||
def test_show_error_for_duplicate_name(self):
|
||||
self.setup_tag(name="existing_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.new"), {"name": "existing_tag"}
|
||||
)
|
||||
|
||||
self.assertContains(
|
||||
response, "Tag "existing_tag" already exists", status_code=422
|
||||
)
|
||||
self.assertEqual(Tag.objects.count(), 1)
|
||||
|
||||
def test_show_error_for_duplicate_name_different_casing(self):
|
||||
self.setup_tag(name="existing_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.new"), {"name": "existing_TAG"}
|
||||
)
|
||||
|
||||
self.assertContains(
|
||||
response, "Tag "existing_TAG" already exists", status_code=422
|
||||
)
|
||||
self.assertEqual(Tag.objects.count(), 1)
|
||||
|
||||
def test_no_error_for_duplicate_name_different_user(self):
|
||||
other_user = self.setup_user()
|
||||
self.setup_tag(name="existing_tag", user=other_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.new"), {"name": "existing_tag"}
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
self.assertEqual(Tag.objects.count(), 2)
|
||||
self.assertEqual(
|
||||
Tag.objects.filter(name="existing_tag", owner=self.user).count(), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
Tag.objects.filter(name="existing_tag", owner=other_user).count(), 1
|
||||
)
|
||||
|
||||
def test_create_shows_success_message(self):
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.new"), {"name": "new_tag"}, follow=True
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<div class="toast toast-success" role="alert">
|
||||
Tag "new_tag" created successfully.
|
||||
</div>
|
||||
""",
|
||||
response.content.decode(),
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from bookmarks.utils import (
|
||||
humanize_absolute_date,
|
||||
humanize_relative_date,
|
||||
parse_timestamp,
|
||||
normalize_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -182,3 +183,181 @@ class UtilsTestCase(TestCase):
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.verify_timestamp(now, 1000000000)
|
||||
|
||||
def test_normalize_url_trailing_slash_handling(self):
|
||||
test_cases = [
|
||||
("https://example.com/", "https://example.com"),
|
||||
(
|
||||
"https://example.com/path/",
|
||||
"https://example.com/path",
|
||||
),
|
||||
("https://example.com/path/to/page/", "https://example.com/path/to/page"),
|
||||
(
|
||||
"https://example.com/path",
|
||||
"https://example.com/path",
|
||||
),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_query_parameters(self):
|
||||
test_cases = [
|
||||
("https://example.com?z=1&a=2", "https://example.com?a=2&z=1"),
|
||||
("https://example.com?c=3&b=2&a=1", "https://example.com?a=1&b=2&c=3"),
|
||||
("https://example.com?param=value", "https://example.com?param=value"),
|
||||
("https://example.com?", "https://example.com"),
|
||||
(
|
||||
"https://example.com?empty=&filled=value",
|
||||
"https://example.com?empty=&filled=value",
|
||||
),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_case_sensitivity(self):
|
||||
test_cases = [
|
||||
(
|
||||
"https://EXAMPLE.com/Path/To/Page",
|
||||
"https://example.com/Path/To/Page",
|
||||
),
|
||||
("https://EXAMPLE.COM/API/v1/Users", "https://example.com/API/v1/Users"),
|
||||
(
|
||||
"HTTPS://EXAMPLE.COM/path",
|
||||
"https://example.com/path",
|
||||
),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_special_characters_and_encoding(self):
|
||||
test_cases = [
|
||||
(
|
||||
"https://example.com/path%20with%20spaces",
|
||||
"https://example.com/path%20with%20spaces",
|
||||
),
|
||||
("https://example.com/caf%C3%A9", "https://example.com/caf%C3%A9"),
|
||||
(
|
||||
"https://example.com/path?q=hello%20world",
|
||||
"https://example.com/path?q=hello%20world",
|
||||
),
|
||||
("https://example.com/pàth", "https://example.com/pàth"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_various_protocols(self):
|
||||
test_cases = [
|
||||
("FTP://example.com", "ftp://example.com"),
|
||||
("HTTP://EXAMPLE.COM", "http://example.com"),
|
||||
("https://example.com", "https://example.com"),
|
||||
("file:///path/to/file", "file:///path/to/file"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_port_handling(self):
|
||||
test_cases = [
|
||||
("https://example.com:8080", "https://example.com:8080"),
|
||||
("https://EXAMPLE.COM:8080", "https://example.com:8080"),
|
||||
("http://example.com:80", "http://example.com:80"),
|
||||
("https://example.com:443", "https://example.com:443"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_authentication_handling(self):
|
||||
test_cases = [
|
||||
("https://user:pass@EXAMPLE.COM", "https://user:pass@example.com"),
|
||||
("https://user@EXAMPLE.COM", "https://user@example.com"),
|
||||
("ftp://admin:secret@EXAMPLE.COM", "ftp://admin:secret@example.com"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_fragment_handling(self):
|
||||
test_cases = [
|
||||
("https://example.com#", "https://example.com"),
|
||||
("https://example.com#section", "https://example.com#section"),
|
||||
("https://EXAMPLE.COM/path#Section", "https://example.com/path#Section"),
|
||||
("https://EXAMPLE.COM/path/#Section", "https://example.com/path#Section"),
|
||||
("https://example.com?a=1#fragment", "https://example.com?a=1#fragment"),
|
||||
(
|
||||
"https://example.com?z=2&a=1#fragment",
|
||||
"https://example.com?a=1&z=2#fragment",
|
||||
),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_edge_cases(self):
|
||||
test_cases = [
|
||||
("", ""),
|
||||
(" ", ""),
|
||||
(" https://example.com ", "https://example.com"),
|
||||
("not-a-url", "not-a-url"),
|
||||
("://invalid", "://invalid"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_internationalized_domain_names(self):
|
||||
test_cases = [
|
||||
(
|
||||
"https://xn--fsq.xn--0zwm56d",
|
||||
"https://xn--fsq.xn--0zwm56d",
|
||||
),
|
||||
("https://测试.中国", "https://测试.中国"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected.lower() if expected else expected, result)
|
||||
|
||||
def test_normalize_url_complex_query_parameters(self):
|
||||
test_cases = [
|
||||
(
|
||||
"https://example.com?z=1&a=2&z=3&b=4",
|
||||
"https://example.com?a=2&b=4&z=1&z=3", # Multiple values for same key
|
||||
),
|
||||
(
|
||||
"https://example.com?param=value1¶m=value2",
|
||||
"https://example.com?param=value1¶m=value2",
|
||||
),
|
||||
(
|
||||
"https://example.com?special=%21%40%23%24%25",
|
||||
"https://example.com?special=%21%40%23%24%25",
|
||||
),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
@@ -25,6 +25,7 @@ class A11yNavigationFocusTest(LinkdingE2ETestCase):
|
||||
focused_tag = page.evaluate(
|
||||
"document.activeElement?.tagName + '|' + document.activeElement?.name"
|
||||
)
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
self.assertEqual("INPUT|url", focused_tag)
|
||||
|
||||
def test_page_navigation_focus(self):
|
||||
|
||||
@@ -140,8 +140,8 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
# Delete bookmark, verify return url
|
||||
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||
details_modal.get_by_text("Delete...").click()
|
||||
details_modal.get_by_text("Confirm").click()
|
||||
details_modal.get_by_text("Delete").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
# verify bookmark is deleted
|
||||
self.locate_bookmark(bookmark.title)
|
||||
@@ -173,7 +173,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
# Remove snapshot
|
||||
asset_list.get_by_text("Remove", exact=False).click()
|
||||
asset_list.get_by_text("Confirm", exact=False).click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm", exact=False).click()
|
||||
|
||||
# Snapshot is removed
|
||||
expect(snapshot).not_to_be_visible()
|
||||
|
||||
@@ -46,7 +46,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
@@ -84,7 +84,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
@@ -122,7 +122,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
@@ -160,7 +160,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
@@ -291,7 +291,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
# Execute bulk action
|
||||
self.select_bulk_action("Mark as unread")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
@@ -323,7 +323,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.open(reverse("linkding:bookmarks.index"), p)
|
||||
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
@@ -140,7 +140,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread")
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Unread").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("unread")
|
||||
self.assertReloads(0)
|
||||
@@ -156,7 +156,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared")
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Shared").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("shared")
|
||||
self.assertReloads(0)
|
||||
@@ -173,7 +173,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
).click()
|
||||
self.select_bulk_action("Archive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
@@ -191,7 +191,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
@@ -216,7 +216,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.open(reverse("linkding:bookmarks.archived"), p)
|
||||
|
||||
self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("Archived Bookmark 2").get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
@@ -234,7 +234,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
).click()
|
||||
self.select_bulk_action("Unarchive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
@@ -252,7 +252,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
@@ -293,7 +293,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.open(reverse("linkding:bookmarks.shared"), p)
|
||||
|
||||
self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("My Bookmark 2").get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(
|
||||
[
|
||||
|
||||
@@ -18,28 +18,43 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
||||
enable_public_sharing_label = page.get_by_text(
|
||||
"Enable public bookmark sharing"
|
||||
)
|
||||
default_mark_shared = page.get_by_label(
|
||||
"Create bookmarks as shared by default"
|
||||
)
|
||||
default_mark_shared_label = page.get_by_text(
|
||||
"Create bookmarks as shared by default"
|
||||
)
|
||||
|
||||
# Public sharing is disabled by default
|
||||
# Public sharing and default shared are disabled by default
|
||||
expect(enable_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).to_be_disabled()
|
||||
expect(default_mark_shared).not_to_be_checked()
|
||||
expect(default_mark_shared).to_be_disabled()
|
||||
|
||||
# Enable sharing
|
||||
enable_sharing_label.click()
|
||||
expect(enable_sharing).to_be_checked()
|
||||
expect(enable_public_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).to_be_enabled()
|
||||
expect(default_mark_shared).not_to_be_checked()
|
||||
expect(default_mark_shared).to_be_enabled()
|
||||
|
||||
# Enable public sharing
|
||||
# Enable public sharing and default shared
|
||||
enable_public_sharing_label.click()
|
||||
default_mark_shared_label.click()
|
||||
expect(enable_public_sharing).to_be_checked()
|
||||
expect(enable_public_sharing).to_be_enabled()
|
||||
expect(default_mark_shared).to_be_checked()
|
||||
expect(default_mark_shared).to_be_enabled()
|
||||
|
||||
# Disable sharing
|
||||
enable_sharing_label.click()
|
||||
expect(enable_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).to_be_disabled()
|
||||
expect(default_mark_shared).not_to_be_checked()
|
||||
expect(default_mark_shared).to_be_disabled()
|
||||
|
||||
def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
|
||||
@@ -92,3 +92,6 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
).click()
|
||||
else:
|
||||
self.page.locator("nav").get_by_text(main_menu_item, exact=True).click()
|
||||
|
||||
def locate_confirm_dialog(self):
|
||||
return self.page.locator(".dropdown.confirm-dropdown")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user