mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-02-28 23:13:12 +08:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
04248a7fba | ||
|
|
0ff36a94fe | ||
|
|
f83eb25569 | ||
|
|
c746afcf76 | ||
|
|
aaa0f6e119 | ||
|
|
cd215a9237 | ||
|
|
1e56b0e6f3 | ||
|
|
5cc8c9c010 | ||
|
|
846808d870 | ||
|
|
6d9a694756 | ||
|
|
de38e56b3f | ||
|
|
c6fb695af2 | ||
|
|
93faf70b37 | ||
|
|
5330252db9 | ||
|
|
ef00d289f5 | ||
|
|
4e8318d0ae | ||
|
|
a8623d11ef | ||
|
|
8cd992ca30 | ||
|
|
68c104ba54 | ||
|
|
7a4236d179 | ||
|
|
e87304501f | ||
|
|
809e9e02f3 | ||
|
|
2bb33ff96d | ||
|
|
549554cc17 | ||
|
|
20e31397cc | ||
|
|
94ae5fb41c | ||
|
|
2a550e2315 | ||
|
|
a79e8bcd59 | ||
|
|
1710d44df7 | ||
|
|
9967b3e27b | ||
|
|
1672dc0152 | ||
|
|
8be72a5d1f | ||
|
|
bb796c9bdb | ||
|
|
578680c3c1 | ||
|
|
8debb5c5aa | ||
|
|
be752f8146 |
@@ -10,10 +10,10 @@
|
||||
!/package.json
|
||||
!/package-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
|
||||
|
||||
88
CHANGELOG.md
88
CHANGELOG.md
@@ -1,5 +1,93 @@
|
||||
# Changelog
|
||||
|
||||
## v1.41.0 (19/06/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add bundles for organizing bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1097
|
||||
* Add REST API for bookmark bundles by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1100
|
||||
* Add date filters for REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1080
|
||||
* Fix side panel not being hidden on smaller viewports by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1089
|
||||
* Fix assets not using correct icon by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1098
|
||||
* Add LinkBuddy to community section by @peterto in https://github.com/sissbruecker/linkding/pull/1088
|
||||
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1084
|
||||
* Bump django from 5.1.9 to 5.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/1086
|
||||
* Bump requests from 2.32.3 to 2.32.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/1090
|
||||
* Bump urllib3 from 2.2.3 to 2.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/1096
|
||||
|
||||
### New Contributors
|
||||
* @peterto made their first contribution in https://github.com/sissbruecker/linkding/pull/1088
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.40.0...v1.41.0
|
||||
|
||||
---
|
||||
|
||||
## v1.40.0 (17/05/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add bulk and single bookmark metadata refresh by @Teknicallity in https://github.com/sissbruecker/linkding/pull/999
|
||||
* Prefer local snapshot over web archive link in bookmark list links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1021
|
||||
* Push Docker images to GHCR in addition to Docker Hub by @caycehouse in https://github.com/sissbruecker/linkding/pull/1024
|
||||
* Allow auto tagging rules to match URL fragments by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1045
|
||||
* Linkify plain URLs in notes by @sonicdoe in https://github.com/sissbruecker/linkding/pull/1051
|
||||
* Add opensearch declaration by @jzorn in https://github.com/sissbruecker/linkding/pull/1058
|
||||
* Allow pre-filling tags in new bookmark form by @dasrecht in https://github.com/sissbruecker/linkding/pull/1060
|
||||
* Handle lowercase "true" in environment variables by @jose-elias-alvarez in https://github.com/sissbruecker/linkding/pull/1020
|
||||
* Accessibility improvements in page structure by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1014
|
||||
* Improve announcements after navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1015
|
||||
* Fix OIDC login link by @cite in https://github.com/sissbruecker/linkding/pull/1019
|
||||
* Fix bookmark asset download endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1033
|
||||
* Add docs for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1009
|
||||
* Fix typo in index.mdx tagline by @cenviity in https://github.com/sissbruecker/linkding/pull/1052
|
||||
* Add how-to for using linkding PWA in native Android share sheet by @kzshantonu in https://github.com/sissbruecker/linkding/pull/1055
|
||||
* Adding linktiles to community projects by @haondt in https://github.com/sissbruecker/linkding/pull/1025
|
||||
* Bump django from 5.1.5 to 5.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/1007
|
||||
* Bump django from 5.1.7 to 5.1.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/1030
|
||||
* Bump tar-fs in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1028
|
||||
* Bump prismjs from 1.29.0 to 1.30.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1034
|
||||
* Bump @babel/helpers from 7.26.7 to 7.27.0 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1035
|
||||
* Bump vite from 5.4.14 to 5.4.17 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1036
|
||||
* Bump esbuild, @astrojs/starlight and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/1037
|
||||
* Bump django from 5.1.8 to 5.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/1059
|
||||
|
||||
### New Contributors
|
||||
* @cite made their first contribution in https://github.com/sissbruecker/linkding/pull/1019
|
||||
* @jose-elias-alvarez made their first contribution in https://github.com/sissbruecker/linkding/pull/1020
|
||||
* @Teknicallity made their first contribution in https://github.com/sissbruecker/linkding/pull/999
|
||||
* @haondt made their first contribution in https://github.com/sissbruecker/linkding/pull/1025
|
||||
* @caycehouse made their first contribution in https://github.com/sissbruecker/linkding/pull/1024
|
||||
* @cenviity made their first contribution in https://github.com/sissbruecker/linkding/pull/1052
|
||||
* @sonicdoe made their first contribution in https://github.com/sissbruecker/linkding/pull/1051
|
||||
* @jzorn made their first contribution in https://github.com/sissbruecker/linkding/pull/1058
|
||||
* @dasrecht made their first contribution in https://github.com/sissbruecker/linkding/pull/1060
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.39.1...v1.40.0
|
||||
|
||||
---
|
||||
|
||||
## v1.39.1 (06/03/2025)
|
||||
|
||||
> [!WARNING]
|
||||
> Due to changes in the release process the `1.39.0` Docker image accidentally runs the application in debug mode. Please upgrade to `1.39.1` instead.
|
||||
|
||||
---
|
||||
|
||||
## v1.39.0 (06/03/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add REST endpoint for uploading snapshots from the Singlefile extension by @sissbruecker in https://github.com/sissbruecker/linkding/pull/996
|
||||
* Add bookmark assets API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/1003
|
||||
* Allow providing REST API authentication token with Bearer keyword by @sissbruecker in https://github.com/sissbruecker/linkding/pull/995
|
||||
* Add Telegram bot to community section by @marb08 in https://github.com/sissbruecker/linkding/pull/1001
|
||||
* Adding linklater to community projects by @nsartor in https://github.com/sissbruecker/linkding/pull/1002
|
||||
|
||||
### New Contributors
|
||||
* @marb08 made their first contribution in https://github.com/sissbruecker/linkding/pull/1001
|
||||
* @nsartor made their first contribution in https://github.com/sissbruecker/linkding/pull/1002
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.1...v1.39.0
|
||||
|
||||
---
|
||||
|
||||
## v1.38.1 (22/02/2025)
|
||||
|
||||
### 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
|
||||
43
README.md
43
README.md
@@ -61,43 +61,31 @@ Small improvements, bugfixes and documentation improvements are always welcome.
|
||||
The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -117,6 +105,11 @@ make format
|
||||
|
||||
### DevContainers
|
||||
|
||||
> [!WARNING]
|
||||
> The dev container setup is currently broken after switching to uv.
|
||||
> Feel free to contribute a PR if you want to fix it.
|
||||
> The instructions below are outdated until then.
|
||||
|
||||
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/sissbruecker/linkding.git)
|
||||
|
||||
Once checked out, only the following commands are required to get started:
|
||||
|
||||
@@ -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
|
||||
@@ -11,7 +12,15 @@ from huey.contrib.djhuey import HUEY as huey
|
||||
from rest_framework.authtoken.admin import TokenAdmin
|
||||
from rest_framework.authtoken.models import TokenProxy
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkBundle,
|
||||
Tag,
|
||||
UserProfile,
|
||||
Toast,
|
||||
FeedToken,
|
||||
)
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
@@ -75,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",
|
||||
@@ -83,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,
|
||||
}
|
||||
],
|
||||
@@ -206,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",)
|
||||
@@ -256,6 +266,21 @@ class AdminTag(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class AdminBookmarkBundle(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"owner",
|
||||
"order",
|
||||
"search",
|
||||
"any_tags",
|
||||
"all_tags",
|
||||
"excluded_tags",
|
||||
"date_created",
|
||||
)
|
||||
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||
list_filter = ("owner__username",)
|
||||
|
||||
|
||||
class AdminUserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
@@ -289,6 +314,7 @@ linkding_admin_site = LinkdingAdminSite()
|
||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
|
||||
linkding_admin_site.register(Tag, AdminTag)
|
||||
linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle)
|
||||
linkding_admin_site.register(User, AdminCustomUser)
|
||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||
linkding_admin_site.register(Toast, AdminToast)
|
||||
|
||||
@@ -16,9 +16,18 @@ from bookmarks.api.serializers import (
|
||||
BookmarkAssetSerializer,
|
||||
TagSerializer,
|
||||
UserProfileSerializer,
|
||||
BookmarkBundleSerializer,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
|
||||
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkSearch,
|
||||
Tag,
|
||||
User,
|
||||
BookmarkBundle,
|
||||
)
|
||||
from bookmarks.services import assets, bookmarks, bundles, auto_tagging, website_loader
|
||||
from bookmarks.utils import normalize_url
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
from bookmarks.views import access
|
||||
|
||||
@@ -50,7 +59,7 @@ class BookmarkViewSet(
|
||||
def get_queryset(self):
|
||||
# Provide filtered queryset for list actions
|
||||
user = self.request.user
|
||||
search = BookmarkSearch.from_request(self.request.GET)
|
||||
search = BookmarkSearch.from_request(self.request, self.request.GET)
|
||||
if self.action == "list":
|
||||
return queries.query_bookmarks(user, user.profile, search)
|
||||
elif self.action == "archived":
|
||||
@@ -99,7 +108,10 @@ 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()
|
||||
normalized_url = normalize_url(url)
|
||||
bookmark = Bookmark.objects.filter(
|
||||
owner=request.user, url_normalized=normalized_url
|
||||
).first()
|
||||
existing_bookmark_data = (
|
||||
self.get_serializer(bookmark).data if bookmark else None
|
||||
)
|
||||
@@ -143,7 +155,10 @@ class BookmarkViewSet(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
normalized_url = normalize_url(url)
|
||||
bookmark = Bookmark.objects.filter(
|
||||
owner=request.user, url_normalized=normalized_url
|
||||
).first()
|
||||
|
||||
if not bookmark:
|
||||
bookmark = Bookmark(url=url)
|
||||
@@ -191,13 +206,10 @@ class BookmarkAssetViewSet(
|
||||
if asset.gzip
|
||||
else open(file_path, "rb")
|
||||
)
|
||||
file_name = (
|
||||
f"{asset.display_name}.html"
|
||||
if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
|
||||
else asset.display_name
|
||||
)
|
||||
response = StreamingHttpResponse(file_stream, content_type=content_type)
|
||||
response["Content-Disposition"] = f'attachment; filename="{file_name}"'
|
||||
response["Content-Disposition"] = (
|
||||
f'attachment; filename="{asset.download_name}"'
|
||||
)
|
||||
return response
|
||||
except FileNotFoundError:
|
||||
raise Http404("Asset file does not exist")
|
||||
@@ -264,6 +276,28 @@ class UserViewSet(viewsets.GenericViewSet):
|
||||
return Response(UserProfileSerializer(request.user.profile).data)
|
||||
|
||||
|
||||
class BookmarkBundleViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
request: HttpRequest
|
||||
serializer_class = BookmarkBundleSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return BookmarkBundle.objects.filter(owner=user).order_by("order")
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"user": self.request.user}
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
bundles.delete_bundle(instance)
|
||||
|
||||
|
||||
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
|
||||
# Instead create separate routers for each view set and manually register them in urls.py
|
||||
# The default router is only used to allow reversing a URL for the API root
|
||||
@@ -278,5 +312,8 @@ tag_router.register("", TagViewSet, basename="tag")
|
||||
user_router = SimpleRouter()
|
||||
user_router.register("", UserViewSet, basename="user")
|
||||
|
||||
bundle_router = SimpleRouter()
|
||||
bundle_router.register("", BookmarkBundleViewSet, basename="bundle")
|
||||
|
||||
bookmark_asset_router = SimpleRouter()
|
||||
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
from django.db.models import prefetch_related_objects
|
||||
from django.db.models import Max, prefetch_related_objects
|
||||
from django.templatetags.static import static
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ListSerializer
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
|
||||
from bookmarks.services import bookmarks
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
Tag,
|
||||
build_tag_string,
|
||||
UserProfile,
|
||||
BookmarkBundle,
|
||||
)
|
||||
from bookmarks.services import bookmarks, bundles
|
||||
from bookmarks.services.tags import get_or_create_tag
|
||||
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
||||
from bookmarks.utils import app_version
|
||||
@@ -27,6 +34,32 @@ class EmtpyField(serializers.ReadOnlyField):
|
||||
return None
|
||||
|
||||
|
||||
class BookmarkBundleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = BookmarkBundle
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"search",
|
||||
"any_tags",
|
||||
"all_tags",
|
||||
"excluded_tags",
|
||||
"order",
|
||||
"date_created",
|
||||
"date_modified",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"date_created",
|
||||
"date_modified",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
bundle = BookmarkBundle(**validated_data)
|
||||
bundle.order = validated_data["order"] if "order" in validated_data else None
|
||||
return bundles.create_bundle(bundle, self.context["user"])
|
||||
|
||||
|
||||
class BookmarkSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||
from bookmarks.views import access
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -30,10 +31,16 @@ def sanitize(text: str):
|
||||
class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str | None):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
|
||||
bundle = None
|
||||
bundle_id = request.GET.get("bundle")
|
||||
if bundle_id:
|
||||
bundle = access.bundle_read(request, bundle_id)
|
||||
|
||||
search = BookmarkSearch(
|
||||
q=request.GET.get("q", ""),
|
||||
unread=request.GET.get("unread", ""),
|
||||
shared=request.GET.get("shared", ""),
|
||||
bundle=bundle,
|
||||
)
|
||||
query_set = self.get_query_set(feed_token, search)
|
||||
return FeedContext(request, feed_token, query_set)
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
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.utils import normalize_url
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
|
||||
|
||||
class CustomErrorList(ErrorList):
|
||||
template_name = "shared/error_list.html"
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
@@ -44,11 +57,14 @@ 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, " ")}
|
||||
data = request.POST if request.method == "POST" else None
|
||||
super().__init__(data, instance=instance, initial=initial)
|
||||
super().__init__(
|
||||
data, instance=instance, initial=initial, error_class=CustomErrorList
|
||||
)
|
||||
|
||||
@property
|
||||
def is_auto_close(self):
|
||||
@@ -78,8 +94,11 @@ class BookmarkForm(forms.ModelForm):
|
||||
# raise a validation error in that case.
|
||||
url = self.cleaned_data["url"]
|
||||
if self.instance.pk:
|
||||
normalized_url = normalize_url(url)
|
||||
is_duplicate = (
|
||||
Bookmark.objects.filter(owner=self.instance.owner, url=url)
|
||||
Bookmark.objects.filter(
|
||||
owner=self.instance.owner, url_normalized=normalized_url
|
||||
)
|
||||
.exclude(pk=self.instance.pk)
|
||||
.exists()
|
||||
)
|
||||
@@ -93,3 +112,88 @@ def convert_tag_string(tag_string: str):
|
||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||
# 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);
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class FormSubmit extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.element.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Check for Ctrl/Cmd + Enter combination
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.element.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AutoSubmitBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
@@ -17,6 +39,36 @@ class AutoSubmitBehavior extends Behavior {
|
||||
}
|
||||
}
|
||||
|
||||
// Resets form controls to their initial values before Turbo caches the DOM.
|
||||
// Useful for filter forms where navigating back would otherwise still show
|
||||
// values from after the form submission, which means the filters would be out
|
||||
// of sync with the URL.
|
||||
class FormResetBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.controls = this.element.querySelectorAll("input, select");
|
||||
this.controls.forEach((control) => {
|
||||
if (control.type === "checkbox" || control.type === "radio") {
|
||||
control.__initialValue = control.checked;
|
||||
} else {
|
||||
control.__initialValue = control.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.controls.forEach((control) => {
|
||||
if (control.type === "checkbox" || control.type === "radio") {
|
||||
control.checked = control.__initialValue;
|
||||
} else {
|
||||
control.value = control.__initialValue;
|
||||
}
|
||||
delete control.__initialValue;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class UploadButton extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
@@ -51,5 +103,7 @@ 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,21 +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") || "",
|
||||
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,168 +0,0 @@
|
||||
<script>
|
||||
import {cache} from "../cache";
|
||||
import {getCurrentWord, getCurrentWordBounds} from "../util";
|
||||
|
||||
export let id;
|
||||
export let name;
|
||||
export let value;
|
||||
export let placeholder;
|
||||
export let variant = 'default';
|
||||
|
||||
let isFocus = false;
|
||||
let isOpen = false;
|
||||
let input = null;
|
||||
let suggestionList = null;
|
||||
|
||||
let suggestions = [];
|
||||
let selectedIndex = 0;
|
||||
|
||||
function handleFocus() {
|
||||
isFocus = true;
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
isFocus = false;
|
||||
close();
|
||||
}
|
||||
|
||||
async function handleInput(e) {
|
||||
input = e.target;
|
||||
|
||||
const tags = await cache.getTags();
|
||||
const word = getCurrentWord(input);
|
||||
|
||||
suggestions = word
|
||||
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
|
||||
: [];
|
||||
|
||||
if (word && suggestions.length > 0) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
|
||||
const suggestion = suggestions[selectedIndex];
|
||||
complete(suggestion);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 27) {
|
||||
close();
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 38) {
|
||||
updateSelection(-1);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 40) {
|
||||
updateSelection(1);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen = true;
|
||||
selectedIndex = 0;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
suggestions = [];
|
||||
selectedIndex = 0;
|
||||
}
|
||||
|
||||
function complete(suggestion) {
|
||||
const bounds = getCurrentWordBounds(input);
|
||||
const value = input.value;
|
||||
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
||||
|
||||
close();
|
||||
}
|
||||
|
||||
function updateSelection(dir) {
|
||||
|
||||
const length = suggestions.length;
|
||||
let newIndex = selectedIndex + dir;
|
||||
|
||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||
if (newIndex >= length) newIndex = 0;
|
||||
|
||||
selectedIndex = newIndex;
|
||||
|
||||
// Scroll to selected list item
|
||||
setTimeout(() => {
|
||||
if (suggestionList) {
|
||||
const selectedListItem = suggestionList.querySelector('li.selected');
|
||||
if (selectedListItem) {
|
||||
selectedListItem.scrollIntoView({block: 'center'});
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-autocomplete" class:small={variant === 'small'}>
|
||||
<!-- autocomplete input container -->
|
||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||
<!-- autocomplete real input box -->
|
||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
|
||||
class="form-input" type="text" autocomplete="off" autocapitalize="off"
|
||||
on:input={handleInput} on:keydown={handleKeyDown}
|
||||
on:focus={handleFocus} on:blur={handleBlur}>
|
||||
</div>
|
||||
|
||||
<!-- autocomplete suggestion list -->
|
||||
<ul class="menu" class:open={isOpen && suggestions.length > 0}
|
||||
bind:this={suggestionList}>
|
||||
<!-- menu list items -->
|
||||
{#each suggestions as tag,i}
|
||||
<li class="menu-item" class:selected={selectedIndex === i}>
|
||||
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
||||
{tag.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
display: none;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.menu.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete-input input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .menu .menu-item {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -11,7 +11,5 @@ import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/search-autocomplete";
|
||||
import "./behaviors/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;
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 5.1.9 on 2025-06-19 08:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0044_bookmark_latest_snapshot"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="hide_bundles",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BookmarkBundle",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=256)),
|
||||
("search", models.CharField(blank=True, max_length=256)),
|
||||
("any_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("all_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("excluded_tags", models.CharField(blank=True, max_length=1024)),
|
||||
("order", models.IntegerField(default=0)),
|
||||
("date_created", models.DateTimeField(auto_now_add=True)),
|
||||
("date_modified", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
bookmarks/migrations/0046_add_url_normalized_field.py
Normal file
18
bookmarks/migrations/0046_add_url_normalized_field.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2025-08-22 08:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0045_userprofile_hide_bundles_bookmarkbundle"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="url_normalized",
|
||||
field=models.CharField(blank=True, db_index=True, max_length=2048),
|
||||
),
|
||||
]
|
||||
38
bookmarks/migrations/0047_populate_url_normalized_field.py
Normal file
38
bookmarks/migrations/0047_populate_url_normalized_field.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.3 on 2025-08-22 08:28
|
||||
|
||||
from django.db import migrations, transaction
|
||||
from bookmarks.utils import normalize_url
|
||||
|
||||
|
||||
def populate_url_normalized(apps, schema_editor):
|
||||
Bookmark = apps.get_model("bookmarks", "Bookmark")
|
||||
|
||||
batch_size = 500
|
||||
with transaction.atomic():
|
||||
qs = Bookmark.objects.all()
|
||||
for start in range(0, qs.count(), batch_size):
|
||||
batch = list(qs[start : start + batch_size])
|
||||
for bookmark in batch:
|
||||
bookmark.url_normalized = normalize_url(bookmark.url)
|
||||
Bookmark.objects.bulk_update(
|
||||
batch, ["url_normalized"], batch_size=batch_size
|
||||
)
|
||||
|
||||
|
||||
def reverse_populate_url_normalized(apps, schema_editor):
|
||||
Bookmark = apps.get_model("bookmarks", "Bookmark")
|
||||
Bookmark.objects.all().update(url_normalized="")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0046_add_url_normalized_field"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
populate_url_normalized,
|
||||
reverse_populate_url_normalized,
|
||||
),
|
||||
]
|
||||
18
bookmarks/migrations/0048_userprofile_default_mark_shared.py
Normal file
18
bookmarks/migrations/0048_userprofile_default_mark_shared.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2025-08-22 17:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0047_populate_url_normalized_field"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="default_mark_shared",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
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),
|
||||
]
|
||||
@@ -13,7 +13,7 @@ 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__)
|
||||
@@ -39,7 +39,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ","):
|
||||
return []
|
||||
names = tag_string.strip().split(delimiter)
|
||||
# remove empty names, sanitize remaining names
|
||||
names = [sanitize_tag_name(name) for name in names if name]
|
||||
names = [sanitize_tag_name(name) for name in names if name.strip()]
|
||||
# remove duplicates
|
||||
names = unique(names, str.lower)
|
||||
names.sort(key=str.lower)
|
||||
@@ -53,6 +53,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)
|
||||
@@ -95,6 +96,10 @@ 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] + "...)"
|
||||
|
||||
@@ -132,6 +137,14 @@ class BookmarkAsset(models.Model):
|
||||
status = models.CharField(max_length=64, blank=False, null=False)
|
||||
gzip = models.BooleanField(default=False, null=False)
|
||||
|
||||
@property
|
||||
def download_name(self):
|
||||
return (
|
||||
f"{self.display_name}.html"
|
||||
if self.asset_type == BookmarkAsset.TYPE_SNAPSHOT
|
||||
else self.display_name
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.file:
|
||||
try:
|
||||
@@ -157,6 +170,27 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
|
||||
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
|
||||
|
||||
|
||||
class BookmarkBundle(models.Model):
|
||||
name = models.CharField(max_length=256, blank=False)
|
||||
search = models.CharField(max_length=256, blank=True)
|
||||
any_tags = models.CharField(max_length=1024, blank=True)
|
||||
all_tags = models.CharField(max_length=1024, blank=True)
|
||||
excluded_tags = models.CharField(max_length=1024, blank=True)
|
||||
order = models.IntegerField(null=False, default=0)
|
||||
date_created = models.DateTimeField(auto_now_add=True, null=False)
|
||||
date_modified = models.DateTimeField(auto_now=True, null=False)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class BookmarkBundleForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = BookmarkBundle
|
||||
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
|
||||
|
||||
|
||||
class BookmarkSearch:
|
||||
SORT_ADDED_ASC = "added_asc"
|
||||
SORT_ADDED_DESC = "added_desc"
|
||||
@@ -171,34 +205,54 @@ class BookmarkSearch:
|
||||
FILTER_UNREAD_YES = "yes"
|
||||
FILTER_UNREAD_NO = "no"
|
||||
|
||||
params = ["q", "user", "sort", "shared", "unread"]
|
||||
params = [
|
||||
"q",
|
||||
"user",
|
||||
"bundle",
|
||||
"sort",
|
||||
"shared",
|
||||
"unread",
|
||||
"modified_since",
|
||||
"added_since",
|
||||
]
|
||||
preferences = ["sort", "shared", "unread"]
|
||||
defaults = {
|
||||
"q": "",
|
||||
"user": "",
|
||||
"bundle": None,
|
||||
"sort": SORT_ADDED_DESC,
|
||||
"shared": FILTER_SHARED_OFF,
|
||||
"unread": FILTER_UNREAD_OFF,
|
||||
"modified_since": None,
|
||||
"added_since": None,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
q: str = None,
|
||||
user: str = None,
|
||||
bundle: BookmarkBundle = None,
|
||||
sort: str = None,
|
||||
shared: str = None,
|
||||
unread: str = None,
|
||||
modified_since: str = None,
|
||||
added_since: str = None,
|
||||
preferences: dict = None,
|
||||
request: any = None,
|
||||
):
|
||||
if not preferences:
|
||||
preferences = {}
|
||||
self.defaults = {**BookmarkSearch.defaults, **preferences}
|
||||
self.request = request
|
||||
|
||||
self.q = q or self.defaults["q"]
|
||||
self.user = user or self.defaults["user"]
|
||||
self.bundle = bundle or self.defaults["bundle"]
|
||||
self.sort = sort or self.defaults["sort"]
|
||||
self.shared = shared or self.defaults["shared"]
|
||||
self.unread = unread or self.defaults["unread"]
|
||||
self.modified_since = modified_since or self.defaults["modified_since"]
|
||||
self.added_since = added_since or self.defaults["added_since"]
|
||||
|
||||
def is_modified(self, param):
|
||||
value = self.__dict__[param]
|
||||
@@ -226,7 +280,14 @@ class BookmarkSearch:
|
||||
|
||||
@property
|
||||
def query_params(self):
|
||||
return {param: self.__dict__[param] for param in self.modified_params}
|
||||
query_params = {}
|
||||
for param in self.modified_params:
|
||||
value = self.__dict__[param]
|
||||
if isinstance(value, models.Model):
|
||||
query_params[param] = value.id
|
||||
else:
|
||||
query_params[param] = value
|
||||
return query_params
|
||||
|
||||
@property
|
||||
def preferences_dict(self):
|
||||
@@ -235,14 +296,21 @@ class BookmarkSearch:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_request(query_dict: QueryDict, preferences: dict = None):
|
||||
def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
|
||||
initial_values = {}
|
||||
for param in BookmarkSearch.params:
|
||||
value = query_dict.get(param)
|
||||
if value:
|
||||
initial_values[param] = value
|
||||
if param == "bundle":
|
||||
initial_values[param] = BookmarkBundle.objects.filter(
|
||||
owner=request.user, pk=value
|
||||
).first()
|
||||
else:
|
||||
initial_values[param] = value
|
||||
|
||||
return BookmarkSearch(**initial_values, preferences=preferences)
|
||||
return BookmarkSearch(
|
||||
**initial_values, preferences=preferences, request=request
|
||||
)
|
||||
|
||||
|
||||
class BookmarkSearchForm(forms.Form):
|
||||
@@ -265,9 +333,12 @@ class BookmarkSearchForm(forms.Form):
|
||||
|
||||
q = forms.CharField()
|
||||
user = forms.ChoiceField(required=False)
|
||||
bundle = forms.CharField(required=False)
|
||||
sort = forms.ChoiceField(choices=SORT_CHOICES)
|
||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||
modified_since = forms.CharField(required=False)
|
||||
added_since = forms.CharField(required=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -287,7 +358,11 @@ class BookmarkSearchForm(forms.Form):
|
||||
|
||||
for param in search.params:
|
||||
# set initial values for modified params
|
||||
self.fields[param].initial = search.__dict__[param]
|
||||
value = search.__dict__.get(param)
|
||||
if isinstance(value, models.Model):
|
||||
self.fields[param].initial = value.id
|
||||
else:
|
||||
self.fields[param].initial = value
|
||||
|
||||
# Mark non-editable modified fields as hidden. That way, templates
|
||||
# rendering a form can just loop over hidden_fields to ensure that
|
||||
@@ -403,11 +478,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:
|
||||
@@ -443,11 +521,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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -2,16 +2,38 @@ from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkBundle,
|
||||
BookmarkSearch,
|
||||
Tag,
|
||||
UserProfile,
|
||||
parse_tag_string,
|
||||
)
|
||||
from bookmarks.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
|
||||
|
||||
|
||||
def query_bookmarks(
|
||||
user: User, profile: UserProfile, search: BookmarkSearch
|
||||
user: User,
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
|
||||
|
||||
@@ -35,17 +57,92 @@ def query_shared_bookmarks(
|
||||
return _base_bookmarks_query(user, profile, search).filter(conditions)
|
||||
|
||||
|
||||
def _base_bookmarks_query(
|
||||
user: Optional[User], profile: UserProfile, search: BookmarkSearch
|
||||
) -> QuerySet:
|
||||
query_set = Bookmark.objects
|
||||
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)
|
||||
)
|
||||
|
||||
# Filter for user
|
||||
if user:
|
||||
query_set = query_set.filter(owner=user)
|
||||
# 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(search.q)
|
||||
query = parse_query_string(query_string)
|
||||
|
||||
# Filter for search terms and tags
|
||||
for term in query["search_terms"]:
|
||||
@@ -73,6 +170,83 @@ def _base_bookmarks_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"]
|
||||
for term in search_terms:
|
||||
conditions = (
|
||||
Q(title__icontains=term)
|
||||
| Q(description__icontains=term)
|
||||
| Q(notes__icontains=term)
|
||||
| Q(url__icontains=term)
|
||||
)
|
||||
query_set = query_set.filter(conditions)
|
||||
|
||||
# Any tags - at least one tag must match
|
||||
any_tags = parse_tag_string(bundle.any_tags, " ")
|
||||
if len(any_tags) > 0:
|
||||
tag_conditions = Q()
|
||||
for tag in any_tags:
|
||||
tag_conditions |= Q(tags__name__iexact=tag)
|
||||
|
||||
query_set = query_set.filter(
|
||||
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
|
||||
)
|
||||
|
||||
# All tags - all tags must match
|
||||
all_tags = parse_tag_string(bundle.all_tags, " ")
|
||||
for tag in all_tags:
|
||||
query_set = query_set.filter(tags__name__iexact=tag)
|
||||
|
||||
# Excluded tags - no tags must match
|
||||
exclude_tags = parse_tag_string(bundle.excluded_tags, " ")
|
||||
if len(exclude_tags) > 0:
|
||||
tag_conditions = Q()
|
||||
for tag in exclude_tags:
|
||||
tag_conditions |= Q(tags__name__iexact=tag)
|
||||
query_set = query_set.exclude(
|
||||
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
|
||||
)
|
||||
|
||||
return query_set
|
||||
|
||||
|
||||
def _base_bookmarks_query(
|
||||
user: Optional[User],
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
) -> QuerySet:
|
||||
query_set = Bookmark.objects
|
||||
|
||||
# Filter for user
|
||||
if user:
|
||||
query_set = query_set.filter(owner=user)
|
||||
|
||||
# Filter by modified_since if provided
|
||||
if search.modified_since:
|
||||
try:
|
||||
query_set = query_set.filter(date_modified__gt=search.modified_since)
|
||||
except ValidationError:
|
||||
# If the date format is invalid, ignore the filter
|
||||
pass
|
||||
|
||||
# Filter by added_since if provided
|
||||
if search.added_since:
|
||||
try:
|
||||
query_set = query_set.filter(date_added__gt=search.added_since)
|
||||
except ValidationError:
|
||||
# If the date format is invalid, ignore the filter
|
||||
pass
|
||||
|
||||
# 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:
|
||||
query_set = query_set.filter(unread=True)
|
||||
@@ -85,6 +259,10 @@ def _base_bookmarks_query(
|
||||
elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
|
||||
query_set = query_set.filter(shared=False)
|
||||
|
||||
# Filter by bundle
|
||||
if search.bundle:
|
||||
query_set = _filter_bundle(query_set, search.bundle)
|
||||
|
||||
# Sort
|
||||
if (
|
||||
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
||||
@@ -168,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
|
||||
@@ -53,6 +54,7 @@ def create_snapshot(asset: BookmarkAsset):
|
||||
asset.save()
|
||||
|
||||
asset.bookmark.latest_snapshot = asset
|
||||
asset.bookmark.date_modified = timezone.now()
|
||||
asset.bookmark.save()
|
||||
except Exception as error:
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
@@ -75,6 +77,7 @@ def upload_snapshot(bookmark: Bookmark, html: bytes):
|
||||
asset.save()
|
||||
|
||||
asset.bookmark.latest_snapshot = asset
|
||||
asset.bookmark.date_modified = timezone.now()
|
||||
asset.bookmark.save()
|
||||
|
||||
return asset
|
||||
@@ -92,14 +95,33 @@ def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
|
||||
gzip=False,
|
||||
)
|
||||
name, extension = os.path.splitext(upload_file.name)
|
||||
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
for chunk in upload_file.chunks():
|
||||
f.write(chunk)
|
||||
asset.file = filename
|
||||
asset.file_size = upload_file.size
|
||||
|
||||
# automatically gzip the file if it is not already gzipped
|
||||
if upload_file.content_type != "application/gzip":
|
||||
filename = _generate_asset_filename(
|
||||
asset, name, extension.lstrip(".") + ".gz"
|
||||
)
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with gzip.open(filepath, "wb", compresslevel=9) as f:
|
||||
for chunk in upload_file.chunks():
|
||||
f.write(chunk)
|
||||
asset.gzip = True
|
||||
asset.file = filename
|
||||
asset.file_size = os.path.getsize(filepath)
|
||||
else:
|
||||
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
for chunk in upload_file.chunks():
|
||||
f.write(chunk)
|
||||
asset.file = filename
|
||||
asset.file_size = upload_file.size
|
||||
|
||||
asset.save()
|
||||
|
||||
asset.bookmark.date_modified = timezone.now()
|
||||
asset.bookmark.save()
|
||||
|
||||
logger.info(
|
||||
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
||||
)
|
||||
@@ -128,9 +150,10 @@ def remove_asset(asset: BookmarkAsset):
|
||||
)
|
||||
|
||||
bookmark.latest_snapshot = latest
|
||||
bookmark.save()
|
||||
|
||||
asset.delete()
|
||||
bookmark.date_modified = timezone.now()
|
||||
bookmark.save()
|
||||
|
||||
|
||||
def _generate_asset_filename(
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Union
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, User, parse_tag_string
|
||||
from bookmarks.utils import normalize_url
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services import website_loader
|
||||
@@ -19,8 +20,9 @@ def create_bookmark(
|
||||
disable_html_snapshot: bool = False,
|
||||
):
|
||||
# If URL is already bookmarked, then update it
|
||||
normalized_url = normalize_url(bookmark.url)
|
||||
existing_bookmark: Bookmark = Bookmark.objects.filter(
|
||||
owner=current_user, url=bookmark.url
|
||||
owner=current_user, url_normalized=normalized_url
|
||||
).first()
|
||||
|
||||
if existing_bookmark is not None:
|
||||
@@ -208,6 +210,15 @@ def refresh_bookmarks_metadata(bookmark_ids: [Union[int, str]], current_user: Us
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
|
||||
|
||||
def create_html_snapshots(bookmark_ids: list[Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
owned_bookmarks = Bookmark.objects.filter(
|
||||
owner=current_user, id__in=sanitized_bookmark_ids
|
||||
)
|
||||
|
||||
tasks.create_html_snapshots(owned_bookmarks)
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
to_bookmark.title = from_bookmark.title
|
||||
to_bookmark.description = from_bookmark.description
|
||||
|
||||
37
bookmarks/services/bundles.py
Normal file
37
bookmarks/services/bundles.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from django.db.models import Max
|
||||
|
||||
from bookmarks.models import BookmarkBundle, User
|
||||
|
||||
|
||||
def create_bundle(bundle: BookmarkBundle, current_user: User):
|
||||
bundle.owner = current_user
|
||||
if bundle.order is None:
|
||||
max_order_result = BookmarkBundle.objects.filter(owner=current_user).aggregate(
|
||||
Max("order", default=-1)
|
||||
)
|
||||
bundle.order = max_order_result["order__max"] + 1
|
||||
bundle.save()
|
||||
return bundle
|
||||
|
||||
|
||||
def move_bundle(bundle_to_move: BookmarkBundle, new_order: int):
|
||||
user_bundles = list(
|
||||
BookmarkBundle.objects.filter(owner=bundle_to_move.owner).order_by("order")
|
||||
)
|
||||
|
||||
if new_order != user_bundles.index(bundle_to_move):
|
||||
user_bundles.remove(bundle_to_move)
|
||||
user_bundles.insert(new_order, bundle_to_move)
|
||||
for bundle_index, bundle in enumerate(user_bundles):
|
||||
bundle.order = bundle_index
|
||||
|
||||
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
|
||||
|
||||
|
||||
def delete_bundle(bundle: BookmarkBundle):
|
||||
bundle.delete()
|
||||
|
||||
user_bundles = BookmarkBundle.objects.filter(owner=bundle.owner).order_by("order")
|
||||
for index, user_bundle in enumerate(user_bundles):
|
||||
user_bundle.order = index
|
||||
BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
|
||||
@@ -96,6 +96,13 @@ def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User)
|
||||
|
||||
for netscape_bookmark in netscape_bookmarks:
|
||||
for tag_name in netscape_bookmark.tag_names:
|
||||
# Skip tag names that exceed the maximum allowed length
|
||||
if len(tag_name) > 64:
|
||||
logger.warning(
|
||||
f"Ignoring tag '{tag_name}' (length {len(tag_name)}) as it exceeds maximum length of 64 characters"
|
||||
)
|
||||
continue
|
||||
|
||||
tag = tag_cache.get(tag_name)
|
||||
if not tag:
|
||||
tag = Tag(name=tag_name, owner=user)
|
||||
|
||||
@@ -22,9 +22,10 @@ def create_snapshot(url: str, filepath: str):
|
||||
command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}"
|
||||
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)
|
||||
@@ -116,8 +116,6 @@ TIME_ZONE = os.getenv("TZ", "UTC")
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
/* Common styles */
|
||||
.bookmark-details {
|
||||
.title {
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& .weblinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -49,50 +57,9 @@
|
||||
& .assets {
|
||||
margin-top: var(--unit-2);
|
||||
|
||||
& .asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2) 0;
|
||||
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset:last-child {
|
||||
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .asset-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
& .asset-text {
|
||||
flex: 1 1 0;
|
||||
gap: var(--unit-2);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .asset-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
& .asset-text .filesize {
|
||||
& .filesize {
|
||||
color: var(--tertiary-text-color);
|
||||
}
|
||||
|
||||
& .asset-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .assets-actions {
|
||||
|
||||
@@ -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)
|
||||
@@ -379,6 +374,26 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
}
|
||||
|
||||
.bundle-menu {
|
||||
list-style-type: none;
|
||||
margin: 0 0 var(--unit-6);
|
||||
|
||||
.bundle-menu-item {
|
||||
margin: 0;
|
||||
margin-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
.bundle-menu-item a {
|
||||
padding: var(--unit-1) var(--unit-2);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.bundle-menu-item.selected a {
|
||||
background: var(--primary-color);
|
||||
color: var(--contrast-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
|
||||
29
bookmarks/styles/bundles.css
Normal file
29
bookmarks/styles/bundles.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.bundles-page {
|
||||
.crud-table {
|
||||
svg {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
tr.drag-start {
|
||||
--secondary-border-color: transparent;
|
||||
}
|
||||
|
||||
tr.dragging > * {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bundles-editor-page {
|
||||
&.grid {
|
||||
gap: var(--unit-9);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
border-top: solid 1px var(--secondary-border-color);
|
||||
background: var(--body-color);
|
||||
padding: var(--unit-3) 0;
|
||||
}
|
||||
}
|
||||
@@ -25,28 +25,23 @@
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.section-header {
|
||||
.section-header:not(.no-wrap) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,3 +55,60 @@ span.confirmation {
|
||||
.turbo-progress-bar {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.message-list {
|
||||
margin: var(--unit-4) 0;
|
||||
|
||||
.toast {
|
||||
margin-bottom: var(--unit-2);
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Item list */
|
||||
.item-list {
|
||||
& .list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2) 0;
|
||||
border-top: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .list-item:last-child {
|
||||
border-bottom: var(--unit-o) solid var(--secondary-border-color);
|
||||
}
|
||||
|
||||
& .list-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
& .list-item-text {
|
||||
flex: 1 1 0;
|
||||
gap: var(--unit-2);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .list-item-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
& .list-item-actions {
|
||||
display: flex;
|
||||
gap: var(--unit-4);
|
||||
align-items: center;
|
||||
|
||||
& .btn.btn-link {
|
||||
height: unset;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
bookmarks/styles/crud.css
Normal file
65
bookmarks/styles/crud.css
Normal file
@@ -0,0 +1,65 @@
|
||||
.crud-page {
|
||||
.crud-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--unit-6);
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.crud-filters {
|
||||
background: var(--body-color-contrast);
|
||||
border-radius: var(--border-radius);
|
||||
border: solid 1px var(--secondary-border-color);
|
||||
padding: var(--unit-3);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--unit-4);
|
||||
|
||||
& .form-group {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.form-input,
|
||||
&.form-select {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
& .form-group:has(.form-checkbox) {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.crud-table {
|
||||
.btn.btn-link {
|
||||
padding: 0;
|
||||
height: unset;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
max-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
th.actions,
|
||||
td.actions {
|
||||
width: 1%;
|
||||
max-width: 150px;
|
||||
|
||||
*:not(:last-child) {
|
||||
margin-right: var(--unit-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,15 +27,3 @@ header {
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
header .toasts {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.toast {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.toast a.btn-clear:visited {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
6
bookmarks/styles/tags.css
Normal file
6
bookmarks/styles/tags.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.tags-editor-page {
|
||||
main {
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,12 @@
|
||||
@import "responsive.css";
|
||||
@import "layout.css";
|
||||
@import "components.css";
|
||||
@import "crud.css";
|
||||
@import "bookmark-details.css";
|
||||
@import "bookmark-form.css";
|
||||
@import "bookmark-page.css";
|
||||
@import "markdown.css";
|
||||
@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);
|
||||
|
||||
@@ -224,12 +224,13 @@ textarea.form-input {
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
top: calc((var(--control-size-sm) - var(--control-icon-size)) / 2);
|
||||
left: 0;
|
||||
height: var(--control-icon-size);
|
||||
width: var(--control-icon-size);
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible + .form-icon {
|
||||
outline: var(--focus-outline);
|
||||
@@ -243,9 +244,9 @@ textarea.form-input {
|
||||
}
|
||||
|
||||
.form-icon {
|
||||
pointer-events: none;
|
||||
border: var(--border-width) solid var(--checkbox-border-color);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
transition:
|
||||
@@ -429,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,44 @@
|
||||
margin-top: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.m-6 {
|
||||
margin: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.ml-6 {
|
||||
margin-left: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.mr-6 {
|
||||
margin-right: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-top: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.mx-6 {
|
||||
margin-left: var(--unit-6) !important;
|
||||
margin-right: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.my-6 {
|
||||
margin-bottom: var(--unit-6) !important;
|
||||
margin-top: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mr-auto {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
@@ -283,6 +321,10 @@
|
||||
}
|
||||
|
||||
/* Flex */
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
@@ -294,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);
|
||||
}
|
||||
|
||||
@@ -30,16 +30,10 @@
|
||||
</form>
|
||||
</main>
|
||||
|
||||
{# Tag cloud #}
|
||||
<div class="side-panel col-1">
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
{# Filters #}
|
||||
<div class="side-panel col-1 hide-md">
|
||||
{% include 'bookmarks/bundle_section.html' %}
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -77,72 +77,76 @@
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% if not bookmark_list.is_preview %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% 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?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
{% if not bookmark_list.is_preview %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% 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?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% 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-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(function () {
|
||||
var bookmarkUrl = window.location;
|
||||
var applicationUrl = '{{ application_url }}';
|
||||
const bookmarkUrl = window.location;
|
||||
|
||||
let applicationUrl = '{{ application_url }}';
|
||||
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
|
||||
applicationUrl += '&auto_close';
|
||||
|
||||
|
||||
25
bookmarks/templates/bookmarks/bookmarklet_clientside.js
Normal file
25
bookmarks/templates/bookmarks/bookmarklet_clientside.js
Normal file
@@ -0,0 +1,25 @@
|
||||
(function () {
|
||||
const bookmarkUrl = window.location;
|
||||
const title =
|
||||
document.querySelector('title')?.textContent ||
|
||||
document
|
||||
.querySelector(`meta[property='og:title']`)
|
||||
?.getAttribute('content') ||
|
||||
'';
|
||||
const description =
|
||||
document
|
||||
.querySelector(`meta[name='description']`)
|
||||
?.getAttribute('content') ||
|
||||
document
|
||||
.querySelector(`meta[property='og:description']`)
|
||||
?.getAttribute(`content`) ||
|
||||
'';
|
||||
|
||||
let applicationUrl = '{{ application_url }}';
|
||||
applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl);
|
||||
applicationUrl += '&title=' + encodeURIComponent(title);
|
||||
applicationUrl += '&description=' + encodeURIComponent(description);
|
||||
applicationUrl += '&auto_close';
|
||||
|
||||
window.open(applicationUrl);
|
||||
})();
|
||||
@@ -23,6 +23,9 @@
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
{% endif %}
|
||||
<option value="bulk_refresh">Refresh from website</option>
|
||||
{% if bookmark_list.snapshot_feature_enabled %}
|
||||
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="tag-autocomplete d-none" ld-tag-autocomplete>
|
||||
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">
|
||||
|
||||
36
bookmarks/templates/bookmarks/bundle_section.html
Normal file
36
bookmarks/templates/bookmarks/bundle_section.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% if not request.user_profile.hide_bundles %}
|
||||
<section aria-labelledby="bundles-heading">
|
||||
<div class="section-header no-wrap">
|
||||
<h2 id="bundles-heading">Bundles</h2>
|
||||
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||
<button class="btn btn-noborder dropdown-toggle" aria-label="Bundles menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M4 6l16 0"/>
|
||||
<path d="M4 12l16 0"/>
|
||||
<path d="M4 18l16 0"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'linkding:bundles.index' %}" class="menu-link">Manage bundles</a>
|
||||
</li>
|
||||
{% if bookmark_list.search.q %}
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'linkding:bundles.new' %}?q={{ bookmark_list.search.q|urlencode }}" class="menu-link">Create
|
||||
bundle from search</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="bundle-menu">
|
||||
{% for bundle in bundles.bundles %}
|
||||
<li class="bundle-menu-item {% if bundle.id == bundles.selected_bundle.id %}selected{% endif %}">
|
||||
<a href="?bundle={{ bundle.id }}">{{ bundle.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -1,12 +1,12 @@
|
||||
<div>
|
||||
{% if details.assets %}
|
||||
<div class="assets">
|
||||
<div class="item-list assets">
|
||||
{% for asset in details.assets %}
|
||||
<div class="asset" data-asset-id="{{ asset.id }}">
|
||||
<div class="asset-icon {{ asset.icon_classes }}">
|
||||
<div class="list-item" data-asset-id="{{ asset.id }}">
|
||||
<div class="list-item-icon {{ asset.icon_classes }}">
|
||||
{% include 'bookmarks/details/asset_icon.html' %}
|
||||
</div>
|
||||
<div class="asset-text {{ asset.text_classes }}">
|
||||
<div class="list-item-text {{ asset.text_classes }}">
|
||||
<span class="truncate">
|
||||
{{ asset.display_name }}
|
||||
{% if asset.status == 'pending' %}(queued){% endif %}
|
||||
@@ -16,7 +16,7 @@
|
||||
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="asset-actions">
|
||||
<div class="list-item-actions">
|
||||
{% if asset.file %}
|
||||
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>{{ details.bookmark.resolved_title }}</h2>
|
||||
<button class="close" aria-label="Close dialog">
|
||||
<h2 class="title">{{ details.bookmark.resolved_title }}</h2>
|
||||
<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>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<h1 id="main-heading">Edit bookmark</h1>
|
||||
</div>
|
||||
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
novalidate>
|
||||
novalidate ld-form-submit>
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load widget_tweaks %}
|
||||
{% load static %}
|
||||
{% load shared %}
|
||||
|
||||
<div class="bookmarks-form">
|
||||
{% csrf_token %}
|
||||
@@ -7,7 +8,7 @@
|
||||
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
|
||||
<div class="has-icon-right">
|
||||
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
|
||||
{{ form.url|form_field:"validation"|add_class:"form-input"|attr:"autofocus" }}
|
||||
<i class="form-icon loading"></i>
|
||||
</div>
|
||||
{% if form.url.errors %}
|
||||
@@ -22,8 +23,8 @@
|
||||
</div>
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
{{ form.tag_string|form_field:"help"|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div id="{{ form.tag_string.auto_id }}_help" class="form-input-hint">
|
||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#).
|
||||
If a tag does not exist it will be automatically created.
|
||||
</div>
|
||||
@@ -35,7 +36,8 @@
|
||||
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
||||
<div class="flex">
|
||||
<button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
|
||||
<button ld-clear-button data-for="{{ form.title.id_for_label }}" class="ml-2 btn btn-link suffix-button clear-button"
|
||||
<button ld-clear-button data-for="{{ form.title.id_for_label }}"
|
||||
class="ml-2 btn btn-link suffix-button clear-button"
|
||||
type="button">Clear
|
||||
</button>
|
||||
</div>
|
||||
@@ -60,31 +62,31 @@
|
||||
<span class="form-label d-inline-block">Notes</span>
|
||||
</summary>
|
||||
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
|
||||
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
|
||||
<div class="form-input-hint">
|
||||
{{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }}
|
||||
<div id="{{ form.notes.auto_id }}_help" class="form-input-hint">
|
||||
Additional notes, supports Markdown.
|
||||
</div>
|
||||
</details>
|
||||
{{ form.notes.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
|
||||
{{ form.unread }}
|
||||
<div class="form-checkbox">
|
||||
{{ form.unread|form_field:"help" }}
|
||||
<i class="form-icon"></i>
|
||||
<span>Mark as unread</span>
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
<label for="{{ form.unread.id_for_label }}">Mark as unread</label>
|
||||
</div>
|
||||
<div id="{{ form.unread.auto_id }}_help" class="form-input-hint">
|
||||
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
|
||||
</div>
|
||||
</div>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<div class="form-group">
|
||||
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
|
||||
{{ form.shared }}
|
||||
<div class="form-checkbox">
|
||||
{{ form.shared|form_field:"help" }}
|
||||
<i class="form-icon"></i>
|
||||
<span>Share</span>
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
<label for="{{ form.shared.id_for_label }}">Share</label>
|
||||
</div>
|
||||
<div id="{{ form.shared.auto_id }}_help" class="form-input-hint">
|
||||
{% if request.user_profile.enable_public_sharing %}
|
||||
Share this bookmark with other registered users and anonymous users.
|
||||
{% else %}
|
||||
@@ -100,7 +102,7 @@
|
||||
{% else %}
|
||||
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn">Nevermind</a>
|
||||
<a href="{{ return_url }}" class="btn">Cancel</a>
|
||||
</div>
|
||||
<script type="application/javascript">
|
||||
/**
|
||||
@@ -227,6 +229,7 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshButton.addEventListener('click', refreshMetadata);
|
||||
|
||||
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
|
||||
|
||||
@@ -32,16 +32,10 @@
|
||||
</form>
|
||||
</main>
|
||||
|
||||
{# Tag cloud #}
|
||||
<div class="side-panel col-1">
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
{# Filters #}
|
||||
<div class="side-panel col-1 hide-md">
|
||||
{% include 'bookmarks/bundle_section.html' %}
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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">
|
||||
@@ -67,7 +43,7 @@
|
||||
|
||||
<header class="container">
|
||||
{% if has_toasts %}
|
||||
<div class="toasts">
|
||||
<div class="message-list">
|
||||
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% for toast in toast_messages %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">New bookmark</h1>
|
||||
</div>
|
||||
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate>
|
||||
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate ld-form-submit>
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<ul class="pagination">
|
||||
{% if prev_link %}
|
||||
<li class="page-item">
|
||||
<a href="?{{ prev_link }}" tabindex="-1">Previous</a>
|
||||
<a href="{{ prev_link }}" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
@@ -14,7 +14,7 @@
|
||||
{% for page_link in page_links %}
|
||||
{% if page_link %}
|
||||
<li class="page-item {% if page_link.active %}active{% endif %}">
|
||||
<a href="?{{ page_link.link }}">{{ page_link.number }}</a>
|
||||
<a href="{{ page_link.link }}">{{ page_link.number }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
{% if next_link %}
|
||||
<li class="page-item">
|
||||
<a href="?{{ next_link }}" tabindex="-1">Next</a>
|
||||
<a href="{{ next_link }}" tabindex="-1">Next</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||
{% endif %}
|
||||
{% if request.user_profile.custom_css %}
|
||||
<link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
<template id="content">{{ content|safe }}</template>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</main>
|
||||
|
||||
{# Filters #}
|
||||
<div class="side-panel col-1">
|
||||
<div class="side-panel col-1 hide-md">
|
||||
<section aria-labelledby="user-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="user-heading">User</h2>
|
||||
@@ -38,14 +38,7 @@
|
||||
<br>
|
||||
</div>
|
||||
</section>
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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>
|
||||
@@ -17,14 +17,14 @@
|
||||
{% 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 %}"
|
||||
<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 %}"
|
||||
<a href="?{{ tag.query_string }}"
|
||||
class="mr-2" data-is-tag-item>
|
||||
<span>{{ tag.name }}</span>
|
||||
</a>
|
||||
|
||||
26
bookmarks/templates/bookmarks/tag_section.html
Normal file
26
bookmarks/templates/bookmarks/tag_section.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header no-wrap">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
{% if user.is_authenticated %}
|
||||
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||
<button class="btn btn-noborder dropdown-toggle" aria-label="Tags menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M4 6l16 0"/>
|
||||
<path d="M4 12l16 0"/>
|
||||
<path d="M4 18l16 0"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'linkding:tags.index' %}" class="menu-link">Manage tags</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
33
bookmarks/templates/bundles/edit.html
Normal file
33
bookmarks/templates/bundles/edit.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Edit bundle - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bundles-editor-page grid columns-md-1">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Edit bundle</h1>
|
||||
</div>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
<form id="bundle-form" action="{% url 'linkding:bundles.edit' bundle.id %}" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'bundles/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<aside class="col-2" aria-labelledby="preview-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="preview-heading">Preview</h2>
|
||||
</div>
|
||||
|
||||
{% include 'bundles/preview.html' %}
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
90
bookmarks/templates/bundles/form.html
Normal file
90
bookmarks/templates/bundles/form.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
|
||||
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||
{% if form.name.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.name.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group {% if form.search.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.search.id_for_label }}" class="form-label">Search terms</label>
|
||||
{{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||
{% if form.search.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.search.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-input-hint">
|
||||
All of these search terms must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.any_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
At least one of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label>
|
||||
{{ form.all_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
All of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label>
|
||||
{{ form.excluded_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
None of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer d-flex mt-4">
|
||||
<input type="submit" name="save" value="Save" class="btn btn-primary btn-wide">
|
||||
<a href="{% url 'linkding:bundles.index' %}" class="btn btn-wide ml-auto">Cancel</a>
|
||||
<a href="{% url 'linkding:bundles.preview' %}" data-turbo-frame="preview" class="d-none" id="preview-link"></a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function init() {
|
||||
const bundleForm = document.getElementById('bundle-form');
|
||||
const previewLink = document.getElementById('preview-link');
|
||||
|
||||
let pendingUpdate;
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (pendingUpdate) {
|
||||
clearTimeout(pendingUpdate);
|
||||
}
|
||||
pendingUpdate = setTimeout(() => {
|
||||
// Ignore if link has been removed (e.g. form submit or navigation)
|
||||
if (!previewLink.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = previewLink.href.split('?')[0];
|
||||
const params = new URLSearchParams();
|
||||
const inputs = bundleForm.querySelectorAll('input[type="text"]:not([name="csrfmiddlewaretoken"]), textarea, select');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.value.trim()) {
|
||||
params.set(input.name, input.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
previewLink.href = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
|
||||
previewLink.click();
|
||||
}, 500)
|
||||
}
|
||||
|
||||
bundleForm.addEventListener('input', scheduleUpdate);
|
||||
})();
|
||||
</script>
|
||||
134
bookmarks/templates/bundles/index.html
Normal file
134
bookmarks/templates/bundles/index.html
Normal file
@@ -0,0 +1,134 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Bundles - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="bundles-page crud-page" aria-labelledby="main-heading">
|
||||
<div class="crud-header">
|
||||
<h1 id="main-heading">Bundles</h1>
|
||||
<a href="{% url 'linkding:bundles.new' %}" class="btn">Add bundle</a>
|
||||
</div>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
{% if bundles %}
|
||||
<form action="{% url 'linkding:bundles.action' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<table class="table crud-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="actions">
|
||||
<span class="text-assistive">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for bundle in bundles %}
|
||||
<tr data-bundle-id="{{ bundle.id }}" draggable="true">
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
</svg>
|
||||
<span>{{ bundle.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
|
||||
<button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}"
|
||||
class="btn btn-link">Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<input type="submit" name="move_bundle" value="" class="d-none">
|
||||
<input type="hidden" name="move_position" value="">
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<p class="empty-title h5">You have no bundles yet</p>
|
||||
<p class="empty-subtitle">Create your first bundle to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function init() {
|
||||
const tableBody = document.querySelector(".crud-table tbody");
|
||||
if (!tableBody) return;
|
||||
|
||||
let draggedElement = null;
|
||||
|
||||
const rows = tableBody.querySelectorAll('tr');
|
||||
rows.forEach((item) => {
|
||||
item.addEventListener('dragstart', handleDragStart);
|
||||
item.addEventListener('dragend', handleDragEnd);
|
||||
item.addEventListener('dragover', handleDragOver);
|
||||
item.addEventListener('dragenter', handleDragEnter);
|
||||
});
|
||||
|
||||
function handleDragStart(e) {
|
||||
draggedElement = this;
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
this.classList.add('drag-start');
|
||||
setTimeout(() => {
|
||||
this.classList.remove('drag-start');
|
||||
this.classList.add('dragging');
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
this.classList.remove('dragging');
|
||||
|
||||
const moveBundleInput = document.querySelector('input[name="move_bundle"]');
|
||||
const movePositionInput = document.querySelector('input[name="move_position"]');
|
||||
moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
|
||||
movePositionInput.value = Array.from(tableBody.children).indexOf(draggedElement);
|
||||
|
||||
const form = this.closest('form');
|
||||
form.requestSubmit(moveBundleInput);
|
||||
|
||||
draggedElement = null;
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleDragEnter() {
|
||||
if (this !== draggedElement) {
|
||||
const listItems = Array.from(tableBody.children);
|
||||
const draggedIndex = listItems.indexOf(draggedElement);
|
||||
const currentIndex = listItems.indexOf(this);
|
||||
|
||||
if (draggedIndex < currentIndex) {
|
||||
this.insertAdjacentElement('afterend', draggedElement);
|
||||
} else {
|
||||
this.insertAdjacentElement('beforebegin', draggedElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
33
bookmarks/templates/bundles/new.html
Normal file
33
bookmarks/templates/bundles/new.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends 'bookmarks/layout.html' %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="New bundle - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bundles-editor-page grid columns-md-1">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">New bundle</h1>
|
||||
</div>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
<form id="bundle-form" action="{% url 'linkding:bundles.new' %}" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'bundles/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<aside class="col-2" aria-labelledby="preview-heading">
|
||||
<div class="section-header">
|
||||
<h2 id="preview-heading">Preview</h2>
|
||||
</div>
|
||||
|
||||
{% include 'bundles/preview.html' %}
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
12
bookmarks/templates/bundles/preview.html
Normal file
12
bookmarks/templates/bundles/preview.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<turbo-frame id="preview">
|
||||
{% if bookmark_list.is_empty %}
|
||||
<div>
|
||||
No bookmarks match the current bundle.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-4">
|
||||
Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.
|
||||
</div>
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
{% endif %}
|
||||
</turbo-frame>
|
||||
@@ -139,6 +139,15 @@
|
||||
Instead, the tags are shown in an expandable drawer.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.hide_bundles.id_for_label }}" class="form-checkbox">
|
||||
{{ form.hide_bundles }}
|
||||
<i class="form-icon"></i> Hide bundles
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Allows to hide the bundles in the side panel if you don't intend to use them.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
||||
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
|
||||
@@ -149,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" }}
|
||||
@@ -261,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>
|
||||
@@ -374,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>
|
||||
@@ -395,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() {
|
||||
|
||||
@@ -25,15 +25,33 @@
|
||||
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
|
||||
application first. Here's how it works:</p>
|
||||
<ul>
|
||||
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
||||
<li>Choose your preferred method for detecting website titles and descriptions below (<a href="https://linkding.link/troubleshooting/#automatically-detected-title-and-description-are-incorrect" target="_blank">Help</a>)</li>
|
||||
<li>Drag the bookmarklet below into your browser's bookmark bar / toolbar</li>
|
||||
<li>Open the website that you want to bookmark</li>
|
||||
<li>Click the bookmarklet in your browsers toolbar</li>
|
||||
<li>Click the bookmarklet in your browser's toolbar</li>
|
||||
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
||||
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
||||
<li>After saving the bookmark, the linkding window closes, and you are back on your website</li>
|
||||
</ul>
|
||||
<p>Drag the following bookmarklet to your browser's toolbar:</p>
|
||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
|
||||
class="btn btn-primary">📎 Add bookmark</a>
|
||||
|
||||
<div class="form-group radio-group" role="radiogroup" aria-labelledby="detection-method-label">
|
||||
<p id="detection-method-label">Choose your preferred bookmarklet:</p>
|
||||
<label for="detection-method-server" class="form-radio">
|
||||
<input id="detection-method-server" type="radio" name="bookmarklet-type" value="server" checked>
|
||||
<i class="form-icon"></i>
|
||||
Detect title and description on the server
|
||||
</label>
|
||||
<label for="detection-method-client" class="form-radio">
|
||||
<input id="detection-method-client" type="radio" name="bookmarklet-type" value="client">
|
||||
<i class="form-icon"></i>
|
||||
Detect title and description in the browser
|
||||
</label>
|
||||
</div>
|
||||
<div class="bookmarklet-container">
|
||||
<a id="bookmarklet-server" href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false"
|
||||
class="btn btn-primary">📎 Add bookmark</a>
|
||||
<a id="bookmarklet-client" href="javascript: {% include 'bookmarks/bookmarklet_clientside.js' %}" data-turbo="false"
|
||||
class="btn btn-primary" style="display: none;">📎 Add bookmark</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="rest-api-heading">
|
||||
@@ -90,4 +108,28 @@
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function init() {
|
||||
const radioButtons = document.querySelectorAll('input[name="bookmarklet-type"]');
|
||||
const serverBookmarklet = document.getElementById('bookmarklet-server');
|
||||
const clientBookmarklet = document.getElementById('bookmarklet-client');
|
||||
|
||||
function toggleBookmarklet() {
|
||||
const selectedValue = document.querySelector('input[name="bookmarklet-type"]:checked').value;
|
||||
if (selectedValue === 'server') {
|
||||
serverBookmarklet.style.display = 'inline-block';
|
||||
clientBookmarklet.style.display = 'none';
|
||||
} else {
|
||||
serverBookmarklet.style.display = 'none';
|
||||
clientBookmarklet.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
|
||||
toggleBookmarklet();
|
||||
radioButtons.forEach(function(radio) {
|
||||
radio.addEventListener('change', toggleBookmarklet);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
6
bookmarks/templates/shared/error_list.html
Normal file
6
bookmarks/templates/shared/error_list.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% load i18n %}
|
||||
|
||||
{# Force rendering validation errors in English language to align with the rest of the app #}
|
||||
{% language 'en-us' %}
|
||||
{% if errors %}<ul class="{{ error_class }}"{% if errors.field_id %} id="{{ errors.field_id }}_error"{% endif %}>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
|
||||
{% endlanguage %}
|
||||
9
bookmarks/templates/shared/messages.html
Normal file
9
bookmarks/templates/shared/messages.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% if messages %}
|
||||
<div class="message-list">
|
||||
{% for message in messages %}
|
||||
<div class="toast toast-{{ message.tags }}" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
23
bookmarks/templates/tags/edit.html
Normal file
23
bookmarks/templates/tags/edit.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load shared %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Edit tag - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tags-editor-page">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Edit tag</h1>
|
||||
</div>
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'tags/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
19
bookmarks/templates/tags/form.html
Normal file
19
bookmarks/templates/tags/form.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.name.id_for_label }}">Name</label>
|
||||
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).</div>
|
||||
{% if form.name.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.name.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="form-group d-flex justify-between">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
|
||||
</div>
|
||||
125
bookmarks/templates/tags/index.html
Normal file
125
bookmarks/templates/tags/index.html
Normal file
@@ -0,0 +1,125 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load shared %}
|
||||
{% load pagination %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Tags - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tags-page crud-page">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="crud-header">
|
||||
<h1 id="main-heading">Tags</h1>
|
||||
<div class="d-flex gap-2 ml-auto">
|
||||
<a href="{% url 'linkding:tags.new' %}" class="btn">Add Tag</a>
|
||||
<a href="{% url 'linkding:tags.merge' %}" class="btn">Merge Tags</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
{# Filters #}
|
||||
<div class="crud-filters">
|
||||
<form method="get" class="mb-2" ld-form-reset>
|
||||
<div class="form-group">
|
||||
<label class="form-label text-assistive" for="search">Search tags</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Search tags..."
|
||||
class="form-input">
|
||||
<button type="submit" class="btn input-group-btn">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label text-assistive" for="sort">Sort by</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path
|
||||
stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 9l4 -4l4 4m-4 -4v14"/><path
|
||||
d="M21 15l-4 4l-4 -4m4 4v-14"/></svg>
|
||||
</span>
|
||||
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||
<option value="name-asc" {% if sort == "name-asc" %}selected{% endif %}>Name A-Z</option>
|
||||
<option value="name-desc" {% if sort == "name-desc" %}selected{% endif %}>Name Z-A</option>
|
||||
<option value="count-asc" {% if sort == "count-asc" %}selected{% endif %}>Fewest bookmarks</option>
|
||||
<option value="count-desc" {% if sort == "count-desc" %}selected{% endif %}>Most bookmarks</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" name="unused" value="true" {% if unused_only %}checked{% endif %} ld-auto-submit>
|
||||
<i class="form-icon"></i> Show only unused tags
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Tags count #}
|
||||
<p class="text-secondary text-small m-0">
|
||||
{% if search or unused_only %}
|
||||
Showing {{ page.paginator.count }} of {{ total_tags }} tags
|
||||
{% else %}
|
||||
{{ total_tags }} tags total
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Tags List #}
|
||||
{% if page.object_list %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<table class="table crud-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th style="width: 25%">Bookmarks</th>
|
||||
<th class="actions">
|
||||
<span class="text-assistive">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in page.object_list %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ tag.name }}
|
||||
</td>
|
||||
<td style="width: 25%">
|
||||
<a class="btn btn-link" href="{% url 'linkding:bookmarks.index' %}?q=%23{{ tag.name|urlencode }}">
|
||||
{{ tag.bookmark_count }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<a class="btn btn-link" href="{% url 'linkding:tags.edit' tag.id %}">Edit</a>
|
||||
<button type="submit" name="delete_tag" value="{{ tag.id }}" class="btn btn-link text-error"
|
||||
ld-confirm-button>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</form>
|
||||
|
||||
{% pagination page %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
{% if search or unused_only %}
|
||||
<p class="empty-title h5">No tags found</p>
|
||||
<p class="empty-subtitle">Try adjusting your search or filters</p>
|
||||
{% else %}
|
||||
<p class="empty-title h5">You have no tags yet</p>
|
||||
<p class="empty-subtitle">Tags will appear here when you add bookmarks with tags</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
68
bookmarks/templates/tags/merge.html
Normal file
68
bookmarks/templates/tags/merge.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load shared %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Merge tags - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tags-editor-page">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Merge tags</h1>
|
||||
</div>
|
||||
|
||||
<details class="mb-4">
|
||||
<summary>
|
||||
<span class="text-bold mb-1">How to merge tags</span>
|
||||
</summary>
|
||||
<ol>
|
||||
<li>Enter the name of the tag you want to keep</li>
|
||||
<li>Enter the names of tags to merge into the target tag</li>
|
||||
<li>The target tag is added to all bookmarks that have any of the merge tags</li>
|
||||
<li>The merged tags are deleted</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-group {% if form.target_tag.errors %}has-error{% endif %}" ld-tag-autocomplete>
|
||||
<label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
|
||||
{{ form.target_tag|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">
|
||||
Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
|
||||
</div>
|
||||
{% if form.target_tag.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.target_tag.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}" ld-tag-autocomplete>
|
||||
<label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
|
||||
{{ form.merge_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">Enter the names of tags to merge into the target tag, separated by spaces. These
|
||||
tags will be deleted after merging.
|
||||
</div>
|
||||
{% if form.merge_tags.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.merge_tags.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="form-group d-flex justify-between">
|
||||
<button type="submit" class="btn btn-primary">Merge Tags</button>
|
||||
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
23
bookmarks/templates/tags/new.html
Normal file
23
bookmarks/templates/tags/new.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load shared %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Add tag - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tags-editor-page">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">New tag</h1>
|
||||
</div>
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'tags/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -13,18 +13,21 @@ register = template.Library()
|
||||
"bookmarks/pagination.html", name="pagination", takes_context=True
|
||||
)
|
||||
def pagination(context, page: Page):
|
||||
request = context["request"]
|
||||
base_url = request.path
|
||||
|
||||
# remove page number and details from query parameters
|
||||
query_params = context["request"].GET.copy()
|
||||
query_params = request.GET.copy()
|
||||
query_params.pop("page", None)
|
||||
query_params.pop("details", None)
|
||||
|
||||
prev_link = (
|
||||
_generate_link(query_params, page.previous_page_number())
|
||||
_generate_link(base_url, query_params, page.previous_page_number())
|
||||
if page.has_previous()
|
||||
else None
|
||||
)
|
||||
next_link = (
|
||||
_generate_link(query_params, page.next_page_number())
|
||||
_generate_link(base_url, query_params, page.next_page_number())
|
||||
if page.has_next()
|
||||
else None
|
||||
)
|
||||
@@ -37,7 +40,7 @@ def pagination(context, page: Page):
|
||||
if page_number == -1:
|
||||
page_links.append(None)
|
||||
else:
|
||||
link = _generate_link(query_params, page_number)
|
||||
link = _generate_link(base_url, query_params, page_number)
|
||||
page_links.append(
|
||||
{
|
||||
"active": page_number == page.number,
|
||||
@@ -92,6 +95,7 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
||||
return reduce(append_page, visible_pages, [])
|
||||
|
||||
|
||||
def _generate_link(query_params: QueryDict, page_number: int) -> str:
|
||||
def _generate_link(base_url: str, query_params: QueryDict, page_number: int) -> str:
|
||||
query_params = query_params.copy()
|
||||
query_params["page"] = page_number
|
||||
return query_params.urlencode()
|
||||
return f"{base_url}?{query_params.urlencode()}"
|
||||
|
||||
@@ -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]
|
||||
@@ -145,3 +93,30 @@ def render_markdown(context, markdown_text):
|
||||
linkified_html = bleach.linkify(sanitized_html)
|
||||
|
||||
return mark_safe(linkified_html)
|
||||
|
||||
|
||||
def append_attr(widget, attr, value):
|
||||
attrs = widget.attrs
|
||||
if attrs.get(attr):
|
||||
attrs[attr] += " " + value
|
||||
else:
|
||||
attrs[attr] = value
|
||||
|
||||
|
||||
@register.filter("form_field")
|
||||
def form_field(field, modifier_string):
|
||||
modifiers = modifier_string.split(",")
|
||||
has_errors = hasattr(field, "errors") and field.errors
|
||||
|
||||
if "validation" in modifiers and has_errors:
|
||||
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_error")
|
||||
if "help" in modifiers:
|
||||
append_attr(field.field.widget, "aria-describedby", field.auto_id + "_help")
|
||||
|
||||
# Some assistive technologies announce a field as invalid when it has the
|
||||
# required attribute, even if the user has not interacted with the field
|
||||
# yet. Set aria-invalid false to prevent this behavior.
|
||||
if field.field.required and not has_errors:
|
||||
append_attr(field.field.widget, "aria-invalid", "false")
|
||||
|
||||
return field
|
||||
|
||||
@@ -17,7 +17,7 @@ from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, User
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Tag, User
|
||||
|
||||
|
||||
class BookmarkFactoryMixin:
|
||||
@@ -166,6 +166,33 @@ class BookmarkFactoryMixin:
|
||||
def get_numbered_bookmark(self, title: str):
|
||||
return Bookmark.objects.get(title=title)
|
||||
|
||||
def setup_bundle(
|
||||
self,
|
||||
user: User = None,
|
||||
name: str = None,
|
||||
search: str = "",
|
||||
any_tags: str = "",
|
||||
all_tags: str = "",
|
||||
excluded_tags: str = "",
|
||||
order: int = 0,
|
||||
):
|
||||
if user is None:
|
||||
user = self.get_or_create_test_user()
|
||||
if not name:
|
||||
name = get_random_string(length=32)
|
||||
bundle = BookmarkBundle(
|
||||
name=name,
|
||||
owner=user,
|
||||
date_created=timezone.now(),
|
||||
search=search,
|
||||
any_tags=any_tags,
|
||||
all_tags=all_tags,
|
||||
excluded_tags=excluded_tags,
|
||||
order=order,
|
||||
)
|
||||
bundle.save()
|
||||
return bundle
|
||||
|
||||
def setup_asset(
|
||||
self,
|
||||
bookmark: Bookmark,
|
||||
@@ -209,8 +236,17 @@ class BookmarkFactoryMixin:
|
||||
|
||||
def read_asset_file(self, asset: BookmarkAsset):
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||
with open(filepath, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
if asset.gzip:
|
||||
with gzip.open(filepath, "rb") as f:
|
||||
return f.read()
|
||||
else:
|
||||
with open(filepath, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def get_asset_filesize(self, asset: BookmarkAsset):
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||
return os.path.getsize(filepath) if os.path.exists(filepath) else 0
|
||||
|
||||
def has_asset_file(self, asset: BookmarkAsset):
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||
@@ -239,7 +275,7 @@ class BookmarkFactoryMixin:
|
||||
user.profile.save()
|
||||
return user
|
||||
|
||||
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
|
||||
def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):
|
||||
all_tags = []
|
||||
for bookmark in bookmarks:
|
||||
all_tags = all_tags + list(bookmark.tags.all())
|
||||
|
||||
@@ -55,7 +55,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertIsNone(asset.id)
|
||||
|
||||
def test_create_snapshot(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
initial_modified = timezone.datetime(
|
||||
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
bookmark = self.setup_bookmark(
|
||||
url="https://example.com", modified=initial_modified
|
||||
)
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
asset.save()
|
||||
asset.date_created = timezone.datetime(
|
||||
@@ -91,6 +96,9 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(asset.file, expected_filename)
|
||||
self.assertTrue(asset.gzip)
|
||||
|
||||
# should update bookmark modified date
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
def test_create_snapshot_failure(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
asset = assets.create_snapshot_asset(bookmark)
|
||||
@@ -120,7 +128,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
|
||||
|
||||
def test_upload_snapshot(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
initial_modified = timezone.datetime(
|
||||
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
bookmark = self.setup_bookmark(
|
||||
url="https://example.com", modified=initial_modified
|
||||
)
|
||||
asset = assets.upload_snapshot(bookmark, self.html_content.encode())
|
||||
|
||||
# should create gzip file in asset folder
|
||||
@@ -145,6 +158,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(asset.file, saved_file_name)
|
||||
self.assertTrue(asset.gzip)
|
||||
|
||||
# should update bookmark modified date
|
||||
bookmark.refresh_from_db()
|
||||
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||
|
||||
def test_upload_snapshot_failure(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
|
||||
@@ -173,7 +190,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
@disable_logging
|
||||
def test_upload_asset(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
initial_modified = timezone.datetime(
|
||||
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
bookmark = self.setup_bookmark(modified=initial_modified)
|
||||
file_content = b"test content"
|
||||
upload_file = SimpleUploadedFile(
|
||||
"test_file.txt", file_content, content_type="text/plain"
|
||||
@@ -187,11 +207,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
# verify file name
|
||||
self.assertTrue(saved_file_name.startswith("upload_"))
|
||||
self.assertTrue(saved_file_name.endswith("_test_file.txt"))
|
||||
self.assertTrue(saved_file_name.endswith("_test_file.txt.gz"))
|
||||
|
||||
# file should contain the correct content
|
||||
with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file:
|
||||
self.assertEqual(file.read(), file_content)
|
||||
self.assertEqual(self.read_asset_file(asset), file_content)
|
||||
|
||||
# should create asset
|
||||
self.assertIsNotNone(asset.id)
|
||||
@@ -201,9 +220,52 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(asset.display_name, upload_file.name)
|
||||
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
|
||||
self.assertEqual(asset.file, saved_file_name)
|
||||
self.assertEqual(asset.file_size, self.get_asset_filesize(asset))
|
||||
self.assertTrue(asset.gzip)
|
||||
|
||||
# should update bookmark modified date
|
||||
bookmark.refresh_from_db()
|
||||
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||
|
||||
@disable_logging
|
||||
def test_upload_gzip_asset(self):
|
||||
initial_modified = timezone.datetime(
|
||||
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
bookmark = self.setup_bookmark(modified=initial_modified)
|
||||
file_content = gzip.compress(b"<html>test content</html>")
|
||||
upload_file = SimpleUploadedFile(
|
||||
"test_file.html.gz", file_content, content_type="application/gzip"
|
||||
)
|
||||
|
||||
asset = assets.upload_asset(bookmark, upload_file)
|
||||
|
||||
# should create file in asset folder
|
||||
saved_file_name = self.get_saved_snapshot_file()
|
||||
self.assertIsNotNone(upload_file)
|
||||
|
||||
# verify file name
|
||||
self.assertTrue(saved_file_name.startswith("upload_"))
|
||||
self.assertTrue(saved_file_name.endswith("_test_file.html.gz"))
|
||||
|
||||
# file should contain the correct content
|
||||
self.assertEqual(self.read_asset_file(asset), file_content)
|
||||
|
||||
# should create asset
|
||||
self.assertIsNotNone(asset.id)
|
||||
self.assertEqual(asset.bookmark, bookmark)
|
||||
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
|
||||
self.assertEqual(asset.content_type, "application/gzip")
|
||||
self.assertEqual(asset.display_name, upload_file.name)
|
||||
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
|
||||
self.assertEqual(asset.file, saved_file_name)
|
||||
self.assertEqual(asset.file_size, len(file_content))
|
||||
self.assertFalse(asset.gzip)
|
||||
|
||||
# should update bookmark modified date
|
||||
bookmark.refresh_from_db()
|
||||
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||
|
||||
@disable_logging
|
||||
def test_upload_asset_truncates_asset_file_name(self):
|
||||
# Create a bookmark with a very long URL
|
||||
@@ -221,7 +283,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertEqual(192, len(saved_file))
|
||||
self.assertTrue(saved_file.startswith("upload_"))
|
||||
self.assertTrue(saved_file.endswith("aaaa.txt"))
|
||||
self.assertTrue(saved_file.endswith("aaaa.txt.gz"))
|
||||
|
||||
@disable_logging
|
||||
def test_upload_asset_failure(self):
|
||||
@@ -409,3 +471,36 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
# Verify that latest_snapshot hasn't changed
|
||||
self.assertEqual(bookmark.latest_snapshot, latest_asset)
|
||||
|
||||
@disable_logging
|
||||
def test_remove_asset(self):
|
||||
initial_modified = timezone.datetime(
|
||||
2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
bookmark = self.setup_bookmark(modified=initial_modified)
|
||||
file_content = b"test content for removal"
|
||||
upload_file = SimpleUploadedFile(
|
||||
"test_remove_file.txt", file_content, content_type="text/plain"
|
||||
)
|
||||
|
||||
asset = assets.upload_asset(bookmark, upload_file)
|
||||
asset_filepath = os.path.join(self.assets_dir, asset.file)
|
||||
|
||||
# Verify asset and file exist
|
||||
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||
self.assertTrue(os.path.exists(asset_filepath))
|
||||
|
||||
bookmark.date_modified = initial_modified
|
||||
bookmark.save()
|
||||
|
||||
# Remove the asset
|
||||
assets.remove_asset(asset)
|
||||
|
||||
# Verify asset is removed from DB
|
||||
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||
# Verify file is removed from disk
|
||||
self.assertFalse(os.path.exists(asset_filepath))
|
||||
|
||||
# Verify bookmark modified date is updated
|
||||
bookmark.refresh_from_db()
|
||||
self.assertGreater(bookmark.date_modified, initial_modified)
|
||||
|
||||
@@ -844,6 +844,26 @@ class BookmarkActionViewTestCase(
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_index_action_bulk_select_across_respects_bundle(self):
|
||||
self.setup_numbered_bookmarks(3, prefix="foo")
|
||||
self.setup_numbered_bookmarks(3, prefix="bar")
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
self.client.post(
|
||||
reverse("linkding:bookmarks.index.action") + f"?bundle={bundle.id}",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
@@ -889,6 +909,26 @@ class BookmarkActionViewTestCase(
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_archived_action_bulk_select_across_respects_bundle(self):
|
||||
self.setup_numbered_bookmarks(3, prefix="foo", archived=True)
|
||||
self.setup_numbered_bookmarks(3, prefix="bar", archived=True)
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
self.client.post(
|
||||
reverse("linkding:bookmarks.archived.action") + f"?bundle={bundle.id}",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_shared_action_bulk_select_across_not_supported(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import urllib.parse
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import BookmarkSearch, UserProfile
|
||||
@@ -9,7 +9,6 @@ from bookmarks.tests.helpers import (
|
||||
BookmarkFactoryMixin,
|
||||
BookmarkListTestMixin,
|
||||
TagCloudTestMixin,
|
||||
collapse_whitespace,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +59,23 @@ class BookmarkArchivedViewTestCase(
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_bookmarks_matching_bundle(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, prefix="foo", archived=True
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, prefix="bar", archived=True
|
||||
)
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
@@ -105,6 +120,26 @@ class BookmarkArchivedViewTestCase(
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_tags_for_bookmarks_matching_bundle(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=True, prefix="foo", tag_prefix="foo"
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=True, prefix="bar", tag_prefix="bar"
|
||||
)
|
||||
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||
|
||||
bundle = self.setup_bundle(search="foo")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
|
||||
)
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.search_preferences = {
|
||||
@@ -284,6 +319,28 @@ class BookmarkArchivedViewTestCase(
|
||||
html,
|
||||
)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_allowed_bulk_actions_with_html_snapshot_enabled(self):
|
||||
url = reverse("linkding:bookmarks.archived")
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_unarchive">Unarchive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
<option value="bulk_refresh">Refresh from website</option>
|
||||
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||
</select>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
def test_allowed_bulk_actions_with_sharing_enabled(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.enable_sharing = True
|
||||
@@ -310,6 +367,34 @@ class BookmarkArchivedViewTestCase(
|
||||
html,
|
||||
)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_allowed_bulk_actions_with_sharing_and_html_snapshot_enabled(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.enable_sharing = True
|
||||
user_profile.save()
|
||||
|
||||
url = reverse("linkding:bookmarks.archived")
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_unarchive">Unarchive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
<option value="bulk_refresh">Refresh from website</option>
|
||||
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||
</select>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
def test_apply_search_preferences(self):
|
||||
# no params
|
||||
response = self.client.post(reverse("linkding:bookmarks.archived"))
|
||||
@@ -515,3 +600,20 @@ class BookmarkArchivedViewTestCase(
|
||||
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNone(feed)
|
||||
|
||||
def test_hide_bundles_when_enabled_in_profile(self):
|
||||
# visible by default
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
|
||||
|
||||
# hidden when disabled in profile
|
||||
user_profile = self.get_or_create_test_user().profile
|
||||
user_profile.hide_bundles = True
|
||||
user_profile.save()
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)
|
||||
|
||||
@@ -4,9 +4,8 @@ from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import (
|
||||
BookmarkFactoryMixin,
|
||||
)
|
||||
from bookmarks.models import BookmarkAsset
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
@@ -23,7 +22,21 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setup_asset_with_file(self, bookmark):
|
||||
filename = f"temp_{bookmark.id}.html.gzip"
|
||||
self.setup_asset_file(filename)
|
||||
asset = self.setup_asset(bookmark=bookmark, file=filename)
|
||||
asset = self.setup_asset(
|
||||
bookmark=bookmark, file=filename, display_name=f"Snapshot {bookmark.id}"
|
||||
)
|
||||
return asset
|
||||
|
||||
def setup_asset_with_uploaded_file(self, bookmark):
|
||||
filename = f"temp_{bookmark.id}.png.gzip"
|
||||
self.setup_asset_file(filename)
|
||||
asset = self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
file=filename,
|
||||
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||
content_type="image/png",
|
||||
display_name=f"Uploaded file {bookmark.id}.png",
|
||||
)
|
||||
return asset
|
||||
|
||||
def view_access_test(self, view_name: str):
|
||||
@@ -127,3 +140,25 @@ 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):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset_with_file(bookmark)
|
||||
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
|
||||
|
||||
self.assertEqual(response["Content-Type"], asset.content_type)
|
||||
self.assertEqual(
|
||||
response["Content-Disposition"],
|
||||
f'inline; filename="{asset.display_name}.html"',
|
||||
)
|
||||
|
||||
def test_uploaded_file_download_name(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset_with_uploaded_file(bookmark)
|
||||
response = self.client.get(reverse("linkding:assets.view", args=[asset.id]))
|
||||
|
||||
self.assertEqual(response["Content-Type"], asset.content_type)
|
||||
self.assertEqual(
|
||||
response["Content-Disposition"],
|
||||
f'inline; filename="{asset.display_name}"',
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user