mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-06 09:53:12 +08:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
128e1afbce | ||
|
|
d33719dc7c | ||
|
|
357c2d1399 | ||
|
|
9cda5a54d3 | ||
|
|
67d5b17450 | ||
|
|
3ec6c0a7f8 | ||
|
|
86c2bdd138 | ||
|
|
82e5b7d9d5 | ||
|
|
d873342105 | ||
|
|
d519cb74eb | ||
|
|
ff0e6f0ff6 | ||
|
|
77c45c63f3 | ||
|
|
e45e63bfb1 | ||
|
|
004319adae | ||
|
|
d8358f1b12 | ||
|
|
b90ae1b202 | ||
|
|
6c874afff2 | ||
|
|
723b843c13 | ||
|
|
96176ba50e | ||
|
|
f6fb46e8ad | ||
|
|
3804640574 | ||
|
|
8f61fbd04a | ||
|
|
22bc713ed8 |
@@ -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
|
||||
|
||||
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:
|
||||
|
||||
@@ -214,7 +214,7 @@ class AdminBookmarkAsset(admin.ModelAdmin):
|
||||
|
||||
list_display = ("custom_display_name", "date_created", "status")
|
||||
search_fields = (
|
||||
"custom_display_name",
|
||||
"display_name",
|
||||
"file",
|
||||
)
|
||||
list_filter = ("status",)
|
||||
|
||||
@@ -27,6 +27,7 @@ from bookmarks.models import (
|
||||
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
|
||||
|
||||
@@ -107,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
|
||||
)
|
||||
@@ -151,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)
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
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):
|
||||
@@ -49,6 +57,7 @@ class BookmarkForm(forms.ModelForm):
|
||||
"tag_string": request.GET.get("tags"),
|
||||
"auto_close": "auto_close" in request.GET,
|
||||
"unread": request.user_profile.default_mark_unread,
|
||||
"shared": request.user_profile.default_mark_shared,
|
||||
}
|
||||
if instance is not None and request.method == "GET":
|
||||
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
|
||||
@@ -85,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()
|
||||
)
|
||||
@@ -100,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);
|
||||
|
||||
@@ -39,6 +39,36 @@ class AutoSubmitBehavior extends Behavior {
|
||||
}
|
||||
}
|
||||
|
||||
// Resets form controls to their initial values before Turbo caches the DOM.
|
||||
// Useful for filter forms where navigating back would otherwise still show
|
||||
// values from after the form submission, which means the filters would be out
|
||||
// of sync with the URL.
|
||||
class FormResetBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.controls = this.element.querySelectorAll("input, select");
|
||||
this.controls.forEach((control) => {
|
||||
if (control.type === "checkbox" || control.type === "radio") {
|
||||
control.__initialValue = control.checked;
|
||||
} else {
|
||||
control.__initialValue = control.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.controls.forEach((control) => {
|
||||
if (control.type === "checkbox" || control.type === "radio") {
|
||||
control.checked = control.__initialValue;
|
||||
} else {
|
||||
control.value = control.__initialValue;
|
||||
}
|
||||
delete control.__initialValue;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class UploadButton extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
@@ -75,4 +105,5 @@ class UploadButton extends Behavior {
|
||||
|
||||
registerBehavior("ld-form-submit", FormSubmit);
|
||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||
registerBehavior("ld-form-reset", FormResetBehavior);
|
||||
registerBehavior("ld-upload-button", UploadButton);
|
||||
|
||||
@@ -54,8 +54,6 @@ export class Behavior {
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
Behavior.interacting = false;
|
||||
|
||||
export function registerBehavior(name, behavior) {
|
||||
behaviorRegistry[name] = behavior;
|
||||
}
|
||||
|
||||
@@ -23,32 +23,22 @@ export class ModalBehavior extends Behavior {
|
||||
this.closeButton.removeEventListener("click", this.onClose);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.clearInert();
|
||||
this.removeScrollLock();
|
||||
this.focusTrap.destroy();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupInert();
|
||||
this.setupScrollLock();
|
||||
this.focusTrap = new FocusTrapController(
|
||||
this.element.querySelector(".modal-container"),
|
||||
);
|
||||
}
|
||||
|
||||
setupInert() {
|
||||
// Inert all other elements on the page
|
||||
document
|
||||
.querySelectorAll("body > *:not(.modals)")
|
||||
.forEach((el) => el.setAttribute("inert", ""));
|
||||
// Lock scroll on the body
|
||||
setupScrollLock() {
|
||||
document.body.classList.add("scroll-lock");
|
||||
}
|
||||
|
||||
clearInert() {
|
||||
// Clear inert attribute from all elements to allow focus outside the modal again
|
||||
document
|
||||
.querySelectorAll("body > *")
|
||||
.forEach((el) => el.removeAttribute("inert"));
|
||||
// Remove scroll lock from the body
|
||||
removeScrollLock() {
|
||||
document.body.classList.remove("scroll-lock");
|
||||
}
|
||||
|
||||
@@ -85,7 +75,7 @@ export class ModalBehavior extends Behavior {
|
||||
|
||||
doClose() {
|
||||
this.element.remove();
|
||||
this.clearInert();
|
||||
this.removeScrollLock();
|
||||
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte";
|
||||
import "../components/SearchAutocomplete.js";
|
||||
|
||||
class SearchAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
@@ -10,26 +10,20 @@ class SearchAutocomplete extends Behavior {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
|
||||
new SearchAutoCompleteComponent({
|
||||
target: container,
|
||||
props: {
|
||||
name: "q",
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
value: input.value,
|
||||
linkTarget: input.dataset.linkTarget,
|
||||
mode: input.dataset.mode,
|
||||
search: {
|
||||
user: input.dataset.user,
|
||||
shared: input.dataset.shared,
|
||||
unread: input.dataset.unread,
|
||||
},
|
||||
},
|
||||
});
|
||||
const autocomplete = document.createElement("ld-search-autocomplete");
|
||||
autocomplete.name = "q";
|
||||
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
||||
autocomplete.value = input.value;
|
||||
autocomplete.linkTarget = input.dataset.linkTarget || "_blank";
|
||||
autocomplete.mode = input.dataset.mode || "";
|
||||
autocomplete.search = {
|
||||
user: input.dataset.user,
|
||||
shared: input.dataset.shared,
|
||||
unread: input.dataset.unread,
|
||||
};
|
||||
|
||||
this.input = input;
|
||||
this.autocomplete = container.firstElementChild;
|
||||
this.autocomplete = autocomplete;
|
||||
input.replaceWith(this.autocomplete);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||
import "../components/TagAutocomplete.js";
|
||||
|
||||
class TagAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
@@ -10,22 +10,16 @@ class TagAutocomplete extends Behavior {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
|
||||
new TagAutoCompleteComponent({
|
||||
target: container,
|
||||
props: {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
placeholder: input.getAttribute("placeholder") || "",
|
||||
ariaDescribedBy: input.getAttribute("aria-describedby") || "",
|
||||
variant: input.getAttribute("variant"),
|
||||
},
|
||||
});
|
||||
const autocomplete = document.createElement("ld-tag-autocomplete");
|
||||
autocomplete.id = input.id;
|
||||
autocomplete.name = input.name;
|
||||
autocomplete.value = input.value;
|
||||
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
||||
autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || "";
|
||||
autocomplete.variant = input.getAttribute("variant") || "default";
|
||||
|
||||
this.input = input;
|
||||
this.autocomplete = container.firstElementChild;
|
||||
this.autocomplete = autocomplete;
|
||||
input.replaceWith(this.autocomplete);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
<script>
|
||||
import {SearchHistory} from "./SearchHistory";
|
||||
import {api} from "../api";
|
||||
import {cache} from "../cache";
|
||||
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
|
||||
|
||||
const searchHistory = new SearchHistory()
|
||||
|
||||
export let name;
|
||||
export let placeholder;
|
||||
export let value;
|
||||
export let mode = '';
|
||||
export let search;
|
||||
export let linkTarget = '_blank';
|
||||
|
||||
let isFocus = false;
|
||||
let isOpen = false;
|
||||
let suggestions = []
|
||||
let selectedIndex = undefined;
|
||||
let input = null;
|
||||
|
||||
// Track current search query after loading the page
|
||||
searchHistory.pushCurrent()
|
||||
updateSuggestions()
|
||||
|
||||
function handleFocus() {
|
||||
isFocus = true;
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
isFocus = false;
|
||||
close();
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
value = e.target.value
|
||||
debouncedLoadSuggestions()
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
// Enter
|
||||
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
|
||||
const suggestion = suggestions.total[selectedIndex];
|
||||
if (suggestion) completeSuggestion(suggestion);
|
||||
e.preventDefault();
|
||||
}
|
||||
// Escape
|
||||
if (e.keyCode === 27) {
|
||||
close();
|
||||
e.preventDefault();
|
||||
}
|
||||
// Up arrow
|
||||
if (e.keyCode === 38) {
|
||||
updateSelection(-1);
|
||||
e.preventDefault();
|
||||
}
|
||||
// Down arrow
|
||||
if (e.keyCode === 40) {
|
||||
if (!isOpen) {
|
||||
loadSuggestions()
|
||||
} else {
|
||||
updateSelection(1);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
updateSuggestions()
|
||||
selectedIndex = undefined
|
||||
}
|
||||
|
||||
function hasSuggestions() {
|
||||
return suggestions.total.length > 0
|
||||
}
|
||||
|
||||
async function loadSuggestions() {
|
||||
|
||||
let suggestionIndex = 0
|
||||
|
||||
function nextIndex() {
|
||||
return suggestionIndex++
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
const tags = await cache.getTags();
|
||||
let tagSuggestions = []
|
||||
const currentWord = getCurrentWord(input)
|
||||
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
||||
const searchTag = currentWord.substring(1, currentWord.length)
|
||||
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
||||
.slice(0, 5)
|
||||
.map(tag => ({
|
||||
type: 'tag',
|
||||
index: nextIndex(),
|
||||
label: `#${tag.name}`,
|
||||
tagName: tag.name
|
||||
}))
|
||||
}
|
||||
|
||||
// Recent search suggestions
|
||||
const recentSearches = searchHistory.getRecentSearches(value, 5).map(value => ({
|
||||
type: 'search',
|
||||
index: nextIndex(),
|
||||
label: value,
|
||||
value
|
||||
}))
|
||||
|
||||
// Bookmark suggestions
|
||||
let bookmarks = []
|
||||
|
||||
if (value && value.length >= 3) {
|
||||
const path = mode ? `/${mode}` : ''
|
||||
const suggestionSearch = {
|
||||
...search,
|
||||
q: value
|
||||
}
|
||||
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
|
||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||
const fullLabel = bookmark.title || bookmark.url
|
||||
const label = clampText(fullLabel, 60)
|
||||
return {
|
||||
type: 'bookmark',
|
||||
index: nextIndex(),
|
||||
label,
|
||||
bookmark
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updateSuggestions(recentSearches, bookmarks, tagSuggestions)
|
||||
|
||||
if (hasSuggestions()) {
|
||||
open()
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedLoadSuggestions = debounce(loadSuggestions)
|
||||
|
||||
function updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
|
||||
recentSearches = recentSearches || []
|
||||
bookmarks = bookmarks || []
|
||||
tagSuggestions = tagSuggestions || []
|
||||
suggestions = {
|
||||
recentSearches,
|
||||
bookmarks,
|
||||
tags: tagSuggestions,
|
||||
total: [
|
||||
...tagSuggestions,
|
||||
...recentSearches,
|
||||
...bookmarks,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function completeSuggestion(suggestion) {
|
||||
if (suggestion.type === 'search') {
|
||||
value = suggestion.value
|
||||
close()
|
||||
}
|
||||
if (suggestion.type === 'bookmark') {
|
||||
window.open(suggestion.bookmark.url, linkTarget)
|
||||
close()
|
||||
}
|
||||
if (suggestion.type === 'tag') {
|
||||
const bounds = getCurrentWordBounds(input);
|
||||
const inputValue = input.value;
|
||||
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelection(dir) {
|
||||
|
||||
const length = suggestions.total.length;
|
||||
|
||||
if (length === 0) return
|
||||
|
||||
if (selectedIndex === undefined) {
|
||||
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
let newIndex = selectedIndex + dir;
|
||||
|
||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||
if (newIndex >= length) newIndex = 0;
|
||||
|
||||
selectedIndex = newIndex;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-autocomplete">
|
||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
|
||||
bind:this={input}
|
||||
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
|
||||
</div>
|
||||
|
||||
<ul class="menu" class:open={isOpen}>
|
||||
{#if suggestions.tags.length > 0}
|
||||
<li class="menu-item group-item">Tags</li>
|
||||
{/if}
|
||||
{#each suggestions.tags as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
{suggestion.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#if suggestions.recentSearches.length > 0}
|
||||
<li class="menu-item group-item">Recent Searches</li>
|
||||
{/if}
|
||||
{#each suggestions.recentSearches as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
{suggestion.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#if suggestions.bookmarks.length > 0}
|
||||
<li class="menu-item group-item">Bookmarks</li>
|
||||
{/if}
|
||||
{#each suggestions.bookmarks as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
{suggestion.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
display: none;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.menu.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-autocomplete-input {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete-input.is-focused {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
</style>
|
||||
304
bookmarks/frontend/components/SearchAutocomplete.js
Normal file
304
bookmarks/frontend/components/SearchAutocomplete.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { SearchHistory } from "./SearchHistory.js";
|
||||
import { api } from "../api.js";
|
||||
import { cache } from "../cache.js";
|
||||
import {
|
||||
clampText,
|
||||
debounce,
|
||||
getCurrentWord,
|
||||
getCurrentWordBounds,
|
||||
} from "../util.js";
|
||||
|
||||
export class SearchAutocomplete extends LitElement {
|
||||
static properties = {
|
||||
name: { type: String },
|
||||
placeholder: { type: String },
|
||||
value: { type: String },
|
||||
mode: { type: String },
|
||||
search: { type: Object },
|
||||
linkTarget: { type: String },
|
||||
isFocus: { state: true },
|
||||
isOpen: { state: true },
|
||||
suggestions: { state: true },
|
||||
selectedIndex: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.name = "";
|
||||
this.placeholder = "";
|
||||
this.value = "";
|
||||
this.mode = "";
|
||||
this.search = {};
|
||||
this.linkTarget = "_blank";
|
||||
this.isFocus = false;
|
||||
this.isOpen = false;
|
||||
this.suggestions = {
|
||||
recentSearches: [],
|
||||
bookmarks: [],
|
||||
tags: [],
|
||||
total: [],
|
||||
};
|
||||
this.selectedIndex = undefined;
|
||||
this.input = null;
|
||||
this.searchHistory = new SearchHistory();
|
||||
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.style.setProperty("--menu-max-height", "400px");
|
||||
this.input = this.querySelector("input");
|
||||
// Track current search query after loading the page
|
||||
this.searchHistory.pushCurrent();
|
||||
this.updateSuggestions();
|
||||
}
|
||||
|
||||
handleFocus() {
|
||||
this.isFocus = true;
|
||||
}
|
||||
|
||||
handleBlur() {
|
||||
this.isFocus = false;
|
||||
this.close();
|
||||
}
|
||||
|
||||
handleInput(e) {
|
||||
this.value = e.target.value;
|
||||
this.debouncedLoadSuggestions();
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
// Enter
|
||||
if (
|
||||
this.isOpen &&
|
||||
this.selectedIndex !== undefined &&
|
||||
(e.keyCode === 13 || e.keyCode === 9)
|
||||
) {
|
||||
const suggestion = this.suggestions.total[this.selectedIndex];
|
||||
if (suggestion) this.completeSuggestion(suggestion);
|
||||
e.preventDefault();
|
||||
}
|
||||
// Escape
|
||||
if (e.keyCode === 27) {
|
||||
this.close();
|
||||
e.preventDefault();
|
||||
}
|
||||
// Up arrow
|
||||
if (e.keyCode === 38) {
|
||||
this.updateSelection(-1);
|
||||
e.preventDefault();
|
||||
}
|
||||
// Down arrow
|
||||
if (e.keyCode === 40) {
|
||||
if (!this.isOpen) {
|
||||
this.loadSuggestions();
|
||||
} else {
|
||||
this.updateSelection(1);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.updateSuggestions();
|
||||
this.selectedIndex = undefined;
|
||||
}
|
||||
|
||||
hasSuggestions() {
|
||||
return this.suggestions.total.length > 0;
|
||||
}
|
||||
|
||||
async loadSuggestions() {
|
||||
let suggestionIndex = 0;
|
||||
|
||||
function nextIndex() {
|
||||
return suggestionIndex++;
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
const tags = await cache.getTags();
|
||||
let tagSuggestions = [];
|
||||
const currentWord = getCurrentWord(this.input);
|
||||
if (currentWord && currentWord.length > 1 && currentWord[0] === "#") {
|
||||
const searchTag = currentWord.substring(1, currentWord.length);
|
||||
tagSuggestions = (tags || [])
|
||||
.filter(
|
||||
(tag) =>
|
||||
tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0,
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((tag) => ({
|
||||
type: "tag",
|
||||
index: nextIndex(),
|
||||
label: `#${tag.name}`,
|
||||
tagName: tag.name,
|
||||
}));
|
||||
}
|
||||
|
||||
// Recent search suggestions
|
||||
const recentSearches = this.searchHistory
|
||||
.getRecentSearches(this.value, 5)
|
||||
.map((value) => ({
|
||||
type: "search",
|
||||
index: nextIndex(),
|
||||
label: value,
|
||||
value,
|
||||
}));
|
||||
|
||||
// Bookmark suggestions
|
||||
let bookmarks = [];
|
||||
|
||||
if (this.value && this.value.length >= 3) {
|
||||
const path = this.mode ? `/${this.mode}` : "";
|
||||
const suggestionSearch = {
|
||||
...this.search,
|
||||
q: this.value,
|
||||
};
|
||||
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {
|
||||
limit: 5,
|
||||
offset: 0,
|
||||
path,
|
||||
});
|
||||
bookmarks = fetchedBookmarks.map((bookmark) => {
|
||||
const fullLabel = bookmark.title || bookmark.url;
|
||||
const label = clampText(fullLabel, 60);
|
||||
return {
|
||||
type: "bookmark",
|
||||
index: nextIndex(),
|
||||
label,
|
||||
bookmark,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
this.updateSuggestions(recentSearches, bookmarks, tagSuggestions);
|
||||
|
||||
if (this.hasSuggestions()) {
|
||||
this.open();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
|
||||
recentSearches = recentSearches || [];
|
||||
bookmarks = bookmarks || [];
|
||||
tagSuggestions = tagSuggestions || [];
|
||||
this.suggestions = {
|
||||
recentSearches,
|
||||
bookmarks,
|
||||
tags: tagSuggestions,
|
||||
total: [...tagSuggestions, ...recentSearches, ...bookmarks],
|
||||
};
|
||||
}
|
||||
|
||||
completeSuggestion(suggestion) {
|
||||
if (suggestion.type === "search") {
|
||||
this.value = suggestion.value;
|
||||
this.close();
|
||||
}
|
||||
if (suggestion.type === "bookmark") {
|
||||
window.open(suggestion.bookmark.url, this.linkTarget);
|
||||
this.close();
|
||||
}
|
||||
if (suggestion.type === "tag") {
|
||||
const bounds = getCurrentWordBounds(this.input);
|
||||
const inputValue = this.input.value;
|
||||
this.input.value =
|
||||
inputValue.substring(0, bounds.start) +
|
||||
`#${suggestion.tagName} ` +
|
||||
inputValue.substring(bounds.end);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
updateSelection(dir) {
|
||||
const length = this.suggestions.total.length;
|
||||
|
||||
if (length === 0) return;
|
||||
|
||||
if (this.selectedIndex === undefined) {
|
||||
this.selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
let newIndex = this.selectedIndex + dir;
|
||||
|
||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||
if (newIndex >= length) newIndex = 0;
|
||||
|
||||
this.selectedIndex = newIndex;
|
||||
}
|
||||
|
||||
renderSuggestions(suggestions, title) {
|
||||
if (suggestions.length === 0) return "";
|
||||
|
||||
return html`
|
||||
<li class="menu-item group-item">${title}</li>
|
||||
${suggestions.map(
|
||||
(suggestion) => html`
|
||||
<li
|
||||
class="menu-item ${this.selectedIndex === suggestion.index
|
||||
? "selected"
|
||||
: ""}"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
@mousedown=${(e) => {
|
||||
e.preventDefault();
|
||||
this.completeSuggestion(suggestion);
|
||||
}}
|
||||
>
|
||||
${suggestion.label}
|
||||
</a>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="form-autocomplete">
|
||||
<div
|
||||
class="form-autocomplete-input form-input ${this.isFocus
|
||||
? "is-focused"
|
||||
: ""}"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
class="form-input"
|
||||
name="${this.name}"
|
||||
placeholder="${this.placeholder}"
|
||||
autocomplete="off"
|
||||
.value="${this.value}"
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="menu ${this.isOpen ? "open" : ""}">
|
||||
${this.renderSuggestions(this.suggestions.tags, "Tags")}
|
||||
${this.renderSuggestions(
|
||||
this.suggestions.recentSearches,
|
||||
"Recent Searches",
|
||||
)}
|
||||
${this.renderSuggestions(this.suggestions.bookmarks, "Bookmarks")}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-search-autocomplete", SearchAutocomplete);
|
||||
194
bookmarks/frontend/components/TagAutocomplete.js
Normal file
194
bookmarks/frontend/components/TagAutocomplete.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { cache } from "../cache.js";
|
||||
import { getCurrentWord, getCurrentWordBounds } from "../util.js";
|
||||
|
||||
export class TagAutocomplete extends LitElement {
|
||||
static properties = {
|
||||
id: { type: String },
|
||||
name: { type: String },
|
||||
value: { type: String },
|
||||
placeholder: { type: String },
|
||||
ariaDescribedBy: { type: String, attribute: "aria-described-by" },
|
||||
variant: { type: String },
|
||||
isFocus: { state: true },
|
||||
isOpen: { state: true },
|
||||
suggestions: { state: true },
|
||||
selectedIndex: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.id = "";
|
||||
this.name = "";
|
||||
this.value = "";
|
||||
this.placeholder = "";
|
||||
this.ariaDescribedBy = "";
|
||||
this.variant = "default";
|
||||
this.isFocus = false;
|
||||
this.isOpen = false;
|
||||
this.suggestions = [];
|
||||
this.selectedIndex = 0;
|
||||
this.input = null;
|
||||
this.suggestionList = null;
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.input = this.querySelector("input");
|
||||
this.suggestionList = this.querySelector(".menu");
|
||||
}
|
||||
|
||||
handleFocus() {
|
||||
this.isFocus = true;
|
||||
}
|
||||
|
||||
handleBlur() {
|
||||
this.isFocus = false;
|
||||
this.close();
|
||||
}
|
||||
|
||||
async handleInput(e) {
|
||||
this.input = e.target;
|
||||
|
||||
const tags = await cache.getTags();
|
||||
const word = getCurrentWord(this.input);
|
||||
|
||||
this.suggestions = word
|
||||
? tags.filter(
|
||||
(tag) => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0,
|
||||
)
|
||||
: [];
|
||||
|
||||
if (word && this.suggestions.length > 0) {
|
||||
this.open();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
if (this.isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
|
||||
const suggestion = this.suggestions[this.selectedIndex];
|
||||
this.complete(suggestion);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 27) {
|
||||
this.close();
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 38) {
|
||||
this.updateSelection(-1);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 40) {
|
||||
this.updateSelection(1);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.suggestions = [];
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
complete(suggestion) {
|
||||
const bounds = getCurrentWordBounds(this.input);
|
||||
const value = this.input.value;
|
||||
this.input.value =
|
||||
value.substring(0, bounds.start) +
|
||||
suggestion.name +
|
||||
" " +
|
||||
value.substring(bounds.end);
|
||||
this.input.dispatchEvent(new CustomEvent("change", { bubbles: true }));
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
updateSelection(dir) {
|
||||
const length = this.suggestions.length;
|
||||
let newIndex = this.selectedIndex + dir;
|
||||
|
||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||
if (newIndex >= length) newIndex = 0;
|
||||
|
||||
this.selectedIndex = newIndex;
|
||||
|
||||
// Scroll to selected list item
|
||||
setTimeout(() => {
|
||||
if (this.suggestionList) {
|
||||
const selectedListItem =
|
||||
this.suggestionList.querySelector("li.selected");
|
||||
if (selectedListItem) {
|
||||
selectedListItem.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="form-autocomplete ${this.variant === "small" ? "small" : ""}">
|
||||
<!-- autocomplete input container -->
|
||||
<div
|
||||
class="form-autocomplete-input form-input ${this.isFocus
|
||||
? "is-focused"
|
||||
: ""}"
|
||||
>
|
||||
<!-- autocomplete real input box -->
|
||||
<input
|
||||
id="${this.id}"
|
||||
name="${this.name}"
|
||||
.value="${this.value || ""}"
|
||||
placeholder="${this.placeholder || " "}"
|
||||
class="form-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-describedby="${this.ariaDescribedBy}"
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- autocomplete suggestion list -->
|
||||
<ul
|
||||
class="menu ${this.isOpen && this.suggestions.length > 0
|
||||
? "open"
|
||||
: ""}"
|
||||
>
|
||||
<!-- menu list items -->
|
||||
${this.suggestions.map(
|
||||
(tag, i) => html`
|
||||
<li
|
||||
class="menu-item ${this.selectedIndex === i ? "selected" : ""}"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
@mousedown=${(e) => {
|
||||
e.preventDefault();
|
||||
this.complete(tag);
|
||||
}}
|
||||
>
|
||||
${tag.name}
|
||||
</a>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-tag-autocomplete", TagAutocomplete);
|
||||
@@ -1,171 +0,0 @@
|
||||
<script>
|
||||
import {cache} from "../cache";
|
||||
import {getCurrentWord, getCurrentWordBounds} from "../util";
|
||||
|
||||
export let id;
|
||||
export let name;
|
||||
export let value;
|
||||
export let placeholder;
|
||||
export let ariaDescribedBy;
|
||||
export let variant = 'default';
|
||||
|
||||
let isFocus = false;
|
||||
let isOpen = false;
|
||||
let input = null;
|
||||
let suggestionList = null;
|
||||
|
||||
let suggestions = [];
|
||||
let selectedIndex = 0;
|
||||
|
||||
function handleFocus() {
|
||||
isFocus = true;
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
isFocus = false;
|
||||
close();
|
||||
}
|
||||
|
||||
async function handleInput(e) {
|
||||
input = e.target;
|
||||
|
||||
const tags = await cache.getTags();
|
||||
const word = getCurrentWord(input);
|
||||
|
||||
suggestions = word
|
||||
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
|
||||
: [];
|
||||
|
||||
if (word && suggestions.length > 0) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
|
||||
const suggestion = suggestions[selectedIndex];
|
||||
complete(suggestion);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 27) {
|
||||
close();
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 38) {
|
||||
updateSelection(-1);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.keyCode === 40) {
|
||||
updateSelection(1);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen = true;
|
||||
selectedIndex = 0;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
suggestions = [];
|
||||
selectedIndex = 0;
|
||||
}
|
||||
|
||||
function complete(suggestion) {
|
||||
const bounds = getCurrentWordBounds(input);
|
||||
const value = input.value;
|
||||
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
|
||||
input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
|
||||
|
||||
close();
|
||||
}
|
||||
|
||||
function updateSelection(dir) {
|
||||
|
||||
const length = suggestions.length;
|
||||
let newIndex = selectedIndex + dir;
|
||||
|
||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||
if (newIndex >= length) newIndex = 0;
|
||||
|
||||
selectedIndex = newIndex;
|
||||
|
||||
// Scroll to selected list item
|
||||
setTimeout(() => {
|
||||
if (suggestionList) {
|
||||
const selectedListItem = suggestionList.querySelector('li.selected');
|
||||
if (selectedListItem) {
|
||||
selectedListItem.scrollIntoView({block: 'center'});
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-autocomplete" class:small={variant === 'small'}>
|
||||
<!-- autocomplete input container -->
|
||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||
<!-- autocomplete real input box -->
|
||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
|
||||
class="form-input" type="text" autocomplete="off" autocapitalize="off"
|
||||
aria-describedby="{ariaDescribedBy}"
|
||||
on:input={handleInput} on:keydown={handleKeyDown}
|
||||
on:focus={handleFocus} on:blur={handleBlur}>
|
||||
</div>
|
||||
|
||||
<!-- autocomplete suggestion list -->
|
||||
<ul class="menu" class:open={isOpen && suggestions.length > 0}
|
||||
bind:this={suggestionList}>
|
||||
<!-- menu list items -->
|
||||
{#each suggestions as tag,i}
|
||||
<li class="menu-item" class:selected={selectedIndex === i}>
|
||||
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
||||
{tag.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
display: none;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.menu.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete-input input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .menu .menu-item {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -11,7 +11,5 @@ import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/search-autocomplete";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
export { api } from "./api";
|
||||
export { cache } from "./cache";
|
||||
|
||||
@@ -9,6 +9,13 @@ export function debounce(callback, delay = 250) {
|
||||
};
|
||||
}
|
||||
|
||||
export function preventDefault(fn) {
|
||||
return function (event) {
|
||||
event.preventDefault();
|
||||
fn.call(this, event);
|
||||
};
|
||||
}
|
||||
|
||||
export function clampText(text, maxChars = 30) {
|
||||
if (!text || text.length <= 30) return text;
|
||||
|
||||
|
||||
18
bookmarks/migrations/0046_add_url_normalized_field.py
Normal file
18
bookmarks/migrations/0046_add_url_normalized_field.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2025-08-22 08:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0045_userprofile_hide_bundles_bookmarkbundle"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="url_normalized",
|
||||
field=models.CharField(blank=True, db_index=True, max_length=2048),
|
||||
),
|
||||
]
|
||||
38
bookmarks/migrations/0047_populate_url_normalized_field.py
Normal file
38
bookmarks/migrations/0047_populate_url_normalized_field.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.3 on 2025-08-22 08:28
|
||||
|
||||
from django.db import migrations, transaction
|
||||
from bookmarks.utils import normalize_url
|
||||
|
||||
|
||||
def populate_url_normalized(apps, schema_editor):
|
||||
Bookmark = apps.get_model("bookmarks", "Bookmark")
|
||||
|
||||
batch_size = 500
|
||||
with transaction.atomic():
|
||||
qs = Bookmark.objects.all()
|
||||
for start in range(0, qs.count(), batch_size):
|
||||
batch = list(qs[start : start + batch_size])
|
||||
for bookmark in batch:
|
||||
bookmark.url_normalized = normalize_url(bookmark.url)
|
||||
Bookmark.objects.bulk_update(
|
||||
batch, ["url_normalized"], batch_size=batch_size
|
||||
)
|
||||
|
||||
|
||||
def reverse_populate_url_normalized(apps, schema_editor):
|
||||
Bookmark = apps.get_model("bookmarks", "Bookmark")
|
||||
Bookmark.objects.all().update(url_normalized="")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0046_add_url_normalized_field"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
populate_url_normalized,
|
||||
reverse_populate_url_normalized,
|
||||
),
|
||||
]
|
||||
18
bookmarks/migrations/0048_userprofile_default_mark_shared.py
Normal file
18
bookmarks/migrations/0048_userprofile_default_mark_shared.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2025-08-22 17:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0047_populate_url_normalized_field"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="default_mark_shared",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -14,7 +14,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__)
|
||||
@@ -54,6 +54,7 @@ def build_tag_string(tag_names: List[str], delimiter: str = ","):
|
||||
|
||||
class Bookmark(models.Model):
|
||||
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
|
||||
url_normalized = models.CharField(max_length=2048, blank=True, db_index=True)
|
||||
title = models.CharField(max_length=512, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
@@ -96,6 +97,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] + "...)"
|
||||
|
||||
@@ -474,6 +479,7 @@ 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)]
|
||||
)
|
||||
@@ -515,6 +521,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"display_remove_bookmark_action",
|
||||
"permanent_notes",
|
||||
"default_mark_unread",
|
||||
"default_mark_shared",
|
||||
"custom_css",
|
||||
"auto_tagging_rules",
|
||||
"items_per_page",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -346,12 +346,6 @@ li[ld-bookmark-item] {
|
||||
.bookmark-pagination {
|
||||
margin-top: var(--unit-4);
|
||||
|
||||
/* Remove left padding from first pagination link */
|
||||
|
||||
& .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.sticky {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
@@ -365,7 +359,8 @@ li[ld-bookmark-item] {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(
|
||||
-1 * calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
|
||||
-1 *
|
||||
calc(var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset))
|
||||
);
|
||||
width: calc(
|
||||
var(--bulk-edit-toggle-width) + var(--bulk-edit-toggle-offset)
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
.bundles-page {
|
||||
h1 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
.item-list {
|
||||
.list-item .list-item-icon {
|
||||
.crud-table {
|
||||
svg {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.list-item.drag-start {
|
||||
tr.drag-start {
|
||||
--secondary-border-color: transparent;
|
||||
}
|
||||
|
||||
.list-item.dragging > * {
|
||||
visibility: hidden;
|
||||
tr.dragging > * {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,22 +31,17 @@
|
||||
}
|
||||
|
||||
/* Confirm button component */
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--unit-1);
|
||||
color: var(--error-color) !important;
|
||||
.confirm-dropdown.active {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: var(--error-color) !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
& .menu {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
65
bookmarks/styles/crud.css
Normal file
65
bookmarks/styles/crud.css
Normal file
@@ -0,0 +1,65 @@
|
||||
.crud-page {
|
||||
.crud-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--unit-6);
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.crud-filters {
|
||||
background: var(--body-color-contrast);
|
||||
border-radius: var(--border-radius);
|
||||
border: solid 1px var(--secondary-border-color);
|
||||
padding: var(--unit-3);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--unit-4);
|
||||
|
||||
& .form-group {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.form-input,
|
||||
&.form-select {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
& .form-group:has(.form-checkbox) {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.crud-table {
|
||||
.btn.btn-link {
|
||||
padding: 0;
|
||||
height: unset;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
max-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
th.actions,
|
||||
td.actions {
|
||||
width: 1%;
|
||||
max-width: 150px;
|
||||
|
||||
*:not(:last-child) {
|
||||
margin-right: var(--unit-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
bookmarks/styles/tags.css
Normal file
6
bookmarks/styles/tags.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.tags-editor-page {
|
||||
main {
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
@import "responsive.css";
|
||||
@import "layout.css";
|
||||
@import "components.css";
|
||||
@import "crud.css";
|
||||
@import "bookmark-details.css";
|
||||
@import "bookmark-form.css";
|
||||
@import "bookmark-page.css";
|
||||
@@ -29,3 +30,4 @@
|
||||
@import "reader-mode.css";
|
||||
@import "settings.css";
|
||||
@import "bundles.css";
|
||||
@import "tags.css";
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
position: relative;
|
||||
|
||||
& .form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
align-content: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
min-height: var(--unit-8);
|
||||
padding: var(--unit-h);
|
||||
background: var(--input-bg-color);
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
|
||||
&.is-focused {
|
||||
outline: var(--focus-outline);
|
||||
@@ -22,10 +23,11 @@
|
||||
box-shadow: none;
|
||||
display: inline-block;
|
||||
flex: 1 0 auto;
|
||||
height: var(--unit-6);
|
||||
line-height: var(--unit-4);
|
||||
margin: var(--unit-h);
|
||||
width: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
@@ -33,11 +35,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
.form-autocomplete-input {
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete-input input {
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.menu .menu-item {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
& .menu {
|
||||
display: none;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
max-height: var(--menu-max-height, 200px);
|
||||
overflow: auto;
|
||||
|
||||
& .menu-item.selected > a,
|
||||
& .menu-item > a:hover {
|
||||
@@ -54,4 +75,8 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
& .menu.open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Button no border */
|
||||
&.btn-noborder {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Button Link */
|
||||
|
||||
&.btn-link {
|
||||
|
||||
@@ -430,13 +430,21 @@ textarea.form-input {
|
||||
/* Form element: Input groups */
|
||||
.input-group {
|
||||
display: flex;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--input-box-shadow);
|
||||
|
||||
> * {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.input-group-addon {
|
||||
background: var(--body-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--input-bg-color);
|
||||
border: var(--border-width) solid var(--input-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
line-height: var(--line-height);
|
||||
padding: var(--control-padding-y) var(--control-padding-x);
|
||||
padding: 0 var(--control-padding-x);
|
||||
white-space: nowrap;
|
||||
|
||||
&.addon-sm {
|
||||
|
||||
@@ -87,4 +87,43 @@
|
||||
border-bottom: solid 1px var(--secondary-border-color);
|
||||
margin: var(--unit-2) 0;
|
||||
}
|
||||
|
||||
&.with-arrow {
|
||||
overflow: visible;
|
||||
--arrow-size: 16px;
|
||||
--arrow-offset: 0px;
|
||||
|
||||
.menu-arrow {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset-inline-start: calc(50% + var(--arrow-offset));
|
||||
top: 0;
|
||||
width: var(--arrow-size);
|
||||
height: var(--arrow-size);
|
||||
translate: -50% -50%;
|
||||
rotate: 45deg;
|
||||
background: inherit;
|
||||
border: inherit;
|
||||
clip-path: polygon(0 0, 0 100%, 100% 0);
|
||||
}
|
||||
|
||||
&.top-aligned {
|
||||
transform: translateY(
|
||||
calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm))
|
||||
);
|
||||
}
|
||||
|
||||
&.bottom-aligned {
|
||||
transform: translateY(
|
||||
calc(calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm)) * -1)
|
||||
);
|
||||
|
||||
.menu-arrow {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
rotate: 225deg;
|
||||
translate: -50% 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,17 +80,8 @@
|
||||
}
|
||||
|
||||
& .close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +97,6 @@
|
||||
& .modal-footer {
|
||||
padding: var(--unit-6);
|
||||
padding-top: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child a {
|
||||
/* Remove left padding from first pagination link */
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
& a {
|
||||
background: var(--primary-color);
|
||||
|
||||
@@ -5,22 +5,19 @@
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
/* Scrollable tables */
|
||||
|
||||
&.table-scroll {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.75rem;
|
||||
white-space: nowrap;
|
||||
td,
|
||||
th {
|
||||
border-bottom: var(--border-width) solid var(--secondary-border-color);
|
||||
padding: var(--unit-2) var(--unit-2);
|
||||
}
|
||||
|
||||
& td,
|
||||
& th {
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
padding: var(--unit-3) var(--unit-2);
|
||||
th {
|
||||
font-weight: 500;
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
|
||||
& th {
|
||||
border-bottom-width: var(--border-width-lg);
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,36 @@
|
||||
margin-top: var(--unit-4) !important;
|
||||
}
|
||||
|
||||
.m-6 {
|
||||
margin: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.ml-6 {
|
||||
margin-left: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.mr-6 {
|
||||
margin-right: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-top: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.mx-6 {
|
||||
margin-left: var(--unit-6) !important;
|
||||
margin-right: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.my-6 {
|
||||
margin-bottom: var(--unit-6) !important;
|
||||
margin-top: var(--unit-6) !important;
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -291,6 +321,10 @@
|
||||
}
|
||||
|
||||
/* Flex */
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
@@ -302,3 +336,7 @@
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: var(--unit-2);
|
||||
}
|
||||
|
||||
@@ -49,20 +49,22 @@
|
||||
--body-color-contrast: var(--gray-100);
|
||||
|
||||
/* Fonts */
|
||||
--base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto;
|
||||
--mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier,
|
||||
monospace;
|
||||
--base-font-family:
|
||||
-apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto;
|
||||
--mono-font-family:
|
||||
"SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace;
|
||||
--fallback-font-family: "Helvetica Neue", sans-serif;
|
||||
--cjk-zh-hans-font-family: var(--base-font-family), "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", var(--fallback-font-family);
|
||||
--cjk-zh-hant-font-family: var(--base-font-family), "PingFang TC",
|
||||
"Hiragino Sans CNS", "Microsoft JhengHei", var(--fallback-font-family);
|
||||
--cjk-jp-font-family: var(--base-font-family), "Hiragino Sans",
|
||||
"Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo,
|
||||
var(--fallback-font-family);
|
||||
--cjk-ko-font-family: var(--base-font-family), "Malgun Gothic",
|
||||
var(--fallback-font-family);
|
||||
--cjk-zh-hans-font-family:
|
||||
var(--base-font-family), "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei", var(--fallback-font-family);
|
||||
--cjk-zh-hant-font-family:
|
||||
var(--base-font-family), "PingFang TC", "Hiragino Sans CNS",
|
||||
"Microsoft JhengHei", var(--fallback-font-family);
|
||||
--cjk-jp-font-family:
|
||||
var(--base-font-family), "Hiragino Sans", "Hiragino Kaku Gothic Pro",
|
||||
"Yu Gothic", YuGothic, Meiryo, var(--fallback-font-family);
|
||||
--cjk-ko-font-family:
|
||||
var(--base-font-family), "Malgun Gothic", var(--fallback-font-family);
|
||||
--body-font-family: var(--base-font-family), var(--fallback-font-family);
|
||||
|
||||
/* Unit sizes */
|
||||
@@ -145,6 +147,6 @@
|
||||
/* Shadows */
|
||||
--box-shadow-xs: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
||||
--box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--box-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--box-shadow-lg:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
ld-confirm-button ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
@@ -130,7 +130,7 @@
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
ld-confirm-button ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="section-header no-wrap">
|
||||
<h2 id="bundles-heading">Bundles</h2>
|
||||
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||
<button class="btn dropdown-toggle" aria-label="Bundles menu">
|
||||
<button class="btn btn-noborder dropdown-toggle" aria-label="Bundles menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2 class="title">{{ details.bookmark.resolved_title }}</h2>
|
||||
<button class="close" aria-label="Close dialog">
|
||||
<button class="btn btn-noborder close" aria-label="Close dialog">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
@@ -32,7 +32,7 @@
|
||||
<input type="hidden" name="disable_turbo" value="true">
|
||||
<button ld-confirm-button class="btn btn-error btn-wide"
|
||||
type="submit" name="remove" value="{{ details.bookmark.id }}">
|
||||
Delete...
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
<section aria-labelledby="tags-heading">
|
||||
<div class="section-header">
|
||||
<div class="section-header no-wrap">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
{% if user.is_authenticated %}
|
||||
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||
<button class="btn btn-noborder dropdown-toggle" aria-label="Tags menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M4 6l16 0"/>
|
||||
<path d="M4 12l16 0"/>
|
||||
<path d="M4 18l16 0"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="menu" role="list" tabindex="-1">
|
||||
<li class="menu-item">
|
||||
<a href="{% url 'linkding:tags.index' %}" class="menu-link">Manage tags</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
|
||||
@@ -7,41 +7,55 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="bundles-page" aria-labelledby="main-heading">
|
||||
<h1 id="main-heading">Bundles</h1>
|
||||
<main class="bundles-page crud-page" aria-labelledby="main-heading">
|
||||
<div class="crud-header">
|
||||
<h1 id="main-heading">Bundles</h1>
|
||||
<a href="{% url 'linkding:bundles.new' %}" class="btn">Add bundle</a>
|
||||
</div>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
{% if bundles %}
|
||||
<form action="{% url 'linkding:bundles.action' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="item-list bundles">
|
||||
<table class="table crud-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="actions">
|
||||
<span class="text-assistive">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for bundle in bundles %}
|
||||
<div class="list-item" data-bundle-id="{{ bundle.id }}" draggable="true">
|
||||
<div class="list-item-icon text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="list-item-text">
|
||||
<span class="truncate">{{ bundle.name }}</span>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<tr data-bundle-id="{{ bundle.id }}" draggable="true">
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
</svg>
|
||||
<span>{{ bundle.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
|
||||
<button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}"
|
||||
class="btn btn-link">Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<input type="submit" name="move_bundle" value="" class="d-none">
|
||||
<input type="hidden" name="move_position" value="">
|
||||
</form>
|
||||
@@ -51,21 +65,17 @@
|
||||
<p class="empty-subtitle">Create your first bundle to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'linkding:bundles.new' %}" class="btn btn-primary">Add new bundle</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function init() {
|
||||
const bundlesList = document.querySelector(".item-list.bundles");
|
||||
if (!bundlesList) return;
|
||||
const tableBody = document.querySelector(".crud-table tbody");
|
||||
if (!tableBody) return;
|
||||
|
||||
let draggedElement = null;
|
||||
|
||||
const listItems = bundlesList.querySelectorAll('.list-item');
|
||||
listItems.forEach((item) => {
|
||||
const rows = tableBody.querySelectorAll('tr');
|
||||
rows.forEach((item) => {
|
||||
item.addEventListener('dragstart', handleDragStart);
|
||||
item.addEventListener('dragend', handleDragEnd);
|
||||
item.addEventListener('dragover', handleDragOver);
|
||||
@@ -91,7 +101,7 @@
|
||||
const moveBundleInput = document.querySelector('input[name="move_bundle"]');
|
||||
const movePositionInput = document.querySelector('input[name="move_position"]');
|
||||
moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
|
||||
movePositionInput.value = Array.from(bundlesList.children).indexOf(draggedElement);
|
||||
movePositionInput.value = Array.from(tableBody.children).indexOf(draggedElement);
|
||||
|
||||
const form = this.closest('form');
|
||||
form.requestSubmit(moveBundleInput);
|
||||
@@ -108,7 +118,7 @@
|
||||
|
||||
function handleDragEnter() {
|
||||
if (this !== draggedElement) {
|
||||
const listItems = Array.from(bundlesList.children);
|
||||
const listItems = Array.from(tableBody.children);
|
||||
const draggedIndex = listItems.indexOf(draggedElement);
|
||||
const currentIndex = listItems.indexOf(this);
|
||||
|
||||
|
||||
@@ -270,6 +270,17 @@ reddit.com/r/Music music reddit</pre>
|
||||
This can be overridden when creating each new bookmark.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.default_mark_shared.id_for_label }}" class="form-checkbox">
|
||||
{{ form.default_mark_shared }}
|
||||
<i class="form-icon"></i> Create bookmarks as shared by default
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Sets the default state for the "Share" option when creating a new bookmark.
|
||||
Setting this option will make all new bookmarks default to shared.
|
||||
This can be overridden when creating each new bookmark.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<details {% if form.custom_css.value %}open{% endif %}>
|
||||
<summary>
|
||||
@@ -383,17 +394,17 @@ reddit.com/r/Music music reddit</pre>
|
||||
<td>{{ version_info }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3" style="vertical-align: top">Links</td>
|
||||
<td><a href="https://github.com/sissbruecker/linkding/"
|
||||
target="_blank">GitHub</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://linkding.link/"
|
||||
target="_blank">Documentation</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
|
||||
target="_blank">Changelog</a></td>
|
||||
<td style="vertical-align: top">Links</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<a href="https://github.com/sissbruecker/linkding/"
|
||||
target="_blank">GitHub</a>
|
||||
<a href="https://linkding.link/"
|
||||
target="_blank">Documentation</a>
|
||||
<a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
|
||||
target="_blank">Changelog</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -404,21 +415,25 @@ reddit.com/r/Music music reddit</pre>
|
||||
(function init() {
|
||||
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
|
||||
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
|
||||
const defaultMarkShared = document.getElementById("{{ form.default_mark_shared.id_for_label }}");
|
||||
const bookmarkDescriptionDisplay = document.getElementById("{{ form.bookmark_description_display.id_for_label }}");
|
||||
const bookmarkDescriptionMaxLines = document.getElementById("{{ form.bookmark_description_max_lines.id_for_label }}");
|
||||
|
||||
// Automatically disable public bookmark sharing if bookmark sharing is disabled
|
||||
function updatePublicSharing() {
|
||||
// Automatically disable public bookmark sharing and default shared option if bookmark sharing is disabled
|
||||
function updateSharingOptions() {
|
||||
if (enableSharing.checked) {
|
||||
enablePublicSharing.disabled = false;
|
||||
defaultMarkShared.disabled = false;
|
||||
} else {
|
||||
enablePublicSharing.disabled = true;
|
||||
enablePublicSharing.checked = false;
|
||||
defaultMarkShared.disabled = true;
|
||||
defaultMarkShared.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
updatePublicSharing();
|
||||
enableSharing.addEventListener("change", updatePublicSharing);
|
||||
updateSharingOptions();
|
||||
enableSharing.addEventListener("change", updateSharingOptions);
|
||||
|
||||
// Automatically hide the bookmark description max lines input if the description display is set to inline
|
||||
function updateBookmarkDescriptionMaxLines() {
|
||||
|
||||
23
bookmarks/templates/tags/edit.html
Normal file
23
bookmarks/templates/tags/edit.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load shared %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Edit tag - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tags-editor-page">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Edit tag</h1>
|
||||
</div>
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'tags/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
19
bookmarks/templates/tags/form.html
Normal file
19
bookmarks/templates/tags/form.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.name.id_for_label }}">Name</label>
|
||||
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).</div>
|
||||
{% if form.name.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.name.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="form-group d-flex justify-between">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
|
||||
</div>
|
||||
125
bookmarks/templates/tags/index.html
Normal file
125
bookmarks/templates/tags/index.html
Normal file
@@ -0,0 +1,125 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load shared %}
|
||||
{% load pagination %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Tags - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tags-page crud-page">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="crud-header">
|
||||
<h1 id="main-heading">Tags</h1>
|
||||
<div class="d-flex gap-2 ml-auto">
|
||||
<a href="{% url 'linkding:tags.new' %}" class="btn">Add Tag</a>
|
||||
<a href="{% url 'linkding:tags.merge' %}" class="btn">Merge Tags</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'shared/messages.html' %}
|
||||
|
||||
{# Filters #}
|
||||
<div class="crud-filters">
|
||||
<form method="get" class="mb-2" ld-form-reset>
|
||||
<div class="form-group">
|
||||
<label class="form-label text-assistive" for="search">Search tags</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Search tags..."
|
||||
class="form-input">
|
||||
<button type="submit" class="btn input-group-btn">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label text-assistive" for="sort">Sort by</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path
|
||||
stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 9l4 -4l4 4m-4 -4v14"/><path
|
||||
d="M21 15l-4 4l-4 -4m4 4v-14"/></svg>
|
||||
</span>
|
||||
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||
<option value="name-asc" {% if sort == "name-asc" %}selected{% endif %}>Name A-Z</option>
|
||||
<option value="name-desc" {% if sort == "name-desc" %}selected{% endif %}>Name Z-A</option>
|
||||
<option value="count-asc" {% if sort == "count-asc" %}selected{% endif %}>Fewest bookmarks</option>
|
||||
<option value="count-desc" {% if sort == "count-desc" %}selected{% endif %}>Most bookmarks</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" name="unused" value="true" {% if unused_only %}checked{% endif %} ld-auto-submit>
|
||||
<i class="form-icon"></i> Show only unused tags
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Tags count #}
|
||||
<p class="text-secondary text-small m-0">
|
||||
{% if search or unused_only %}
|
||||
Showing {{ page.paginator.count }} of {{ total_tags }} tags
|
||||
{% else %}
|
||||
{{ total_tags }} tags total
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Tags List #}
|
||||
{% if page.object_list %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<table class="table crud-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th style="width: 25%">Bookmarks</th>
|
||||
<th class="actions">
|
||||
<span class="text-assistive">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in page.object_list %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ tag.name }}
|
||||
</td>
|
||||
<td style="width: 25%">
|
||||
<a class="btn btn-link" href="{% url 'linkding:bookmarks.index' %}?q=%23{{ tag.name|urlencode }}">
|
||||
{{ tag.bookmark_count }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<a class="btn btn-link" href="{% url 'linkding:tags.edit' tag.id %}">Edit</a>
|
||||
<button type="submit" name="delete_tag" value="{{ tag.id }}" class="btn btn-link text-error"
|
||||
ld-confirm-button>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</form>
|
||||
|
||||
{% pagination page %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
{% if search or unused_only %}
|
||||
<p class="empty-title h5">No tags found</p>
|
||||
<p class="empty-subtitle">Try adjusting your search or filters</p>
|
||||
{% else %}
|
||||
<p class="empty-title h5">You have no tags yet</p>
|
||||
<p class="empty-subtitle">Tags will appear here when you add bookmarks with tags</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
68
bookmarks/templates/tags/merge.html
Normal file
68
bookmarks/templates/tags/merge.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load shared %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Merge tags - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tags-editor-page">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Merge tags</h1>
|
||||
</div>
|
||||
|
||||
<details class="mb-4">
|
||||
<summary>
|
||||
<span class="text-bold mb-1">How to merge tags</span>
|
||||
</summary>
|
||||
<ol>
|
||||
<li>Enter the name of the tag you want to keep</li>
|
||||
<li>Enter the names of tags to merge into the target tag</li>
|
||||
<li>The target tag is added to all bookmarks that have any of the merge tags</li>
|
||||
<li>The merged tags are deleted</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-group {% if form.target_tag.errors %}has-error{% endif %}" ld-tag-autocomplete>
|
||||
<label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
|
||||
{{ form.target_tag|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">
|
||||
Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
|
||||
</div>
|
||||
{% if form.target_tag.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.target_tag.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}" ld-tag-autocomplete>
|
||||
<label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
|
||||
{{ form.merge_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">Enter the names of tags to merge into the target tag, separated by spaces. These
|
||||
tags will be deleted after merging.
|
||||
</div>
|
||||
{% if form.merge_tags.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.merge_tags.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="form-group d-flex justify-between">
|
||||
<button type="submit" class="btn btn-primary">Merge Tags</button>
|
||||
<a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
23
bookmarks/templates/tags/new.html
Normal file
23
bookmarks/templates/tags/new.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load shared %}
|
||||
|
||||
{% block head %}
|
||||
{% with page_title="Add tag - Linkding" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tags-editor-page">
|
||||
<main aria-labelledby="main-heading">
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">New tag</h1>
|
||||
</div>
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% include 'tags/form.html' %}
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -501,7 +501,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
modal = self.get_index_details_modal(bookmark)
|
||||
delete_button = modal.find("button", {"type": "submit", "name": "remove"})
|
||||
self.assertIsNotNone(delete_button)
|
||||
self.assertEqual("Delete...", delete_button.text.strip())
|
||||
self.assertEqual("Delete", delete_button.text.strip())
|
||||
self.assertEqual(str(bookmark.id), delete_button["value"])
|
||||
|
||||
form = delete_button.find_parent("form")
|
||||
|
||||
@@ -188,6 +188,25 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
edited_bookmark.refresh_from_db()
|
||||
self.assertNotEqual(edited_bookmark.url, existing_bookmark.url)
|
||||
|
||||
def test_should_prevent_duplicate_normalized_urls(self):
|
||||
self.setup_bookmark(url="https://EXAMPLE.COM/path/?z=1&a=2")
|
||||
|
||||
edited_bookmark = self.setup_bookmark(url="http://different.com")
|
||||
|
||||
form_data = self.create_form_data({"url": "https://example.com/path?a=2&z=1"})
|
||||
response = self.client.post(
|
||||
reverse("linkding:bookmarks.edit", args=[edited_bookmark.id]), form_data
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertInHTML(
|
||||
"<li>A bookmark with this URL already exists.</li>",
|
||||
response.content.decode(),
|
||||
)
|
||||
|
||||
edited_bookmark.refresh_from_db()
|
||||
self.assertEqual(edited_bookmark.url, "http://different.com")
|
||||
|
||||
def test_should_redirect_to_return_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
form_data = self.create_form_data()
|
||||
|
||||
@@ -281,3 +281,28 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
'<input type="checkbox" name="unread" id="id_unread" checked="" aria-describedby="id_unread_help">',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_should_not_check_shared_by_default(self):
|
||||
self.user.profile.enable_sharing = True
|
||||
self.user.profile.save()
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.new"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="checkbox" name="shared" id="id_shared" aria-describedby="id_shared_help">',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_should_check_shared_when_configured_in_profile(self):
|
||||
self.user.profile.enable_sharing = True
|
||||
self.user.profile.default_mark_shared = True
|
||||
self.user.profile.save()
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.new"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="checkbox" name="shared" id="id_shared" checked="" aria-describedby="id_shared_help">',
|
||||
html,
|
||||
)
|
||||
|
||||
@@ -660,3 +660,21 @@ class BookmarkSharedViewTestCase(
|
||||
feed = soup.select_one('head link[type="application/rss+xml"]')
|
||||
self.assertIsNotNone(feed)
|
||||
self.assertEqual(feed.attrs["href"], reverse("linkding:feeds.public_shared"))
|
||||
|
||||
def test_tag_menu_visible_for_authenticated_user(self):
|
||||
self.authenticate()
|
||||
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
html = response.content.decode()
|
||||
|
||||
soup = self.make_soup(html)
|
||||
tag_menu = soup.find(attrs={"aria-label": "Tags menu"})
|
||||
self.assertIsNotNone(tag_menu)
|
||||
|
||||
def test_tag_menu_not_visible_for_unauthenticated_user(self):
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
html = response.content.decode()
|
||||
|
||||
soup = self.make_soup(html)
|
||||
tag_menu = soup.find(attrs={"aria-label": "Tags menu"})
|
||||
self.assertIsNone(tag_menu)
|
||||
|
||||
@@ -1047,6 +1047,29 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(expected_metadata.description, metadata["description"])
|
||||
self.assertEqual(expected_metadata.preview_image, metadata["preview_image"])
|
||||
|
||||
def test_check_returns_bookmark_using_normalized_url(self):
|
||||
self.authenticate()
|
||||
|
||||
# Create bookmark with one URL variant
|
||||
bookmark = self.setup_bookmark(
|
||||
url="https://EXAMPLE.COM/path/?z=1&a=2",
|
||||
title="Example title",
|
||||
description="Example description",
|
||||
)
|
||||
|
||||
# Check with different URL variant that should normalize to the same URL
|
||||
url = reverse("linkding:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com/path?a=2&z=1")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
bookmark_data = response.data["bookmark"]
|
||||
|
||||
# Should find the existing bookmark despite URL differences
|
||||
self.assertIsNotNone(bookmark_data)
|
||||
self.assertEqual(bookmark.id, bookmark_data["id"])
|
||||
self.assertEqual(bookmark.title, bookmark_data["title"])
|
||||
|
||||
def test_check_returns_no_auto_tags_if_none_configured(self):
|
||||
self.authenticate()
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
f"""
|
||||
<button type="submit" name="unshare" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
ld-confirm-button ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
@@ -247,7 +247,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
f"""
|
||||
<button type="submit" name="mark_as_read" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
ld-confirm-button ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
|
||||
@@ -95,6 +95,41 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
# Saving a duplicate bookmark should not modify archive flag - right?
|
||||
self.assertFalse(updated_bookmark.is_archived)
|
||||
|
||||
def test_create_should_update_existing_bookmark_with_normalized_url(
|
||||
self,
|
||||
):
|
||||
original_bookmark = self.setup_bookmark(
|
||||
url="https://EXAMPLE.com/path/?a=1&z=2", unread=False, shared=False
|
||||
)
|
||||
bookmark_data = Bookmark(
|
||||
url="HTTPS://example.com/path?z=2&a=1",
|
||||
title="Updated Title",
|
||||
description="Updated description",
|
||||
)
|
||||
updated_bookmark = create_bookmark(
|
||||
bookmark_data, "", self.get_or_create_test_user()
|
||||
)
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 1)
|
||||
self.assertEqual(updated_bookmark.id, original_bookmark.id)
|
||||
self.assertEqual(updated_bookmark.title, bookmark_data.title)
|
||||
|
||||
def test_create_should_populate_url_normalized_field(self):
|
||||
bookmark_data = Bookmark(
|
||||
url="https://EXAMPLE.COM/path/?z=1&a=2",
|
||||
title="Test Title",
|
||||
description="Test description",
|
||||
)
|
||||
created_bookmark = create_bookmark(
|
||||
bookmark_data, "", self.get_or_create_test_user()
|
||||
)
|
||||
|
||||
created_bookmark.refresh_from_db()
|
||||
self.assertEqual(created_bookmark.url, "https://EXAMPLE.COM/path/?z=1&a=2")
|
||||
self.assertEqual(
|
||||
created_bookmark.url_normalized, "https://example.com/path?a=2&z=1"
|
||||
)
|
||||
|
||||
def test_create_should_create_web_archive_snapshot(self):
|
||||
with patch.object(
|
||||
tasks, "create_web_archive_snapshot"
|
||||
|
||||
@@ -25,27 +25,27 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
for bundle in bundles:
|
||||
expected_list_item = f"""
|
||||
<div class="list-item" data-bundle-id="{bundle.id}" draggable="true">
|
||||
<div class="list-item-icon text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="list-item-text">
|
||||
<span class="truncate">{bundle.name}</span>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<tr data-bundle-id="{bundle.id}" draggable="true">
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
|
||||
</svg>
|
||||
<span>{ bundle.name }</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<a class="btn btn-link" href="{reverse("linkding:bundles.edit", args=[bundle.id])}">Edit</a>
|
||||
<button ld-confirm-button type="submit" name="remove_bundle" value="{bundle.id}" class="btn btn-link">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
self.assertInHTML(expected_list_item, html)
|
||||
@@ -61,7 +61,7 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'<span class="truncate">{user_bundle.name}</span>', html)
|
||||
self.assertInHTML(f"<span>{user_bundle.name}</span>", html)
|
||||
self.assertNotIn(other_user_bundle.name, html)
|
||||
|
||||
def test_empty_state(self):
|
||||
@@ -83,7 +83,7 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
f'<a href="{reverse("linkding:bundles.new")}" class="btn btn-primary">Add new bundle</a>',
|
||||
f'<a href="{reverse("linkding:bundles.new")}" class="btn">Add bundle</a>',
|
||||
html,
|
||||
)
|
||||
|
||||
|
||||
@@ -324,3 +324,14 @@ class ParserTestCase(TestCase, ImportTestMixin):
|
||||
self.assertEqual(
|
||||
bookmarks[0].notes, "Interesting notes about the <style> HTML element."
|
||||
)
|
||||
|
||||
def test_unescape_href_attribute(self):
|
||||
html = self.render_html(
|
||||
tags_html="""
|
||||
<DT><A HREF="https://example.com¢er=123" ADD_DATE="1">Imported bookmark</A>
|
||||
<DD>Imported bookmark description
|
||||
"""
|
||||
)
|
||||
|
||||
bookmarks = parse(html)
|
||||
self.assertEqual(bookmarks[0].href, "https://example.com¢er=123")
|
||||
|
||||
@@ -115,6 +115,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"display_remove_bookmark_action": False,
|
||||
"permanent_notes": True,
|
||||
"default_mark_unread": True,
|
||||
"default_mark_shared": True,
|
||||
"custom_css": "body { background-color: #000; }",
|
||||
"auto_tagging_rules": "example.com tag",
|
||||
"items_per_page": "10",
|
||||
@@ -188,6 +189,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(
|
||||
self.user.profile.default_mark_unread, form_data["default_mark_unread"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.user.profile.default_mark_shared, form_data["default_mark_shared"]
|
||||
)
|
||||
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
|
||||
self.assertEqual(
|
||||
self.user.profile.auto_tagging_rules, form_data["auto_tagging_rules"]
|
||||
|
||||
113
bookmarks/tests/test_tags_edit_view.py
Normal file
113
bookmarks/tests/test_tags_edit_view.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self) -> None:
|
||||
self.user = self.get_or_create_test_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_update_tag(self):
|
||||
tag = self.setup_tag(name="old_name")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag.id]), {"name": "new_name"}
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
|
||||
tag.refresh_from_db()
|
||||
self.assertEqual(tag.name, "new_name")
|
||||
|
||||
def test_allow_case_changes(self):
|
||||
tag = self.setup_tag(name="tag")
|
||||
|
||||
self.client.post(reverse("linkding:tags.edit", args=[tag.id]), {"name": "TAG"})
|
||||
|
||||
tag.refresh_from_db()
|
||||
self.assertEqual(tag.name, "TAG")
|
||||
|
||||
def test_can_only_edit_own_tags(self):
|
||||
other_user = self.setup_user()
|
||||
tag = self.setup_tag(user=other_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag.id]), {"name": "new_name"}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
tag.refresh_from_db()
|
||||
self.assertNotEqual(tag.name, "new_name")
|
||||
|
||||
def test_show_error_for_empty_name(self):
|
||||
tag = self.setup_tag(name="tag1")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag.id]), {"name": ""}
|
||||
)
|
||||
|
||||
self.assertContains(response, "This field is required", status_code=422)
|
||||
tag.refresh_from_db()
|
||||
self.assertEqual(tag.name, "tag1")
|
||||
|
||||
def test_show_error_for_duplicate_name(self):
|
||||
tag1 = self.setup_tag(name="tag1")
|
||||
self.setup_tag(name="tag2")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag1.id]), {"name": "tag2"}
|
||||
)
|
||||
|
||||
self.assertContains(
|
||||
response, "Tag "tag2" already exists", status_code=422
|
||||
)
|
||||
tag1.refresh_from_db()
|
||||
self.assertEqual(tag1.name, "tag1")
|
||||
|
||||
def test_show_error_for_duplicate_name_different_casing(self):
|
||||
tag1 = self.setup_tag(name="tag1")
|
||||
self.setup_tag(name="tag2")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag1.id]), {"name": "TAG2"}
|
||||
)
|
||||
|
||||
self.assertContains(
|
||||
response, "Tag "TAG2" already exists", status_code=422
|
||||
)
|
||||
tag1.refresh_from_db()
|
||||
self.assertEqual(tag1.name, "tag1")
|
||||
|
||||
def test_no_error_for_duplicate_name_different_user(self):
|
||||
other_user = self.setup_user()
|
||||
self.setup_tag(name="tag1", user=other_user)
|
||||
|
||||
tag2 = self.setup_tag(name="tag2")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag2.id]), {"name": "tag1"}
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
tag2.refresh_from_db()
|
||||
self.assertEqual(tag2.name, "tag1")
|
||||
|
||||
def test_update_shows_success_message(self):
|
||||
tag = self.setup_tag(name="old_name")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.edit", args=[tag.id]),
|
||||
{"name": "new_name"},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<div class="toast toast-success" role="alert">
|
||||
Tag "new_name" updated successfully.
|
||||
</div>
|
||||
""",
|
||||
response.content.decode(),
|
||||
)
|
||||
281
bookmarks/tests/test_tags_index_view.py
Normal file
281
bookmarks/tests/test_tags_index_view.py
Normal file
@@ -0,0 +1,281 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Tag
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
def setUp(self) -> None:
|
||||
self.user = self.get_or_create_test_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def get_rows(self, response):
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
return soup.select(".crud-table tbody tr")
|
||||
|
||||
def find_row(self, rows, tag):
|
||||
for row in rows:
|
||||
if tag.name in row.get_text():
|
||||
return row
|
||||
return None
|
||||
|
||||
def assertRows(self, response, tags):
|
||||
rows = self.get_rows(response)
|
||||
self.assertEqual(len(rows), len(tags))
|
||||
for tag in tags:
|
||||
row = self.find_row(rows, tag)
|
||||
self.assertIsNotNone(row, f"Tag '{tag.name}' not found in table")
|
||||
|
||||
def assertOrderedRows(self, response, tags):
|
||||
rows = self.get_rows(response)
|
||||
self.assertEqual(len(rows), len(tags))
|
||||
for index, tag in enumerate(tags):
|
||||
row = rows[index]
|
||||
self.assertIn(
|
||||
tag.name,
|
||||
row.get_text(),
|
||||
f"Tag '{tag.name}' not found at index {index}",
|
||||
)
|
||||
|
||||
def test_list_tags(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
tag3 = self.setup_tag()
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertRows(response, [tag1, tag2, tag3])
|
||||
|
||||
def test_show_user_owned_tags(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
tag3 = self.setup_tag()
|
||||
|
||||
other_user = self.setup_user()
|
||||
self.setup_tag(user=other_user)
|
||||
self.setup_tag(user=other_user)
|
||||
self.setup_tag(user=other_user)
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
|
||||
self.assertRows(response, [tag1, tag2, tag3])
|
||||
|
||||
def test_search_tags(self):
|
||||
tag1 = self.setup_tag(name="programming")
|
||||
self.setup_tag(name="python")
|
||||
self.setup_tag(name="django")
|
||||
self.setup_tag(name="design")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?search=prog")
|
||||
|
||||
self.assertRows(response, [tag1])
|
||||
|
||||
def test_filter_unused_tags(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
tag3 = self.setup_tag()
|
||||
|
||||
self.setup_bookmark(tags=[tag1])
|
||||
self.setup_bookmark(tags=[tag3])
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?unused=true")
|
||||
|
||||
self.assertRows(response, [tag2])
|
||||
|
||||
def test_rows_have_links_to_filtered_bookmarks(self):
|
||||
tag1 = self.setup_tag(name="python")
|
||||
tag2 = self.setup_tag(name="django-framework")
|
||||
|
||||
self.setup_bookmark(tags=[tag1])
|
||||
self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
|
||||
rows = self.get_rows(response)
|
||||
|
||||
tag1_row = self.find_row(rows, tag1)
|
||||
view_link = tag1_row.find("a", string=lambda s: s and s.strip() == "2")
|
||||
expected_url = reverse("linkding:bookmarks.index") + "?q=%23python"
|
||||
self.assertEqual(view_link["href"], expected_url)
|
||||
|
||||
tag2_row = self.find_row(rows, tag2)
|
||||
view_link = tag2_row.find("a", string=lambda s: s and s.strip() == "1")
|
||||
expected_url = reverse("linkding:bookmarks.index") + "?q=%23django-framework"
|
||||
self.assertEqual(view_link["href"], expected_url)
|
||||
|
||||
def test_shows_tag_total(self):
|
||||
tag1 = self.setup_tag(name="python")
|
||||
tag2 = self.setup_tag(name="javascript")
|
||||
tag3 = self.setup_tag(name="design")
|
||||
self.setup_tag(name="unused-tag")
|
||||
|
||||
self.setup_bookmark(tags=[tag1])
|
||||
self.setup_bookmark(tags=[tag2])
|
||||
self.setup_bookmark(tags=[tag3])
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
self.assertContains(response, "4 tags total")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?search=python")
|
||||
self.assertContains(response, "Showing 1 of 4 tags")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?unused=true")
|
||||
self.assertContains(response, "Showing 1 of 4 tags")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("linkding:tags.index") + "?search=nonexistent"
|
||||
)
|
||||
self.assertContains(response, "Showing 0 of 4 tags")
|
||||
|
||||
def test_pagination(self):
|
||||
tags = []
|
||||
for i in range(75):
|
||||
tags.append(self.setup_tag())
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
rows = self.get_rows(response)
|
||||
self.assertEqual(len(rows), 50)
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?page=2")
|
||||
rows = self.get_rows(response)
|
||||
self.assertEqual(len(rows), 25)
|
||||
|
||||
def test_delete_action(self):
|
||||
tag = self.setup_tag(name="tag_to_delete")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.index"), {"delete_tag": tag.id}
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
self.assertFalse(Tag.objects.filter(id=tag.id).exists())
|
||||
|
||||
def test_tag_delete_action_shows_success_message(self):
|
||||
tag = self.setup_tag(name="tag_to_delete")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.index"), {"delete_tag": tag.id}, follow=True
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<div class="toast toast-success" role="alert">
|
||||
Tag "tag_to_delete" deleted successfully.
|
||||
</div>
|
||||
""",
|
||||
response.content.decode(),
|
||||
)
|
||||
|
||||
def test_tag_delete_action_preserves_query_parameters(self):
|
||||
tag = self.setup_tag(name="search_tag")
|
||||
|
||||
url = (
|
||||
reverse("linkding:tags.index")
|
||||
+ "?search=search&unused=true&page=2&sort=name-desc"
|
||||
)
|
||||
response = self.client.post(url, {"delete_tag": tag.id})
|
||||
|
||||
self.assertRedirects(response, url)
|
||||
|
||||
def test_tag_delete_action_only_deletes_own_tags(self):
|
||||
other_user = self.setup_user()
|
||||
other_tag = self.setup_tag(user=other_user, name="other_user_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.index"), {"delete_tag": other_tag.id}, follow=True
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_sort_by_name_ascending(self):
|
||||
tag_c = self.setup_tag(name="c_tag")
|
||||
tag_a = self.setup_tag(name="a_tag")
|
||||
tag_b = self.setup_tag(name="b_tag")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-asc")
|
||||
|
||||
self.assertOrderedRows(response, [tag_a, tag_b, tag_c])
|
||||
|
||||
def test_sort_by_name_descending(self):
|
||||
tag_c = self.setup_tag(name="c_tag")
|
||||
tag_a = self.setup_tag(name="a_tag")
|
||||
tag_b = self.setup_tag(name="b_tag")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-desc")
|
||||
|
||||
self.assertOrderedRows(response, [tag_c, tag_b, tag_a])
|
||||
|
||||
def test_sort_by_bookmark_count_ascending(self):
|
||||
tag_few = self.setup_tag(name="few_bookmarks")
|
||||
tag_many = self.setup_tag(name="many_bookmarks")
|
||||
tag_none = self.setup_tag(name="no_bookmarks")
|
||||
|
||||
self.setup_bookmark(tags=[tag_few])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?sort=count-asc")
|
||||
|
||||
self.assertOrderedRows(response, [tag_none, tag_few, tag_many])
|
||||
|
||||
def test_sort_by_bookmark_count_descending(self):
|
||||
tag_few = self.setup_tag(name="few_bookmarks")
|
||||
tag_many = self.setup_tag(name="many_bookmarks")
|
||||
tag_none = self.setup_tag(name="no_bookmarks")
|
||||
|
||||
self.setup_bookmark(tags=[tag_few])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
self.setup_bookmark(tags=[tag_many])
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?sort=count-desc")
|
||||
|
||||
self.assertOrderedRows(response, [tag_many, tag_few, tag_none])
|
||||
|
||||
def test_default_sort_is_name_ascending(self):
|
||||
tag_c = self.setup_tag(name="c_tag")
|
||||
tag_a = self.setup_tag(name="a_tag")
|
||||
tag_b = self.setup_tag(name="b_tag")
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
|
||||
self.assertOrderedRows(response, [tag_a, tag_b, tag_c])
|
||||
|
||||
def test_sort_select_has_correct_options_and_selection(self):
|
||||
self.setup_tag()
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||
<option value="name-asc" selected>Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
<option value="count-asc">Fewest bookmarks</option>
|
||||
<option value="count-desc">Most bookmarks</option>
|
||||
</select>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("linkding:tags.index") + "?sort=name-desc")
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc" selected>Name Z-A</option>
|
||||
<option value="count-asc">Fewest bookmarks</option>
|
||||
<option value="count-desc">Most bookmarks</option>
|
||||
</select>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
219
bookmarks/tests/test_tags_merge_view.py
Normal file
219
bookmarks/tests/test_tags_merge_view.py
Normal file
@@ -0,0 +1,219 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
def setUp(self) -> None:
|
||||
self.user = self.get_or_create_test_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def get_form_group(self, response, input_name):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
input_element = soup.find("input", {"name": input_name})
|
||||
if input_element:
|
||||
return input_element.find_parent("div", class_="form-group")
|
||||
return None
|
||||
|
||||
def test_merge_tags(self):
|
||||
target_tag = self.setup_tag(name="target_tag")
|
||||
merge_tag1 = self.setup_tag(name="merge_tag1")
|
||||
merge_tag2 = self.setup_tag(name="merge_tag2")
|
||||
|
||||
bookmark1 = self.setup_bookmark(tags=[merge_tag1])
|
||||
bookmark2 = self.setup_bookmark(tags=[merge_tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[target_tag])
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
|
||||
self.assertEqual(Tag.objects.count(), 1)
|
||||
self.assertFalse(Tag.objects.filter(id=merge_tag1.id).exists())
|
||||
self.assertFalse(Tag.objects.filter(id=merge_tag2.id).exists())
|
||||
|
||||
self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])
|
||||
|
||||
def test_merge_tags_complex(self):
|
||||
target_tag = self.setup_tag(name="target_tag")
|
||||
merge_tag1 = self.setup_tag(name="merge_tag1")
|
||||
merge_tag2 = self.setup_tag(name="merge_tag2")
|
||||
other_tag = self.setup_tag(name="other_tag")
|
||||
|
||||
bookmark1 = self.setup_bookmark(tags=[merge_tag1])
|
||||
bookmark2 = self.setup_bookmark(tags=[merge_tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[target_tag])
|
||||
bookmark4 = self.setup_bookmark(
|
||||
tags=[merge_tag1, merge_tag2]
|
||||
) # both merge tags
|
||||
bookmark5 = self.setup_bookmark(
|
||||
tags=[merge_tag2, target_tag]
|
||||
) # already has target tag
|
||||
bookmark6 = self.setup_bookmark(
|
||||
tags=[merge_tag1, merge_tag2, target_tag]
|
||||
) # both merge tags and target
|
||||
bookmark7 = self.setup_bookmark(tags=[other_tag]) # unrelated tag
|
||||
bookmark8 = self.setup_bookmark(
|
||||
tags=[other_tag, merge_tag1]
|
||||
) # merge and unrelated tag
|
||||
bookmark9 = self.setup_bookmark(
|
||||
tags=[other_tag, target_tag]
|
||||
) # merge and target tag
|
||||
bookmark10 = self.setup_bookmark(tags=[]) # no tags
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 10)
|
||||
self.assertEqual(Tag.objects.count(), 2)
|
||||
self.assertEqual(Bookmark.tags.through.objects.count(), 11)
|
||||
|
||||
self.assertCountEqual(list(Tag.objects.all()), [target_tag, other_tag])
|
||||
|
||||
self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark4.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark5.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark6.tags.all()), [target_tag])
|
||||
self.assertCountEqual(list(bookmark7.tags.all()), [other_tag])
|
||||
self.assertCountEqual(list(bookmark8.tags.all()), [other_tag, target_tag])
|
||||
self.assertCountEqual(list(bookmark9.tags.all()), [other_tag, target_tag])
|
||||
self.assertCountEqual(list(bookmark10.tags.all()), [])
|
||||
|
||||
def test_can_only_merge_own_tags(self):
|
||||
other_user = self.setup_user()
|
||||
self.setup_tag(name="target_tag", user=other_user)
|
||||
self.setup_tag(name="merge_tag", user=other_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "merge_tag"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
target_tag_group = self.get_form_group(response, "target_tag")
|
||||
self.assertIn('Tag "target_tag" does not exist', target_tag_group.get_text())
|
||||
|
||||
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||
self.assertIn('Tag "merge_tag" does not exist', merge_tags_group.get_text())
|
||||
|
||||
def test_validate_missing_target_tag(self):
|
||||
merge_tag = self.setup_tag(name="merge_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "", "merge_tags": "merge_tag"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
target_tag_group = self.get_form_group(response, "target_tag")
|
||||
self.assertIn("This field is required", target_tag_group.get_text())
|
||||
self.assertTrue(Tag.objects.filter(id=merge_tag.id).exists())
|
||||
|
||||
def test_validate_missing_merge_tags(self):
|
||||
self.setup_tag(name="target_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": ""},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||
self.assertIn("This field is required", merge_tags_group.get_text())
|
||||
|
||||
def test_validate_nonexistent_target_tag(self):
|
||||
merge_tag = self.setup_tag(name="merge_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "nonexistent_tag", "merge_tags": "merge_tag"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
target_tag_group = self.get_form_group(response, "target_tag")
|
||||
self.assertIn(
|
||||
'Tag "nonexistent_tag" does not exist', target_tag_group.get_text()
|
||||
)
|
||||
|
||||
def test_validate_nonexistent_merge_tag(self):
|
||||
self.setup_tag(name="target_tag")
|
||||
self.setup_tag(name="merge_tag1")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "merge_tag1 nonexistent_tag"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||
self.assertIn(
|
||||
'Tag "nonexistent_tag" does not exist', merge_tags_group.get_text()
|
||||
)
|
||||
|
||||
def test_validate_multiple_target_tags(self):
|
||||
self.setup_tag(name="target_tag1")
|
||||
self.setup_tag(name="target_tag2")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag1 target_tag2", "merge_tags": "some_tag"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
target_tag_group = self.get_form_group(response, "target_tag")
|
||||
self.assertIn(
|
||||
"Please enter only one tag name for the target tag",
|
||||
target_tag_group.get_text(),
|
||||
)
|
||||
|
||||
def test_validate_target_tag_in_merge_list(self):
|
||||
self.setup_tag(name="target_tag")
|
||||
self.setup_tag(name="merge_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "target_tag merge_tag"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
merge_tags_group = self.get_form_group(response, "merge_tags")
|
||||
self.assertIn(
|
||||
"The target tag cannot be selected for merging", merge_tags_group.get_text()
|
||||
)
|
||||
|
||||
def test_merge_shows_success_message(self):
|
||||
self.setup_tag(name="target_tag")
|
||||
self.setup_tag(name="merge_tag1")
|
||||
self.setup_tag(name="merge_tag2")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.merge"),
|
||||
{"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<div class="toast toast-success" role="alert">
|
||||
Successfully merged 2 tags (merge_tag1, merge_tag2) into "target_tag".
|
||||
</div>
|
||||
""",
|
||||
response.content.decode(),
|
||||
)
|
||||
79
bookmarks/tests/test_tags_new_view.py
Normal file
79
bookmarks/tests/test_tags_new_view.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Tag
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class TagsNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self) -> None:
|
||||
self.user = self.get_or_create_test_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_create_tag(self):
|
||||
response = self.client.post(reverse("linkding:tags.new"), {"name": "new_tag"})
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
self.assertEqual(Tag.objects.count(), 1)
|
||||
self.assertTrue(Tag.objects.filter(name="new_tag", owner=self.user).exists())
|
||||
|
||||
def test_show_error_for_empty_name(self):
|
||||
response = self.client.post(reverse("linkding:tags.new"), {"name": ""})
|
||||
|
||||
self.assertContains(response, "This field is required", status_code=422)
|
||||
self.assertEqual(Tag.objects.count(), 0)
|
||||
|
||||
def test_show_error_for_duplicate_name(self):
|
||||
self.setup_tag(name="existing_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.new"), {"name": "existing_tag"}
|
||||
)
|
||||
|
||||
self.assertContains(
|
||||
response, "Tag "existing_tag" already exists", status_code=422
|
||||
)
|
||||
self.assertEqual(Tag.objects.count(), 1)
|
||||
|
||||
def test_show_error_for_duplicate_name_different_casing(self):
|
||||
self.setup_tag(name="existing_tag")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.new"), {"name": "existing_TAG"}
|
||||
)
|
||||
|
||||
self.assertContains(
|
||||
response, "Tag "existing_TAG" already exists", status_code=422
|
||||
)
|
||||
self.assertEqual(Tag.objects.count(), 1)
|
||||
|
||||
def test_no_error_for_duplicate_name_different_user(self):
|
||||
other_user = self.setup_user()
|
||||
self.setup_tag(name="existing_tag", user=other_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.new"), {"name": "existing_tag"}
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||
self.assertEqual(Tag.objects.count(), 2)
|
||||
self.assertEqual(
|
||||
Tag.objects.filter(name="existing_tag", owner=self.user).count(), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
Tag.objects.filter(name="existing_tag", owner=other_user).count(), 1
|
||||
)
|
||||
|
||||
def test_create_shows_success_message(self):
|
||||
response = self.client.post(
|
||||
reverse("linkding:tags.new"), {"name": "new_tag"}, follow=True
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<div class="toast toast-success" role="alert">
|
||||
Tag "new_tag" created successfully.
|
||||
</div>
|
||||
""",
|
||||
response.content.decode(),
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from bookmarks.utils import (
|
||||
humanize_absolute_date,
|
||||
humanize_relative_date,
|
||||
parse_timestamp,
|
||||
normalize_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -182,3 +183,181 @@ class UtilsTestCase(TestCase):
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.verify_timestamp(now, 1000000000)
|
||||
|
||||
def test_normalize_url_trailing_slash_handling(self):
|
||||
test_cases = [
|
||||
("https://example.com/", "https://example.com"),
|
||||
(
|
||||
"https://example.com/path/",
|
||||
"https://example.com/path",
|
||||
),
|
||||
("https://example.com/path/to/page/", "https://example.com/path/to/page"),
|
||||
(
|
||||
"https://example.com/path",
|
||||
"https://example.com/path",
|
||||
),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_query_parameters(self):
|
||||
test_cases = [
|
||||
("https://example.com?z=1&a=2", "https://example.com?a=2&z=1"),
|
||||
("https://example.com?c=3&b=2&a=1", "https://example.com?a=1&b=2&c=3"),
|
||||
("https://example.com?param=value", "https://example.com?param=value"),
|
||||
("https://example.com?", "https://example.com"),
|
||||
(
|
||||
"https://example.com?empty=&filled=value",
|
||||
"https://example.com?empty=&filled=value",
|
||||
),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_case_sensitivity(self):
|
||||
test_cases = [
|
||||
(
|
||||
"https://EXAMPLE.com/Path/To/Page",
|
||||
"https://example.com/Path/To/Page",
|
||||
),
|
||||
("https://EXAMPLE.COM/API/v1/Users", "https://example.com/API/v1/Users"),
|
||||
(
|
||||
"HTTPS://EXAMPLE.COM/path",
|
||||
"https://example.com/path",
|
||||
),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_special_characters_and_encoding(self):
|
||||
test_cases = [
|
||||
(
|
||||
"https://example.com/path%20with%20spaces",
|
||||
"https://example.com/path%20with%20spaces",
|
||||
),
|
||||
("https://example.com/caf%C3%A9", "https://example.com/caf%C3%A9"),
|
||||
(
|
||||
"https://example.com/path?q=hello%20world",
|
||||
"https://example.com/path?q=hello%20world",
|
||||
),
|
||||
("https://example.com/pàth", "https://example.com/pàth"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_various_protocols(self):
|
||||
test_cases = [
|
||||
("FTP://example.com", "ftp://example.com"),
|
||||
("HTTP://EXAMPLE.COM", "http://example.com"),
|
||||
("https://example.com", "https://example.com"),
|
||||
("file:///path/to/file", "file:///path/to/file"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_port_handling(self):
|
||||
test_cases = [
|
||||
("https://example.com:8080", "https://example.com:8080"),
|
||||
("https://EXAMPLE.COM:8080", "https://example.com:8080"),
|
||||
("http://example.com:80", "http://example.com:80"),
|
||||
("https://example.com:443", "https://example.com:443"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_authentication_handling(self):
|
||||
test_cases = [
|
||||
("https://user:pass@EXAMPLE.COM", "https://user:pass@example.com"),
|
||||
("https://user@EXAMPLE.COM", "https://user@example.com"),
|
||||
("ftp://admin:secret@EXAMPLE.COM", "ftp://admin:secret@example.com"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_fragment_handling(self):
|
||||
test_cases = [
|
||||
("https://example.com#", "https://example.com"),
|
||||
("https://example.com#section", "https://example.com#section"),
|
||||
("https://EXAMPLE.COM/path#Section", "https://example.com/path#Section"),
|
||||
("https://EXAMPLE.COM/path/#Section", "https://example.com/path#Section"),
|
||||
("https://example.com?a=1#fragment", "https://example.com?a=1#fragment"),
|
||||
(
|
||||
"https://example.com?z=2&a=1#fragment",
|
||||
"https://example.com?a=1&z=2#fragment",
|
||||
),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_edge_cases(self):
|
||||
test_cases = [
|
||||
("", ""),
|
||||
(" ", ""),
|
||||
(" https://example.com ", "https://example.com"),
|
||||
("not-a-url", "not-a-url"),
|
||||
("://invalid", "://invalid"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_normalize_url_internationalized_domain_names(self):
|
||||
test_cases = [
|
||||
(
|
||||
"https://xn--fsq.xn--0zwm56d",
|
||||
"https://xn--fsq.xn--0zwm56d",
|
||||
),
|
||||
("https://测试.中国", "https://测试.中国"),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected.lower() if expected else expected, result)
|
||||
|
||||
def test_normalize_url_complex_query_parameters(self):
|
||||
test_cases = [
|
||||
(
|
||||
"https://example.com?z=1&a=2&z=3&b=4",
|
||||
"https://example.com?a=2&b=4&z=1&z=3", # Multiple values for same key
|
||||
),
|
||||
(
|
||||
"https://example.com?param=value1¶m=value2",
|
||||
"https://example.com?param=value1¶m=value2",
|
||||
),
|
||||
(
|
||||
"https://example.com?special=%21%40%23%24%25",
|
||||
"https://example.com?special=%21%40%23%24%25",
|
||||
),
|
||||
]
|
||||
|
||||
for original, expected in test_cases:
|
||||
with self.subTest(url=original):
|
||||
result = normalize_url(original)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
@@ -25,6 +25,7 @@ class A11yNavigationFocusTest(LinkdingE2ETestCase):
|
||||
focused_tag = page.evaluate(
|
||||
"document.activeElement?.tagName + '|' + document.activeElement?.name"
|
||||
)
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
self.assertEqual("INPUT|url", focused_tag)
|
||||
|
||||
def test_page_navigation_focus(self):
|
||||
|
||||
@@ -140,8 +140,8 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
# Delete bookmark, verify return url
|
||||
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||
details_modal.get_by_text("Delete...").click()
|
||||
details_modal.get_by_text("Confirm").click()
|
||||
details_modal.get_by_text("Delete").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
# verify bookmark is deleted
|
||||
self.locate_bookmark(bookmark.title)
|
||||
@@ -173,7 +173,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
# Remove snapshot
|
||||
asset_list.get_by_text("Remove", exact=False).click()
|
||||
asset_list.get_by_text("Confirm", exact=False).click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm", exact=False).click()
|
||||
|
||||
# Snapshot is removed
|
||||
expect(snapshot).not_to_be_visible()
|
||||
|
||||
@@ -46,7 +46,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
@@ -84,7 +84,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
@@ -122,7 +122,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
@@ -160,7 +160,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
@@ -291,7 +291,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
# Execute bulk action
|
||||
self.select_bulk_action("Mark as unread")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
@@ -323,7 +323,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.open(reverse("linkding:bookmarks.index"), p)
|
||||
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
@@ -140,7 +140,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread")
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Unread").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("unread")
|
||||
self.assertReloads(0)
|
||||
@@ -156,7 +156,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared")
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Shared").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("shared")
|
||||
self.assertReloads(0)
|
||||
@@ -173,7 +173,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
).click()
|
||||
self.select_bulk_action("Archive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
@@ -191,7 +191,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
@@ -216,7 +216,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.open(reverse("linkding:bookmarks.archived"), p)
|
||||
|
||||
self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("Archived Bookmark 2").get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
@@ -234,7 +234,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
).click()
|
||||
self.select_bulk_action("Unarchive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
@@ -252,7 +252,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
@@ -293,7 +293,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.open(reverse("linkding:bookmarks.shared"), p)
|
||||
|
||||
self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("My Bookmark 2").get_by_text("Confirm").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(
|
||||
[
|
||||
|
||||
@@ -18,28 +18,43 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
||||
enable_public_sharing_label = page.get_by_text(
|
||||
"Enable public bookmark sharing"
|
||||
)
|
||||
default_mark_shared = page.get_by_label(
|
||||
"Create bookmarks as shared by default"
|
||||
)
|
||||
default_mark_shared_label = page.get_by_text(
|
||||
"Create bookmarks as shared by default"
|
||||
)
|
||||
|
||||
# Public sharing is disabled by default
|
||||
# Public sharing and default shared are disabled by default
|
||||
expect(enable_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).to_be_disabled()
|
||||
expect(default_mark_shared).not_to_be_checked()
|
||||
expect(default_mark_shared).to_be_disabled()
|
||||
|
||||
# Enable sharing
|
||||
enable_sharing_label.click()
|
||||
expect(enable_sharing).to_be_checked()
|
||||
expect(enable_public_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).to_be_enabled()
|
||||
expect(default_mark_shared).not_to_be_checked()
|
||||
expect(default_mark_shared).to_be_enabled()
|
||||
|
||||
# Enable public sharing
|
||||
# Enable public sharing and default shared
|
||||
enable_public_sharing_label.click()
|
||||
default_mark_shared_label.click()
|
||||
expect(enable_public_sharing).to_be_checked()
|
||||
expect(enable_public_sharing).to_be_enabled()
|
||||
expect(default_mark_shared).to_be_checked()
|
||||
expect(default_mark_shared).to_be_enabled()
|
||||
|
||||
# Disable sharing
|
||||
enable_sharing_label.click()
|
||||
expect(enable_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).to_be_disabled()
|
||||
expect(default_mark_shared).not_to_be_checked()
|
||||
expect(default_mark_shared).to_be_disabled()
|
||||
|
||||
def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
|
||||
@@ -92,3 +92,6 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
).click()
|
||||
else:
|
||||
self.page.locator("nav").get_by_text(main_menu_item, exact=True).click()
|
||||
|
||||
def locate_confirm_dialog(self):
|
||||
return self.page.locator(".dropdown.confirm-dropdown")
|
||||
|
||||
@@ -49,6 +49,11 @@ urlpatterns = [
|
||||
path("bundles/new", views.bundles.new, name="bundles.new"),
|
||||
path("bundles/<int:bundle_id>/edit", views.bundles.edit, name="bundles.edit"),
|
||||
path("bundles/preview", views.bundles.preview, name="bundles.preview"),
|
||||
# Tags
|
||||
path("tags", views.tags.tags_index, name="tags.index"),
|
||||
path("tags/new", views.tags.tag_new, name="tags.new"),
|
||||
path("tags/<int:tag_id>/edit", views.tags.tag_edit, name="tags.edit"),
|
||||
path("tags/merge", views.tags.tag_merge, name="tags.merge"),
|
||||
# Settings
|
||||
path("settings", views.settings.general, name="settings.index"),
|
||||
path("settings/general", views.settings.general, name="settings.general"),
|
||||
@@ -122,10 +127,9 @@ if settings.LD_ENABLE_OIDC:
|
||||
urlpatterns.append(path("oidc/", include("mozilla_django_oidc.urls")))
|
||||
|
||||
# Debug toolbar
|
||||
if settings.DEBUG:
|
||||
import debug_toolbar
|
||||
|
||||
urlpatterns.append(path("__debug__/", include(debug_toolbar.urls)))
|
||||
# if settings.DEBUG:
|
||||
# import debug_toolbar
|
||||
# urlpatterns.append(path("__debug__/", include(debug_toolbar.urls)))
|
||||
|
||||
# Registration
|
||||
if settings.ALLOW_REGISTRATION:
|
||||
|
||||
@@ -139,3 +139,49 @@ def generate_username(email, claims):
|
||||
else:
|
||||
username = email
|
||||
return unicodedata.normalize("NFKC", username)[:150]
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
if not url or not isinstance(url, str):
|
||||
return ""
|
||||
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return ""
|
||||
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
|
||||
# Normalize the scheme to lowercase
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
# Normalize the netloc (domain) to lowercase
|
||||
netloc = parsed.hostname.lower() if parsed.hostname else ""
|
||||
if parsed.port:
|
||||
netloc += f":{parsed.port}"
|
||||
if parsed.username:
|
||||
auth = parsed.username
|
||||
if parsed.password:
|
||||
auth += f":{parsed.password}"
|
||||
netloc = f"{auth}@{netloc}"
|
||||
|
||||
# Remove trailing slashes from all paths
|
||||
path = parsed.path.rstrip("/") if parsed.path else ""
|
||||
|
||||
# Sort query parameters alphabetically
|
||||
query = ""
|
||||
if parsed.query:
|
||||
query_params = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
||||
query_params.sort(key=lambda x: (x[0], x[1]))
|
||||
query = urllib.parse.urlencode(query_params, quote_via=urllib.parse.quote)
|
||||
|
||||
# Keep fragment as-is
|
||||
fragment = parsed.fragment
|
||||
|
||||
# Reconstruct the normalized URL
|
||||
return urllib.parse.urlunparse(
|
||||
(scheme, netloc, path, parsed.params, query, fragment)
|
||||
)
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return url
|
||||
|
||||
@@ -2,6 +2,7 @@ from .assets import *
|
||||
from .auth import *
|
||||
from .bookmarks import *
|
||||
from . import bundles
|
||||
from . import tags
|
||||
from .settings import *
|
||||
from .toasts import *
|
||||
from .health import health
|
||||
|
||||
151
bookmarks/views/tags.py
Normal file
151
bookmarks/views/tags.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.forms import TagForm, TagMergeForm
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.type_defs import HttpRequest
|
||||
|
||||
|
||||
@login_required
|
||||
def tags_index(request: HttpRequest):
|
||||
if request.method == "POST" and "delete_tag" in request.POST:
|
||||
tag_id = request.POST.get("delete_tag")
|
||||
tag = get_object_or_404(Tag, id=tag_id, owner=request.user)
|
||||
tag_name = tag.name
|
||||
tag.delete()
|
||||
messages.success(request, f'Tag "{tag_name}" deleted successfully.')
|
||||
|
||||
redirect_url = reverse("linkding:tags.index")
|
||||
if request.GET:
|
||||
redirect_url += "?" + request.GET.urlencode()
|
||||
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
|
||||
search = request.GET.get("search", "").strip()
|
||||
unused_only = request.GET.get("unused", "") == "true"
|
||||
sort = request.GET.get("sort", "name-asc")
|
||||
|
||||
tags_queryset = Tag.objects.filter(owner=request.user).annotate(
|
||||
bookmark_count=Count("bookmark")
|
||||
)
|
||||
|
||||
if sort == "name-desc":
|
||||
tags_queryset = tags_queryset.order_by("-name")
|
||||
elif sort == "count-asc":
|
||||
tags_queryset = tags_queryset.order_by("bookmark_count", "name")
|
||||
elif sort == "count-desc":
|
||||
tags_queryset = tags_queryset.order_by("-bookmark_count", "name")
|
||||
else: # Default: name-asc
|
||||
tags_queryset = tags_queryset.order_by("name")
|
||||
total_tags = tags_queryset.count()
|
||||
|
||||
if search:
|
||||
tags_queryset = tags_queryset.filter(name__icontains=search)
|
||||
|
||||
if unused_only:
|
||||
tags_queryset = tags_queryset.filter(bookmark_count=0)
|
||||
|
||||
paginator = Paginator(tags_queryset, 50)
|
||||
page_number = request.GET.get("page")
|
||||
page = paginator.get_page(page_number)
|
||||
|
||||
context = {
|
||||
"page": page,
|
||||
"search": search,
|
||||
"unused_only": unused_only,
|
||||
"sort": sort,
|
||||
"total_tags": total_tags,
|
||||
}
|
||||
|
||||
return render(request, "tags/index.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def tag_new(request: HttpRequest):
|
||||
form_data = request.POST if request.method == "POST" else None
|
||||
form = TagForm(user=request.user, data=form_data)
|
||||
|
||||
if request.method == "POST":
|
||||
if form.is_valid():
|
||||
tag = form.save()
|
||||
messages.success(request, f'Tag "{tag.name}" created successfully.')
|
||||
return HttpResponseRedirect(reverse("linkding:tags.index"))
|
||||
|
||||
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||
return render(request, "tags/new.html", {"form": form}, status=status)
|
||||
|
||||
|
||||
@login_required
|
||||
def tag_edit(request: HttpRequest, tag_id: int):
|
||||
tag = get_object_or_404(Tag, id=tag_id, owner=request.user)
|
||||
form_data = request.POST if request.method == "POST" else None
|
||||
form = TagForm(user=request.user, data=form_data, instance=tag)
|
||||
|
||||
if request.method == "POST":
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, f'Tag "{tag.name}" updated successfully.')
|
||||
return HttpResponseRedirect(reverse("linkding:tags.index"))
|
||||
|
||||
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||
context = {
|
||||
"tag": tag,
|
||||
"form": form,
|
||||
}
|
||||
return render(request, "tags/edit.html", context, status=status)
|
||||
|
||||
|
||||
@login_required
|
||||
def tag_merge(request: HttpRequest):
|
||||
form_data = request.POST if request.method == "POST" else None
|
||||
form = TagMergeForm(user=request.user, data=form_data)
|
||||
|
||||
if request.method == "POST":
|
||||
if form.is_valid():
|
||||
target_tag = form.cleaned_data["target_tag"]
|
||||
merge_tags = form.cleaned_data["merge_tags"]
|
||||
|
||||
with transaction.atomic():
|
||||
BookmarkTag = Bookmark.tags.through
|
||||
|
||||
# Get all bookmarks that have any of the merge tags, but do not
|
||||
# already have the target tag
|
||||
bookmark_ids = list(
|
||||
Bookmark.objects.filter(tags__in=merge_tags)
|
||||
.exclude(tags=target_tag)
|
||||
.values_list("id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Create new relationships to the target tag
|
||||
new_relationships = [
|
||||
BookmarkTag(tag_id=target_tag.id, bookmark_id=bookmark_id)
|
||||
for bookmark_id in bookmark_ids
|
||||
]
|
||||
|
||||
if new_relationships:
|
||||
BookmarkTag.objects.bulk_create(new_relationships)
|
||||
|
||||
# Bulk delete all relationships for merge tags
|
||||
merge_tag_ids = [tag.id for tag in merge_tags]
|
||||
BookmarkTag.objects.filter(tag_id__in=merge_tag_ids).delete()
|
||||
|
||||
# Delete the merged tags
|
||||
tag_names = [tag.name for tag in merge_tags]
|
||||
Tag.objects.filter(id__in=merge_tag_ids).delete()
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f'Successfully merged {len(merge_tags)} tags ({", ".join(tag_names)}) into "{target_tag.name}".',
|
||||
)
|
||||
|
||||
return HttpResponseRedirect(reverse("linkding:tags.index"))
|
||||
|
||||
status = 422 if request.method == "POST" and not form.is_valid() else 200
|
||||
return render(request, "tags/merge.html", {"form": form}, status=status)
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:18-alpine AS node-build
|
||||
FROM node:22-alpine AS node-build
|
||||
WORKDIR /etc/linkding
|
||||
# install build dependencies
|
||||
COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./
|
||||
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.12.9-alpine3.21 AS build-deps
|
||||
FROM python:3.13.7-alpine3.22 AS build-deps
|
||||
# Add required packages
|
||||
# alpine-sdk linux-headers pkgconfig: build Python packages from source
|
||||
# libpq-dev: build Postgres client from source
|
||||
@@ -18,14 +18,12 @@ FROM python:3.12.9-alpine3.21 AS build-deps
|
||||
# libffi-dev openssl-dev rust cargo: build Python cryptography from source
|
||||
RUN apk update && apk add alpine-sdk linux-headers libpq-dev pkgconfig icu-dev sqlite-dev libffi-dev openssl-dev rust cargo
|
||||
WORKDIR /etc/linkding
|
||||
# install uv, use installer script for now as distroless images are not availabe for armv7
|
||||
ADD https://astral.sh/uv/0.8.13/install.sh /uv-installer.sh
|
||||
RUN chmod +x /uv-installer.sh && /uv-installer.sh
|
||||
# install python dependencies
|
||||
COPY requirements.txt requirements.txt
|
||||
# Need to build psycopg2 from source for ARM platforms
|
||||
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
|
||||
RUN mkdir /opt/venv && \
|
||||
python -m venv --upgrade-deps --copies /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip wheel && \
|
||||
/opt/venv/bin/pip install -r requirements.txt
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN /root/.local/bin/uv sync --no-dev --group postgres
|
||||
|
||||
|
||||
FROM build-deps AS compile-icu
|
||||
@@ -49,7 +47,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
||||
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||
|
||||
|
||||
FROM python:3.12.9-alpine3.21 AS linkding
|
||||
FROM python:3.13.7-alpine3.22 AS linkding
|
||||
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||
# install runtime dependencies
|
||||
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
||||
@@ -59,7 +57,7 @@ RUN set -x ; \
|
||||
adduser -u 82 -D -S -G www-data www-data && exit 0 ; exit 1
|
||||
WORKDIR /etc/linkding
|
||||
# copy python dependencies
|
||||
COPY --from=build-deps /opt/venv /opt/venv
|
||||
COPY --from=build-deps /etc/linkding/.venv /etc/linkding/.venv
|
||||
# copy output from node build
|
||||
COPY --from=node-build /etc/linkding/bookmarks/static bookmarks/static/
|
||||
# copy compiled icu extension
|
||||
@@ -67,8 +65,8 @@ COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
|
||||
# copy application code
|
||||
COPY . .
|
||||
# Activate virtual env
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
ENV VIRTUAL_ENV=/etc/linkding/.venv
|
||||
ENV PATH="/etc/linkding/.venv/bin:$PATH"
|
||||
# Generate static files, remove source styles that are not needed
|
||||
RUN mkdir data && \
|
||||
python manage.py collectstatic
|
||||
@@ -87,30 +85,28 @@ CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health ||
|
||||
CMD ["./bootstrap.sh"]
|
||||
|
||||
|
||||
FROM node:18-alpine AS ublock-build
|
||||
FROM node:22-alpine AS ublock-build
|
||||
WORKDIR /etc/linkding
|
||||
# Install necessary tools
|
||||
# Download and unzip the latest uBlock Origin Lite release
|
||||
# Patch manifest to enable annoyances by default
|
||||
# Patch ruleset-manager.js to use rulesets enabled in manifest by default
|
||||
RUN apk add --no-cache curl jq unzip && \
|
||||
TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name') && \
|
||||
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/$TAG.chromium.mv3.zip && \
|
||||
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/uBOLite_$TAG.chromium.zip && \
|
||||
echo "Downloading $DOWNLOAD_URL" && \
|
||||
curl -L -o uBOLite.zip $DOWNLOAD_URL && \
|
||||
unzip uBOLite.zip -d uBOLite.chromium.mv3 && \
|
||||
rm uBOLite.zip && \
|
||||
jq '.declarative_net_request.rule_resources |= map(if .id == "annoyances-overlays" or .id == "annoyances-cookies" or .id == "annoyances-social" or .id == "annoyances-widgets" or .id == "annoyances-others" then .enabled = true else . end)' \
|
||||
uBOLite.chromium.mv3/manifest.json > temp.json && \
|
||||
mv temp.json uBOLite.chromium.mv3/manifest.json && \
|
||||
sed -i 's/const out = \[ '\''default'\'' \];/const out = await dnr.getEnabledRulesets();/' uBOLite.chromium.mv3/js/ruleset-manager.js
|
||||
mv temp.json uBOLite.chromium.mv3/manifest.json
|
||||
|
||||
|
||||
FROM linkding AS linkding-plus
|
||||
# install node, chromium
|
||||
RUN apk update && apk add nodejs npm chromium
|
||||
# install single-file from fork for now, which contains several hotfixes
|
||||
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
|
||||
RUN apk update && apk add nodejs npm chromium-swiftshader
|
||||
# install single-file-cli
|
||||
RUN npm install -g single-file-cli@2.0.75
|
||||
# copy uBlock
|
||||
COPY --from=ublock-build /etc/linkding/uBOLite.chromium.mv3 uBOLite.chromium.mv3/
|
||||
# create chromium profile folder for user running background tasks and set permissions
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:18-alpine AS node-build
|
||||
FROM node:22-alpine AS node-build
|
||||
WORKDIR /etc/linkding
|
||||
# install build dependencies
|
||||
COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./
|
||||
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.12.9-slim-bookworm AS build-deps
|
||||
FROM python:3.13.7-slim-trixie AS build-deps
|
||||
# Add required packages
|
||||
# build-essential pkg-config: build Python packages from source
|
||||
# libpq-dev: build Postgres client from source
|
||||
@@ -20,14 +20,12 @@ RUN apt-get update && apt-get -y install build-essential pkg-config libpq-dev li
|
||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
WORKDIR /etc/linkding
|
||||
# install uv, use installer script for now as distroless images are not availabe for armv7
|
||||
ADD https://astral.sh/uv/0.8.13/install.sh /uv-installer.sh
|
||||
RUN chmod +x /uv-installer.sh && /uv-installer.sh
|
||||
# install python dependencies
|
||||
COPY requirements.txt requirements.txt
|
||||
# Need to build psycopg2 from source for ARM platforms
|
||||
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
|
||||
RUN mkdir /opt/venv && \
|
||||
python -m venv --upgrade-deps --copies /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip wheel && \
|
||||
/opt/venv/bin/pip install -r requirements.txt
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN /root/.local/bin/uv sync --no-dev --group postgres
|
||||
|
||||
|
||||
FROM build-deps AS compile-icu
|
||||
@@ -51,13 +49,13 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
||||
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||
|
||||
|
||||
FROM python:3.12.9-slim-bookworm AS linkding
|
||||
FROM python:3.13.7-slim-trixie AS linkding
|
||||
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||
# install runtime dependencies
|
||||
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
|
||||
RUN apt-get update && apt-get -y install media-types libpq-dev libicu-dev libssl3t64 curl
|
||||
WORKDIR /etc/linkding
|
||||
# copy python dependencies
|
||||
COPY --from=build-deps /opt/venv /opt/venv
|
||||
COPY --from=build-deps /etc/linkding/.venv /etc/linkding/.venv
|
||||
# copy output from node build
|
||||
COPY --from=node-build /etc/linkding/bookmarks/static bookmarks/static/
|
||||
# copy compiled icu extension
|
||||
@@ -65,8 +63,8 @@ COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
|
||||
# copy application code
|
||||
COPY . .
|
||||
# Activate virtual env
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
ENV VIRTUAL_ENV=/etc/linkding/.venv
|
||||
ENV PATH="/etc/linkding/.venv/bin:$PATH"
|
||||
# Generate static files
|
||||
RUN mkdir data && \
|
||||
python manage.py collectstatic
|
||||
@@ -85,36 +83,34 @@ CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health ||
|
||||
CMD ["./bootstrap.sh"]
|
||||
|
||||
|
||||
FROM node:18-alpine AS ublock-build
|
||||
FROM node:22-alpine AS ublock-build
|
||||
WORKDIR /etc/linkding
|
||||
# Install necessary tools
|
||||
# Download and unzip the latest uBlock Origin Lite release
|
||||
# Patch manifest to enable annoyances by default
|
||||
# Patch ruleset-manager.js to use rulesets enabled in manifest by default
|
||||
RUN apk add --no-cache curl jq unzip && \
|
||||
TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name') && \
|
||||
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/$TAG.chromium.mv3.zip && \
|
||||
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/uBOLite_$TAG.chromium.zip && \
|
||||
echo "Downloading $DOWNLOAD_URL" && \
|
||||
curl -L -o uBOLite.zip $DOWNLOAD_URL && \
|
||||
unzip uBOLite.zip -d uBOLite.chromium.mv3 && \
|
||||
rm uBOLite.zip && \
|
||||
jq '.declarative_net_request.rule_resources |= map(if .id == "annoyances-overlays" or .id == "annoyances-cookies" or .id == "annoyances-social" or .id == "annoyances-widgets" or .id == "annoyances-others" then .enabled = true else . end)' \
|
||||
uBOLite.chromium.mv3/manifest.json > temp.json && \
|
||||
mv temp.json uBOLite.chromium.mv3/manifest.json && \
|
||||
sed -i 's/const out = \[ '\''default'\'' \];/const out = await dnr.getEnabledRulesets();/' uBOLite.chromium.mv3/js/ruleset-manager.js
|
||||
mv temp.json uBOLite.chromium.mv3/manifest.json
|
||||
|
||||
|
||||
FROM linkding AS linkding-plus
|
||||
# install chromium
|
||||
RUN apt-get update && apt-get -y install chromium
|
||||
# install node
|
||||
ENV NODE_MAJOR=20
|
||||
ENV NODE_MAJOR=22
|
||||
RUN apt-get install -y gnupg2 apt-transport-https ca-certificates && \
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \
|
||||
apt-get update && apt-get install -y nodejs
|
||||
# install single-file from fork for now, which contains several hotfixes
|
||||
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
|
||||
# install single-file-cli
|
||||
RUN npm install -g single-file-cli@2.0.75
|
||||
# copy uBlock
|
||||
COPY --from=ublock-build /etc/linkding/uBOLite.chromium.mv3 uBOLite.chromium.mv3/
|
||||
# create chromium profile folder for user running background tasks and set permissions
|
||||
|
||||
32
docs/package-lock.json
generated
32
docs/package-lock.json
generated
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.3",
|
||||
"@astrojs/starlight": "^0.34.3",
|
||||
"astro": "^5.12.8",
|
||||
"astro": "^5.13.2",
|
||||
"sharp": "^0.32.5",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
@@ -2047,14 +2047,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/astro": {
|
||||
"version": "5.12.8",
|
||||
"resolved": "https://registry.npmjs.org/astro/-/astro-5.12.8.tgz",
|
||||
"integrity": "sha512-KkJ7FR+c2SyZYlpakm48XBiuQcRsrVtdjG5LN5an0givI/tLik+ePJ4/g3qrAVhYMjJOxBA2YgFQxANPiWB+Mw==",
|
||||
"version": "5.13.2",
|
||||
"resolved": "https://registry.npmjs.org/astro/-/astro-5.13.2.tgz",
|
||||
"integrity": "sha512-yjcXY0Ua3EwjpVd3GoUXa65HQ6qgmURBptA+M9GzE0oYvgfuyM7bIbH8IR/TWIbdefVUJR5b7nZ0oVnMytmyfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/compiler": "^2.12.2",
|
||||
"@astrojs/internal-helpers": "0.7.1",
|
||||
"@astrojs/markdown-remark": "6.3.5",
|
||||
"@astrojs/internal-helpers": "0.7.2",
|
||||
"@astrojs/markdown-remark": "6.3.6",
|
||||
"@astrojs/telemetry": "3.3.0",
|
||||
"@capsizecss/unpack": "^2.4.0",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
@@ -2144,18 +2144,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@astrojs/internal-helpers": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.1.tgz",
|
||||
"integrity": "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.2.tgz",
|
||||
"integrity": "sha512-KCkCqR3Goym79soqEtbtLzJfqhTWMyVaizUi35FLzgGSzBotSw8DB1qwsu7U96ihOJgYhDk2nVPz+3LnXPeX6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/astro/node_modules/@astrojs/markdown-remark": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.5.tgz",
|
||||
"integrity": "sha512-MiR92CkE2BcyWf3b86cBBw/1dKiOH0qhLgXH2OXA6cScrrmmks1Rr4Tl0p/lFpvmgQQrP54Pd1uidJfmxGrpWQ==",
|
||||
"version": "6.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.6.tgz",
|
||||
"integrity": "sha512-bwylYktCTsLMVoCOEHbn2GSUA3c5KT/qilekBKA3CBng0bo1TYjNZPr761vxumRk9kJGqTOtU+fgCAp5Vwokug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/internal-helpers": "0.7.1",
|
||||
"@astrojs/internal-helpers": "0.7.2",
|
||||
"@astrojs/prism": "3.3.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"hast-util-from-html": "^2.0.3",
|
||||
@@ -7125,9 +7125,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"version": "6.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
|
||||
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.3",
|
||||
"@astrojs/starlight": "^0.34.3",
|
||||
"astro": "^5.12.8",
|
||||
"astro": "^5.13.2",
|
||||
"sharp": "^0.32.5",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ Parameters:
|
||||
- `offset` - Index from which to start returning results
|
||||
- `modified_since` - Filter results to only include bookmarks modified after the specified date (format: ISO 8601, e.g. "2025-01-01T00:00:00Z")
|
||||
- `added_since` - Filter results to only include bookmarks added after the specified date (format: ISO 8601, e.g. "2025-05-29T00:00:00Z")
|
||||
- `bundle` - Filter results by bundle id to only include bookmarks matched by a given bundle
|
||||
|
||||
Example response:
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ description: "Community projects around linkding"
|
||||
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md) to add your project to this section.
|
||||
|
||||
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||
- [alfred-linkding-bookmarks](https://github.com/firefingers21/alfred-linkding-bookmarks) An Alfred workflow for searching and opening linkding bookmarks. By [FireFingers21](https://github.com/FireFingers21)
|
||||
- [cosmicding](https://github.com/vkhitrin/cosmicding) Desktop client built using [libcosmic](https://github.com/pop-os/libcosmic). By [vkhitrin](https://github.com/vkhitrin)
|
||||
- [DingDrop](https://github.com/marb08/DingDrop) A Telegram bot that allows you to quickly save bookmarks to your Linkding instance via Telegram using Linkding APIs. By [marb08](https://github.com/marb08)
|
||||
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
|
||||
@@ -30,6 +31,6 @@ This section lists community projects around using linkding, in alphabetical ord
|
||||
- [linktiles](https://github.com/haondt/linktiles) A web app that displays your links as tiles in a configurable mosaic. By [haondt](https://github.com/haondt)
|
||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
|
||||
- [Pocket2Linkding](https://github.com/hkclark/Pocket2Linkding/) A tool to migrate from Mozilla Pocket to lingding. Preserves the date the link was added to pocket and any tags.
|
||||
- [Pocket2Linkding](https://github.com/hkclark/Pocket2Linkding/) A tool to migrate from Mozilla Pocket to linkding. Preserves the date the link was added to pocket and any tags.
|
||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||
- [serchding](https://github.com/ldwgchen/serchding) Full-text search for linkding. By [ldwgchen](https://github.com/ldwgchen)
|
||||
|
||||
1608
package-lock.json
generated
1608
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -23,16 +23,15 @@
|
||||
"homepage": "https://github.com/sissbruecker/linkding#readme",
|
||||
"dependencies": {
|
||||
"@hotwired/turbo": "^8.0.6",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/wasm-node": "^4.13.0",
|
||||
"cssnano": "^7.0.6",
|
||||
"lit": "^3.3.1",
|
||||
"postcss": "^8.4.45",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-nesting": "^13.0.0",
|
||||
"rollup-plugin-svelte": "^7.2.0",
|
||||
"svelte": "^4.0.0"
|
||||
"postcss-nesting": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.3.3"
|
||||
|
||||
49
pyproject.toml
Normal file
49
pyproject.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[project]
|
||||
name = "linkding"
|
||||
version = "1.42.0"
|
||||
description = "Self-hosted bookmark manager that is designed be to be minimal, fast, and easy to set up using Docker."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"beautifulsoup4>=4.12.3",
|
||||
"bleach>=6.1.0",
|
||||
"bleach-allowlist>=1.0.3",
|
||||
"django>=5.2.3",
|
||||
"django-registration>=3.4",
|
||||
"django-widget-tweaks>=1.5.0",
|
||||
"djangorestframework>=3.15.2",
|
||||
"huey>=2.5.1",
|
||||
"markdown>=3.7",
|
||||
"mozilla-django-oidc>=4.0.1",
|
||||
"python-dateutil>=2.9.0.post0",
|
||||
"requests>=2.32.4",
|
||||
"supervisor>=4.2.5",
|
||||
"uwsgi>=2.0.28",
|
||||
"waybackpy>=3.0.6",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"black>=25.1.0",
|
||||
"coverage>=7.10.4",
|
||||
"django-debug-toolbar>=6.0.0",
|
||||
"playwright>=1.54.0",
|
||||
"psycopg[binary]>=3.2.9",
|
||||
"pytest>=8.4.1",
|
||||
"pytest-django>=4.11.1",
|
||||
"pytest-xdist>=3.8.0",
|
||||
]
|
||||
# For PostgreSQL support, use the binary release for development so that not
|
||||
# everyone needs to build from source. For production, use a separate dependency
|
||||
# group that builds the driver from source. uv also needs to build it from
|
||||
# source to update the lockfile, which requires libpq. On macOS:
|
||||
# - brew install libpq
|
||||
# - export PATH="/opt/homebrew/opt/libpq/bin:$PATH"
|
||||
# - uv add --group postgres psycopg[c]
|
||||
postgres = [
|
||||
"psycopg[c]>=3.2.9",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
# Prefer system Python for now, less complications when copying the venv in the Docker build
|
||||
python-preference = "system"
|
||||
@@ -1,7 +0,0 @@
|
||||
black
|
||||
coverage
|
||||
django-debug-toolbar
|
||||
playwright
|
||||
pytest
|
||||
pytest-django
|
||||
pytest-xdist
|
||||
@@ -1,55 +0,0 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile requirements.dev.in
|
||||
#
|
||||
asgiref==3.8.1
|
||||
# via django
|
||||
black==24.8.0
|
||||
# via -r requirements.dev.in
|
||||
click==8.1.7
|
||||
# via black
|
||||
coverage==7.6.1
|
||||
# via -r requirements.dev.in
|
||||
django==5.2.3
|
||||
# via django-debug-toolbar
|
||||
django-debug-toolbar==5.2.0
|
||||
# via -r requirements.dev.in
|
||||
execnet==2.1.1
|
||||
# via pytest-xdist
|
||||
greenlet==3.0.3
|
||||
# via playwright
|
||||
iniconfig==2.0.0
|
||||
# via pytest
|
||||
mypy-extensions==1.0.0
|
||||
# via black
|
||||
packaging==24.1
|
||||
# via
|
||||
# black
|
||||
# pytest
|
||||
pathspec==0.12.1
|
||||
# via black
|
||||
platformdirs==4.3.6
|
||||
# via black
|
||||
playwright==1.47.0
|
||||
# via -r requirements.dev.in
|
||||
pluggy==1.5.0
|
||||
# via pytest
|
||||
pyee==12.0.0
|
||||
# via playwright
|
||||
pytest==8.3.3
|
||||
# via
|
||||
# -r requirements.dev.in
|
||||
# pytest-django
|
||||
# pytest-xdist
|
||||
pytest-django==4.9.0
|
||||
# via -r requirements.dev.in
|
||||
pytest-xdist==3.6.1
|
||||
# via -r requirements.dev.in
|
||||
sqlparse==0.5.1
|
||||
# via
|
||||
# django
|
||||
# django-debug-toolbar
|
||||
typing-extensions==4.12.2
|
||||
# via pyee
|
||||
@@ -1,16 +0,0 @@
|
||||
beautifulsoup4
|
||||
bleach
|
||||
bleach-allowlist
|
||||
Django
|
||||
django-registration
|
||||
django-widget-tweaks
|
||||
djangorestframework
|
||||
huey
|
||||
Markdown
|
||||
mozilla-django-oidc
|
||||
psycopg2-binary
|
||||
python-dateutil
|
||||
requests
|
||||
supervisor
|
||||
uWSGI
|
||||
waybackpy
|
||||
@@ -1,87 +0,0 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile
|
||||
#
|
||||
asgiref==3.8.1
|
||||
# via django
|
||||
beautifulsoup4==4.12.3
|
||||
# via -r requirements.in
|
||||
bleach==6.1.0
|
||||
# via -r requirements.in
|
||||
bleach-allowlist==1.0.3
|
||||
# via -r requirements.in
|
||||
certifi==2024.8.30
|
||||
# via requests
|
||||
cffi==1.17.1
|
||||
# via cryptography
|
||||
charset-normalizer==3.3.2
|
||||
# via requests
|
||||
click==8.1.7
|
||||
# via waybackpy
|
||||
confusable-homoglyphs==3.3.1
|
||||
# via django-registration
|
||||
cryptography==43.0.1
|
||||
# via
|
||||
# josepy
|
||||
# mozilla-django-oidc
|
||||
# pyopenssl
|
||||
django==5.2.3
|
||||
# via
|
||||
# -r requirements.in
|
||||
# django-registration
|
||||
# djangorestframework
|
||||
# mozilla-django-oidc
|
||||
django-registration==3.4
|
||||
# via -r requirements.in
|
||||
django-widget-tweaks==1.5.0
|
||||
# via -r requirements.in
|
||||
djangorestframework==3.15.2
|
||||
# via -r requirements.in
|
||||
huey==2.5.1
|
||||
# via -r requirements.in
|
||||
idna==3.10
|
||||
# via requests
|
||||
josepy==1.14.0
|
||||
# via mozilla-django-oidc
|
||||
markdown==3.7
|
||||
# via -r requirements.in
|
||||
mozilla-django-oidc==4.0.1
|
||||
# via -r requirements.in
|
||||
psycopg2-binary==2.9.9
|
||||
# via -r requirements.in
|
||||
pycparser==2.22
|
||||
# via cffi
|
||||
pyopenssl==24.2.1
|
||||
# via josepy
|
||||
python-dateutil==2.9.0.post0
|
||||
# via -r requirements.in
|
||||
requests==2.32.4
|
||||
# via
|
||||
# -r requirements.in
|
||||
# mozilla-django-oidc
|
||||
# waybackpy
|
||||
six==1.16.0
|
||||
# via
|
||||
# bleach
|
||||
# python-dateutil
|
||||
soupsieve==2.6
|
||||
# via beautifulsoup4
|
||||
sqlparse==0.5.1
|
||||
# via django
|
||||
supervisor==4.2.5
|
||||
# via -r requirements.in
|
||||
urllib3==2.5.0
|
||||
# via
|
||||
# requests
|
||||
# waybackpy
|
||||
uwsgi==2.0.28
|
||||
# via -r requirements.in
|
||||
waybackpy==3.0.6
|
||||
# via -r requirements.in
|
||||
webencodings==0.5.1
|
||||
# via bleach
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# setuptools
|
||||
@@ -1,4 +1,3 @@
|
||||
import svelte from 'rollup-plugin-svelte';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
|
||||
@@ -14,10 +13,6 @@ export default {
|
||||
file: 'bookmarks/static/bundle.js',
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
emitCss: false,
|
||||
}),
|
||||
|
||||
// If you have external dependencies installed from
|
||||
// npm, you'll most likely need these plugins. In
|
||||
// some cases you'll need additional configuration —
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
coverage erase
|
||||
coverage run manage.py test
|
||||
coverage report --sort=cover
|
||||
uv run coverage erase
|
||||
uv run coverage run manage.py test
|
||||
uv run coverage report --sort=cover
|
||||
|
||||
@@ -24,6 +24,6 @@ export LD_DB_PASSWORD=linkding
|
||||
export LD_SUPERUSER_NAME=admin
|
||||
export LD_SUPERUSER_PASSWORD=admin
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py create_initial_superuser
|
||||
python manage.py runserver
|
||||
uv run manage.py migrate
|
||||
uv run manage.py create_initial_superuser
|
||||
uv run manage.py runserver
|
||||
|
||||
@@ -2,15 +2,12 @@ rm -rf uBOLite.chromium.mv3
|
||||
|
||||
# Download uBlock Origin Lite
|
||||
TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name')
|
||||
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/$TAG.chromium.mv3.zip
|
||||
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/uBOLite_$TAG.chromium.zip
|
||||
echo "Downloading $DOWNLOAD_URL"
|
||||
curl -L -o uBOLite.zip $DOWNLOAD_URL
|
||||
unzip uBOLite.zip -d uBOLite.chromium.mv3
|
||||
rm uBOLite.zip
|
||||
|
||||
# Patch uBlock Origin Lite to respect rulesets enabled in manifest.json
|
||||
sed -i '' "s/const out = \[ 'default' \];/const out = await dnr.getEnabledRulesets();/" uBOLite.chromium.mv3/js/ruleset-manager.js
|
||||
|
||||
# Enable annoyances rulesets in manifest.json
|
||||
jq '.declarative_net_request.rule_resources |= map(if .id == "annoyances-overlays" or .id == "annoyances-cookies" or .id == "annoyances-social" or .id == "annoyances-widgets" or .id == "annoyances-others" then .enabled = true else . end)' uBOLite.chromium.mv3/manifest.json > temp.json
|
||||
mv temp.json uBOLite.chromium.mv3/manifest.json
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Make sure Chromium is installed
|
||||
playwright install chromium
|
||||
uv run playwright install chromium
|
||||
|
||||
# Test server loads assets from static folder, so make sure files there are up-to-date
|
||||
rm -rf static
|
||||
npm run build
|
||||
python manage.py collectstatic
|
||||
uv run manage.py collectstatic
|
||||
|
||||
# Run E2E tests
|
||||
python manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"
|
||||
uv run manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user