Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c919e79759 | ||
|
|
8ff9b42a79 | ||
|
|
4280ab40c6 | ||
|
|
db1906942a | ||
|
|
69877a32e5 | ||
|
|
e5a9a772f0 | ||
|
|
2f56d418cf | ||
|
|
a4df586a8a | ||
|
|
d9b7996e06 | ||
|
|
92f62d3ded | ||
|
|
9c48085829 | ||
|
|
77e1525402 | ||
|
|
9df80e01de | ||
|
|
ec34cc523f | ||
|
|
eb0b092d17 | ||
|
|
39e8f03345 | ||
|
|
d43b97e0c0 | ||
|
|
d6484ba8e9 | ||
|
|
4c26d66177 | ||
|
|
c51dcafa40 | ||
|
|
262dd2b28f | ||
|
|
01ad7f4d9e | ||
|
|
d0d5c15345 | ||
|
|
afb752765d | ||
|
|
ce213775b6 | ||
|
|
fd1bbadcf3 | ||
|
|
83c2530df4 | ||
|
|
39782e75e7 | ||
|
|
4bee104b62 | ||
|
|
f4ecffbb7f | ||
|
|
6f52bafda8 | ||
|
|
2deecc5c91 | ||
|
|
54cfa13861 | ||
|
|
ee4f99261f |
@@ -13,7 +13,7 @@
|
|||||||
!/package-lock.json
|
!/package-lock.json
|
||||||
!/requirements.dev.txt
|
!/requirements.dev.txt
|
||||||
!/requirements.txt
|
!/requirements.txt
|
||||||
!/rollup.config.js
|
!/rollup.config.mjs
|
||||||
!/supervisord.conf
|
!/supervisord.conf
|
||||||
!/uwsgi.ini
|
!/uwsgi.ini
|
||||||
!/version.txt
|
!/version.txt
|
||||||
|
|||||||
28
.github/workflows/main.yaml
vendored
@@ -1,23 +1,28 @@
|
|||||||
name: linkding CI
|
name: linkding CI
|
||||||
|
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit_tests:
|
unit_tests:
|
||||||
name: Unit Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
- name: Install Node dependencies
|
- name: Install Node dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
- name: Setup Python environment
|
- name: Setup Python environment
|
||||||
run: pip install -r requirements.txt -r requirements.dev.txt
|
run: pip install -r requirements.txt -r requirements.dev.txt
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
@@ -26,17 +31,18 @@ jobs:
|
|||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
- name: Install Node dependencies
|
- name: Install Node dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
- name: Setup Python environment
|
- name: Setup Python environment
|
||||||
run: |
|
run: |
|
||||||
pip install -r requirements.txt -r requirements.dev.txt
|
pip install -r requirements.txt -r requirements.dev.txt
|
||||||
|
|||||||
62
CHANGELOG.md
@@ -1,5 +1,67 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.26.0 (30/03/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add option for showing bookmark description as separate block by @sissbruecker in https://github.com/sissbruecker/linkding/pull/663
|
||||||
|
* Add bookmark details view by @sissbruecker in https://github.com/sissbruecker/linkding/pull/665
|
||||||
|
* Make bookmark list actions configurable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/666
|
||||||
|
* Bump black from 24.1.1 to 24.3.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/662
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.25.0...v1.26.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.25.0 (18/03/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Improve PWA capabilities by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/630
|
||||||
|
* build improvements by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/649
|
||||||
|
* Add support for oidc by @Nighmared in https://github.com/sissbruecker/linkding/pull/389
|
||||||
|
* Add option for custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/652
|
||||||
|
* Update backup location to safe directory by @bphenriques in https://github.com/sissbruecker/linkding/pull/653
|
||||||
|
* Include web archive link in /api/bookmarks/ by @sissbruecker in https://github.com/sissbruecker/linkding/pull/655
|
||||||
|
* Add RSS feeds for shared bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/656
|
||||||
|
* Bump django from 5.0.2 to 5.0.3 by @dependabot in https://github.com/sissbruecker/linkding/pull/658
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @hugo-vrijswijk made their first contribution in https://github.com/sissbruecker/linkding/pull/630
|
||||||
|
* @Nighmared made their first contribution in https://github.com/sissbruecker/linkding/pull/389
|
||||||
|
* @bphenriques made their first contribution in https://github.com/sissbruecker/linkding/pull/653
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.2...v1.25.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.24.2 (16/03/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Fix logout button by @sissbruecker in https://github.com/sissbruecker/linkding/pull/648
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.1...v1.24.2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.24.1 (16/03/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/618
|
||||||
|
* Persist secret key in data folder by @sissbruecker in https://github.com/sissbruecker/linkding/pull/620
|
||||||
|
* Group ideographic characters in tag cloud by @jonathan-s in https://github.com/sissbruecker/linkding/pull/613
|
||||||
|
* Bump django from 5.0.1 to 5.0.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/625
|
||||||
|
* Add k8s setup to community section by @jzck in https://github.com/sissbruecker/linkding/pull/633
|
||||||
|
* Added a new Linkding client to community section by @JGeek00 in https://github.com/sissbruecker/linkding/pull/638
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @jzck made their first contribution in https://github.com/sissbruecker/linkding/pull/633
|
||||||
|
* @JGeek00 made their first contribution in https://github.com/sissbruecker/linkding/pull/638
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.0...v1.24.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.24.0 (27/01/2024)
|
## v1.24.0 (27/01/2024)
|
||||||
|
|
||||||
### What's Changed
|
### What's Changed
|
||||||
|
|||||||
69
README.md
@@ -33,18 +33,16 @@ The name comes from:
|
|||||||
**Feature Overview:**
|
**Feature Overview:**
|
||||||
- Clean UI optimized for readability
|
- Clean UI optimized for readability
|
||||||
- Organize bookmarks with tags
|
- Organize bookmarks with tags
|
||||||
- Add notes using Markdown
|
- Bulk editing, Markdown notes, read it later functionality
|
||||||
- Read it later functionality
|
- Share bookmarks with other users or guests
|
||||||
- Share bookmarks with other users
|
|
||||||
- Bulk editing
|
|
||||||
- Automatically provides titles, descriptions and icons of bookmarked websites
|
- Automatically provides titles, descriptions and icons of bookmarked websites
|
||||||
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
|
- Automatically archive websites, either as local HTML file or on Internet Archive
|
||||||
- Import and export bookmarks in Netscape HTML format
|
- Import and export bookmarks in Netscape HTML format
|
||||||
|
- Installable as a Progressive Web App (PWA)
|
||||||
- Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
|
- Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
|
||||||
- Light and dark themes
|
- SSO support via OIDC or authentication proxies
|
||||||
- REST API for developing 3rd party apps
|
- REST API for developing 3rd party apps
|
||||||
- Admin panel for user self-service and raw data access
|
- Admin panel for user self-service and raw data access
|
||||||
- Easy setup using Docker and a SQLite database, with PostgreSQL as an option
|
|
||||||
|
|
||||||
|
|
||||||
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)
|
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)
|
||||||
@@ -61,27 +59,45 @@ The Docker image is compatible with ARM platforms, so it can be run on a Raspber
|
|||||||
linkding uses an SQLite database by default.
|
linkding uses an SQLite database by default.
|
||||||
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
|
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
|
||||||
|
|
||||||
<details>
|
|
||||||
|
|
||||||
<summary>🧪 Alpine-based image</summary>
|
|
||||||
|
|
||||||
The default Docker image (`latest` tag) is based on a slim variant of Debian Linux.
|
|
||||||
Alternatively, there is an image based on Alpine Linux (`latest-alpine` tag) which has a smaller size, resulting in a smaller download and less disk space required.
|
|
||||||
The Alpine image is currently about 45 MB in compressed size, compared to about 130 MB for the Debian image.
|
|
||||||
|
|
||||||
To use it, replace the `latest` tag with `latest-alpine`, either in the CLI command below when using Docker, or in the `docker-compose.yml` file when using docker-compose.
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> The image is currently considered experimental in order to gather feedback and iron out any issues.
|
|
||||||
> Only use it if you are comfortable running experimental software or want to help out with testing.
|
|
||||||
> While there should be no issues with creating new installations, there might be issues when migrating existing installations.
|
|
||||||
> If you plan to migrate your existing installation, make sure to create proper [backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) first.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### Using Docker
|
### Using Docker
|
||||||
|
|
||||||
To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
|
The Docker image comes in several variants. To use a different image than the default, replace `latest` with the desired tag in the commands below, or in the docker-compose file.
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tag</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>latest</code></td>
|
||||||
|
<td>Provides the basic functionality of linkding</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>latest-plus</code></td>
|
||||||
|
<td>
|
||||||
|
Includes feature for archiving websites as HTML snapshots
|
||||||
|
<ul>
|
||||||
|
<li>Significantly larger image size as it includes a Chromium installation</li>
|
||||||
|
<li>Requires more runtime memory to run Chromium</li>
|
||||||
|
<li>Requires more disk space for storing HTML snapshots</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>latest-alpine</code></td>
|
||||||
|
<td><code>latest</code>, but based on Alpine Linux. 🧪 Experimental</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>latest-plus-alpine</code></td>
|
||||||
|
<td><code>latest-plus</code>, but based on Alpine Linux. 🧪 Experimental</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
To install linkding using Docker you can just run the image from [Docker Hub](https://hub.docker.com/repository/docker/sissbruecker/linkding):
|
||||||
```shell
|
```shell
|
||||||
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
||||||
```
|
```
|
||||||
@@ -183,6 +199,7 @@ Self-hosting web applications still requires a lot of technical know-how and com
|
|||||||
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
|
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
|
||||||
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods))
|
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods))
|
||||||
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
|
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
|
||||||
|
- [linkding on railway.app](https://github.com/tianheg/linkding-on-railway) - Guide for hosting a linkding installation on [railway.app](https://railway.app/). By [tianheg](https://github.com/tianheg)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
"notes",
|
"notes",
|
||||||
"website_title",
|
"website_title",
|
||||||
"website_description",
|
"website_description",
|
||||||
|
"web_archive_snapshot_url",
|
||||||
"is_archived",
|
"is_archived",
|
||||||
"unread",
|
"unread",
|
||||||
"shared",
|
"shared",
|
||||||
@@ -40,6 +41,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"website_title",
|
"website_title",
|
||||||
"website_description",
|
"website_description",
|
||||||
|
"web_archive_snapshot_url",
|
||||||
"date_added",
|
"date_added",
|
||||||
"date_modified",
|
"date_modified",
|
||||||
]
|
]
|
||||||
|
|||||||
133
bookmarks/e2e/e2e_test_bookmark_details_modal.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
from bookmarks.models import Bookmark
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_show_details(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
title = details_modal.locator("h2")
|
||||||
|
expect(title).to_have_text(bookmark.title)
|
||||||
|
|
||||||
|
def test_close_details(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
# close with close button
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
details_modal.locator("button.close").click()
|
||||||
|
expect(details_modal).to_be_hidden()
|
||||||
|
|
||||||
|
# close with backdrop
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
overlay = details_modal.locator(".modal-overlay")
|
||||||
|
overlay.click(position={"x": 0, "y": 0})
|
||||||
|
expect(details_modal).to_be_hidden()
|
||||||
|
|
||||||
|
def test_toggle_archived(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# archive
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
details_modal.get_by_text("Archived", exact=False).click()
|
||||||
|
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||||
|
|
||||||
|
# unarchive
|
||||||
|
url = reverse("bookmarks:archived")
|
||||||
|
self.page.goto(self.live_server_url + url)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
details_modal.get_by_text("Archived", exact=False).click()
|
||||||
|
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||||
|
|
||||||
|
def test_toggle_unread(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# mark as unread
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
|
||||||
|
details_modal.get_by_text("Unread").click()
|
||||||
|
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||||
|
expect(bookmark_item.get_by_text("Unread")).to_be_visible()
|
||||||
|
|
||||||
|
# mark as read
|
||||||
|
details_modal.get_by_text("Unread").click()
|
||||||
|
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||||
|
expect(bookmark_item.get_by_text("Unread")).not_to_be_visible()
|
||||||
|
|
||||||
|
def test_toggle_shared(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# share bookmark
|
||||||
|
url = reverse("bookmarks:index")
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
|
||||||
|
details_modal.get_by_text("Shared").click()
|
||||||
|
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||||
|
expect(bookmark_item.get_by_text("Shared")).to_be_visible()
|
||||||
|
|
||||||
|
# unshare bookmark
|
||||||
|
details_modal.get_by_text("Shared").click()
|
||||||
|
bookmark_item = self.locate_bookmark(bookmark.title)
|
||||||
|
expect(bookmark_item.get_by_text("Shared")).not_to_be_visible()
|
||||||
|
|
||||||
|
def test_edit_return_url(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
|
||||||
|
# Navigate to edit page
|
||||||
|
with self.page.expect_navigation():
|
||||||
|
details_modal.get_by_text("Edit").click()
|
||||||
|
|
||||||
|
# Cancel edit, verify return url
|
||||||
|
with self.page.expect_navigation(url=self.live_server_url + url):
|
||||||
|
self.page.get_by_text("Nevermind").click()
|
||||||
|
|
||||||
|
def test_delete(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
|
||||||
|
self.open(url, p)
|
||||||
|
|
||||||
|
details_modal = self.open_details_modal(bookmark)
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# verify bookmark is deleted
|
||||||
|
self.locate_bookmark(bookmark.title)
|
||||||
|
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||||
|
|
||||||
|
self.assertEqual(Bookmark.objects.count(), 0)
|
||||||
37
bookmarks/e2e/e2e_test_bookmark_details_view.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_edit_return_url(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
||||||
|
|
||||||
|
# Navigate to edit page
|
||||||
|
with self.page.expect_navigation():
|
||||||
|
self.page.get_by_text("Edit").click()
|
||||||
|
|
||||||
|
# Cancel edit, verify return url
|
||||||
|
with self.page.expect_navigation(
|
||||||
|
url=self.live_server_url
|
||||||
|
+ reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
):
|
||||||
|
self.page.get_by_text("Nevermind").click()
|
||||||
|
|
||||||
|
def test_delete_return_url(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
|
||||||
|
|
||||||
|
# Trigger delete, verify return url
|
||||||
|
# Should probably return to last bookmark list page, but for now just returns to index
|
||||||
|
with self.page.expect_navigation(
|
||||||
|
url=self.live_server_url + reverse("bookmarks:index")
|
||||||
|
):
|
||||||
|
self.page.get_by_text("Delete...").click()
|
||||||
|
self.page.get_by_text("Confirm").click()
|
||||||
@@ -39,6 +39,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
|||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
self.open(reverse("bookmarks:index"), p)
|
self.open(reverse("bookmarks:index"), p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
self.locate_bulk_edit_toggle().click()
|
self.locate_bulk_edit_toggle().click()
|
||||||
self.locate_bulk_edit_select_all().click()
|
self.locate_bulk_edit_select_all().click()
|
||||||
self.locate_bulk_edit_select_across().click()
|
self.locate_bulk_edit_select_across().click()
|
||||||
@@ -46,6 +47,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
|||||||
self.select_bulk_action("Delete")
|
self.select_bulk_action("Delete")
|
||||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||||
|
expect(bookmark_list).not_to_be_visible()
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
0,
|
0,
|
||||||
@@ -74,6 +77,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
|||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
self.open(reverse("bookmarks:archived"), p)
|
self.open(reverse("bookmarks:archived"), p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
self.locate_bulk_edit_toggle().click()
|
self.locate_bulk_edit_toggle().click()
|
||||||
self.locate_bulk_edit_select_all().click()
|
self.locate_bulk_edit_select_all().click()
|
||||||
self.locate_bulk_edit_select_across().click()
|
self.locate_bulk_edit_select_across().click()
|
||||||
@@ -81,6 +85,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
|||||||
self.select_bulk_action("Delete")
|
self.select_bulk_action("Delete")
|
||||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||||
|
expect(bookmark_list).not_to_be_visible()
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
50,
|
50,
|
||||||
@@ -109,6 +115,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
|||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
self.open(reverse("bookmarks:index") + "?q=foo", p)
|
self.open(reverse("bookmarks:index") + "?q=foo", p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
self.locate_bulk_edit_toggle().click()
|
self.locate_bulk_edit_toggle().click()
|
||||||
self.locate_bulk_edit_select_all().click()
|
self.locate_bulk_edit_select_all().click()
|
||||||
self.locate_bulk_edit_select_across().click()
|
self.locate_bulk_edit_select_across().click()
|
||||||
@@ -116,6 +123,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
|||||||
self.select_bulk_action("Delete")
|
self.select_bulk_action("Delete")
|
||||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||||
|
expect(bookmark_list).not_to_be_visible()
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
50,
|
50,
|
||||||
@@ -144,6 +153,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
|||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
self.open(reverse("bookmarks:archived") + "?q=foo", p)
|
self.open(reverse("bookmarks:archived") + "?q=foo", p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
self.locate_bulk_edit_toggle().click()
|
self.locate_bulk_edit_toggle().click()
|
||||||
self.locate_bulk_edit_select_all().click()
|
self.locate_bulk_edit_select_all().click()
|
||||||
self.locate_bulk_edit_select_across().click()
|
self.locate_bulk_edit_select_across().click()
|
||||||
@@ -151,6 +161,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
|||||||
self.select_bulk_action("Delete")
|
self.select_bulk_action("Delete")
|
||||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||||
|
expect(bookmark_list).not_to_be_visible()
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
50,
|
50,
|
||||||
@@ -269,14 +281,13 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
|||||||
url = reverse("bookmarks:index")
|
url = reverse("bookmarks:index")
|
||||||
page = self.open(url, p)
|
page = self.open(url, p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
|
|
||||||
# Select all bookmarks, enable select across
|
# Select all bookmarks, enable select across
|
||||||
self.locate_bulk_edit_toggle().click()
|
self.locate_bulk_edit_toggle().click()
|
||||||
self.locate_bulk_edit_select_all().click()
|
self.locate_bulk_edit_select_all().click()
|
||||||
self.locate_bulk_edit_select_across().click()
|
self.locate_bulk_edit_select_across().click()
|
||||||
|
|
||||||
# Get reference for bookmark list
|
|
||||||
bookmark_list = page.locator("ul[ld-bookmark-list]")
|
|
||||||
|
|
||||||
# Execute bulk action
|
# Execute bulk action
|
||||||
self.select_bulk_action("Mark as unread")
|
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("Execute").click()
|
||||||
@@ -302,6 +313,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
|||||||
url = reverse("bookmarks:index")
|
url = reverse("bookmarks:index")
|
||||||
self.open(url, p)
|
self.open(url, p)
|
||||||
|
|
||||||
|
bookmark_list = self.locate_bookmark_list()
|
||||||
self.locate_bulk_edit_toggle().click()
|
self.locate_bulk_edit_toggle().click()
|
||||||
self.locate_bulk_edit_select_all().click()
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
|
||||||
@@ -312,7 +324,10 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
|||||||
self.select_bulk_action("Delete")
|
self.select_bulk_action("Delete")
|
||||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||||
|
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||||
|
expect(bookmark_list).not_to_be_visible()
|
||||||
|
|
||||||
|
expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
|
||||||
self.locate_bulk_edit_select_all().click()
|
self.locate_bulk_edit_select_all().click()
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from django.urls import reverse
|
|||||||
from playwright.sync_api import sync_playwright, expect
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
from bookmarks.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
||||||
@@ -39,3 +40,49 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
|||||||
expect(enable_sharing).not_to_be_checked()
|
expect(enable_sharing).not_to_be_checked()
|
||||||
expect(enable_public_sharing).not_to_be_checked()
|
expect(enable_public_sharing).not_to_be_checked()
|
||||||
expect(enable_public_sharing).to_be_disabled()
|
expect(enable_public_sharing).to_be_disabled()
|
||||||
|
|
||||||
|
def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.bookmark_description_display = (
|
||||||
|
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
|
||||||
|
)
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||||
|
|
||||||
|
max_lines = page.get_by_label("Bookmark description max lines")
|
||||||
|
expect(max_lines).to_be_hidden()
|
||||||
|
|
||||||
|
def test_should_show_bookmark_description_max_lines_when_display_separate(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.bookmark_description_display = (
|
||||||
|
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
|
||||||
|
)
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||||
|
|
||||||
|
max_lines = page.get_by_label("Bookmark description max lines")
|
||||||
|
expect(max_lines).to_be_visible()
|
||||||
|
|
||||||
|
def test_should_update_bookmark_description_max_lines_when_changing_display(self):
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||||
|
|
||||||
|
max_lines = page.get_by_label("Bookmark description max lines")
|
||||||
|
expect(max_lines).to_be_hidden()
|
||||||
|
|
||||||
|
display = page.get_by_label("Bookmark description", exact=True)
|
||||||
|
display.select_option("separate")
|
||||||
|
expect(max_lines).to_be_visible()
|
||||||
|
|
||||||
|
display.select_option("inline")
|
||||||
|
expect(max_lines).to_be_hidden()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.contrib.staticfiles.testing import LiveServerTestCase
|
from django.contrib.staticfiles.testing import LiveServerTestCase
|
||||||
from playwright.sync_api import BrowserContext, Playwright, Page
|
from playwright.sync_api import BrowserContext, Playwright, Page
|
||||||
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
@@ -38,10 +39,25 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
|||||||
def assertReloads(self, count: int):
|
def assertReloads(self, count: int):
|
||||||
self.assertEqual(self.num_loads, count)
|
self.assertEqual(self.num_loads, count)
|
||||||
|
|
||||||
|
def locate_bookmark_list(self):
|
||||||
|
return self.page.locator("ul[ld-bookmark-list]")
|
||||||
|
|
||||||
def locate_bookmark(self, title: str):
|
def locate_bookmark(self, title: str):
|
||||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||||
return bookmark_tags.filter(has_text=title)
|
return bookmark_tags.filter(has_text=title)
|
||||||
|
|
||||||
|
def locate_details_modal(self):
|
||||||
|
return self.page.locator(".modal.bookmark-details")
|
||||||
|
|
||||||
|
def open_details_modal(self, bookmark):
|
||||||
|
details_button = self.locate_bookmark(bookmark.title).get_by_text("View")
|
||||||
|
details_button.click()
|
||||||
|
|
||||||
|
details_modal = self.locate_details_modal()
|
||||||
|
expect(details_modal).to_be_visible()
|
||||||
|
|
||||||
|
return details_modal
|
||||||
|
|
||||||
def locate_bulk_edit_bar(self):
|
def locate_bulk_edit_bar(self):
|
||||||
return self.page.locator(".bulk-edit-bar")
|
return self.page.locator(".bulk-edit-bar")
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ from django.db.models import QuerySet
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken
|
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FeedContext:
|
class FeedContext:
|
||||||
feed_token: FeedToken
|
feed_token: FeedToken | None
|
||||||
query_set: QuerySet[Bookmark]
|
query_set: QuerySet[Bookmark]
|
||||||
|
|
||||||
|
|
||||||
@@ -67,3 +67,39 @@ class UnreadBookmarksFeed(BaseBookmarksFeed):
|
|||||||
|
|
||||||
def items(self, context: FeedContext):
|
def items(self, context: FeedContext):
|
||||||
return context.query_set.filter(unread=True)
|
return context.query_set.filter(unread=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||||
|
title = "Shared bookmarks"
|
||||||
|
description = "All shared bookmarks"
|
||||||
|
|
||||||
|
def get_object(self, request, feed_key: str):
|
||||||
|
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||||
|
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||||
|
query_set = queries.query_shared_bookmarks(
|
||||||
|
None, feed_token.user.profile, search, False
|
||||||
|
)
|
||||||
|
return FeedContext(feed_token, query_set)
|
||||||
|
|
||||||
|
def link(self, context: FeedContext):
|
||||||
|
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
||||||
|
|
||||||
|
def items(self, context: FeedContext):
|
||||||
|
return context.query_set
|
||||||
|
|
||||||
|
|
||||||
|
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||||
|
title = "Public shared bookmarks"
|
||||||
|
description = "All public shared bookmarks"
|
||||||
|
|
||||||
|
def get_object(self, request):
|
||||||
|
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||||
|
default_profile = UserProfile()
|
||||||
|
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
|
||||||
|
return FeedContext(None, query_set)
|
||||||
|
|
||||||
|
def link(self, context: FeedContext):
|
||||||
|
return reverse("bookmarks:feeds.public_shared")
|
||||||
|
|
||||||
|
def items(self, context: FeedContext):
|
||||||
|
return context.query_set
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { registerBehavior, swap } from "./index";
|
import { registerBehavior, swapContent } from "./index";
|
||||||
|
|
||||||
class BookmarkPage {
|
class BookmarkPage {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
@@ -8,6 +8,10 @@ class BookmarkPage {
|
|||||||
|
|
||||||
this.bookmarkList = element.querySelector(".bookmark-list-container");
|
this.bookmarkList = element.querySelector(".bookmark-list-container");
|
||||||
this.tagCloud = element.querySelector(".tag-cloud-container");
|
this.tagCloud = element.querySelector(".tag-cloud-container");
|
||||||
|
|
||||||
|
document.addEventListener("bookmark-page-refresh", () => {
|
||||||
|
this.refresh();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onFormSubmit(event) {
|
async onFormSubmit(event) {
|
||||||
@@ -33,8 +37,8 @@ class BookmarkPage {
|
|||||||
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
|
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
|
||||||
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
|
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
|
||||||
]).then(([bookmarkListHtml, tagCloudHtml]) => {
|
]).then(([bookmarkListHtml, tagCloudHtml]) => {
|
||||||
swap(this.bookmarkList, bookmarkListHtml);
|
swapContent(this.bookmarkList, bookmarkListHtml);
|
||||||
swap(this.tagCloud, tagCloudHtml);
|
swapContent(this.tagCloud, tagCloudHtml);
|
||||||
|
|
||||||
// Dispatch list updated event
|
// Dispatch list updated event
|
||||||
const listElement = this.bookmarkList.querySelector(
|
const listElement = this.bookmarkList.querySelector(
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ class ConfirmButtonBehavior {
|
|||||||
container.append(question);
|
container.append(question);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buttonClasses = Array.from(this.button.classList.values())
|
||||||
|
.filter((cls) => cls.startsWith("btn"))
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
const cancelButton = document.createElement(this.button.nodeName);
|
const cancelButton = document.createElement(this.button.nodeName);
|
||||||
cancelButton.type = "button";
|
cancelButton.type = "button";
|
||||||
cancelButton.innerText = question ? "No" : "Cancel";
|
cancelButton.innerText = question ? "No" : "Cancel";
|
||||||
cancelButton.className = "btn btn-link btn-sm mr-1";
|
cancelButton.className = `${buttonClasses} mr-1`;
|
||||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||||
|
|
||||||
const confirmButton = document.createElement(this.button.nodeName);
|
const confirmButton = document.createElement(this.button.nodeName);
|
||||||
@@ -49,7 +53,7 @@ class ConfirmButtonBehavior {
|
|||||||
confirmButton.name = this.button.dataset.name;
|
confirmButton.name = this.button.dataset.name;
|
||||||
confirmButton.value = this.button.dataset.value;
|
confirmButton.value = this.button.dataset.value;
|
||||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||||
confirmButton.className = "btn btn-link btn-sm";
|
confirmButton.className = buttonClasses;
|
||||||
confirmButton.addEventListener("click", this.reset.bind(this));
|
confirmButton.addEventListener("click", this.reset.bind(this));
|
||||||
|
|
||||||
container.append(cancelButton, confirmButton);
|
container.append(cancelButton, confirmButton);
|
||||||
|
|||||||
54
bookmarks/frontend/behaviors/form.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { registerBehavior, swap } from "./index";
|
||||||
|
|
||||||
|
class FormBehavior {
|
||||||
|
constructor(element) {
|
||||||
|
this.element = element;
|
||||||
|
element.addEventListener("submit", this.onFormSubmit.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onFormSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const url = this.element.action;
|
||||||
|
const formData = new FormData(this.element);
|
||||||
|
if (event.submitter) {
|
||||||
|
formData.append(event.submitter.name, event.submitter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
redirect: "manual", // ignore redirect
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch refresh events
|
||||||
|
const refreshEvents = this.element.getAttribute("refresh-events");
|
||||||
|
if (refreshEvents) {
|
||||||
|
refreshEvents.split(",").forEach((eventName) => {
|
||||||
|
document.dispatchEvent(new CustomEvent(eventName));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh form
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
const refreshUrl = this.element.getAttribute("refresh-url");
|
||||||
|
const html = await fetch(refreshUrl).then((response) => response.text());
|
||||||
|
swap(this.element, html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FormAutoSubmitBehavior {
|
||||||
|
constructor(element) {
|
||||||
|
this.element = element;
|
||||||
|
this.element.addEventListener("change", () => {
|
||||||
|
const form = this.element.closest("form");
|
||||||
|
form.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBehavior("ld-form", FormBehavior);
|
||||||
|
registerBehavior("ld-form-auto-submit", FormAutoSubmitBehavior);
|
||||||
@@ -12,7 +12,14 @@ export function applyBehaviors(container, behaviorNames = null) {
|
|||||||
|
|
||||||
behaviorNames.forEach((behaviorName) => {
|
behaviorNames.forEach((behaviorName) => {
|
||||||
const behavior = behaviorRegistry[behaviorName];
|
const behavior = behaviorRegistry[behaviorName];
|
||||||
const elements = container.querySelectorAll(`[${behaviorName}]`);
|
const elements = Array.from(
|
||||||
|
container.querySelectorAll(`[${behaviorName}]`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Include the container element if it has the behavior
|
||||||
|
if (container.hasAttribute && container.hasAttribute(behaviorName)) {
|
||||||
|
elements.push(container);
|
||||||
|
}
|
||||||
|
|
||||||
elements.forEach((element) => {
|
elements.forEach((element) => {
|
||||||
element.__behaviors = element.__behaviors || [];
|
element.__behaviors = element.__behaviors || [];
|
||||||
@@ -31,6 +38,13 @@ export function applyBehaviors(container, behaviorNames = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function swap(element, html) {
|
export function swap(element, html) {
|
||||||
|
const dom = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
const newElement = dom.body.firstChild;
|
||||||
|
element.replaceWith(newElement);
|
||||||
|
applyBehaviors(newElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swapContent(element, html) {
|
||||||
element.innerHTML = html;
|
element.innerHTML = html;
|
||||||
applyBehaviors(element);
|
applyBehaviors(element);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { registerBehavior } from "./index";
|
import { applyBehaviors, registerBehavior } from "./index";
|
||||||
|
|
||||||
class ModalBehavior {
|
class ModalBehavior {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
@@ -7,14 +7,50 @@ class ModalBehavior {
|
|||||||
this.toggle = toggle;
|
this.toggle = toggle;
|
||||||
}
|
}
|
||||||
|
|
||||||
onToggleClick() {
|
async onToggleClick(event) {
|
||||||
|
// Ignore Ctrl + click
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
// Create modal either by teleporting existing content or fetching from URL
|
||||||
|
const modal = this.toggle.hasAttribute("modal-content")
|
||||||
|
? this.createFromContent()
|
||||||
|
: await this.createFromUrl();
|
||||||
|
|
||||||
|
if (!modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register close handlers
|
||||||
|
const modalOverlay = modal.querySelector(".modal-overlay");
|
||||||
|
const closeButton = modal.querySelector("button.close");
|
||||||
|
modalOverlay.addEventListener("click", this.onClose.bind(this));
|
||||||
|
closeButton.addEventListener("click", this.onClose.bind(this));
|
||||||
|
|
||||||
|
document.body.append(modal);
|
||||||
|
applyBehaviors(document.body);
|
||||||
|
this.modal = modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFromUrl() {
|
||||||
|
const url = this.toggle.getAttribute("modal-url");
|
||||||
|
const modalHtml = await fetch(url).then((response) => response.text());
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(modalHtml, "text/html");
|
||||||
|
return doc.querySelector(".modal");
|
||||||
|
}
|
||||||
|
|
||||||
|
createFromContent() {
|
||||||
const contentSelector = this.toggle.getAttribute("modal-content");
|
const contentSelector = this.toggle.getAttribute("modal-content");
|
||||||
const content = document.querySelector(contentSelector);
|
const content = document.querySelector(contentSelector);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create modal
|
// Todo: make title configurable, only used for tag cloud for now
|
||||||
const modal = document.createElement("div");
|
const modal = document.createElement("div");
|
||||||
modal.classList.add("modal", "active");
|
modal.classList.add("modal", "active");
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
@@ -22,7 +58,7 @@ class ModalBehavior {
|
|||||||
<div class="modal-container">
|
<div class="modal-container">
|
||||||
<div class="modal-header d-flex justify-between align-center">
|
<div class="modal-header d-flex justify-between align-center">
|
||||||
<div class="modal-title h5">Tags</div>
|
<div class="modal-title h5">Tags</div>
|
||||||
<button class="btn btn-link close">
|
<button class="close">
|
||||||
<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">
|
<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>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
<path d="M18 6l-12 12"></path>
|
<path d="M18 6l-12 12"></path>
|
||||||
@@ -36,29 +72,28 @@ class ModalBehavior {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Teleport content element
|
|
||||||
const contentOwner = content.parentElement;
|
const contentOwner = content.parentElement;
|
||||||
const contentContainer = modal.querySelector(".content");
|
const contentContainer = modal.querySelector(".content");
|
||||||
contentContainer.append(content);
|
contentContainer.append(content);
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.contentOwner = contentOwner;
|
this.contentOwner = contentOwner;
|
||||||
|
|
||||||
// Register close handlers
|
return modal;
|
||||||
const modalOverlay = modal.querySelector(".modal-overlay");
|
|
||||||
const closeButton = modal.querySelector(".btn.close");
|
|
||||||
modalOverlay.addEventListener("click", this.onClose.bind(this));
|
|
||||||
closeButton.addEventListener("click", this.onClose.bind(this));
|
|
||||||
|
|
||||||
document.body.append(modal);
|
|
||||||
this.modal = modal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose() {
|
onClose() {
|
||||||
// Teleport content back
|
// Teleport content back
|
||||||
this.contentOwner.append(this.content);
|
if (this.content && this.contentOwner) {
|
||||||
|
this.contentOwner.append(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove modal
|
// Remove modal
|
||||||
this.modal.remove();
|
this.modal.classList.add("closing");
|
||||||
|
this.modal.addEventListener("animationend", (event) => {
|
||||||
|
if (event.animationName === "fade-out") {
|
||||||
|
this.modal.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,4 +258,4 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -151,18 +151,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-autocomplete.small .form-autocomplete-input {
|
.form-autocomplete.small .form-autocomplete-input {
|
||||||
height: 1.4rem;
|
height: var(--control-size-sm);
|
||||||
min-height: 1.4rem;
|
min-height: var(--control-size-sm);
|
||||||
padding: 0.05rem 0.3rem;
|
padding: 0.05rem 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-autocomplete.small .form-autocomplete-input input {
|
.form-autocomplete.small .form-autocomplete-input input {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.7rem;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-autocomplete.small .menu .menu-item {
|
.form-autocomplete.small .menu .menu-item {
|
||||||
font-size: 0.7rem;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import TagAutoComplete from "./components/TagAutocomplete.svelte";
|
|
||||||
import SearchAutoComplete from "./components/SearchAutoComplete.svelte";
|
|
||||||
import { ApiClient } from "./api";
|
|
||||||
import "./behaviors/bookmark-page";
|
import "./behaviors/bookmark-page";
|
||||||
import "./behaviors/bulk-edit";
|
import "./behaviors/bulk-edit";
|
||||||
import "./behaviors/confirm-button";
|
import "./behaviors/confirm-button";
|
||||||
import "./behaviors/dropdown";
|
import "./behaviors/dropdown";
|
||||||
|
import "./behaviors/form";
|
||||||
import "./behaviors/modal";
|
import "./behaviors/modal";
|
||||||
import "./behaviors/global-shortcuts";
|
import "./behaviors/global-shortcuts";
|
||||||
import "./behaviors/tag-autocomplete";
|
import "./behaviors/tag-autocomplete";
|
||||||
|
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||||
export default {
|
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||||
ApiClient,
|
export { ApiClient } from "./api";
|
||||||
TagAutoComplete,
|
|
||||||
SearchAutoComplete,
|
|
||||||
};
|
|
||||||
|
|||||||
18
bookmarks/migrations/0026_userprofile_custom_css.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2024-03-16 23:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0025_userprofile_search_preferences"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="custom_css",
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2024-03-23 21:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0026_userprofile_custom_css"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="bookmark_description_display",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("inline", "Inline"), ("separate", "Separate")],
|
||||||
|
default="inline",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="bookmark_description_max_lines",
|
||||||
|
field=models.IntegerField(default=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2024-03-29 20:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0027_userprofile_bookmark_description_display_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="display_archive_bookmark_action",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="display_edit_bookmark_action",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="display_remove_bookmark_action",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="display_view_bookmark_action",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
34
bookmarks/migrations/0029_bookmark_list_actions_toast.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2024-03-29 21:25
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from bookmarks.models import Toast
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def forwards(apps, schema_editor):
|
||||||
|
|
||||||
|
for user in User.objects.all():
|
||||||
|
toast = Toast(
|
||||||
|
key="bookmark_list_actions_hint",
|
||||||
|
message="This version adds a new link to each bookmark to view details in a dialog. If you feel there is too much clutter you can now hide individual links in the settings.",
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
toast.save()
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(apps, schema_editor):
|
||||||
|
Toast.objects.filter(key="bookmark_list_actions_hint").delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0028_userprofile_display_archive_bookmark_action_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forwards, reverse),
|
||||||
|
]
|
||||||
43
bookmarks/migrations/0030_bookmarkasset.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2024-03-31 08:21
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0029_bookmark_list_actions_toast"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BookmarkAsset",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("date_created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("file", models.CharField(blank=True, max_length=2048)),
|
||||||
|
("file_size", models.IntegerField(null=True)),
|
||||||
|
("asset_type", models.CharField(max_length=64)),
|
||||||
|
("content_type", models.CharField(max_length=128)),
|
||||||
|
("display_name", models.CharField(blank=True, max_length=2048)),
|
||||||
|
("status", models.CharField(max_length=64)),
|
||||||
|
("gzip", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"bookmark",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="bookmarks.bookmark",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2024-04-01 10:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0030_bookmarkasset"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="enable_automatic_html_snapshots",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
34
bookmarks/migrations/0032_html_snapshots_hint_toast.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2024-04-01 12:17
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from bookmarks.models import Toast
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def forwards(apps, schema_editor):
|
||||||
|
|
||||||
|
for user in User.objects.all():
|
||||||
|
toast = Toast(
|
||||||
|
key="html_snapshots_hint",
|
||||||
|
message="This version adds a new feature for archiving snapshots of websites locally. To use it, you need to switch to a different Docker image. See the installation instructions on GitHub for details.",
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
toast.save()
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(apps, schema_editor):
|
||||||
|
Toast.objects.filter(key="bookmark_list_actions_hint").delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0031_userprofile_enable_automatic_html_snapshots"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forwards, reverse),
|
||||||
|
]
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
import binascii
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
import binascii
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save, post_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
|
|
||||||
from bookmarks.utils import unique
|
from bookmarks.utils import unique
|
||||||
from bookmarks.validators import BookmarkURLValidator
|
from bookmarks.validators import BookmarkURLValidator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Tag(models.Model):
|
class Tag(models.Model):
|
||||||
name = models.CharField(max_length=64)
|
name = models.CharField(max_length=64)
|
||||||
@@ -85,6 +89,47 @@ class Bookmark(models.Model):
|
|||||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkAsset(models.Model):
|
||||||
|
TYPE_SNAPSHOT = "snapshot"
|
||||||
|
|
||||||
|
CONTENT_TYPE_HTML = "text/html"
|
||||||
|
|
||||||
|
STATUS_PENDING = "pending"
|
||||||
|
STATUS_COMPLETE = "complete"
|
||||||
|
STATUS_FAILURE = "failure"
|
||||||
|
|
||||||
|
bookmark = models.ForeignKey(Bookmark, on_delete=models.CASCADE)
|
||||||
|
date_created = models.DateTimeField(auto_now_add=True, null=False)
|
||||||
|
file = models.CharField(max_length=2048, blank=True, null=False)
|
||||||
|
file_size = models.IntegerField(null=True)
|
||||||
|
asset_type = models.CharField(max_length=64, blank=False, null=False)
|
||||||
|
content_type = models.CharField(max_length=128, blank=False, null=False)
|
||||||
|
display_name = models.CharField(max_length=2048, blank=True, null=False)
|
||||||
|
status = models.CharField(max_length=64, blank=False, null=False)
|
||||||
|
gzip = models.BooleanField(default=False, null=False)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.file:
|
||||||
|
try:
|
||||||
|
file_path = os.path.join(settings.LD_ASSET_FOLDER, self.file)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
self.file_size = os.path.getsize(file_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=BookmarkAsset)
|
||||||
|
def bookmark_asset_deleted(sender, instance, **kwargs):
|
||||||
|
if instance.file:
|
||||||
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, instance.file)
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkForm(forms.ModelForm):
|
class BookmarkForm(forms.ModelForm):
|
||||||
# Use URLField for URL
|
# Use URLField for URL
|
||||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||||
@@ -278,6 +323,12 @@ class UserProfile(models.Model):
|
|||||||
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, "Absolute"),
|
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, "Absolute"),
|
||||||
(BOOKMARK_DATE_DISPLAY_HIDDEN, "Hidden"),
|
(BOOKMARK_DATE_DISPLAY_HIDDEN, "Hidden"),
|
||||||
]
|
]
|
||||||
|
BOOKMARK_DESCRIPTION_DISPLAY_INLINE = "inline"
|
||||||
|
BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE = "separate"
|
||||||
|
BOOKMARK_DESCRIPTION_DISPLAY_CHOICES = [
|
||||||
|
(BOOKMARK_DESCRIPTION_DISPLAY_INLINE, "Inline"),
|
||||||
|
(BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE, "Separate"),
|
||||||
|
]
|
||||||
BOOKMARK_LINK_TARGET_BLANK = "_blank"
|
BOOKMARK_LINK_TARGET_BLANK = "_blank"
|
||||||
BOOKMARK_LINK_TARGET_SELF = "_self"
|
BOOKMARK_LINK_TARGET_SELF = "_self"
|
||||||
BOOKMARK_LINK_TARGET_CHOICES = [
|
BOOKMARK_LINK_TARGET_CHOICES = [
|
||||||
@@ -308,6 +359,16 @@ class UserProfile(models.Model):
|
|||||||
blank=False,
|
blank=False,
|
||||||
default=BOOKMARK_DATE_DISPLAY_RELATIVE,
|
default=BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||||
)
|
)
|
||||||
|
bookmark_description_display = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=BOOKMARK_DESCRIPTION_DISPLAY_CHOICES,
|
||||||
|
blank=False,
|
||||||
|
default=BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
|
||||||
|
)
|
||||||
|
bookmark_description_max_lines = models.IntegerField(
|
||||||
|
null=False,
|
||||||
|
default=1,
|
||||||
|
)
|
||||||
bookmark_link_target = models.CharField(
|
bookmark_link_target = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
choices=BOOKMARK_LINK_TARGET_CHOICES,
|
choices=BOOKMARK_LINK_TARGET_CHOICES,
|
||||||
@@ -330,8 +391,14 @@ class UserProfile(models.Model):
|
|||||||
enable_public_sharing = models.BooleanField(default=False, null=False)
|
enable_public_sharing = models.BooleanField(default=False, null=False)
|
||||||
enable_favicons = models.BooleanField(default=False, null=False)
|
enable_favicons = models.BooleanField(default=False, null=False)
|
||||||
display_url = models.BooleanField(default=False, null=False)
|
display_url = models.BooleanField(default=False, null=False)
|
||||||
|
display_view_bookmark_action = models.BooleanField(default=True, null=False)
|
||||||
|
display_edit_bookmark_action = models.BooleanField(default=True, null=False)
|
||||||
|
display_archive_bookmark_action = models.BooleanField(default=True, null=False)
|
||||||
|
display_remove_bookmark_action = models.BooleanField(default=True, null=False)
|
||||||
permanent_notes = models.BooleanField(default=False, null=False)
|
permanent_notes = models.BooleanField(default=False, null=False)
|
||||||
|
custom_css = models.TextField(blank=True, null=False)
|
||||||
search_preferences = models.JSONField(default=dict, null=False)
|
search_preferences = models.JSONField(default=dict, null=False)
|
||||||
|
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||||
|
|
||||||
|
|
||||||
class UserProfileForm(forms.ModelForm):
|
class UserProfileForm(forms.ModelForm):
|
||||||
@@ -340,14 +407,22 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
fields = [
|
fields = [
|
||||||
"theme",
|
"theme",
|
||||||
"bookmark_date_display",
|
"bookmark_date_display",
|
||||||
|
"bookmark_description_display",
|
||||||
|
"bookmark_description_max_lines",
|
||||||
"bookmark_link_target",
|
"bookmark_link_target",
|
||||||
"web_archive_integration",
|
"web_archive_integration",
|
||||||
"tag_search",
|
"tag_search",
|
||||||
"enable_sharing",
|
"enable_sharing",
|
||||||
"enable_public_sharing",
|
"enable_public_sharing",
|
||||||
"enable_favicons",
|
"enable_favicons",
|
||||||
|
"enable_automatic_html_snapshots",
|
||||||
"display_url",
|
"display_url",
|
||||||
|
"display_view_bookmark_action",
|
||||||
|
"display_edit_bookmark_action",
|
||||||
|
"display_archive_bookmark_action",
|
||||||
|
"display_remove_bookmark_action",
|
||||||
"permanent_notes",
|
"permanent_notes",
|
||||||
|
"custom_css",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
|||||||
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
||||||
# Load favicon
|
# Load favicon
|
||||||
tasks.load_favicon(current_user, bookmark)
|
tasks.load_favicon(current_user, bookmark)
|
||||||
|
# Create HTML snapshot
|
||||||
|
if current_user.profile.enable_automatic_html_snapshots:
|
||||||
|
tasks.create_html_snapshot(bookmark)
|
||||||
|
|
||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
|
|||||||
32
bookmarks/services/monolith.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import gzip
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class MonolithError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Monolith isn't used at the moment, as the local snapshot implementation
|
||||||
|
# switched to single-file after the prototype. Keeping this around in case
|
||||||
|
# it turns out to be useful in the future.
|
||||||
|
def create_snapshot(url: str, filepath: str):
|
||||||
|
monolith_path = settings.LD_MONOLITH_PATH
|
||||||
|
monolith_options = settings.LD_MONOLITH_OPTIONS
|
||||||
|
temp_filepath = filepath + ".tmp"
|
||||||
|
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
shutil.copyfileobj(raw_file, gz_file)
|
||||||
|
|
||||||
|
os.remove(temp_filepath)
|
||||||
|
except subprocess.CalledProcessError as error:
|
||||||
|
raise MonolithError(f"Failed to create snapshot: {error.stderr}")
|
||||||
33
bookmarks/services/singlefile.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class SingeFileError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def create_snapshot(url: str, filepath: str):
|
||||||
|
singlefile_path = settings.LD_SINGLEFILE_PATH
|
||||||
|
singlefile_options = settings.LD_SINGLEFILE_OPTIONS
|
||||||
|
temp_filepath = filepath + ".tmp"
|
||||||
|
|
||||||
|
try:
|
||||||
|
command = f"{singlefile_path} '{url}' {singlefile_options} {temp_filepath}"
|
||||||
|
subprocess.run(command, check=True, shell=True)
|
||||||
|
|
||||||
|
# single-file doesn't return exit codes apparently, so check if the file was created
|
||||||
|
if not os.path.exists(temp_filepath):
|
||||||
|
raise SingeFileError("Failed to create snapshot")
|
||||||
|
|
||||||
|
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)
|
||||||
|
except subprocess.CalledProcessError as error:
|
||||||
|
raise SingeFileError(f"Failed to create snapshot: {error.stderr}")
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import waybackpy
|
import waybackpy
|
||||||
from background_task import background
|
from background_task import background
|
||||||
@@ -7,10 +8,11 @@ from django.conf import settings
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound
|
from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound
|
||||||
|
from django.utils import timezone, formats
|
||||||
|
|
||||||
import bookmarks.services.wayback
|
import bookmarks.services.wayback
|
||||||
from bookmarks.models import Bookmark, UserProfile
|
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
|
||||||
from bookmarks.services import favicon_loader
|
from bookmarks.services import favicon_loader, singlefile
|
||||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -193,3 +195,64 @@ def _schedule_refresh_favicons_task(user_id: int):
|
|||||||
tasks.append(task)
|
tasks.append(task)
|
||||||
|
|
||||||
Task.objects.bulk_create(tasks)
|
Task.objects.bulk_create(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
def is_html_snapshot_feature_active() -> bool:
|
||||||
|
return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||||
|
|
||||||
|
|
||||||
|
def create_html_snapshot(bookmark: Bookmark):
|
||||||
|
if not is_html_snapshot_feature_active():
|
||||||
|
return
|
||||||
|
|
||||||
|
timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT")
|
||||||
|
asset = BookmarkAsset(
|
||||||
|
bookmark=bookmark,
|
||||||
|
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||||
|
content_type="text/html",
|
||||||
|
display_name=f"HTML snapshot from {timestamp}",
|
||||||
|
status=BookmarkAsset.STATUS_PENDING,
|
||||||
|
)
|
||||||
|
asset.save()
|
||||||
|
_create_html_snapshot_task(asset.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_snapshot_filename(asset: BookmarkAsset) -> str:
|
||||||
|
def sanitize_char(char):
|
||||||
|
if char.isalnum() or char in ("-", "_", "."):
|
||||||
|
return char
|
||||||
|
else:
|
||||||
|
return "_"
|
||||||
|
|
||||||
|
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
|
||||||
|
sanitized_url = "".join(sanitize_char(char) for char in asset.bookmark.url)
|
||||||
|
|
||||||
|
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_url}.html.gz"
|
||||||
|
|
||||||
|
|
||||||
|
@background()
|
||||||
|
def _create_html_snapshot_task(asset_id: int):
|
||||||
|
try:
|
||||||
|
asset = BookmarkAsset.objects.get(id=asset_id)
|
||||||
|
except BookmarkAsset.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
filename = _generate_snapshot_filename(asset)
|
||||||
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||||
|
singlefile.create_snapshot(asset.bookmark.url, filepath)
|
||||||
|
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||||
|
asset.file = filename
|
||||||
|
asset.gzip = True
|
||||||
|
logger.info(
|
||||||
|
f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}"
|
||||||
|
)
|
||||||
|
except singlefile.SingeFileError as error:
|
||||||
|
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||||
|
logger.error(
|
||||||
|
f"Failed to create HTML snapshot for bookmark. url={asset.bookmark.url}",
|
||||||
|
exc_info=error,
|
||||||
|
)
|
||||||
|
asset.save()
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.5 KiB |
BIN
bookmarks/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 12 KiB |
1
bookmarks/static/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 663 B |
BIN
bookmarks/static/linkding-screenshot.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
bookmarks/static/logo-192.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
bookmarks/static/logo-512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.6 KiB |
1
bookmarks/static/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 688 B |
BIN
bookmarks/static/maskable-logo-192.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
bookmarks/static/maskable-logo-512.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
1
bookmarks/static/maskable-logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m512 512h-512v-512h512" fill="#5856e0" fill-rule="nonzero" stroke-width=".293"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m249.095 110.679-141.167 141.167s-48.578 47.432 4.257 101.668c53.026 54.426 101.654 4.242 101.654 4.242l141.166-141.166"/><path d="m263.892 400.446 140.673-141.659s48.412-47.602-4.612-101.652c-53.215-54.24-101.667-3.888-101.667-3.888l-140.674 141.659"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 564 B |
1
bookmarks/static/safari-pinned-tab.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="700pt" preserveAspectRatio="xMidYMid meet" viewBox="0 0 700 700" width="700pt" xmlns="http://www.w3.org/2000/svg"><path d="m3210 6573c-780-79-1463-417-1985-983-444-481-716-1082-791-1750-18-160-18-490 0-650 80-713 380-1341 880-1842 492-494 1125-801 1822-883 150-18 512-21 654-5 407 44 737 142 1094 323 775 394 1350 1108 1571 1952 71 271 98 487 98 785-1 311-35 562-117 847-54 188-99 302-201 508-216 439-510 795-900 1090-441 335-992 550-1544 605-95 9-499 12-581 3zm-639-2228c-543-544-1003-1011-1020-1038-91-134-135-274-134-422 1-167 61-314 200-485 135-165 308-291 467-338 110-32 264-27 376 12 185 65 130 15 1233 1115l1007 1006 150-150c83-82 150-154 150-160s-442-452-982-992c-658-656-1010-1000-1064-1040-304-223-643-298-965-214-271 72-548 272-751 543-115 153-179 283-226 457-22 85-26 115-25 256 0 140 3 172 26 257 34 130 95 266 169 380 54 83 172 205 1067 1101l1006 1007 152-152 153-153zm2359 1051c242-44 461-167 675-381 282-281 415-567 415-890 0-119-17-230-51-336-32-101-123-277-188-363-54-71-2003-2046-2020-2046-11 0-301 281-301 292 0 6 435 449 967 985 533 536 986 998 1007 1027 53 70 121 216 140 303 30 136 14 277-48 416-65 147-245 351-396 449-211 137-403 161-617 79-162-63-173-73-1009-913-428-431-871-876-984-991-112-114-207-207-211-207s-75 67-160 148l-153 149 785 789c431 434 865 872 964 973 266 271 397 369 612 455 176 70 396 94 573 62z" transform="matrix(.1 0 0 -.1 0 700)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -9,7 +9,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
margin-bottom: $unit-10;
|
margin-bottom: $unit-9;
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
@@ -50,14 +50,14 @@ section.content-area {
|
|||||||
border-bottom: solid 1px $border-color;
|
border-bottom: solid 1px $border-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
column-gap: $unit-6;
|
column-gap: $unit-5;
|
||||||
padding-bottom: $unit-2;
|
padding-bottom: $unit-1;
|
||||||
margin-bottom: $unit-4;
|
margin-bottom: $unit-3;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
line-height: 1.8rem;
|
line-height: $unit-9;
|
||||||
margin-bottom: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
@@ -95,10 +95,6 @@ span.confirmation {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-sm {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-gray-dark {
|
.text-gray-dark {
|
||||||
color: $gray-color-dark;
|
color: $gray-color-dark;
|
||||||
}
|
}
|
||||||
@@ -124,10 +120,6 @@ span.confirmation {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ml-auto {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.btn-wide {
|
.btn.btn-wide {
|
||||||
padding-left: $unit-6;
|
padding-left: $unit-6;
|
||||||
padding-right: $unit-6;
|
padding-right: $unit-6;
|
||||||
|
|||||||
126
bookmarks/styles/bookmark-details.scss
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/* Common styles */
|
||||||
|
.bookmark-details {
|
||||||
|
h2 {
|
||||||
|
flex: 1 1 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weblinks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.weblink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.weblink img, a.weblink svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: $body-font-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.weblink span {
|
||||||
|
flex: 1 1 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assets {
|
||||||
|
margin-top: $unit-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assets .asset {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-3;
|
||||||
|
padding: $unit-2 0;
|
||||||
|
border-top: $unit-o solid $border-color-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assets .asset:last-child {
|
||||||
|
border-bottom: $unit-o solid $border-color-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assets .asset-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assets .asset-text {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assets .asset-text .filesize {
|
||||||
|
color: $gray-color;
|
||||||
|
margin-left: $unit-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assets .asset-actions, .assets-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-3;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assets .asset-actions .btn, .assets-actions .btn {
|
||||||
|
height: unset;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assets-actions {
|
||||||
|
margin-top: $unit-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags a {
|
||||||
|
color: $alternative-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status form {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status .form-group, .status .form-switch {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark details view specific */
|
||||||
|
.bookmark-details.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark details modal specific */
|
||||||
|
.bookmark-details.modal {
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $unit-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
.bookmarks-page.grid {
|
.bookmarks-page.grid {
|
||||||
grid-gap: $unit-10;
|
grid-gap: $unit-9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bookmark area header controls */
|
/* Bookmark area header controls */
|
||||||
.bookmarks-page .content-area-header {
|
.bookmarks-page .content-area-header {
|
||||||
--searchbox-max-width: 350px;
|
--searchbox-max-width: 350px;
|
||||||
--searchbox-height: 1.8rem;
|
|
||||||
|
|
||||||
@media (max-width: $size-sm) {
|
@media (max-width: $size-sm) {
|
||||||
--searchbox-max-width: initial;
|
--searchbox-max-width: initial;
|
||||||
@@ -20,18 +19,18 @@
|
|||||||
|
|
||||||
// Regular input
|
// Regular input
|
||||||
input[type='search'] {
|
input[type='search'] {
|
||||||
height: var(--searchbox-height);
|
height: $control-size;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced auto-complete input
|
// Enhanced auto-complete input
|
||||||
// This needs a bit more wrangling to make the CSS component align with the attached button
|
// This needs a bit more wrangling to make the CSS component align with the attached button
|
||||||
.form-autocomplete {
|
.form-autocomplete {
|
||||||
height: var(--searchbox-height);
|
height: $control-size;
|
||||||
|
|
||||||
.form-autocomplete-input {
|
.form-autocomplete-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: var(--searchbox-height);
|
height: $control-size;
|
||||||
|
|
||||||
input[type='search'] {
|
input[type='search'] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -72,6 +71,7 @@
|
|||||||
.menu {
|
.menu {
|
||||||
padding: $unit-4;
|
padding: $unit-4;
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
|
font-size: $font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu .actions {
|
.menu .actions {
|
||||||
@@ -82,9 +82,11 @@
|
|||||||
|
|
||||||
.radio-group {
|
.radio-group {
|
||||||
margin-bottom: $unit-1;
|
margin-bottom: $unit-1;
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-radio.form-inline {
|
.form-radio.form-inline {
|
||||||
margin: 0 $unit-2 0 0;
|
margin: 0 $unit-2 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -92,6 +94,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: $unit-1;
|
column-gap: $unit-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-icon {
|
.form-icon {
|
||||||
top: 0;
|
top: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -105,6 +108,9 @@ ul.bookmark-list {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
|
/* Increase line-height for better separation within / between items */
|
||||||
|
line-height: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes appear {
|
@keyframes appear {
|
||||||
@@ -122,59 +128,81 @@ ul.bookmark-list {
|
|||||||
/* Bookmarks */
|
/* Bookmarks */
|
||||||
li[ld-bookmark-item] {
|
li[ld-bookmark-item] {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
margin-top: $unit-2;
|
||||||
|
|
||||||
[ld-bulk-edit-checkbox].form-checkbox {
|
[ld-bulk-edit-checkbox].form-checkbox {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title img {
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title img + a {
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.title a {
|
.title a {
|
||||||
display: inline-block;
|
display: block;
|
||||||
vertical-align: top;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
&[data-tooltip]:hover::after, &[data-tooltip]:focus::after {
|
.title a[data-tooltip]:hover::after, .title a[data-tooltip]:focus::after {
|
||||||
content: attr(data-tooltip);
|
content: attr(data-tooltip);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
top: 20px;
|
top: 100%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: max-content;
|
width: max-content;
|
||||||
max-width: 100%;
|
max-width: 90%;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
background-color: #292f62;
|
background-color: #292f62;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: $unit-1;
|
padding: $unit-1;
|
||||||
border-radius: $border-radius;
|
border-radius: $border-radius;
|
||||||
border: 1px solid #424a8c;
|
border: 1px solid #424a8c;
|
||||||
font-size: $font-size-sm;
|
font-size: $font-size-sm;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
animation: 0.3s ease 0s appear;
|
pointer-events: none;
|
||||||
}
|
animation: 0.3s ease 0s appear;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.unread .title a {
|
&.unread .title a {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title img {
|
.url-path, .url-display {
|
||||||
width: 16px;
|
font-size: $font-size-sm;
|
||||||
height: 16px;
|
|
||||||
margin-right: $unit-h;
|
|
||||||
vertical-align: text-top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.url-display {
|
|
||||||
color: $secondary-link-color;
|
color: $secondary-link-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
color: $gray-color-dark;
|
color: $gray-color-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description.separate {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
a, a:visited:hover {
|
a, a:visited:hover {
|
||||||
color: $alternative-color;
|
color: $alternative-color;
|
||||||
}
|
}
|
||||||
@@ -195,6 +223,8 @@ li[ld-bookmark-item] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
|
||||||
a, button.btn-link {
|
a, button.btn-link {
|
||||||
color: $gray-color;
|
color: $gray-color;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -211,10 +241,6 @@ li[ld-bookmark-item] {
|
|||||||
color: $gray-color-dark;
|
color: $gray-color-dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +249,8 @@ li[ld-bookmark-item] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tag-cloud {
|
.tag-cloud {
|
||||||
|
/* Increase line-height for better separation within / between items */
|
||||||
|
line-height: 1.1rem;
|
||||||
|
|
||||||
.selected-tags {
|
.selected-tags {
|
||||||
margin-bottom: $unit-4;
|
margin-bottom: $unit-4;
|
||||||
@@ -258,55 +286,13 @@ ul.bookmark-list {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.show-notes .notes,
|
.notes .markdown {
|
||||||
li.show-notes .notes {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bookmark notes markdown styles */
|
|
||||||
ul.bookmark-list .notes-content {
|
|
||||||
& {
|
|
||||||
padding: $unit-2 $unit-3;
|
padding: $unit-2 $unit-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
p, ul, ol, pre, blockquote {
|
&.show-notes .notes,
|
||||||
margin: 0 0 $unit-2 0;
|
li.show-notes .notes {
|
||||||
}
|
display: block;
|
||||||
|
|
||||||
> *:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
> *:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul, ol {
|
|
||||||
margin-left: $unit-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul li, ol li {
|
|
||||||
margin-top: $unit-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
padding: $unit-1 $unit-2;
|
|
||||||
background-color: $code-bg-color;
|
|
||||||
border-radius: $unit-1;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre code {
|
|
||||||
background: none;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
> pre:first-child:last-child {
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +306,7 @@ $bulk-edit-transition-duration: 400ms;
|
|||||||
.bulk-edit-bar {
|
.bulk-edit-bar {
|
||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
margin-left: -$bulk-edit-bar-offset;
|
margin-left: -$bulk-edit-bar-offset;
|
||||||
margin-bottom: $unit-4;
|
margin-bottom: $unit-3;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-height $bulk-edit-transition-duration;
|
transition: max-height $bulk-edit-transition-duration;
|
||||||
@@ -342,7 +328,6 @@ $bulk-edit-transition-duration: 400ms;
|
|||||||
width: $bulk-edit-toggle-width;
|
width: $bulk-edit-toggle-width;
|
||||||
margin: 0 0 0 $bulk-edit-toggle-offset;
|
margin: 0 0 0 $bulk-edit-toggle-offset;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bookmark checkboxes */
|
/* Bookmark checkboxes */
|
||||||
@@ -350,8 +335,10 @@ $bulk-edit-transition-duration: 400ms;
|
|||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: $bulk-edit-toggle-width;
|
width: $bulk-edit-toggle-width;
|
||||||
|
min-height: $bulk-edit-toggle-width;
|
||||||
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
|
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
|
||||||
top: 0;
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -359,7 +346,7 @@ $bulk-edit-transition-duration: 400ms;
|
|||||||
transition: all $bulk-edit-transition-duration;
|
transition: all $bulk-edit-transition-duration;
|
||||||
|
|
||||||
.form-icon {
|
.form-icon {
|
||||||
top: $unit-1;
|
top: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +358,7 @@ $bulk-edit-transition-duration: 400ms;
|
|||||||
/* Actions */
|
/* Actions */
|
||||||
.bulk-edit-actions {
|
.bulk-edit-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
padding: $unit-1 0;
|
padding: $unit-1 0;
|
||||||
border-top: solid 1px $border-color;
|
border-top: solid 1px $border-color;
|
||||||
gap: $unit-2;
|
gap: $unit-2;
|
||||||
|
|||||||
40
bookmarks/styles/markdown.scss
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
.markdown {
|
||||||
|
p, ul, ol, pre, blockquote {
|
||||||
|
margin: 0 0 $unit-2 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin-left: $unit-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li, ol li {
|
||||||
|
margin-top: $unit-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: $unit-1 $unit-2;
|
||||||
|
background-color: $code-bg-color;
|
||||||
|
border-radius: $unit-1;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> pre:first-child:last-child {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,14 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.columns-2 {
|
||||||
|
--grid-columns: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-0 {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.col-1 {
|
.col-1 {
|
||||||
grid-column: unquote("span min(1, var(--grid-columns))");
|
grid-column: unquote("span min(1, var(--grid-columns))");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
.settings-page {
|
.settings-page {
|
||||||
section.content-area {
|
section.content-area {
|
||||||
margin-bottom: $unit-12;
|
margin-bottom: $unit-10;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin-bottom: $unit-4;
|
margin-bottom: $unit-3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea.custom-css {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.input-group > input[type=submit] {
|
.input-group > input[type=submit] {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,32 @@
|
|||||||
|
|
||||||
// Variables and mixins
|
// Variables and mixins
|
||||||
@import "../../node_modules/spectre.css/src/variables";
|
@import "../../node_modules/spectre.css/src/variables";
|
||||||
|
|
||||||
|
// Customize variables to reduce font and control sizes
|
||||||
|
|
||||||
|
// Can use CSS variables for font sizes, as they are not used in SCSS calculations
|
||||||
|
$font-size: var(--font-size);
|
||||||
|
$font-size-sm: var(--font-size-sm);
|
||||||
|
$font-size-lg: var(--font-size-lg);
|
||||||
|
|
||||||
|
// Can't use CSS variables for these, used in SCSS calculations
|
||||||
|
$line-height: 1rem;
|
||||||
|
$control-size: $unit-8;
|
||||||
|
$control-size-sm: $unit-6;
|
||||||
|
$control-size-lg: $unit-9;
|
||||||
|
|
||||||
|
// Declare defaults for CSS variables, expose SCSS variables as CSS variables
|
||||||
|
html {
|
||||||
|
--font-size: 0.7rem;
|
||||||
|
--font-size-sm: 0.65rem;
|
||||||
|
--font-size-lg: 0.8rem;
|
||||||
|
|
||||||
|
--control-size: #{$control-size};
|
||||||
|
--control-size-sm: #{$control-size-sm};
|
||||||
|
--control-size-lg: #{$control-size-lg};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixins
|
||||||
@import "../../node_modules/spectre.css/src/mixins";
|
@import "../../node_modules/spectre.css/src/mixins";
|
||||||
|
|
||||||
/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
|
/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
|
||||||
@@ -64,19 +90,6 @@ a:visited:hover {
|
|||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix radio button sub-pixel size
|
|
||||||
.form-radio .form-icon {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-radio input:checked + .form-icon::before {
|
|
||||||
top: 3px;
|
|
||||||
left: 3px;
|
|
||||||
transform: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make code work with light and dark theme
|
// Make code work with light and dark theme
|
||||||
code {
|
code {
|
||||||
color: $gray-color-dark;
|
color: $gray-color-dark;
|
||||||
@@ -127,6 +140,53 @@ ul.menu li:first-child {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customize modal animation
|
||||||
|
@keyframes fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-out {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active .modal-container, .modal.active .modal-overlay {
|
||||||
|
animation: fade-in .15s ease 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active.closing .modal-container, .modal.active.closing .modal-overlay {
|
||||||
|
animation: fade-out .15s ease 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customize menu animation
|
||||||
|
.dropdown .menu {
|
||||||
|
animation: fade-in .15s ease 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal close button
|
||||||
|
.modal .modal-header button.close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: .85;
|
||||||
|
color: $gray-color-dark;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Increase input font size on small viewports to prevent zooming on focus the input
|
// Increase input font size on small viewports to prevent zooming on focus the input
|
||||||
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
||||||
// viewport size
|
// viewport size
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
// Import style modules
|
// Import style modules
|
||||||
@import "base";
|
@import "base";
|
||||||
@import "responsive";
|
@import "responsive";
|
||||||
|
@import "bookmark-details";
|
||||||
@import "bookmark-page";
|
@import "bookmark-page";
|
||||||
@import "bookmark-form";
|
@import "bookmark-form";
|
||||||
@import "settings";
|
@import "settings";
|
||||||
|
@import "markdown";
|
||||||
|
|
||||||
/* Dark theme overrides */
|
/* Dark theme overrides */
|
||||||
|
|
||||||
@@ -40,8 +42,17 @@ a:focus, .btn:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||||
background: $dt-primary-button-color;
|
background: $dt-primary-input-color;
|
||||||
border-color: $dt-primary-button-color;
|
border-color: $dt-primary-input-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-switch .form-icon::before, .form-switch input:active + .form-icon::before {
|
||||||
|
background: $light-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-switch input:checked + .form-icon {
|
||||||
|
background: $dt-primary-input-color;
|
||||||
|
border-color: $dt-primary-input-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-radio input:checked + .form-icon::before {
|
.form-radio input:checked + .form-icon::before {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
// Import style modules
|
// Import style modules
|
||||||
@import "base";
|
@import "base";
|
||||||
@import "responsive";
|
@import "responsive";
|
||||||
|
@import "bookmark-details";
|
||||||
@import "bookmark-page";
|
@import "bookmark-page";
|
||||||
@import "bookmark-form";
|
@import "bookmark-form";
|
||||||
@import "settings";
|
@import "settings";
|
||||||
|
@import "markdown";
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
$html-font-size: 18px !default;
|
|
||||||
|
|
||||||
$body-bg: #161822 !default;
|
$body-bg: #161822 !default;
|
||||||
$bg-color: lighten($body-bg, 5%) !default;
|
$bg-color: lighten($body-bg, 5%) !default;
|
||||||
$bg-color-light: lighten($body-bg, 5%) !default;
|
$bg-color-light: lighten($body-bg, 5%) !default;
|
||||||
@@ -30,4 +28,5 @@ $code-bg-color: rgba(255, 255, 255, 0.1);
|
|||||||
$code-shadow-color: rgba(255, 255, 255, 0.2);
|
$code-shadow-color: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
/* Dark theme specific */
|
/* Dark theme specific */
|
||||||
|
$dt-primary-input-color: #5C68E7 !default;
|
||||||
$dt-primary-button-color: #5761cb !default;
|
$dt-primary-button-color: #5761cb !default;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
$html-font-size: 18px !default;
|
|
||||||
|
|
||||||
$alternative-color: #05a6a3;
|
$alternative-color: #05a6a3;
|
||||||
$alternative-color-dark: darken($alternative-color, 5%);
|
$alternative-color-dark: darken($alternative-color, 5%);
|
||||||
|
|
||||||
|
|||||||
@@ -6,50 +6,66 @@
|
|||||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
||||||
|
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
|
||||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||||
{% for bookmark_item in bookmark_list.items %}
|
{% for bookmark_item in bookmark_list.items %}
|
||||||
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||||
<label ld-bulk-edit-checkbox class="form-checkbox">
|
|
||||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
|
||||||
<i class="form-icon"></i>
|
|
||||||
</label>
|
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener" >
|
<label ld-bulk-edit-checkbox class="form-checkbox">
|
||||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||||
<img src="{% static bookmark_item.favicon_file %}" alt="">
|
<i class="form-icon"></i>
|
||||||
{% endif %}
|
</label>
|
||||||
<span>{{ bookmark_item.title }}</span>
|
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||||
|
<img src="{% static bookmark_item.favicon_file %}" alt="">
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||||
|
<span>{{ bookmark_item.title }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% if bookmark_list.show_url %}
|
{% if bookmark_list.show_url %}
|
||||||
<div class="url-path truncate">
|
<div class="url-path truncate">
|
||||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||||
class="url-display text-sm">
|
class="url-display">
|
||||||
{{ bookmark_item.url }}
|
{{ bookmark_item.url }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="description truncate">
|
{% if bookmark_list.description_display == 'inline' %}
|
||||||
|
<div class="description inline truncate">
|
||||||
|
{% if bookmark_item.tag_names %}
|
||||||
|
<span class="tags">
|
||||||
|
{% for tag_name in bookmark_item.tag_names %}
|
||||||
|
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||||
|
{% if bookmark_item.description %}
|
||||||
|
<span>{{ bookmark_item.description }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if bookmark_item.description %}
|
||||||
|
<div class="description separate">
|
||||||
|
{{ bookmark_item.description }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if bookmark_item.tag_names %}
|
{% if bookmark_item.tag_names %}
|
||||||
<span>
|
<div class="tags">
|
||||||
{% for tag_name in bookmark_item.tag_names %}
|
{% for tag_name in bookmark_item.tag_names %}
|
||||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</span>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.description %}
|
|
||||||
<span>{{ bookmark_item.description }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if bookmark_item.notes %}
|
{% if bookmark_item.notes %}
|
||||||
<div class="notes bg-gray text-gray-dark">
|
<div class="notes bg-gray text-gray-dark">
|
||||||
<div class="notes-content">
|
<div class="markdown">
|
||||||
{% markdown bookmark_item.notes %}
|
{% markdown bookmark_item.notes %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="actions text-gray text-sm">
|
<div class="actions text-gray">
|
||||||
{% if bookmark_item.display_date %}
|
{% if bookmark_item.display_date %}
|
||||||
{% if bookmark_item.web_archive_snapshot_url %}
|
{% if bookmark_item.web_archive_snapshot_url %}
|
||||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||||
@@ -61,23 +77,35 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span>{{ bookmark_item.display_date }}</span>
|
<span>{{ bookmark_item.display_date }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="separator">|</span>
|
<span>|</span>
|
||||||
|
{% endif %}
|
||||||
|
{# View link is visible for both owned and shared bookmarks #}
|
||||||
|
{% if bookmark_list.show_view_action %}
|
||||||
|
<a ld-modal
|
||||||
|
modal-url="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
||||||
|
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.is_editable %}
|
{% if bookmark_item.is_editable %}
|
||||||
{# Bookmark owner actions #}
|
{# Bookmark owner actions #}
|
||||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
{% if bookmark_list.show_edit_action %}
|
||||||
{% if bookmark_item.is_archived %}
|
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
{% endif %}
|
||||||
class="btn btn-link btn-sm">Unarchive
|
{% if bookmark_list.show_archive_action %}
|
||||||
</button>
|
{% if bookmark_item.is_archived %}
|
||||||
{% else %}
|
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
class="btn btn-link btn-sm">Unarchive
|
||||||
class="btn btn-link btn-sm">Archive
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||||
|
class="btn btn-link btn-sm">Archive
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if bookmark_list.show_remove_action %}
|
||||||
|
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||||
|
class="btn btn-link btn-sm">Remove
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
|
||||||
class="btn btn-link btn-sm">Remove
|
|
||||||
</button>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Shared bookmark actions #}
|
{# Shared bookmark actions #}
|
||||||
<span>Shared by
|
<span>Shared by
|
||||||
@@ -86,7 +114,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark_item.has_extra_actions %}
|
{% if bookmark_item.has_extra_actions %}
|
||||||
<div class="extra-actions">
|
<div class="extra-actions">
|
||||||
<span class="separator hide-sm">|</span>
|
<span class="hide-sm">|</span>
|
||||||
{% if bookmark_item.show_mark_as_read %}
|
{% if bookmark_item.show_mark_as_read %}
|
||||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||||
class="btn btn-link btn-sm btn-icon"
|
class="btn btn-link btn-sm btn-icon"
|
||||||
|
|||||||
13
bookmarks/templates/bookmarks/details.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'bookmarks/layout.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="bookmark-details page">
|
||||||
|
{% if details.is_editable %}
|
||||||
|
{% include 'bookmarks/details/actions.html' %}
|
||||||
|
{% endif %}
|
||||||
|
{% include 'bookmarks/details/title.html' %}
|
||||||
|
<div>
|
||||||
|
{% include 'bookmarks/details/form.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
16
bookmarks/templates/bookmarks/details/actions.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="actions">
|
||||||
|
<div class="left-actions">
|
||||||
|
<a class="btn"
|
||||||
|
href="{% url 'bookmarks:edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
|
||||||
|
</div>
|
||||||
|
<div class="right-actions">
|
||||||
|
<form action="{% url 'bookmarks:index.action' %}?return_url={{ details.delete_return_url|urlencode }}"
|
||||||
|
method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button ld-confirm-button type="submit" name="remove" value="{{ details.bookmark.id }}"
|
||||||
|
class="btn btn-link text-error">
|
||||||
|
Delete...
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
42
bookmarks/templates/bookmarks/details/asset_icon.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% if asset.content_type == 'text/html' %}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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="M14 3v4a1 1 0 0 0 1 1h4"/>
|
||||||
|
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/>
|
||||||
|
<path d="M2 21v-6"/>
|
||||||
|
<path d="M5 15v6"/>
|
||||||
|
<path d="M2 18h3"/>
|
||||||
|
<path d="M20 15v6h2"/>
|
||||||
|
<path d="M13 21v-6l2 3l2 -3v6"/>
|
||||||
|
<path d="M7.5 15h3"/>
|
||||||
|
<path d="M9 15v6"/>
|
||||||
|
</svg>
|
||||||
|
{% elif asset.content_type == 'application/pdf' %}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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="M14 3v4a1 1 0 0 0 1 1h4"/>
|
||||||
|
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/>
|
||||||
|
<path d="M5 18h1.5a1.5 1.5 0 0 0 0 -3h-1.5v6"/>
|
||||||
|
<path d="M17 18h2"/>
|
||||||
|
<path d="M20 15h-3v6"/>
|
||||||
|
<path d="M11 15v6h1a2 2 0 0 0 2 -2v-2a2 2 0 0 0 -2 -2h-1z"/>
|
||||||
|
</svg>
|
||||||
|
{% elif asset.content_type == 'image/png' or asset.content_type == 'image/jpeg' or asset.content_type == 'image.gif' %}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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="M15 8h.01"/>
|
||||||
|
<path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z"/>
|
||||||
|
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/>
|
||||||
|
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"/>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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="M14 3v4a1 1 0 0 0 1 1h4"/>
|
||||||
|
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
37
bookmarks/templates/bookmarks/details/assets.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% if details.assets %}
|
||||||
|
<div class="assets">
|
||||||
|
{% for asset in details.assets %}
|
||||||
|
<div class="asset" data-asset-id="{{ asset.id }}">
|
||||||
|
<div class="asset-icon {{ asset.icon_classes }}">
|
||||||
|
{% include 'bookmarks/details/asset_icon.html' %}
|
||||||
|
</div>
|
||||||
|
<div class="asset-text truncate {{ asset.text_classes }}">
|
||||||
|
<span>
|
||||||
|
{{ asset.display_name }}
|
||||||
|
{% if asset.status == 'pending' %}(queued){% endif %}
|
||||||
|
{% if asset.status == 'failure' %}(failed){% endif %}
|
||||||
|
</span>
|
||||||
|
{% if asset.file_size %}
|
||||||
|
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="asset-actions">
|
||||||
|
{% if asset.file %}
|
||||||
|
<a class="btn btn-link" href="{% url 'bookmarks:assets.view' asset.id %}" target="_blank">View</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if details.is_editable %}
|
||||||
|
<button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if details.is_editable %}
|
||||||
|
<div class="assets-actions">
|
||||||
|
<button type="submit" name="create_snapshot" class="btn btn-link">Create HTML snapshot</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
99
bookmarks/templates/bookmarks/details/form.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{% load static %}
|
||||||
|
{% load shared %}
|
||||||
|
|
||||||
|
<form ld-form action="{% url 'bookmarks:details' details.bookmark.id %}"
|
||||||
|
refresh-url="{% url 'bookmarks:partials.details_form' details.bookmark.id %}"
|
||||||
|
refresh-events="bookmark-page-refresh"
|
||||||
|
method="post">
|
||||||
|
<div class="weblinks">
|
||||||
|
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
|
||||||
|
target="{{ details.profile.bookmark_link_target }}">
|
||||||
|
{% if details.show_link_icons %}
|
||||||
|
<img class="favicon" src="{% static details.bookmark.favicon_file %}" alt="">
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ details.bookmark.url }}</span>
|
||||||
|
</a>
|
||||||
|
{% if details.bookmark.web_archive_snapshot_url %}
|
||||||
|
<a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}"
|
||||||
|
target="{{ details.profile.bookmark_link_target }}">
|
||||||
|
{% if details.show_link_icons %}
|
||||||
|
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="m76 82v4h-76l.00080851-4zm-3-6v5h-70v-5zm-62.6696277-54 .8344146.4217275.4176066 6.7436084.4176065 10.9576581v10.5383496l-.4176065 13.1364492-.0694681 8.8498268-1.1825531.3523804h-4.17367003l-1.25202116-.3523804-.48627608-8.8498268-.41840503-13.0662957v-10.5375432l.41840503-11.028618.38167482-6.7798947.87034634-.3854412zm60.0004653 0 .8353798.4217275.4168913 6.7436084.4168913 10.9576581v10.5383496l-.4168913 13.1364492-.0686832 8.8498268-1.1835879.3523804h-4.1737047l-1.2522712-.3523804-.4879704-8.8498268-.4168913-13.0662957v-10.5375432l.4168913-11.028618.3833483-6.7798947.8697215-.3854412zm-42.000632 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2529447-.3523804-.4863246-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8688361-.3854412zm23 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2521462-.3523804-.4871231-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8696347-.3854412zm21.6697944-9v7h-70v-7zm-35.7200748-13 36.7200748 8.4088317-1.4720205 2.5911683h-70.32799254l-2.19998696-2.10140371z"
|
||||||
|
fill="currentColor" fill-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
<span>View on Internet Archive</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<dl class="grid columns-2 columns-sm-1 gap-0">
|
||||||
|
{% if details.is_editable %}
|
||||||
|
<div class="status col-2">
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd class="d-flex" style="gap: .8rem">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-switch">
|
||||||
|
<input ld-form-auto-submit type="checkbox" name="is_archived"
|
||||||
|
{% if details.bookmark.is_archived %}checked{% endif %}>
|
||||||
|
<i class="form-icon"></i> Archived
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-switch">
|
||||||
|
<input ld-form-auto-submit type="checkbox" name="unread"
|
||||||
|
{% if details.bookmark.unread %}checked{% endif %}>
|
||||||
|
<i class="form-icon"></i> Unread
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% if details.profile.enable_sharing %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-switch">
|
||||||
|
<input ld-form-auto-submit type="checkbox" name="shared"
|
||||||
|
{% if details.bookmark.shared %}checked{% endif %}>
|
||||||
|
<i class="form-icon"></i> Shared
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if details.show_files %}
|
||||||
|
<div class="files col-2">
|
||||||
|
<dt>Files</dt>
|
||||||
|
<dd>
|
||||||
|
{% include 'bookmarks/details/assets.html' %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if details.bookmark.tag_names %}
|
||||||
|
<div class="tags col-1">
|
||||||
|
<dt>Tags</dt>
|
||||||
|
<dd>
|
||||||
|
{% for tag_name in details.bookmark.tag_names %}
|
||||||
|
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="date-added col-1">
|
||||||
|
<dt>Date added</dt>
|
||||||
|
<dd>
|
||||||
|
<span>{{ details.bookmark.date_added }}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% if details.bookmark.resolved_description %}
|
||||||
|
<div class="description col-2">
|
||||||
|
<dt>Description</dt>
|
||||||
|
<dd>{{ details.bookmark.resolved_description }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if details.bookmark.notes %}
|
||||||
|
<div class="notes col-2">
|
||||||
|
<dt>Notes</dt>
|
||||||
|
<dd class="markdown">{% markdown details.bookmark.notes %}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</form>
|
||||||
3
bookmarks/templates/bookmarks/details/title.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<h2>
|
||||||
|
{{ details.bookmark.resolved_title }}
|
||||||
|
</h2>
|
||||||
27
bookmarks/templates/bookmarks/details_modal.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<div class="modal active bookmark-details">
|
||||||
|
<div class="modal-overlay" aria-label="Close"></div>
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
{% include 'bookmarks/details/title.html' %}
|
||||||
|
<button class="close">
|
||||||
|
<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>
|
||||||
|
<path d="M18 6l-12 12"></path>
|
||||||
|
<path d="M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="content">
|
||||||
|
{% include 'bookmarks/details/form.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if details.is_editable %}
|
||||||
|
<div class="modal-footer">
|
||||||
|
{% include 'bookmarks/details/actions.html' %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
{# Tag cloud #}
|
{# Tag cloud #}
|
||||||
<section class="content-area col-1 hide-md">
|
<section class="content-area col-1 hide-md">
|
||||||
<div class="content-area-header mb-4">
|
<div class="content-area-header">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-cloud-container">
|
<div class="tag-cloud-container">
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
|
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="{% static 'favicon.png' %}"/>
|
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="48x48">
|
||||||
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
|
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
|
||||||
|
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
|
||||||
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
|
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||||
@@ -19,14 +21,21 @@
|
|||||||
{# Include specific theme variant based on user profile setting #}
|
{# Include specific theme variant based on user profile setting #}
|
||||||
{% if request.user_profile.theme == 'light' %}
|
{% if request.user_profile.theme == 'light' %}
|
||||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||||
|
<meta name="theme-color" content="#5856e0">
|
||||||
{% elif request.user_profile.theme == 'dark' %}
|
{% elif request.user_profile.theme == 'dark' %}
|
||||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||||
|
<meta name="theme-color" content="#161822">
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Use auto theme as fallback #}
|
{# Use auto theme as fallback #}
|
||||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||||
media="(prefers-color-scheme: dark)"/>
|
media="(prefers-color-scheme: dark)"/>
|
||||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||||
media="(prefers-color-scheme: light)"/>
|
media="(prefers-color-scheme: light)"/>
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||||
|
{% endif %}
|
||||||
|
{% if request.user_profile.custom_css %}
|
||||||
|
<style>{{ request.user_profile.custom_css }}</style>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body ld-global-shortcuts>
|
<body ld-global-shortcuts>
|
||||||
@@ -96,9 +105,9 @@
|
|||||||
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% for toast in toast_messages %}
|
{% for toast in toast_messages %}
|
||||||
<div class="toast">
|
<div class="toast d-flex">
|
||||||
{{ toast.message }}
|
{{ toast.message }}
|
||||||
<button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear float-right"></button>
|
<button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear"></button>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
|
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
|
||||||
Bookmarks
|
Bookmarks
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||||
style="height:1rem;width:1rem;vertical-align: text-bottom;">
|
style="height:1rem;width:1rem;vertical-align: middle;">
|
||||||
<path fill-rule="evenodd"
|
<path fill-rule="evenodd"
|
||||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||||
clip-rule="evenodd"/>
|
clip-rule="evenodd"/>
|
||||||
@@ -34,7 +34,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||||
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-link">Logout</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{# Menu drop-down for smaller devices #}
|
{# Menu drop-down for smaller devices #}
|
||||||
<div class="show-md">
|
<div class="show-md">
|
||||||
@@ -74,7 +77,10 @@
|
|||||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-link">Logout</button>
|
||||||
|
</form>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<path d="M18 9v11"></path>
|
<path d="M18 9v11"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="menu text-sm" tabindex="0">
|
<div class="menu" tabindex="0">
|
||||||
<form id="search_preferences" action="" method="post">
|
<form id="search_preferences" action="" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if 'sort' in preferences_form.editable_fields %}
|
{% if 'sort' in preferences_form.editable_fields %}
|
||||||
|
|||||||
@@ -17,22 +17,24 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
||||||
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
|
{{ form.username|add_class:'form-input'|attr:'placeholder: ' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
|
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
|
||||||
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
|
{{ form.password|add_class:'form-input'|attr:'placeholder: ' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
<div class="d-flex justify-between">
|
<div class="d-flex justify-between">
|
||||||
<input type="submit" value="Login" class="btn btn-primary btn-wide">
|
<input type="submit" value="Login" class="btn btn-primary btn-wide"/>
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<input type="hidden" name="next" value="{{ next }}"/>
|
||||||
|
{% if enable_oidc %}
|
||||||
|
<a class="btn btn-link" href="{% url 'oidc_authentication_init' %}">Login with OIDC</a>
|
||||||
|
{% endif %}
|
||||||
{% if allow_registration %}
|
{% if allow_registration %}
|
||||||
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
|
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,6 +29,23 @@
|
|||||||
be hidden.
|
be hidden.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.bookmark_description_display.id_for_label }}" class="form-label">Bookmark
|
||||||
|
description</label>
|
||||||
|
{{ form.bookmark_description_display|add_class:"form-select width-25 width-sm-100" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Whether to show bookmark descriptions and tags in the same line, or as separate blocks.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="form-group {% if request.user_profile.bookmark_description_display == 'inline' %}d-hide{% endif %}">
|
||||||
|
<label for="{{ form.bookmark_description_max_lines.id_for_label }}" class="form-label">Bookmark description
|
||||||
|
max lines</label>
|
||||||
|
{{ form.bookmark_description_max_lines|add_class:"form-input width-25 width-sm-100" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Limits the number of lines that are displayed for the bookmark description.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
|
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
|
||||||
{{ form.display_url }}
|
{{ form.display_url }}
|
||||||
@@ -48,6 +65,28 @@
|
|||||||
Alternatively the keyboard shortcut <code>e</code> can be used to temporarily show all notes.
|
Alternatively the keyboard shortcut <code>e</code> can be used to temporarily show all notes.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bookmark actions</label>
|
||||||
|
<label for="{{ form.display_view_bookmark_action.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ form.display_view_bookmark_action }}
|
||||||
|
<i class="form-icon"></i> View
|
||||||
|
</label>
|
||||||
|
<label for="{{ form.display_edit_bookmark_action.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ form.display_edit_bookmark_action }}
|
||||||
|
<i class="form-icon"></i> Edit
|
||||||
|
</label>
|
||||||
|
<label for="{{ form.display_archive_bookmark_action.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ form.display_archive_bookmark_action }}
|
||||||
|
<i class="form-icon"></i> Archive
|
||||||
|
</label>
|
||||||
|
<label for="{{ form.display_remove_bookmark_action.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ form.display_remove_bookmark_action }}
|
||||||
|
<i class="form-icon"></i> Remove
|
||||||
|
</label>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Which actions to display for each bookmark.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
|
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
|
||||||
{{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }}
|
{{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }}
|
||||||
@@ -124,6 +163,30 @@
|
|||||||
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
|
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if has_snapshot_support %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.enable_automatic_html_snapshots.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ form.enable_automatic_html_snapshots }}
|
||||||
|
<i class="form-icon"></i> Automatically create HTML snapshots
|
||||||
|
</label>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Automatically creates HTML snapshots when adding bookmarks. Alternatively, when disabled, snapshots can be
|
||||||
|
created manually in the details view of a bookmark.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-group">
|
||||||
|
<details {% if form.custom_css.value %}open{% endif %}>
|
||||||
|
<summary>Custom CSS</summary>
|
||||||
|
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
{{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Allows to add custom CSS to the page.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
|
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
|
||||||
{% if update_profile_success_message %}
|
{% if update_profile_success_message %}
|
||||||
@@ -150,7 +213,9 @@
|
|||||||
<i class="form-icon"></i> Import public bookmarks as shared
|
<i class="form-icon"></i> Import public bookmarks as shared
|
||||||
</label>
|
</label>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
When importing bookmarks from a service that supports marking bookmarks as public or private (using the <code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not private as shared bookmarks.
|
When importing bookmarks from a service that supports marking bookmarks as public or private (using the
|
||||||
|
<code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not
|
||||||
|
private as shared bookmarks.
|
||||||
Otherwise, all bookmarks will be imported as private bookmarks.
|
Otherwise, all bookmarks will be imported as private bookmarks.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,10 +246,6 @@
|
|||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
<h2>Export</h2>
|
<h2>Export</h2>
|
||||||
<p>Export all bookmarks in Netscape HTML format.</p>
|
<p>Export all bookmarks in Netscape HTML format.</p>
|
||||||
<p>
|
|
||||||
Note that exporting bookmark notes is currently not supported due to limitations of the format.
|
|
||||||
For proper backups please use a database backup as described in the documentation.
|
|
||||||
</p>
|
|
||||||
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
|
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
|
||||||
{% if export_error %}
|
{% if export_error %}
|
||||||
<div class="has-error">
|
<div class="has-error">
|
||||||
@@ -223,10 +284,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Automatically disable public bookmark sharing if bookmark sharing is disabled
|
|
||||||
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
|
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
|
||||||
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
|
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");
|
||||||
|
const 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() {
|
function updatePublicSharing() {
|
||||||
if (enableSharing.checked) {
|
if (enableSharing.checked) {
|
||||||
enablePublicSharing.disabled = false;
|
enablePublicSharing.disabled = false;
|
||||||
@@ -238,6 +301,18 @@
|
|||||||
|
|
||||||
updatePublicSharing();
|
updatePublicSharing();
|
||||||
enableSharing.addEventListener("change", updatePublicSharing);
|
enableSharing.addEventListener("change", updatePublicSharing);
|
||||||
|
|
||||||
|
// Automatically hide the bookmark description max lines input if the description display is set to inline
|
||||||
|
function updateBookmarkDescriptionMaxLines() {
|
||||||
|
if (bookmarkDescriptionDisplay.value === "inline") {
|
||||||
|
bookmarkDescriptionMaxLines.closest(".form-group").classList.add("d-hide");
|
||||||
|
} else {
|
||||||
|
bookmarkDescriptionMaxLines.closest(".form-group").classList.remove("d-hide");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBookmarkDescriptionMaxLines();
|
||||||
|
bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -49,9 +49,11 @@
|
|||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
<h2>RSS Feeds</h2>
|
<h2>RSS Feeds</h2>
|
||||||
<p>The following URLs provide RSS feeds for your bookmarks:</p>
|
<p>The following URLs provide RSS feeds for your bookmarks:</p>
|
||||||
<ul>
|
<ul style="list-style-position: outside;">
|
||||||
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
|
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
|
||||||
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
|
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
|
||||||
|
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
|
||||||
|
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
All URLs support appending a <code>q</code> URL parameter for specifying a search query.
|
All URLs support appending a <code>q</code> URL parameter for specifying a search query.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import random
|
import random
|
||||||
import logging
|
import logging
|
||||||
import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
@@ -10,7 +10,7 @@ from django.utils.crypto import get_random_string
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, Tag
|
from bookmarks.models import Bookmark, BookmarkAsset, Tag
|
||||||
|
|
||||||
|
|
||||||
class BookmarkFactoryMixin:
|
class BookmarkFactoryMixin:
|
||||||
@@ -84,6 +84,7 @@ class BookmarkFactoryMixin:
|
|||||||
unread: bool = False,
|
unread: bool = False,
|
||||||
shared: bool = False,
|
shared: bool = False,
|
||||||
with_tags: bool = False,
|
with_tags: bool = False,
|
||||||
|
with_web_archive_snapshot_url: bool = False,
|
||||||
user: User = None,
|
user: User = None,
|
||||||
):
|
):
|
||||||
user = user or self.get_or_create_test_user()
|
user = user or self.get_or_create_test_user()
|
||||||
@@ -112,6 +113,9 @@ class BookmarkFactoryMixin:
|
|||||||
if with_tags:
|
if with_tags:
|
||||||
tag_name = f"{tag_prefix} {i}{suffix}"
|
tag_name = f"{tag_prefix} {i}{suffix}"
|
||||||
tags = [self.setup_tag(name=tag_name, user=user)]
|
tags = [self.setup_tag(name=tag_name, user=user)]
|
||||||
|
web_archive_snapshot_url = ""
|
||||||
|
if with_web_archive_snapshot_url:
|
||||||
|
web_archive_snapshot_url = f"https://web.archive.org/web/{i}"
|
||||||
bookmark = self.setup_bookmark(
|
bookmark = self.setup_bookmark(
|
||||||
url=url,
|
url=url,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -119,6 +123,7 @@ class BookmarkFactoryMixin:
|
|||||||
unread=unread,
|
unread=unread,
|
||||||
shared=shared,
|
shared=shared,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
bookmarks.append(bookmark)
|
bookmarks.append(bookmark)
|
||||||
@@ -128,6 +133,38 @@ class BookmarkFactoryMixin:
|
|||||||
def get_numbered_bookmark(self, title: str):
|
def get_numbered_bookmark(self, title: str):
|
||||||
return Bookmark.objects.get(title=title)
|
return Bookmark.objects.get(title=title)
|
||||||
|
|
||||||
|
def setup_asset(
|
||||||
|
self,
|
||||||
|
bookmark: Bookmark,
|
||||||
|
date_created: datetime = None,
|
||||||
|
file: str = None,
|
||||||
|
file_size: int = None,
|
||||||
|
asset_type: str = BookmarkAsset.TYPE_SNAPSHOT,
|
||||||
|
content_type: str = "image/html",
|
||||||
|
display_name: str = None,
|
||||||
|
status: str = BookmarkAsset.STATUS_COMPLETE,
|
||||||
|
gzip: bool = False,
|
||||||
|
):
|
||||||
|
if date_created is None:
|
||||||
|
date_created = timezone.now()
|
||||||
|
if not file:
|
||||||
|
file = get_random_string(length=32)
|
||||||
|
if not display_name:
|
||||||
|
display_name = file
|
||||||
|
asset = BookmarkAsset(
|
||||||
|
bookmark=bookmark,
|
||||||
|
date_created=date_created,
|
||||||
|
file=file,
|
||||||
|
file_size=file_size,
|
||||||
|
asset_type=asset_type,
|
||||||
|
content_type=content_type,
|
||||||
|
display_name=display_name,
|
||||||
|
status=status,
|
||||||
|
gzip=gzip,
|
||||||
|
)
|
||||||
|
asset.save()
|
||||||
|
return asset
|
||||||
|
|
||||||
def setup_tag(self, user: User = None, name: str = ""):
|
def setup_tag(self, user: User = None, name: str = ""):
|
||||||
if user is None:
|
if user is None:
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
|
|||||||
125
bookmarks/tests/test_bookmark_asset_view.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.tests.helpers import (
|
||||||
|
BookmarkFactoryMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
temp_files = [
|
||||||
|
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
|
||||||
|
]
|
||||||
|
for temp_file in temp_files:
|
||||||
|
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
|
||||||
|
|
||||||
|
def setup_asset_file(self, filename):
|
||||||
|
if not os.path.exists(settings.LD_ASSET_FOLDER):
|
||||||
|
os.makedirs(settings.LD_ASSET_FOLDER)
|
||||||
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||||
|
with open(filepath, "w") as f:
|
||||||
|
f.write("test")
|
||||||
|
|
||||||
|
def setup_asset_with_file(self, bookmark):
|
||||||
|
filename = f"temp_{bookmark.id}.html.gzip"
|
||||||
|
self.setup_asset_file(filename)
|
||||||
|
asset = self.setup_asset(bookmark=bookmark, file=filename)
|
||||||
|
return asset
|
||||||
|
|
||||||
|
def test_view_access(self):
|
||||||
|
# own bookmark
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# other user's bookmark
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared, sharing disabled
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# unshared, sharing enabled
|
||||||
|
profile = other_user.profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=False)
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared, sharing enabled
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_view_access_guest_user(self):
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
|
# unshared, sharing disabled
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared, sharing disabled
|
||||||
|
bookmark = self.setup_bookmark(shared=True)
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# unshared, sharing enabled
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
bookmark = self.setup_bookmark(shared=False)
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared, sharing enabled
|
||||||
|
bookmark = self.setup_bookmark(shared=True)
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# unshared, public sharing enabled
|
||||||
|
profile.enable_public_sharing = True
|
||||||
|
profile.save()
|
||||||
|
bookmark = self.setup_bookmark(shared=False)
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared, public sharing enabled
|
||||||
|
bookmark = self.setup_bookmark(shared=True)
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
89
bookmarks/tests/test_bookmark_assets.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from bookmarks.tests.helpers import (
|
||||||
|
BookmarkFactoryMixin,
|
||||||
|
)
|
||||||
|
from bookmarks.services import bookmarks
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def tearDown(self):
|
||||||
|
temp_files = [
|
||||||
|
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
|
||||||
|
]
|
||||||
|
for temp_file in temp_files:
|
||||||
|
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
|
||||||
|
|
||||||
|
def setup_asset_file(self, filename):
|
||||||
|
if not os.path.exists(settings.LD_ASSET_FOLDER):
|
||||||
|
os.makedirs(settings.LD_ASSET_FOLDER)
|
||||||
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||||
|
with open(filepath, "w") as f:
|
||||||
|
f.write("test")
|
||||||
|
|
||||||
|
def setup_asset_with_file(self, bookmark):
|
||||||
|
filename = f"temp_{bookmark.id}.html.gzip"
|
||||||
|
self.setup_asset_file(filename)
|
||||||
|
asset = self.setup_asset(bookmark=bookmark, file=filename)
|
||||||
|
return asset
|
||||||
|
|
||||||
|
def test_delete_bookmark_deletes_asset_file(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
asset = self.setup_asset_with_file(bookmark)
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset.file))
|
||||||
|
)
|
||||||
|
|
||||||
|
bookmark.delete()
|
||||||
|
self.assertFalse(
|
||||||
|
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset.file))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bulk_delete_bookmarks_deletes_asset_files(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
asset1 = self.setup_asset_with_file(bookmark1)
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
asset2 = self.setup_asset_with_file(bookmark2)
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
asset3 = self.setup_asset_with_file(bookmark3)
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset1.file))
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset2.file))
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset3.file))
|
||||||
|
)
|
||||||
|
|
||||||
|
bookmarks.delete_bookmarks(
|
||||||
|
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset1.file))
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset2.file))
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset3.file))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_save_updates_file_size(self):
|
||||||
|
# File does not exist initially
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
asset = self.setup_asset(bookmark=bookmark, file="temp.html.gz")
|
||||||
|
self.assertIsNone(asset.file_size)
|
||||||
|
|
||||||
|
# Add file, save again
|
||||||
|
self.setup_asset_file(asset.file)
|
||||||
|
asset.save()
|
||||||
|
self.assertEqual(asset.file_size, 4)
|
||||||
|
|
||||||
|
# Create asset with initial file
|
||||||
|
asset = self.setup_asset(bookmark=bookmark, file="temp.html.gz")
|
||||||
|
self.assertEqual(asset.file_size, 4)
|
||||||
781
bookmarks/tests/test_bookmark_details_modal.py
Normal file
@@ -0,0 +1,781 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import formats
|
||||||
|
|
||||||
|
from bookmarks.models import BookmarkAsset, UserProfile
|
||||||
|
from bookmarks.services import tasks
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
|
def setUp(self):
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
def get_view_name(self):
|
||||||
|
return "bookmarks:details_modal"
|
||||||
|
|
||||||
|
def get_base_url(self, bookmark):
|
||||||
|
return reverse(self.get_view_name(), args=[bookmark.id])
|
||||||
|
|
||||||
|
def get_details_form(self, soup, bookmark):
|
||||||
|
expected_url = reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
return soup.find("form", {"action": expected_url})
|
||||||
|
|
||||||
|
def get_details(self, bookmark, return_url=""):
|
||||||
|
url = self.get_base_url(bookmark)
|
||||||
|
if return_url:
|
||||||
|
url += f"?return_url={return_url}"
|
||||||
|
response = self.client.get(url)
|
||||||
|
soup = self.make_soup(response.content)
|
||||||
|
return soup
|
||||||
|
|
||||||
|
def find_section(self, soup, section_name):
|
||||||
|
dt = soup.find("dt", string=section_name)
|
||||||
|
dd = dt.find_next_sibling("dd") if dt else None
|
||||||
|
return dd
|
||||||
|
|
||||||
|
def get_section(self, soup, section_name):
|
||||||
|
dd = self.find_section(soup, section_name)
|
||||||
|
self.assertIsNotNone(dd)
|
||||||
|
return dd
|
||||||
|
|
||||||
|
def find_weblink(self, soup, url):
|
||||||
|
return soup.find("a", {"class": "weblink", "href": url})
|
||||||
|
|
||||||
|
def find_asset(self, soup, asset):
|
||||||
|
return soup.find("div", {"data-asset-id": asset.id})
|
||||||
|
|
||||||
|
def details_route_access_test(self, view_name: str, shareable: bool):
|
||||||
|
# own bookmark
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# other user's bookmark
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# non-existent bookmark
|
||||||
|
response = self.client.get(reverse(view_name, args=[9999]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# guest user
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
||||||
|
self.assertEqual(response.status_code, 404 if shareable else 302)
|
||||||
|
|
||||||
|
def details_route_sharing_access_test(self, view_name: str, shareable: bool):
|
||||||
|
# shared bookmark, sharing disabled
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(shared=True, user=other_user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared bookmark, sharing enabled
|
||||||
|
profile = other_user.profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
||||||
|
self.assertEqual(response.status_code, 200 if shareable else 404)
|
||||||
|
|
||||||
|
# shared bookmark, guest user, no public sharing
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
||||||
|
self.assertEqual(response.status_code, 404 if shareable else 302)
|
||||||
|
|
||||||
|
# shared bookmark, guest user, public sharing
|
||||||
|
profile.enable_public_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse(view_name, args=[bookmark.id]))
|
||||||
|
self.assertEqual(response.status_code, 200 if shareable else 302)
|
||||||
|
|
||||||
|
def test_access(self):
|
||||||
|
self.details_route_access_test(self.get_view_name(), True)
|
||||||
|
|
||||||
|
def test_access_with_sharing(self):
|
||||||
|
self.details_route_sharing_access_test(self.get_view_name(), True)
|
||||||
|
|
||||||
|
def test_form_partial_access(self):
|
||||||
|
# form partial is only used when submitting forms, which should be only
|
||||||
|
# accessible to the owner of the bookmark. As such assume it requires
|
||||||
|
# login.
|
||||||
|
self.details_route_access_test("bookmarks:partials.details_form", False)
|
||||||
|
|
||||||
|
def test_form_partial_access_with_sharing(self):
|
||||||
|
# form partial is only used when submitting forms, which should be only
|
||||||
|
# accessible to the owner of the bookmark. As such assume it requires
|
||||||
|
# login.
|
||||||
|
self.details_route_sharing_access_test("bookmarks:partials.details_form", False)
|
||||||
|
|
||||||
|
def test_displays_title(self):
|
||||||
|
# with title
|
||||||
|
bookmark = self.setup_bookmark(title="Test title")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
title = soup.find("h2")
|
||||||
|
self.assertIsNotNone(title)
|
||||||
|
self.assertEqual(title.text.strip(), bookmark.title)
|
||||||
|
|
||||||
|
# with website title
|
||||||
|
bookmark = self.setup_bookmark(title="", website_title="Website title")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
title = soup.find("h2")
|
||||||
|
self.assertIsNotNone(title)
|
||||||
|
self.assertEqual(title.text.strip(), bookmark.website_title)
|
||||||
|
|
||||||
|
# with URL only
|
||||||
|
bookmark = self.setup_bookmark(title="", website_title="")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
title = soup.find("h2")
|
||||||
|
self.assertIsNotNone(title)
|
||||||
|
self.assertEqual(title.text.strip(), bookmark.url)
|
||||||
|
|
||||||
|
def test_website_link(self):
|
||||||
|
# basics
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
|
self.assertIsNotNone(link)
|
||||||
|
self.assertEqual(link["href"], bookmark.url)
|
||||||
|
self.assertEqual(link.text.strip(), bookmark.url)
|
||||||
|
|
||||||
|
# favicons disabled
|
||||||
|
bookmark = self.setup_bookmark(favicon_file="example.png")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
|
image = link.select_one("img")
|
||||||
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
# favicons enabled, no favicon
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_favicons = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark(favicon_file="")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
|
image = link.select_one("img")
|
||||||
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
# favicons enabled, favicon present
|
||||||
|
bookmark = self.setup_bookmark(favicon_file="example.png")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.url)
|
||||||
|
image = link.select_one("img")
|
||||||
|
self.assertIsNotNone(image)
|
||||||
|
self.assertEqual(image["src"], "/static/example.png")
|
||||||
|
|
||||||
|
def test_internet_archive_link(self):
|
||||||
|
# without snapshot url
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
self.assertIsNone(link)
|
||||||
|
|
||||||
|
# with snapshot url
|
||||||
|
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
self.assertIsNotNone(link)
|
||||||
|
self.assertEqual(link["href"], bookmark.web_archive_snapshot_url)
|
||||||
|
self.assertEqual(link.text.strip(), "View on Internet Archive")
|
||||||
|
|
||||||
|
# favicons disabled
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
|
||||||
|
)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
image = link.select_one("svg")
|
||||||
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
# favicons enabled, no favicon
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_favicons = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
web_archive_snapshot_url="https://example.com/", favicon_file=""
|
||||||
|
)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
image = link.select_one("svg")
|
||||||
|
self.assertIsNone(image)
|
||||||
|
|
||||||
|
# favicons enabled, favicon present
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
web_archive_snapshot_url="https://example.com/", favicon_file="example.png"
|
||||||
|
)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
image = link.select_one("svg")
|
||||||
|
self.assertIsNotNone(image)
|
||||||
|
|
||||||
|
def test_weblinks_respect_target_setting(self):
|
||||||
|
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||||
|
|
||||||
|
# target blank
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_BLANK
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
website_link = self.find_weblink(soup, bookmark.url)
|
||||||
|
self.assertIsNotNone(website_link)
|
||||||
|
self.assertEqual(website_link["target"], UserProfile.BOOKMARK_LINK_TARGET_BLANK)
|
||||||
|
|
||||||
|
web_archive_link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
self.assertIsNotNone(web_archive_link)
|
||||||
|
self.assertEqual(
|
||||||
|
web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_BLANK
|
||||||
|
)
|
||||||
|
|
||||||
|
# target self
|
||||||
|
profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
website_link = self.find_weblink(soup, bookmark.url)
|
||||||
|
self.assertIsNotNone(website_link)
|
||||||
|
self.assertEqual(website_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF)
|
||||||
|
|
||||||
|
web_archive_link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
|
self.assertIsNotNone(web_archive_link)
|
||||||
|
self.assertEqual(
|
||||||
|
web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status(self):
|
||||||
|
# renders form
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
form = self.get_details_form(soup, bookmark)
|
||||||
|
self.assertIsNotNone(form)
|
||||||
|
self.assertEqual(
|
||||||
|
form["action"], reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
)
|
||||||
|
self.assertEqual(form["method"], "post")
|
||||||
|
|
||||||
|
# sharing disabled
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
self.assertIsNotNone(archived)
|
||||||
|
unread = section.find("input", {"type": "checkbox", "name": "unread"})
|
||||||
|
self.assertIsNotNone(unread)
|
||||||
|
shared = section.find("input", {"type": "checkbox", "name": "shared"})
|
||||||
|
self.assertIsNone(shared)
|
||||||
|
|
||||||
|
# sharing enabled
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
self.assertIsNotNone(archived)
|
||||||
|
unread = section.find("input", {"type": "checkbox", "name": "unread"})
|
||||||
|
self.assertIsNotNone(unread)
|
||||||
|
shared = section.find("input", {"type": "checkbox", "name": "shared"})
|
||||||
|
self.assertIsNotNone(shared)
|
||||||
|
|
||||||
|
# unchecked
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
self.assertFalse(archived.has_attr("checked"))
|
||||||
|
unread = section.find("input", {"type": "checkbox", "name": "unread"})
|
||||||
|
self.assertFalse(unread.has_attr("checked"))
|
||||||
|
shared = section.find("input", {"type": "checkbox", "name": "shared"})
|
||||||
|
self.assertFalse(shared.has_attr("checked"))
|
||||||
|
|
||||||
|
# checked
|
||||||
|
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Status")
|
||||||
|
|
||||||
|
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
|
||||||
|
self.assertTrue(archived.has_attr("checked"))
|
||||||
|
unread = section.find("input", {"type": "checkbox", "name": "unread"})
|
||||||
|
self.assertTrue(unread.has_attr("checked"))
|
||||||
|
shared = section.find("input", {"type": "checkbox", "name": "shared"})
|
||||||
|
self.assertTrue(shared.has_attr("checked"))
|
||||||
|
|
||||||
|
def test_status_visibility(self):
|
||||||
|
# own bookmark
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.find_section(soup, "Status")
|
||||||
|
self.assertIsNotNone(section)
|
||||||
|
|
||||||
|
# other user's bookmark
|
||||||
|
other_user = self.setup_user(enable_sharing=True)
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.find_section(soup, "Status")
|
||||||
|
self.assertIsNone(section)
|
||||||
|
|
||||||
|
# guest user
|
||||||
|
self.client.logout()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.find_section(soup, "Status")
|
||||||
|
self.assertIsNone(section)
|
||||||
|
|
||||||
|
def test_status_update(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
# update status
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertTrue(bookmark.is_archived)
|
||||||
|
self.assertTrue(bookmark.unread)
|
||||||
|
self.assertTrue(bookmark.shared)
|
||||||
|
|
||||||
|
# update individual status
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "", "unread": "on", "shared": ""},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertFalse(bookmark.is_archived)
|
||||||
|
self.assertTrue(bookmark.unread)
|
||||||
|
self.assertFalse(bookmark.shared)
|
||||||
|
|
||||||
|
def test_status_update_access(self):
|
||||||
|
# no sharing
|
||||||
|
other_user = self.setup_user()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared, sharing disabled
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared, sharing enabled
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
profile = other_user.profile
|
||||||
|
profile.enable_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# shared, public sharing enabled
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
profile = other_user.profile
|
||||||
|
profile.enable_public_sharing = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# guest user
|
||||||
|
self.client.logout()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark),
|
||||||
|
{"is_archived": "on", "unread": "on", "shared": "on"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_date_added(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Date added")
|
||||||
|
|
||||||
|
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
|
||||||
|
date = section.find("span", string=expected_date)
|
||||||
|
self.assertIsNotNone(date)
|
||||||
|
|
||||||
|
def test_tags(self):
|
||||||
|
# without tags
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.find_section(soup, "Tags")
|
||||||
|
self.assertIsNone(section)
|
||||||
|
|
||||||
|
# with tags
|
||||||
|
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Tags")
|
||||||
|
|
||||||
|
for tag in bookmark.tags.all():
|
||||||
|
tag_link = section.find("a", string=f"#{tag.name}")
|
||||||
|
self.assertIsNotNone(tag_link)
|
||||||
|
expected_url = reverse("bookmarks:index") + f"?q=%23{tag.name}"
|
||||||
|
self.assertEqual(tag_link["href"], expected_url)
|
||||||
|
|
||||||
|
def test_description(self):
|
||||||
|
# without description
|
||||||
|
bookmark = self.setup_bookmark(description="", website_description="")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.find_section(soup, "Description")
|
||||||
|
self.assertIsNone(section)
|
||||||
|
|
||||||
|
# with description
|
||||||
|
bookmark = self.setup_bookmark(description="Test description")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.get_section(soup, "Description")
|
||||||
|
self.assertEqual(section.text.strip(), bookmark.description)
|
||||||
|
|
||||||
|
# with website description
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
description="", website_description="Website description"
|
||||||
|
)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.get_section(soup, "Description")
|
||||||
|
self.assertEqual(section.text.strip(), bookmark.website_description)
|
||||||
|
|
||||||
|
def test_notes(self):
|
||||||
|
# without notes
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.find_section(soup, "Notes")
|
||||||
|
self.assertIsNone(section)
|
||||||
|
|
||||||
|
# with notes
|
||||||
|
bookmark = self.setup_bookmark(notes="Test notes")
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
section = self.get_section(soup, "Notes")
|
||||||
|
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
|
||||||
|
|
||||||
|
def test_edit_link(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
# with default return URL
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
edit_link = soup.find("a", string="Edit")
|
||||||
|
self.assertIsNotNone(edit_link)
|
||||||
|
details_url = reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
expected_url = (
|
||||||
|
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=" + details_url
|
||||||
|
)
|
||||||
|
self.assertEqual(edit_link["href"], expected_url)
|
||||||
|
|
||||||
|
# with custom return URL
|
||||||
|
soup = self.get_details(bookmark, return_url="/custom")
|
||||||
|
edit_link = soup.find("a", string="Edit")
|
||||||
|
self.assertIsNotNone(edit_link)
|
||||||
|
expected_url = (
|
||||||
|
reverse("bookmarks:edit", args=[bookmark.id]) + "?return_url=/custom"
|
||||||
|
)
|
||||||
|
self.assertEqual(edit_link["href"], expected_url)
|
||||||
|
|
||||||
|
def test_delete_button(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
# basics
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
|
self.assertIsNotNone(delete_button)
|
||||||
|
self.assertEqual(delete_button.text.strip(), "Delete...")
|
||||||
|
self.assertEqual(delete_button["value"], str(bookmark.id))
|
||||||
|
|
||||||
|
form = delete_button.find_parent("form")
|
||||||
|
self.assertIsNotNone(form)
|
||||||
|
expected_url = reverse("bookmarks:index.action") + f"?return_url=/bookmarks"
|
||||||
|
self.assertEqual(form["action"], expected_url)
|
||||||
|
|
||||||
|
# with custom return URL
|
||||||
|
soup = self.get_details(bookmark, return_url="/custom")
|
||||||
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
|
form = delete_button.find_parent("form")
|
||||||
|
expected_url = reverse("bookmarks:index.action") + f"?return_url=/custom"
|
||||||
|
self.assertEqual(form["action"], expected_url)
|
||||||
|
|
||||||
|
def test_actions_visibility(self):
|
||||||
|
# with sharing
|
||||||
|
other_user = self.setup_user(enable_sharing=True)
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
edit_link = soup.find("a", string="Edit")
|
||||||
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
|
self.assertIsNone(edit_link)
|
||||||
|
self.assertIsNone(delete_button)
|
||||||
|
|
||||||
|
# with public sharing
|
||||||
|
profile = other_user.profile
|
||||||
|
profile.enable_public_sharing = True
|
||||||
|
profile.save()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
edit_link = soup.find("a", string="Edit")
|
||||||
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
|
self.assertIsNone(edit_link)
|
||||||
|
self.assertIsNone(delete_button)
|
||||||
|
|
||||||
|
# guest user
|
||||||
|
self.client.logout()
|
||||||
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
edit_link = soup.find("a", string="Edit")
|
||||||
|
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
|
||||||
|
self.assertIsNone(edit_link)
|
||||||
|
self.assertIsNone(delete_button)
|
||||||
|
|
||||||
|
def test_assets_visibility_no_snapshot_support(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.find_section(soup, "Files")
|
||||||
|
self.assertIsNone(section)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_assets_visibility_with_snapshot_support(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.find_section(soup, "Files")
|
||||||
|
self.assertIsNotNone(section)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_asset_list_visibility(self):
|
||||||
|
# no assets
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Files")
|
||||||
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
|
self.assertIsNone(asset_list)
|
||||||
|
|
||||||
|
# with assets
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
self.setup_asset(bookmark)
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Files")
|
||||||
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
|
self.assertIsNotNone(asset_list)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_asset_list(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
assets = [
|
||||||
|
self.setup_asset(bookmark),
|
||||||
|
self.setup_asset(bookmark),
|
||||||
|
self.setup_asset(bookmark),
|
||||||
|
]
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
section = self.get_section(soup, "Files")
|
||||||
|
asset_list = section.find("div", {"class": "assets"})
|
||||||
|
|
||||||
|
for asset in assets:
|
||||||
|
asset_item = self.find_asset(asset_list, asset)
|
||||||
|
self.assertIsNotNone(asset_item)
|
||||||
|
|
||||||
|
asset_icon = asset_item.select_one(".asset-icon svg")
|
||||||
|
self.assertIsNotNone(asset_icon)
|
||||||
|
|
||||||
|
asset_text = asset_item.select_one(".asset-text span")
|
||||||
|
self.assertIsNotNone(asset_text)
|
||||||
|
self.assertIn(asset.display_name, asset_text.text)
|
||||||
|
|
||||||
|
view_url = reverse("bookmarks:assets.view", args=[asset.id])
|
||||||
|
view_link = asset_item.find("a", {"href": view_url})
|
||||||
|
self.assertIsNotNone(view_link)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_asset_without_file(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
asset = self.setup_asset(bookmark)
|
||||||
|
asset.file = ""
|
||||||
|
asset.save()
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
asset_item = self.find_asset(soup, asset)
|
||||||
|
view_url = reverse("bookmarks:assets.view", args=[asset.id])
|
||||||
|
view_link = asset_item.find("a", {"href": view_url})
|
||||||
|
self.assertIsNone(view_link)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_asset_status(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
|
||||||
|
failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE)
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
asset_item = self.find_asset(soup, pending_asset)
|
||||||
|
asset_text = asset_item.select_one(".asset-text span")
|
||||||
|
self.assertIn("(queued)", asset_text.text)
|
||||||
|
|
||||||
|
asset_item = self.find_asset(soup, failed_asset)
|
||||||
|
asset_text = asset_item.select_one(".asset-text span")
|
||||||
|
self.assertIn("(failed)", asset_text.text)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_asset_file_size(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
asset1 = self.setup_asset(bookmark, file_size=None)
|
||||||
|
asset2 = self.setup_asset(bookmark, file_size=54639)
|
||||||
|
asset3 = self.setup_asset(bookmark, file_size=11492020)
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
asset_item = self.find_asset(soup, asset1)
|
||||||
|
asset_text = asset_item.select_one(".asset-text")
|
||||||
|
self.assertEqual(asset_text.text.strip(), asset1.display_name)
|
||||||
|
|
||||||
|
asset_item = self.find_asset(soup, asset2)
|
||||||
|
asset_text = asset_item.select_one(".asset-text")
|
||||||
|
self.assertIn("53.4\xa0KB", asset_text.text)
|
||||||
|
|
||||||
|
asset_item = self.find_asset(soup, asset3)
|
||||||
|
asset_text = asset_item.select_one(".asset-text")
|
||||||
|
self.assertIn("11.0\xa0MB", asset_text.text)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_asset_actions_visibility(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
# with file
|
||||||
|
asset = self.setup_asset(bookmark)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
asset_item = self.find_asset(soup, asset)
|
||||||
|
view_link = asset_item.find("a", string="View")
|
||||||
|
delete_button = asset_item.find(
|
||||||
|
"button", {"type": "submit", "name": "remove_asset"}
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(view_link)
|
||||||
|
self.assertIsNotNone(delete_button)
|
||||||
|
|
||||||
|
# without file
|
||||||
|
asset.file = ""
|
||||||
|
asset.save()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
asset_item = self.find_asset(soup, asset)
|
||||||
|
view_link = asset_item.find("a", string="View")
|
||||||
|
delete_button = asset_item.find(
|
||||||
|
"button", {"type": "submit", "name": "remove_asset"}
|
||||||
|
)
|
||||||
|
self.assertIsNone(view_link)
|
||||||
|
self.assertIsNotNone(delete_button)
|
||||||
|
|
||||||
|
# shared bookmark
|
||||||
|
other_user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
|
bookmark = self.setup_bookmark(shared=True, user=other_user)
|
||||||
|
asset = self.setup_asset(bookmark)
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
asset_item = self.find_asset(soup, asset)
|
||||||
|
view_link = asset_item.find("a", string="View")
|
||||||
|
delete_button = asset_item.find(
|
||||||
|
"button", {"type": "submit", "name": "remove_asset"}
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(view_link)
|
||||||
|
self.assertIsNone(delete_button)
|
||||||
|
|
||||||
|
# shared bookmark, guest user
|
||||||
|
self.client.logout()
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
|
||||||
|
asset_item = self.find_asset(soup, asset)
|
||||||
|
view_link = asset_item.find("a", string="View")
|
||||||
|
delete_button = asset_item.find(
|
||||||
|
"button", {"type": "submit", "name": "remove_asset"}
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(view_link)
|
||||||
|
self.assertIsNone(delete_button)
|
||||||
|
|
||||||
|
def test_remove_asset(self):
|
||||||
|
# remove asset
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
asset = self.setup_asset(bookmark)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark), {"remove_asset": asset.id}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||||
|
|
||||||
|
# non-existent asset
|
||||||
|
response = self.client.post(self.get_base_url(bookmark), {"remove_asset": 9999})
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# post without asset ID does not remove
|
||||||
|
asset = self.setup_asset(bookmark)
|
||||||
|
response = self.client.post(self.get_base_url(bookmark))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||||
|
|
||||||
|
# guest user
|
||||||
|
asset = self.setup_asset(bookmark)
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark), {"remove_asset": asset.id}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_create_snapshot(self):
|
||||||
|
with patch.object(
|
||||||
|
tasks, "_create_html_snapshot_task"
|
||||||
|
) as mock_create_html_snapshot_task:
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
response = self.client.post(
|
||||||
|
self.get_base_url(bookmark), {"create_snapshot": ""}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
mock_create_html_snapshot_task.assert_called_with(bookmark.id)
|
||||||
|
|
||||||
|
self.assertEqual(bookmark.bookmarkasset_set.count(), 1)
|
||||||
6
bookmarks/tests/test_bookmark_details_view.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from bookmarks.tests.test_bookmark_details_modal import BookmarkDetailsModalTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkDetailsViewTestCase(BookmarkDetailsModalTestCase):
|
||||||
|
def get_view_name(self):
|
||||||
|
return "bookmarks:details"
|
||||||
@@ -35,6 +35,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
expectation["notes"] = bookmark.notes
|
expectation["notes"] = bookmark.notes
|
||||||
expectation["website_title"] = bookmark.website_title
|
expectation["website_title"] = bookmark.website_title
|
||||||
expectation["website_description"] = bookmark.website_description
|
expectation["website_description"] = bookmark.website_description
|
||||||
|
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
|
||||||
expectation["is_archived"] = bookmark.is_archived
|
expectation["is_archived"] = bookmark.is_archived
|
||||||
expectation["unread"] = bookmark.unread
|
expectation["unread"] = bookmark.unread
|
||||||
expectation["shared"] = bookmark.shared
|
expectation["shared"] = bookmark.shared
|
||||||
@@ -61,6 +62,17 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
)
|
)
|
||||||
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||||
|
|
||||||
|
def test_list_bookmarks_with_more_details(self):
|
||||||
|
self.authenticate()
|
||||||
|
bookmarks = self.setup_numbered_bookmarks(
|
||||||
|
5, with_tags=True, with_web_archive_snapshot_url=True
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.get(
|
||||||
|
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
self.assertBookmarkListEqual(response.data["results"], bookmarks)
|
||||||
|
|
||||||
def test_list_bookmarks_does_not_return_archived_bookmarks(self):
|
def test_list_bookmarks_does_not_return_archived_bookmarks(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
bookmarks = self.setup_numbered_bookmarks(5)
|
bookmarks = self.setup_numbered_bookmarks(5)
|
||||||
@@ -436,6 +448,18 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||||
self.assertBookmarkListEqual([response.data], [bookmark])
|
self.assertBookmarkListEqual([response.data], [bookmark])
|
||||||
|
|
||||||
|
def test_get_bookmark_with_more_details(self):
|
||||||
|
self.authenticate()
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
web_archive_snapshot_url="https://web.archive.org/web/1/",
|
||||||
|
tags=[tag1],
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||||
|
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||||
|
self.assertBookmarkListEqual([response.data], [bookmark])
|
||||||
|
|
||||||
def test_update_bookmark(self):
|
def test_update_bookmark(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ from django.utils import timezone, formats
|
|||||||
|
|
||||||
from bookmarks.middlewares import UserProfileMiddleware
|
from bookmarks.middlewares import UserProfileMiddleware
|
||||||
from bookmarks.models import Bookmark, UserProfile, User
|
from bookmarks.models import Bookmark, UserProfile, User
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
from bookmarks.views.partials import contexts
|
from bookmarks.views.partials import contexts
|
||||||
|
|
||||||
|
|
||||||
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
|
|
||||||
def assertBookmarksLink(
|
def assertBookmarksLink(
|
||||||
self, html: str, bookmark: Bookmark, link_target: str = "_blank"
|
self, html: str, bookmark: Bookmark, link_target: str = "_blank"
|
||||||
@@ -26,10 +26,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
)
|
)
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
|
{favicon_img}
|
||||||
<a href="{bookmark.url}"
|
<a href="{bookmark.url}"
|
||||||
target="{link_target}"
|
target="{link_target}"
|
||||||
rel="noopener">
|
rel="noopener">
|
||||||
{favicon_img}
|
|
||||||
<span>{bookmark.resolved_title}</span>
|
<span>{bookmark.resolved_title}</span>
|
||||||
</a>
|
</a>
|
||||||
""",
|
""",
|
||||||
@@ -40,7 +40,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<span>{label_content}</span>
|
<span>{label_content}</span>
|
||||||
<span class="separator">|</span>
|
<span>|</span>
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
@@ -54,19 +54,39 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||||
{label_content} ∞
|
{label_content} ∞
|
||||||
</a>
|
</a>
|
||||||
<span class="separator">|</span>
|
<span>|</span>
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
|
def assertViewLink(
|
||||||
self.assertBookmarkActionsCount(html, bookmark, count=1)
|
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
|
||||||
|
):
|
||||||
|
self.assertViewLinkCount(html, bookmark, return_url=return_url)
|
||||||
|
|
||||||
def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):
|
def assertNoViewLink(
|
||||||
self.assertBookmarkActionsCount(html, bookmark, count=0)
|
self, html: str, bookmark: Bookmark, return_url=reverse("bookmarks:index")
|
||||||
|
):
|
||||||
|
self.assertViewLinkCount(html, bookmark, count=0, return_url=return_url)
|
||||||
|
|
||||||
def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
|
def assertViewLinkCount(
|
||||||
# Edit link
|
self,
|
||||||
|
html: str,
|
||||||
|
bookmark: Bookmark,
|
||||||
|
count=1,
|
||||||
|
return_url=reverse("bookmarks:index"),
|
||||||
|
):
|
||||||
|
details_url = reverse("bookmarks:details", args=[bookmark.id])
|
||||||
|
details_modal_url = reverse("bookmarks:details_modal", args=[bookmark.id])
|
||||||
|
self.assertInHTML(
|
||||||
|
f"""
|
||||||
|
<a ld-modal modal-url="{details_modal_url}?return_url={return_url}" href="{details_url}">View</a>
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
|
|
||||||
|
def assertEditLinkCount(self, html: str, bookmark: Bookmark, count=1):
|
||||||
edit_url = reverse("bookmarks:edit", args=[bookmark.id])
|
edit_url = reverse("bookmarks:edit", args=[bookmark.id])
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
@@ -75,7 +95,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
html,
|
html,
|
||||||
count=count,
|
count=count,
|
||||||
)
|
)
|
||||||
# Archive link
|
|
||||||
|
def assertArchiveLinkCount(self, html: str, bookmark: Bookmark, count=1):
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<button type="submit" name="archive" value="{bookmark.id}"
|
<button type="submit" name="archive" value="{bookmark.id}"
|
||||||
@@ -84,7 +105,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
html,
|
html,
|
||||||
count=count,
|
count=count,
|
||||||
)
|
)
|
||||||
# Delete link
|
|
||||||
|
def assertDeleteLinkCount(self, html: str, bookmark: Bookmark, count=1):
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<button ld-confirm-button type="submit" name="remove" value="{bookmark.id}"
|
<button ld-confirm-button type="submit" name="remove" value="{bookmark.id}"
|
||||||
@@ -94,6 +116,17 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
count=count,
|
count=count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
|
||||||
|
self.assertBookmarkActionsCount(html, bookmark, count=1)
|
||||||
|
|
||||||
|
def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):
|
||||||
|
self.assertBookmarkActionsCount(html, bookmark, count=0)
|
||||||
|
|
||||||
|
def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
|
||||||
|
self.assertEditLinkCount(html, bookmark, count=count)
|
||||||
|
self.assertArchiveLinkCount(html, bookmark, count=count)
|
||||||
|
self.assertDeleteLinkCount(html, bookmark, count=count)
|
||||||
|
|
||||||
def assertShareInfo(self, html: str, bookmark: Bookmark):
|
def assertShareInfo(self, html: str, bookmark: Bookmark):
|
||||||
self.assertShareInfoCount(html, bookmark, 1)
|
self.assertShareInfoCount(html, bookmark, 1)
|
||||||
|
|
||||||
@@ -101,6 +134,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertShareInfoCount(html, bookmark, 0)
|
self.assertShareInfoCount(html, bookmark, 0)
|
||||||
|
|
||||||
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
|
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
|
||||||
|
# Shared by link
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<span>Shared by
|
<span>Shared by
|
||||||
@@ -133,7 +167,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
f"""
|
f"""
|
||||||
<div class="url-path truncate">
|
<div class="url-path truncate">
|
||||||
<a href="{bookmark.url}" target="{link_target}" rel="noopener"
|
<a href="{bookmark.url}" target="{link_target}" rel="noopener"
|
||||||
class="url-display text-sm">
|
class="url-display">
|
||||||
{bookmark.url}
|
{bookmark.url}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,7 +188,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<div class="notes bg-gray text-gray-dark">
|
<div class="notes bg-gray text-gray-dark">
|
||||||
<div class="notes-content">
|
<div class="markdown">
|
||||||
{notes_html}
|
{notes_html}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,6 +275,172 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
user.profile.save()
|
user.profile.save()
|
||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
|
def inline_bookmark_description_test(self, bookmark):
|
||||||
|
html = self.render_template()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
|
||||||
|
has_description = bool(bookmark.description)
|
||||||
|
has_tags = len(bookmark.tags.all()) > 0
|
||||||
|
|
||||||
|
# inline description block exists
|
||||||
|
description = soup.select_one(".description.inline.truncate")
|
||||||
|
self.assertIsNotNone(description)
|
||||||
|
|
||||||
|
# separate description block does not exist
|
||||||
|
separate_description = soup.select_one(".description.separate")
|
||||||
|
self.assertIsNone(separate_description)
|
||||||
|
|
||||||
|
# one direct child element per description or tags
|
||||||
|
children = description.find_all(recursive=False)
|
||||||
|
expected_child_count = (
|
||||||
|
0 + (1 if has_description else 0) + (1 if has_tags else 0)
|
||||||
|
)
|
||||||
|
self.assertEqual(len(children), expected_child_count)
|
||||||
|
|
||||||
|
# has separator between description and tags
|
||||||
|
if has_description and has_tags:
|
||||||
|
self.assertTrue("|" in description.text)
|
||||||
|
|
||||||
|
# contains description text
|
||||||
|
if has_description:
|
||||||
|
description_text = description.find("span", text=bookmark.description)
|
||||||
|
self.assertIsNotNone(description_text)
|
||||||
|
|
||||||
|
if not has_tags:
|
||||||
|
# no tags element
|
||||||
|
tags = soup.select_one(".tags")
|
||||||
|
self.assertIsNone(tags)
|
||||||
|
else:
|
||||||
|
# tags element exists
|
||||||
|
tags = soup.select_one(".tags")
|
||||||
|
self.assertIsNotNone(tags)
|
||||||
|
|
||||||
|
# one link for each tag
|
||||||
|
tag_links = tags.find_all("a")
|
||||||
|
self.assertEqual(len(tag_links), len(bookmark.tags.all()))
|
||||||
|
|
||||||
|
for tag in bookmark.tags.all():
|
||||||
|
tag_link = tags.find("a", text=f"#{tag.name}")
|
||||||
|
self.assertIsNotNone(tag_link)
|
||||||
|
self.assertEqual(tag_link["href"], f"?q=%23{tag.name}")
|
||||||
|
|
||||||
|
def test_inline_bookmark_description(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.bookmark_description_display = (
|
||||||
|
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
|
||||||
|
)
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
# no description, no tags
|
||||||
|
bookmark = self.setup_bookmark(description="")
|
||||||
|
self.inline_bookmark_description_test(bookmark)
|
||||||
|
|
||||||
|
# with description, no tags
|
||||||
|
bookmark = self.setup_bookmark(description="Test description")
|
||||||
|
self.inline_bookmark_description_test(bookmark)
|
||||||
|
|
||||||
|
# no description, with tags
|
||||||
|
Bookmark.objects.all().delete()
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
description="", tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()]
|
||||||
|
)
|
||||||
|
self.inline_bookmark_description_test(bookmark)
|
||||||
|
|
||||||
|
# with description, with tags
|
||||||
|
Bookmark.objects.all().delete()
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
description="Test description",
|
||||||
|
tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()],
|
||||||
|
)
|
||||||
|
self.inline_bookmark_description_test(bookmark)
|
||||||
|
|
||||||
|
def separate_bookmark_description_test(self, bookmark):
|
||||||
|
html = self.render_template()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
|
||||||
|
has_description = bool(bookmark.description)
|
||||||
|
has_tags = len(bookmark.tags.all()) > 0
|
||||||
|
|
||||||
|
# inline description block does not exist
|
||||||
|
inline_description = soup.select_one(".description.inline")
|
||||||
|
self.assertIsNone(inline_description)
|
||||||
|
|
||||||
|
if not has_description:
|
||||||
|
# no description element
|
||||||
|
description = soup.select_one(".description")
|
||||||
|
self.assertIsNone(description)
|
||||||
|
else:
|
||||||
|
# contains description text
|
||||||
|
description = soup.select_one(".description.separate")
|
||||||
|
self.assertIsNotNone(description)
|
||||||
|
self.assertEqual(description.text.strip(), bookmark.description)
|
||||||
|
|
||||||
|
if not has_tags:
|
||||||
|
# no tags element
|
||||||
|
tags = soup.select_one(".tags")
|
||||||
|
self.assertIsNone(tags)
|
||||||
|
else:
|
||||||
|
# tags element exists
|
||||||
|
tags = soup.select_one(".tags")
|
||||||
|
self.assertIsNotNone(tags)
|
||||||
|
|
||||||
|
# one link for each tag
|
||||||
|
tag_links = tags.find_all("a")
|
||||||
|
self.assertEqual(len(tag_links), len(bookmark.tags.all()))
|
||||||
|
|
||||||
|
for tag in bookmark.tags.all():
|
||||||
|
tag_link = tags.find("a", text=f"#{tag.name}")
|
||||||
|
self.assertIsNotNone(tag_link)
|
||||||
|
self.assertEqual(tag_link["href"], f"?q=%23{tag.name}")
|
||||||
|
|
||||||
|
def test_separate_bookmark_description(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.bookmark_description_display = (
|
||||||
|
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
|
||||||
|
)
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
# no description, no tags
|
||||||
|
bookmark = self.setup_bookmark(description="")
|
||||||
|
self.separate_bookmark_description_test(bookmark)
|
||||||
|
|
||||||
|
# with description, no tags
|
||||||
|
bookmark = self.setup_bookmark(description="Test description")
|
||||||
|
self.separate_bookmark_description_test(bookmark)
|
||||||
|
|
||||||
|
# no description, with tags
|
||||||
|
Bookmark.objects.all().delete()
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
description="", tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()]
|
||||||
|
)
|
||||||
|
self.separate_bookmark_description_test(bookmark)
|
||||||
|
|
||||||
|
# with description, with tags
|
||||||
|
Bookmark.objects.all().delete()
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
description="Test description",
|
||||||
|
tags=[self.setup_tag(), self.setup_tag(), self.setup_tag()],
|
||||||
|
)
|
||||||
|
self.separate_bookmark_description_test(bookmark)
|
||||||
|
|
||||||
|
def test_bookmark_description_max_lines(self):
|
||||||
|
self.setup_bookmark()
|
||||||
|
html = self.render_template()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
bookmark_list = soup.select_one("ul.bookmark-list")
|
||||||
|
style = bookmark_list["style"]
|
||||||
|
self.assertIn("--ld-bookmark-description-max-lines:1;", style)
|
||||||
|
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.bookmark_description_max_lines = 3
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
html = self.render_template()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
bookmark_list = soup.select_one("ul.bookmark-list")
|
||||||
|
style = bookmark_list["style"]
|
||||||
|
self.assertIn("--ld-bookmark-description-max-lines:3;", style)
|
||||||
|
|
||||||
def test_should_respect_absolute_date_setting(self):
|
def test_should_respect_absolute_date_setting(self):
|
||||||
bookmark = self.setup_date_format_test(
|
bookmark = self.setup_date_format_test(
|
||||||
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
|
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
|
||||||
@@ -351,9 +551,58 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
html = self.render_template()
|
html = self.render_template()
|
||||||
|
|
||||||
|
self.assertViewLink(html, bookmark)
|
||||||
self.assertBookmarkActions(html, bookmark)
|
self.assertBookmarkActions(html, bookmark)
|
||||||
self.assertNoShareInfo(html, bookmark)
|
self.assertNoShareInfo(html, bookmark)
|
||||||
|
|
||||||
|
def test_hide_view_link(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.display_view_bookmark_action = False
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
html = self.render_template()
|
||||||
|
self.assertViewLinkCount(html, bookmark, count=0)
|
||||||
|
self.assertEditLinkCount(html, bookmark, count=1)
|
||||||
|
self.assertArchiveLinkCount(html, bookmark, count=1)
|
||||||
|
self.assertDeleteLinkCount(html, bookmark, count=1)
|
||||||
|
|
||||||
|
def test_hide_edit_link(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.display_edit_bookmark_action = False
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
html = self.render_template()
|
||||||
|
self.assertViewLinkCount(html, bookmark, count=1)
|
||||||
|
self.assertEditLinkCount(html, bookmark, count=0)
|
||||||
|
self.assertArchiveLinkCount(html, bookmark, count=1)
|
||||||
|
self.assertDeleteLinkCount(html, bookmark, count=1)
|
||||||
|
|
||||||
|
def test_hide_archive_link(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.display_archive_bookmark_action = False
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
html = self.render_template()
|
||||||
|
self.assertViewLinkCount(html, bookmark, count=1)
|
||||||
|
self.assertEditLinkCount(html, bookmark, count=1)
|
||||||
|
self.assertArchiveLinkCount(html, bookmark, count=0)
|
||||||
|
self.assertDeleteLinkCount(html, bookmark, count=1)
|
||||||
|
|
||||||
|
def test_hide_remove_link(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.display_remove_bookmark_action = False
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
html = self.render_template()
|
||||||
|
self.assertViewLinkCount(html, bookmark, count=1)
|
||||||
|
self.assertEditLinkCount(html, bookmark, count=1)
|
||||||
|
self.assertArchiveLinkCount(html, bookmark, count=1)
|
||||||
|
self.assertDeleteLinkCount(html, bookmark, count=0)
|
||||||
|
|
||||||
def test_show_share_info_for_non_owned_bookmarks(self):
|
def test_show_share_info_for_non_owned_bookmarks(self):
|
||||||
other_user = User.objects.create_user(
|
other_user = User.objects.create_user(
|
||||||
"otheruser", "otheruser@example.com", "password123"
|
"otheruser", "otheruser@example.com", "password123"
|
||||||
@@ -364,6 +613,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
||||||
|
|
||||||
|
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
|
||||||
self.assertNoBookmarkActions(html, bookmark)
|
self.assertNoBookmarkActions(html, bookmark)
|
||||||
self.assertShareInfo(html, bookmark)
|
self.assertShareInfo(html, bookmark)
|
||||||
|
|
||||||
@@ -539,9 +789,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
def test_notes_are_hidden_initially_by_default(self):
|
def test_notes_are_hidden_initially_by_default(self):
|
||||||
self.setup_bookmark(notes="Test note")
|
self.setup_bookmark(notes="Test note")
|
||||||
html = collapse_whitespace(self.render_template())
|
html = self.render_template()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
|
||||||
|
|
||||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
|
self.assertIsNone(bookmark_list)
|
||||||
|
|
||||||
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
|
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
|
||||||
profile = self.get_or_create_test_user().profile
|
profile = self.get_or_create_test_user().profile
|
||||||
@@ -549,9 +801,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
self.setup_bookmark(notes="Test note")
|
self.setup_bookmark(notes="Test note")
|
||||||
html = collapse_whitespace(self.render_template())
|
html = self.render_template()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
|
||||||
|
|
||||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
|
self.assertIsNone(bookmark_list)
|
||||||
|
|
||||||
def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
|
def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
|
||||||
profile = self.get_or_create_test_user().profile
|
profile = self.get_or_create_test_user().profile
|
||||||
@@ -559,11 +813,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
self.setup_bookmark(notes="Test note")
|
self.setup_bookmark(notes="Test note")
|
||||||
html = collapse_whitespace(self.render_template())
|
html = self.render_template()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
bookmark_list = soup.select_one("ul.bookmark-list.show-notes")
|
||||||
|
|
||||||
self.assertIn(
|
self.assertIsNotNone(bookmark_list)
|
||||||
'<ul class="bookmark-list show-notes" data-bookmarks-total="1">', html
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_toggle_notes_is_visible_by_default(self):
|
def test_toggle_notes_is_visible_by_default(self):
|
||||||
self.setup_bookmark(notes="Test note")
|
self.setup_bookmark(notes="Test note")
|
||||||
@@ -615,6 +869,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertWebArchiveLink(
|
self.assertWebArchiveLink(
|
||||||
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
|
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
|
||||||
)
|
)
|
||||||
|
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
|
||||||
self.assertNoBookmarkActions(html, bookmark)
|
self.assertNoBookmarkActions(html, bookmark)
|
||||||
self.assertShareInfo(html, bookmark)
|
self.assertShareInfo(html, bookmark)
|
||||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||||
|
|||||||
@@ -105,6 +105,24 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
mock_load_favicon.assert_called_once_with(self.user, bookmark)
|
mock_load_favicon.assert_called_once_with(self.user, bookmark)
|
||||||
|
|
||||||
|
def test_create_should_load_html_snapshot(self):
|
||||||
|
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
|
||||||
|
bookmark_data = Bookmark(url="https://example.com")
|
||||||
|
bookmark = create_bookmark(bookmark_data, "tag1,tag2", self.user)
|
||||||
|
|
||||||
|
mock_create_html_snapshot.assert_called_once_with(bookmark)
|
||||||
|
|
||||||
|
def test_create_should_not_load_html_snapshot_when_setting_is_disabled(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_automatic_html_snapshots = False
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
|
||||||
|
bookmark_data = Bookmark(url="https://example.com")
|
||||||
|
create_bookmark(bookmark_data, "tag1,tag2", self.user)
|
||||||
|
|
||||||
|
mock_create_html_snapshot.assert_not_called()
|
||||||
|
|
||||||
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
|
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
tasks, "create_web_archive_snapshot"
|
tasks, "create_web_archive_snapshot"
|
||||||
@@ -167,6 +185,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
mock_load_favicon.assert_called_once_with(self.user, bookmark)
|
mock_load_favicon.assert_called_once_with(self.user, bookmark)
|
||||||
|
|
||||||
|
def test_update_should_not_create_html_snapshot(self):
|
||||||
|
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
bookmark.title = "updated title"
|
||||||
|
update_bookmark(bookmark, "tag1,tag2", self.user)
|
||||||
|
|
||||||
|
mock_create_html_snapshot.assert_not_called()
|
||||||
|
|
||||||
def test_archive_bookmark(self):
|
def test_archive_bookmark(self):
|
||||||
bookmark = Bookmark(
|
bookmark = Bookmark(
|
||||||
url="https://example.com",
|
url="https://example.com",
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import os.path
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import waybackpy
|
import waybackpy
|
||||||
from background_task.models import Task
|
from background_task.models import Task
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from waybackpy.exceptions import WaybackError
|
from waybackpy.exceptions import WaybackError
|
||||||
|
|
||||||
import bookmarks.services.favicon_loader
|
import bookmarks.services.favicon_loader
|
||||||
import bookmarks.services.wayback
|
import bookmarks.services.wayback
|
||||||
from bookmarks.models import UserProfile
|
from bookmarks.models import BookmarkAsset, UserProfile
|
||||||
from bookmarks.services import tasks
|
from bookmarks.services import tasks, singlefile
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||||
|
|
||||||
|
|
||||||
@@ -626,3 +628,86 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
|
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
|
||||||
|
|
||||||
self.assertEqual(Task.objects.count(), 0)
|
self.assertEqual(Task.objects.count(), 0)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_create_html_snapshot_should_create_pending_asset(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with mock.patch("bookmarks.services.monolith.create_snapshot"):
|
||||||
|
tasks.create_html_snapshot(bookmark)
|
||||||
|
self.assertEqual(BookmarkAsset.objects.count(), 1)
|
||||||
|
|
||||||
|
tasks.create_html_snapshot(bookmark)
|
||||||
|
self.assertEqual(BookmarkAsset.objects.count(), 2)
|
||||||
|
|
||||||
|
assets = BookmarkAsset.objects.filter(bookmark=bookmark)
|
||||||
|
for asset in assets:
|
||||||
|
self.assertEqual(asset.bookmark, bookmark)
|
||||||
|
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
|
||||||
|
self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
|
||||||
|
self.assertIn("HTML snapshot", asset.display_name)
|
||||||
|
self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_create_html_snapshot_should_update_file_info(self):
|
||||||
|
bookmark = self.setup_bookmark(url="https://example.com")
|
||||||
|
|
||||||
|
with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
|
||||||
|
tasks.create_html_snapshot(bookmark)
|
||||||
|
asset = BookmarkAsset.objects.get(bookmark=bookmark)
|
||||||
|
asset.date_created = datetime.datetime(2021, 1, 2, 3, 44, 55)
|
||||||
|
asset.save()
|
||||||
|
expected_filename = "snapshot_2021-01-02_034455_https___example.com.html.gz"
|
||||||
|
|
||||||
|
self.run_pending_task(tasks._create_html_snapshot_task)
|
||||||
|
|
||||||
|
mock_create.assert_called_once_with(
|
||||||
|
"https://example.com",
|
||||||
|
os.path.join(settings.LD_ASSET_FOLDER, expected_filename),
|
||||||
|
)
|
||||||
|
|
||||||
|
asset = BookmarkAsset.objects.get(bookmark=bookmark)
|
||||||
|
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
|
||||||
|
self.assertEqual(asset.file, expected_filename)
|
||||||
|
self.assertTrue(asset.gzip)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_create_html_snapshot_should_handle_error(self):
|
||||||
|
bookmark = self.setup_bookmark(url="https://example.com")
|
||||||
|
|
||||||
|
with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
|
||||||
|
mock_create.side_effect = singlefile.SingeFileError("Error")
|
||||||
|
|
||||||
|
tasks.create_html_snapshot(bookmark)
|
||||||
|
self.run_pending_task(tasks._create_html_snapshot_task)
|
||||||
|
|
||||||
|
asset = BookmarkAsset.objects.get(bookmark=bookmark)
|
||||||
|
self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)
|
||||||
|
self.assertEqual(asset.file, "")
|
||||||
|
self.assertFalse(asset.gzip)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_create_html_snapshot_should_handle_missing_bookmark(self):
|
||||||
|
with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
|
||||||
|
tasks._create_html_snapshot_task(123)
|
||||||
|
self.run_pending_task(tasks._create_html_snapshot_task)
|
||||||
|
|
||||||
|
mock_create.assert_not_called()
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=False)
|
||||||
|
def test_create_html_snapshot_should_not_run_when_single_file_is_disabled(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
tasks.create_html_snapshot(bookmark)
|
||||||
|
|
||||||
|
self.assertEqual(Task.objects.count(), 0)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True, LD_DISABLE_BACKGROUND_TASKS=True)
|
||||||
|
def test_create_html_snapshot_should_not_run_when_background_tasks_are_disabled(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
tasks.create_html_snapshot(bookmark)
|
||||||
|
|
||||||
|
self.assertEqual(Task.objects.count(), 0)
|
||||||
|
|||||||
21
bookmarks/tests/test_custom_css.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class CustomCssTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def setUp(self):
|
||||||
|
self.client.force_login(self.get_or_create_test_user())
|
||||||
|
|
||||||
|
def test_does_not_render_custom_style_tag_by_default(self):
|
||||||
|
response = self.client.get(reverse("bookmarks:index"))
|
||||||
|
self.assertNotContains(response, "<style>")
|
||||||
|
|
||||||
|
def test_renders_custom_style_tag_if_user_has_custom_css(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.custom_css = "body { background-color: red; }"
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:index"))
|
||||||
|
self.assertContains(response, "<style>body { background-color: red; }</style>")
|
||||||
@@ -194,7 +194,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.setup_bookmark(unread=True)
|
self.setup_bookmark(unread=True)
|
||||||
self.setup_bookmark(unread=True)
|
self.setup_bookmark(unread=True)
|
||||||
|
|
||||||
feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
|
feed_url = reverse("bookmarks:feeds.unread", args=[self.token.key])
|
||||||
|
|
||||||
url = feed_url + f"?q={bookmark1.title}"
|
url = feed_url + f"?q={bookmark1.title}"
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
@@ -229,3 +229,172 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
self.assertContains(response, "<item>", count=0)
|
self.assertContains(response, "<item>", count=0)
|
||||||
|
|
||||||
|
def test_shared_returns_404_for_unknown_feed_token(self):
|
||||||
|
response = self.client.get(reverse("bookmarks:feeds.shared", args=["foo"]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_shared_metadata(self):
|
||||||
|
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
|
||||||
|
response = self.client.get(feed_url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.assertContains(response, "<title>Shared bookmarks</title>")
|
||||||
|
self.assertContains(response, "<description>All shared bookmarks</description>")
|
||||||
|
self.assertContains(response, f"<link>http://testserver{feed_url}</link>")
|
||||||
|
self.assertContains(
|
||||||
|
response, f'<atom:link href="http://testserver{feed_url}" rel="self"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_shared_returns_shared_bookmarks_only(self):
|
||||||
|
user1 = self.setup_user(enable_sharing=True)
|
||||||
|
user2 = self.setup_user(enable_sharing=False)
|
||||||
|
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark(shared=False, user=user1)
|
||||||
|
self.setup_bookmark(shared=True, user=user2)
|
||||||
|
|
||||||
|
shared_bookmarks = [
|
||||||
|
self.setup_bookmark(shared=True, user=user1, description="test"),
|
||||||
|
self.setup_bookmark(shared=True, user=user1, description="test"),
|
||||||
|
self.setup_bookmark(shared=True, user=user1, description="test"),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:feeds.shared", args=[self.token.key])
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.assertContains(response, "<item>", count=len(shared_bookmarks))
|
||||||
|
|
||||||
|
for bookmark in shared_bookmarks:
|
||||||
|
expected_item = (
|
||||||
|
"<item>"
|
||||||
|
f"<title>{bookmark.resolved_title}</title>"
|
||||||
|
f"<link>{bookmark.url}</link>"
|
||||||
|
f"<description>{bookmark.resolved_description}</description>"
|
||||||
|
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
|
||||||
|
f"<guid>{bookmark.url}</guid>"
|
||||||
|
"</item>"
|
||||||
|
)
|
||||||
|
self.assertContains(response, expected_item, count=1)
|
||||||
|
|
||||||
|
def test_shared_with_query(self):
|
||||||
|
user = self.setup_user(enable_sharing=True)
|
||||||
|
|
||||||
|
tag1 = self.setup_tag(user=user)
|
||||||
|
bookmark1 = self.setup_bookmark(shared=True, user=user)
|
||||||
|
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
|
||||||
|
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
|
||||||
|
|
||||||
|
self.setup_bookmark(shared=True, user=user)
|
||||||
|
self.setup_bookmark(shared=True, user=user)
|
||||||
|
self.setup_bookmark(shared=True, user=user)
|
||||||
|
|
||||||
|
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
|
||||||
|
|
||||||
|
url = feed_url + f"?q={bookmark1.title}"
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=1)
|
||||||
|
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
|
||||||
|
|
||||||
|
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=2)
|
||||||
|
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
|
||||||
|
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
|
||||||
|
|
||||||
|
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=1)
|
||||||
|
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
|
||||||
|
|
||||||
|
def test_public_shared_does_not_require_auth(self):
|
||||||
|
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_public_shared_metadata(self):
|
||||||
|
feed_url = reverse("bookmarks:feeds.public_shared")
|
||||||
|
response = self.client.get(feed_url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.assertContains(response, "<title>Public shared bookmarks</title>")
|
||||||
|
self.assertContains(
|
||||||
|
response, "<description>All public shared bookmarks</description>"
|
||||||
|
)
|
||||||
|
self.assertContains(response, f"<link>http://testserver{feed_url}</link>")
|
||||||
|
self.assertContains(
|
||||||
|
response, f'<atom:link href="http://testserver{feed_url}" rel="self"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_public_shared_returns_publicly_shared_bookmarks_only(self):
|
||||||
|
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
|
user3 = self.setup_user(enable_sharing=False)
|
||||||
|
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark(shared=False, user=user1)
|
||||||
|
self.setup_bookmark(shared=False, user=user2)
|
||||||
|
self.setup_bookmark(shared=True, user=user2)
|
||||||
|
self.setup_bookmark(shared=True, user=user3)
|
||||||
|
|
||||||
|
public_shared_bookmarks = [
|
||||||
|
self.setup_bookmark(shared=True, user=user1, description="test"),
|
||||||
|
self.setup_bookmark(shared=True, user=user1, description="test"),
|
||||||
|
self.setup_bookmark(shared=True, user=user1, description="test"),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.assertContains(response, "<item>", count=len(public_shared_bookmarks))
|
||||||
|
|
||||||
|
for bookmark in public_shared_bookmarks:
|
||||||
|
expected_item = (
|
||||||
|
"<item>"
|
||||||
|
f"<title>{bookmark.resolved_title}</title>"
|
||||||
|
f"<link>{bookmark.url}</link>"
|
||||||
|
f"<description>{bookmark.resolved_description}</description>"
|
||||||
|
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
|
||||||
|
f"<guid>{bookmark.url}</guid>"
|
||||||
|
"</item>"
|
||||||
|
)
|
||||||
|
self.assertContains(response, expected_item, count=1)
|
||||||
|
|
||||||
|
def test_public_shared_with_query(self):
|
||||||
|
user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
|
|
||||||
|
tag1 = self.setup_tag(user=user)
|
||||||
|
bookmark1 = self.setup_bookmark(shared=True, user=user)
|
||||||
|
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
|
||||||
|
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
|
||||||
|
|
||||||
|
self.setup_bookmark(shared=True, user=user)
|
||||||
|
self.setup_bookmark(shared=True, user=user)
|
||||||
|
self.setup_bookmark(shared=True, user=user)
|
||||||
|
|
||||||
|
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
|
||||||
|
|
||||||
|
url = feed_url + f"?q={bookmark1.title}"
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=1)
|
||||||
|
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
|
||||||
|
|
||||||
|
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=2)
|
||||||
|
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
|
||||||
|
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
|
||||||
|
|
||||||
|
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=1)
|
||||||
|
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
|
||||||
|
|||||||
29
bookmarks/tests/test_login_view.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
from bookmarks.tests.helpers import HtmlTestMixin
|
||||||
|
from siteroot.urls import urlpatterns as base_patterns
|
||||||
|
|
||||||
|
# Register OIDC urls for this test, otherwise login template can not render when OIDC is enabled
|
||||||
|
urlpatterns = base_patterns + [path("oidc/", include("mozilla_django_oidc.urls"))]
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROOT_URLCONF=__name__)
|
||||||
|
class LoginViewTestCase(TestCase, HtmlTestMixin):
|
||||||
|
|
||||||
|
def test_should_not_show_oidc_login_by_default(self):
|
||||||
|
response = self.client.get("/login/")
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
|
||||||
|
oidc_login_link = soup.find("a", text="Login with OIDC")
|
||||||
|
|
||||||
|
self.assertIsNone(oidc_login_link)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_OIDC=True)
|
||||||
|
def test_should_show_oidc_login_when_enabled(self):
|
||||||
|
response = self.client.get("/login/")
|
||||||
|
soup = self.make_soup(response.content.decode())
|
||||||
|
|
||||||
|
oidc_login_link = soup.find("a", text="Login with OIDC")
|
||||||
|
|
||||||
|
self.assertIsNotNone(oidc_login_link)
|
||||||
@@ -11,9 +11,91 @@ class MetadataViewTestCase(TestCase):
|
|||||||
response_body = response.json()
|
response_body = response.json()
|
||||||
expected_body = {
|
expected_body = {
|
||||||
"short_name": "linkding",
|
"short_name": "linkding",
|
||||||
|
"name": "linkding",
|
||||||
|
"description": "Self-hosted bookmark service",
|
||||||
"start_url": "bookmarks",
|
"start_url": "bookmarks",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
|
"theme_color": "#5856e0",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/logo.svg",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/logo-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/logo-192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/maskable-logo.svg",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/maskable-logo-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/maskable-logo-192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Add bookmark",
|
||||||
|
"url": "/bookmarks/new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Archived",
|
||||||
|
"url": "/bookmarks/archived",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Unread",
|
||||||
|
"url": "/bookmarks?unread=yes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Untagged",
|
||||||
|
"url": "/bookmarks?q=!untagged",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shared",
|
||||||
|
"url": "/bookmarks/shared",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/static/linkding-screenshot.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "2158x1160",
|
||||||
|
"form_factor": "wide",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"share_target": {
|
||||||
|
"action": "/bookmarks/new",
|
||||||
|
"method": "GET",
|
||||||
|
"enctype": "application/x-www-form-urlencoded",
|
||||||
|
"params": {
|
||||||
|
"url": "url",
|
||||||
|
"text": "url",
|
||||||
|
"title": "title",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
self.assertDictEqual(response_body, expected_body)
|
self.assertDictEqual(response_body, expected_body)
|
||||||
|
|
||||||
@@ -26,8 +108,90 @@ class MetadataViewTestCase(TestCase):
|
|||||||
response_body = response.json()
|
response_body = response.json()
|
||||||
expected_body = {
|
expected_body = {
|
||||||
"short_name": "linkding",
|
"short_name": "linkding",
|
||||||
|
"name": "linkding",
|
||||||
|
"description": "Self-hosted bookmark service",
|
||||||
"start_url": "bookmarks",
|
"start_url": "bookmarks",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"scope": "/linkding/",
|
"scope": "/linkding/",
|
||||||
|
"theme_color": "#5856e0",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/linkding/static/logo.svg",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/linkding/static/logo-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/linkding/static/logo-192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/linkding/static/maskable-logo.svg",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/linkding/static/maskable-logo-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/linkding/static/maskable-logo-192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Add bookmark",
|
||||||
|
"url": "/linkding/bookmarks/new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Archived",
|
||||||
|
"url": "/linkding/bookmarks/archived",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Unread",
|
||||||
|
"url": "/linkding/bookmarks?unread=yes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Untagged",
|
||||||
|
"url": "/linkding/bookmarks?q=!untagged",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shared",
|
||||||
|
"url": "/linkding/bookmarks/shared",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/linkding/static/linkding-screenshot.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "2158x1160",
|
||||||
|
"form_factor": "wide",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"share_target": {
|
||||||
|
"action": "/linkding/bookmarks/new",
|
||||||
|
"method": "GET",
|
||||||
|
"enctype": "application/x-www-form-urlencoded",
|
||||||
|
"params": {
|
||||||
|
"url": "url",
|
||||||
|
"text": "url",
|
||||||
|
"title": "title",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
self.assertDictEqual(response_body, expected_body)
|
self.assertDictEqual(response_body, expected_body)
|
||||||
|
|||||||
44
bookmarks/tests/test_monolith_service.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
from unittest import mock
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from bookmarks.services import monolith
|
||||||
|
|
||||||
|
|
||||||
|
class MonolithServiceTestCase(TestCase):
|
||||||
|
html_content = "<html><body><h1>Hello, World!</h1></body></html>"
|
||||||
|
html_filepath = "temp.html.gz"
|
||||||
|
temp_html_filepath = "temp.html.gz.tmp"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if os.path.exists(self.html_filepath):
|
||||||
|
os.remove(self.html_filepath)
|
||||||
|
if os.path.exists(self.temp_html_filepath):
|
||||||
|
os.remove(self.temp_html_filepath)
|
||||||
|
|
||||||
|
def create_test_file(self, *args, **kwargs):
|
||||||
|
with open(self.temp_html_filepath, "w") as file:
|
||||||
|
file.write(self.html_content)
|
||||||
|
|
||||||
|
def test_create_snapshot(self):
|
||||||
|
with mock.patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.side_effect = self.create_test_file
|
||||||
|
|
||||||
|
monolith.create_snapshot("http://example.com", self.html_filepath)
|
||||||
|
|
||||||
|
self.assertTrue(os.path.exists(self.html_filepath))
|
||||||
|
self.assertFalse(os.path.exists(self.temp_html_filepath))
|
||||||
|
|
||||||
|
with gzip.open(self.html_filepath, "rt") as file:
|
||||||
|
content = file.read()
|
||||||
|
self.assertEqual(content, self.html_content)
|
||||||
|
|
||||||
|
def test_create_snapshot_failure(self):
|
||||||
|
with mock.patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.side_effect = subprocess.CalledProcessError(1, "command")
|
||||||
|
|
||||||
|
with self.assertRaises(monolith.MonolithError):
|
||||||
|
monolith.create_snapshot("http://example.com", self.html_filepath)
|
||||||
51
bookmarks/tests/test_oidc_support.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import URLResolver
|
||||||
|
|
||||||
|
|
||||||
|
class OidcSupportTest(TestCase):
|
||||||
|
def test_should_not_add_oidc_urls_by_default(self):
|
||||||
|
siteroot_urls = importlib.import_module("siteroot.urls")
|
||||||
|
importlib.reload(siteroot_urls)
|
||||||
|
oidc_url_found = any(
|
||||||
|
isinstance(urlpattern, URLResolver) and urlpattern.pattern._route == "oidc/"
|
||||||
|
for urlpattern in siteroot_urls.urlpatterns
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(oidc_url_found)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_OIDC=True)
|
||||||
|
def test_should_add_oidc_urls_when_enabled(self):
|
||||||
|
siteroot_urls = importlib.import_module("siteroot.urls")
|
||||||
|
importlib.reload(siteroot_urls)
|
||||||
|
oidc_url_found = any(
|
||||||
|
isinstance(urlpattern, URLResolver) and urlpattern.pattern._route == "oidc/"
|
||||||
|
for urlpattern in siteroot_urls.urlpatterns
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(oidc_url_found)
|
||||||
|
|
||||||
|
def test_should_not_add_oidc_authentication_backend_by_default(self):
|
||||||
|
base_settings = importlib.import_module("siteroot.settings.base")
|
||||||
|
importlib.reload(base_settings)
|
||||||
|
|
||||||
|
self.assertListEqual(
|
||||||
|
["django.contrib.auth.backends.ModelBackend"],
|
||||||
|
base_settings.AUTHENTICATION_BACKENDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_should_add_oidc_authentication_backend_when_enabled(self):
|
||||||
|
os.environ["LD_ENABLE_OIDC"] = "True"
|
||||||
|
base_settings = importlib.import_module("siteroot.settings.base")
|
||||||
|
importlib.reload(base_settings)
|
||||||
|
|
||||||
|
self.assertListEqual(
|
||||||
|
[
|
||||||
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
"mozilla_django_oidc.auth.OIDCAuthenticationBackend",
|
||||||
|
],
|
||||||
|
base_settings.AUTHENTICATION_BACKENDS,
|
||||||
|
)
|
||||||
|
del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable
|
||||||
@@ -24,14 +24,22 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
form_data = {
|
form_data = {
|
||||||
"theme": UserProfile.THEME_AUTO,
|
"theme": UserProfile.THEME_AUTO,
|
||||||
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
|
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||||
|
"bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
|
||||||
|
"bookmark_description_max_lines": 1,
|
||||||
"bookmark_link_target": UserProfile.BOOKMARK_LINK_TARGET_BLANK,
|
"bookmark_link_target": UserProfile.BOOKMARK_LINK_TARGET_BLANK,
|
||||||
"web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
|
"web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
|
||||||
"enable_sharing": False,
|
"enable_sharing": False,
|
||||||
"enable_public_sharing": False,
|
"enable_public_sharing": False,
|
||||||
"enable_favicons": False,
|
"enable_favicons": False,
|
||||||
|
"enable_automatic_html_snapshots": True,
|
||||||
"tag_search": UserProfile.TAG_SEARCH_STRICT,
|
"tag_search": UserProfile.TAG_SEARCH_STRICT,
|
||||||
"display_url": False,
|
"display_url": False,
|
||||||
|
"display_view_bookmark_action": True,
|
||||||
|
"display_edit_bookmark_action": True,
|
||||||
|
"display_archive_bookmark_action": True,
|
||||||
|
"display_remove_bookmark_action": True,
|
||||||
"permanent_notes": False,
|
"permanent_notes": False,
|
||||||
|
"custom_css": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
return {**form_data, **overrides}
|
return {**form_data, **overrides}
|
||||||
@@ -55,14 +63,22 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
"update_profile": "",
|
"update_profile": "",
|
||||||
"theme": UserProfile.THEME_DARK,
|
"theme": UserProfile.THEME_DARK,
|
||||||
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
|
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
|
||||||
|
"bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE,
|
||||||
|
"bookmark_description_max_lines": 3,
|
||||||
"bookmark_link_target": UserProfile.BOOKMARK_LINK_TARGET_SELF,
|
"bookmark_link_target": UserProfile.BOOKMARK_LINK_TARGET_SELF,
|
||||||
"web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
|
"web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
|
||||||
"enable_sharing": True,
|
"enable_sharing": True,
|
||||||
"enable_public_sharing": True,
|
"enable_public_sharing": True,
|
||||||
"enable_favicons": True,
|
"enable_favicons": True,
|
||||||
|
"enable_automatic_html_snapshots": False,
|
||||||
"tag_search": UserProfile.TAG_SEARCH_LAX,
|
"tag_search": UserProfile.TAG_SEARCH_LAX,
|
||||||
"display_url": True,
|
"display_url": True,
|
||||||
|
"display_view_bookmark_action": False,
|
||||||
|
"display_edit_bookmark_action": False,
|
||||||
|
"display_archive_bookmark_action": False,
|
||||||
|
"display_remove_bookmark_action": False,
|
||||||
"permanent_notes": True,
|
"permanent_notes": True,
|
||||||
|
"custom_css": "body { background-color: #000; }",
|
||||||
}
|
}
|
||||||
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
|
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
@@ -74,6 +90,14 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.user.profile.bookmark_date_display, form_data["bookmark_date_display"]
|
self.user.profile.bookmark_date_display, form_data["bookmark_date_display"]
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.profile.bookmark_description_display,
|
||||||
|
form_data["bookmark_description_display"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.profile.bookmark_description_max_lines,
|
||||||
|
form_data["bookmark_description_max_lines"],
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.user.profile.bookmark_link_target, form_data["bookmark_link_target"]
|
self.user.profile.bookmark_link_target, form_data["bookmark_link_target"]
|
||||||
)
|
)
|
||||||
@@ -88,11 +112,32 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.user.profile.enable_favicons, form_data["enable_favicons"]
|
self.user.profile.enable_favicons, form_data["enable_favicons"]
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.profile.enable_automatic_html_snapshots,
|
||||||
|
form_data["enable_automatic_html_snapshots"],
|
||||||
|
)
|
||||||
self.assertEqual(self.user.profile.tag_search, form_data["tag_search"])
|
self.assertEqual(self.user.profile.tag_search, form_data["tag_search"])
|
||||||
self.assertEqual(self.user.profile.display_url, form_data["display_url"])
|
self.assertEqual(self.user.profile.display_url, form_data["display_url"])
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.profile.display_view_bookmark_action,
|
||||||
|
form_data["display_view_bookmark_action"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.profile.display_edit_bookmark_action,
|
||||||
|
form_data["display_edit_bookmark_action"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.profile.display_archive_bookmark_action,
|
||||||
|
form_data["display_archive_bookmark_action"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.profile.display_remove_bookmark_action,
|
||||||
|
form_data["display_remove_bookmark_action"],
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.user.profile.permanent_notes, form_data["permanent_notes"]
|
self.user.profile.permanent_notes, form_data["permanent_notes"]
|
||||||
)
|
)
|
||||||
|
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
"""
|
"""
|
||||||
<p class="form-input-hint">Profile updated</p>
|
<p class="form-input-hint">Profile updated</p>
|
||||||
@@ -246,6 +291,35 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
count=0,
|
count=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_automatic_html_snapshots_should_be_hidden_when_snapshots_not_supported(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
response = self.client.get(reverse("bookmarks:settings.general"))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<input type="checkbox" name="enable_automatic_html_snapshots" id="id_enable_automatic_html_snapshots" checked="">
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||||
|
def test_automatic_html_snapshots_should_be_visible_when_snapshots_supported(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
response = self.client.get(reverse("bookmarks:settings.general"))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<input type="checkbox" name="enable_automatic_html_snapshots" id="id_enable_automatic_html_snapshots" checked="">
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
|
||||||
def test_about_shows_version_info(self):
|
def test_about_shows_version_info(self):
|
||||||
response = self.client.get(reverse("bookmarks:settings.general"))
|
response = self.client.get(reverse("bookmarks:settings.general"))
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
|
|||||||
@@ -74,3 +74,11 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
f'<a href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>',
|
f'<a href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>',
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
self.assertInHTML(
|
||||||
|
f'<a href="http://testserver/feeds/{token.key}/shared">Shared bookmarks</a>',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
self.assertInHTML(
|
||||||
|
f'<a href="http://testserver/feeds/shared">Public shared bookmarks</a>',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|||||||
50
bookmarks/tests/test_singlefile_service.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
from unittest import mock
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from bookmarks.services import singlefile
|
||||||
|
|
||||||
|
|
||||||
|
class SingleFileServiceTestCase(TestCase):
|
||||||
|
html_content = "<html><body><h1>Hello, World!</h1></body></html>"
|
||||||
|
html_filepath = "temp.html.gz"
|
||||||
|
temp_html_filepath = "temp.html.gz.tmp"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if os.path.exists(self.html_filepath):
|
||||||
|
os.remove(self.html_filepath)
|
||||||
|
if os.path.exists(self.temp_html_filepath):
|
||||||
|
os.remove(self.temp_html_filepath)
|
||||||
|
|
||||||
|
def create_test_file(self, *args, **kwargs):
|
||||||
|
with open(self.temp_html_filepath, "w") as file:
|
||||||
|
file.write(self.html_content)
|
||||||
|
|
||||||
|
def test_create_snapshot(self):
|
||||||
|
with mock.patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.side_effect = self.create_test_file
|
||||||
|
|
||||||
|
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||||
|
|
||||||
|
self.assertTrue(os.path.exists(self.html_filepath))
|
||||||
|
self.assertFalse(os.path.exists(self.temp_html_filepath))
|
||||||
|
|
||||||
|
with gzip.open(self.html_filepath, "rt") as file:
|
||||||
|
content = file.read()
|
||||||
|
self.assertEqual(content, self.html_content)
|
||||||
|
|
||||||
|
def test_create_snapshot_failure(self):
|
||||||
|
# subprocess fails - which it probably doesn't as single-file doesn't return exit codes
|
||||||
|
with mock.patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.side_effect = subprocess.CalledProcessError(1, "command")
|
||||||
|
|
||||||
|
with self.assertRaises(singlefile.SingeFileError):
|
||||||
|
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||||
|
|
||||||
|
# so also check that it raises error if output file isn't created
|
||||||
|
with mock.patch("subprocess.run") as mock_run:
|
||||||
|
with self.assertRaises(singlefile.SingeFileError):
|
||||||
|
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||||
@@ -40,7 +40,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
# Should render toasts container
|
# Should render toasts container
|
||||||
self.assertContains(response, '<div class="toasts">')
|
self.assertContains(response, '<div class="toasts">')
|
||||||
# Should render two toasts
|
# Should render two toasts
|
||||||
self.assertContains(response, '<div class="toast">', count=2)
|
self.assertContains(response, '<div class="toast d-flex">', count=2)
|
||||||
|
|
||||||
def test_should_not_render_acknowledged_toasts(self):
|
def test_should_not_render_acknowledged_toasts(self):
|
||||||
self.create_toast(acknowledged=True)
|
self.create_toast(acknowledged=True)
|
||||||
@@ -81,9 +81,9 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
def test_toast_content(self):
|
def test_toast_content(self):
|
||||||
toast = self.create_toast()
|
toast = self.create_toast()
|
||||||
expected_toast = f"""
|
expected_toast = f"""
|
||||||
<div class="toast">
|
<div class="toast d-flex">
|
||||||
{toast.message}
|
{toast.message}
|
||||||
<button type="submit" name="toast" value="{toast.id}" class="btn btn-clear float-right"></button>
|
<button type="submit" name="toast" value="{toast.id}" class="btn btn-clear"></button>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ from django.views.generic import RedirectView
|
|||||||
|
|
||||||
from bookmarks import views
|
from bookmarks import views
|
||||||
from bookmarks.api.routes import router
|
from bookmarks.api.routes import router
|
||||||
from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed
|
from bookmarks.feeds import (
|
||||||
|
AllBookmarksFeed,
|
||||||
|
UnreadBookmarksFeed,
|
||||||
|
SharedBookmarksFeed,
|
||||||
|
PublicSharedBookmarksFeed,
|
||||||
|
)
|
||||||
from bookmarks.views import partials
|
from bookmarks.views import partials
|
||||||
|
|
||||||
app_name = "bookmarks"
|
app_name = "bookmarks"
|
||||||
@@ -29,6 +34,22 @@ urlpatterns = [
|
|||||||
path("bookmarks/new", views.bookmarks.new, name="new"),
|
path("bookmarks/new", views.bookmarks.new, name="new"),
|
||||||
path("bookmarks/close", views.bookmarks.close, name="close"),
|
path("bookmarks/close", views.bookmarks.close, name="close"),
|
||||||
path("bookmarks/<int:bookmark_id>/edit", views.bookmarks.edit, name="edit"),
|
path("bookmarks/<int:bookmark_id>/edit", views.bookmarks.edit, name="edit"),
|
||||||
|
path(
|
||||||
|
"bookmarks/<int:bookmark_id>/details",
|
||||||
|
views.bookmarks.details,
|
||||||
|
name="details",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"bookmarks/<int:bookmark_id>/details_modal",
|
||||||
|
views.bookmarks.details_modal,
|
||||||
|
name="details_modal",
|
||||||
|
),
|
||||||
|
# Assets
|
||||||
|
path(
|
||||||
|
"assets/<int:asset_id>",
|
||||||
|
views.assets.view,
|
||||||
|
name="assets.view",
|
||||||
|
),
|
||||||
# Partials
|
# Partials
|
||||||
path(
|
path(
|
||||||
"bookmarks/partials/bookmark-list/active",
|
"bookmarks/partials/bookmark-list/active",
|
||||||
@@ -60,6 +81,11 @@ urlpatterns = [
|
|||||||
partials.shared_tag_cloud,
|
partials.shared_tag_cloud,
|
||||||
name="partials.tag_cloud.shared",
|
name="partials.tag_cloud.shared",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"bookmarks/partials/details-form/<int:bookmark_id>",
|
||||||
|
partials.details_form,
|
||||||
|
name="partials.details_form",
|
||||||
|
),
|
||||||
# Settings
|
# Settings
|
||||||
path("settings", views.settings.general, name="settings.index"),
|
path("settings", views.settings.general, name="settings.index"),
|
||||||
path("settings/general", views.settings.general, name="settings.general"),
|
path("settings/general", views.settings.general, name="settings.general"),
|
||||||
@@ -77,6 +103,8 @@ urlpatterns = [
|
|||||||
# Feeds
|
# Feeds
|
||||||
path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"),
|
path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"),
|
||||||
path("feeds/<str:feed_key>/unread", UnreadBookmarksFeed(), name="feeds.unread"),
|
path("feeds/<str:feed_key>/unread", UnreadBookmarksFeed(), name="feeds.unread"),
|
||||||
|
path("feeds/<str:feed_key>/shared", SharedBookmarksFeed(), name="feeds.shared"),
|
||||||
|
path("feeds/shared", PublicSharedBookmarksFeed(), name="feeds.public_shared"),
|
||||||
# Health check
|
# Health check
|
||||||
path("health", views.health, name="health"),
|
path("health", views.health, name="health"),
|
||||||
# Manifest
|
# Manifest
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -111,3 +112,12 @@ def get_safe_return_url(return_url: str, fallback_url: str):
|
|||||||
if not return_url or not re.match(r"^/[a-z]+", return_url):
|
if not return_url or not re.match(r"^/[a-z]+", return_url):
|
||||||
return fallback_url
|
return fallback_url
|
||||||
return return_url
|
return return_url
|
||||||
|
|
||||||
|
|
||||||
|
def generate_username(email):
|
||||||
|
# taken from mozilla-django-oidc docs :)
|
||||||
|
|
||||||
|
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
|
||||||
|
# (ascii and unicode), _, @, +, . and - characters. So we normalize
|
||||||
|
# it and slice at 150 characters.
|
||||||
|
return unicodedata.normalize("NFKC", email)[:150]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from .assets import *
|
||||||
from .bookmarks import *
|
from .bookmarks import *
|
||||||
from .settings import *
|
from .settings import *
|
||||||
from .toasts import *
|
from .toasts import *
|
||||||
|
|||||||
43
bookmarks/views/assets.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import (
|
||||||
|
HttpResponse,
|
||||||
|
Http404,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bookmarks.models import BookmarkAsset
|
||||||
|
|
||||||
|
|
||||||
|
def view(request, asset_id: int):
|
||||||
|
try:
|
||||||
|
asset = BookmarkAsset.objects.get(pk=asset_id)
|
||||||
|
except BookmarkAsset.DoesNotExist:
|
||||||
|
raise Http404("Asset does not exist")
|
||||||
|
|
||||||
|
bookmark = asset.bookmark
|
||||||
|
is_owner = bookmark.owner == request.user
|
||||||
|
is_shared = (
|
||||||
|
request.user.is_authenticated
|
||||||
|
and bookmark.shared
|
||||||
|
and bookmark.owner.profile.enable_sharing
|
||||||
|
)
|
||||||
|
is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing
|
||||||
|
|
||||||
|
if not is_owner and not is_shared and not is_public_shared:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
|
||||||
|
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
raise Http404("Asset file does not exist")
|
||||||
|
|
||||||
|
if asset.gzip:
|
||||||
|
with gzip.open(filepath, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
else:
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
return HttpResponse(content, content_type=asset.content_type)
|
||||||
@@ -12,7 +12,13 @@ from django.shortcuts import render
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
from bookmarks.models import Bookmark, BookmarkForm, BookmarkSearch, build_tag_string
|
from bookmarks.models import (
|
||||||
|
Bookmark,
|
||||||
|
BookmarkAsset,
|
||||||
|
BookmarkForm,
|
||||||
|
BookmarkSearch,
|
||||||
|
build_tag_string,
|
||||||
|
)
|
||||||
from bookmarks.services.bookmarks import (
|
from bookmarks.services.bookmarks import (
|
||||||
create_bookmark,
|
create_bookmark,
|
||||||
update_bookmark,
|
update_bookmark,
|
||||||
@@ -28,6 +34,7 @@ from bookmarks.services.bookmarks import (
|
|||||||
share_bookmarks,
|
share_bookmarks,
|
||||||
unshare_bookmarks,
|
unshare_bookmarks,
|
||||||
)
|
)
|
||||||
|
from bookmarks.services import tasks
|
||||||
from bookmarks.utils import get_safe_return_url
|
from bookmarks.utils import get_safe_return_url
|
||||||
from bookmarks.views.partials import contexts
|
from bookmarks.views.partials import contexts
|
||||||
|
|
||||||
@@ -104,6 +111,67 @@ def search_action(request):
|
|||||||
return HttpResponseRedirect(url)
|
return HttpResponseRedirect(url)
|
||||||
|
|
||||||
|
|
||||||
|
def _details(request, bookmark_id: int, template: str):
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(pk=bookmark_id)
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
is_owner = bookmark.owner == request.user
|
||||||
|
is_shared = (
|
||||||
|
request.user.is_authenticated
|
||||||
|
and bookmark.shared
|
||||||
|
and bookmark.owner.profile.enable_sharing
|
||||||
|
)
|
||||||
|
is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing
|
||||||
|
if not is_owner and not is_shared and not is_public_shared:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if not is_owner:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
return_url = get_safe_return_url(
|
||||||
|
request.GET.get("return_url"),
|
||||||
|
reverse("bookmarks:details", args=[bookmark.id]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if "remove_asset" in request.POST:
|
||||||
|
asset_id = request.POST["remove_asset"]
|
||||||
|
try:
|
||||||
|
asset = bookmark.bookmarkasset_set.get(pk=asset_id)
|
||||||
|
except BookmarkAsset.DoesNotExist:
|
||||||
|
raise Http404("Asset does not exist")
|
||||||
|
asset.delete()
|
||||||
|
if "create_snapshot" in request.POST:
|
||||||
|
tasks.create_html_snapshot(bookmark)
|
||||||
|
else:
|
||||||
|
bookmark.is_archived = request.POST.get("is_archived") == "on"
|
||||||
|
bookmark.unread = request.POST.get("unread") == "on"
|
||||||
|
bookmark.shared = request.POST.get("shared") == "on"
|
||||||
|
bookmark.save()
|
||||||
|
|
||||||
|
return HttpResponseRedirect(return_url)
|
||||||
|
|
||||||
|
details_context = contexts.BookmarkDetailsContext(request, bookmark)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
template,
|
||||||
|
{
|
||||||
|
"details": details_context,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def details(request, bookmark_id: int):
|
||||||
|
return _details(request, bookmark_id, "bookmarks/details.html")
|
||||||
|
|
||||||
|
|
||||||
|
def details_modal(request, bookmark_id: int):
|
||||||
|
return _details(request, bookmark_id, "bookmarks/details_modal.html")
|
||||||
|
|
||||||
|
|
||||||
def convert_tag_string(tag_string: str):
|
def convert_tag_string(tag_string: str):
|
||||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||||
# strings
|
# strings
|
||||||
|
|||||||
@@ -5,9 +5,95 @@ from django.conf import settings
|
|||||||
def manifest(request):
|
def manifest(request):
|
||||||
response = {
|
response = {
|
||||||
"short_name": "linkding",
|
"short_name": "linkding",
|
||||||
|
"name": "linkding",
|
||||||
|
"description": "Self-hosted bookmark service",
|
||||||
"start_url": "bookmarks",
|
"start_url": "bookmarks",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"scope": "/" + settings.LD_CONTEXT_PATH,
|
"scope": "/" + settings.LD_CONTEXT_PATH,
|
||||||
|
"theme_color": "#5856e0",
|
||||||
|
"background_color": (
|
||||||
|
"#161822" if request.user_profile.theme == "dark" else "#ffffff"
|
||||||
|
),
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo.svg",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo-192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo.svg",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Add bookmark",
|
||||||
|
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Archived",
|
||||||
|
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/archived",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Unread",
|
||||||
|
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks?unread=yes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Untagged",
|
||||||
|
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks?q=!untagged",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shared",
|
||||||
|
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/shared",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/"
|
||||||
|
+ settings.LD_CONTEXT_PATH
|
||||||
|
+ "static/linkding-screenshot.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "2158x1160",
|
||||||
|
"form_factor": "wide",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"share_target": {
|
||||||
|
"action": "/" + settings.LD_CONTEXT_PATH + "bookmarks/new",
|
||||||
|
"method": "GET",
|
||||||
|
"enctype": "application/x-www-form-urlencoded",
|
||||||
|
"params": {
|
||||||
|
"url": "url",
|
||||||
|
"text": "url",
|
||||||
|
"title": "title",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonResponse(response, status=200)
|
return JsonResponse(response, status=200)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.http import Http404
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
from bookmarks.models import Bookmark
|
||||||
from bookmarks.views.partials import contexts
|
from bookmarks.views.partials import contexts
|
||||||
|
|
||||||
|
|
||||||
@@ -56,3 +58,15 @@ def shared_tag_cloud(request):
|
|||||||
tag_cloud_context = contexts.SharedTagCloudContext(request)
|
tag_cloud_context = contexts.SharedTagCloudContext(request)
|
||||||
|
|
||||||
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
|
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def details_form(request, bookmark_id: int):
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
raise Http404("Bookmark does not exist")
|
||||||
|
|
||||||
|
details_context = contexts.BookmarkDetailsContext(request, bookmark)
|
||||||
|
|
||||||
|
return render(request, "bookmarks/details/form.html", {"details": details_context})
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ from django.core.handlers.wsgi import WSGIRequest
|
|||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
from bookmarks import utils
|
from bookmarks import utils
|
||||||
from bookmarks.models import (
|
from bookmarks.models import (
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
BookmarkAsset,
|
||||||
BookmarkSearch,
|
BookmarkSearch,
|
||||||
User,
|
User,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
@@ -96,7 +98,13 @@ class BookmarkListContext:
|
|||||||
)
|
)
|
||||||
self.link_target = user_profile.bookmark_link_target
|
self.link_target = user_profile.bookmark_link_target
|
||||||
self.date_display = user_profile.bookmark_date_display
|
self.date_display = user_profile.bookmark_date_display
|
||||||
|
self.description_display = user_profile.bookmark_description_display
|
||||||
|
self.description_max_lines = user_profile.bookmark_description_max_lines
|
||||||
self.show_url = user_profile.display_url
|
self.show_url = user_profile.display_url
|
||||||
|
self.show_view_action = user_profile.display_view_bookmark_action
|
||||||
|
self.show_edit_action = user_profile.display_edit_bookmark_action
|
||||||
|
self.show_archive_action = user_profile.display_archive_bookmark_action
|
||||||
|
self.show_remove_action = user_profile.display_remove_bookmark_action
|
||||||
self.show_favicons = user_profile.enable_favicons
|
self.show_favicons = user_profile.enable_favicons
|
||||||
self.show_notes = user_profile.permanent_notes
|
self.show_notes = user_profile.permanent_notes
|
||||||
|
|
||||||
@@ -268,3 +276,55 @@ class SharedTagCloudContext(TagCloudContext):
|
|||||||
return queries.query_shared_bookmark_tags(
|
return queries.query_shared_bookmark_tags(
|
||||||
user, self.request.user_profile, self.search, public_only
|
user, self.request.user_profile, self.search, public_only
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkAssetItem:
|
||||||
|
def __init__(self, asset: BookmarkAsset):
|
||||||
|
self.asset = asset
|
||||||
|
|
||||||
|
self.id = asset.id
|
||||||
|
self.display_name = asset.display_name
|
||||||
|
self.content_type = asset.content_type
|
||||||
|
self.file = asset.file
|
||||||
|
self.file_size = asset.file_size
|
||||||
|
self.status = asset.status
|
||||||
|
|
||||||
|
icon_classes = []
|
||||||
|
text_classes = []
|
||||||
|
if asset.status == BookmarkAsset.STATUS_PENDING:
|
||||||
|
icon_classes.append("text-gray")
|
||||||
|
text_classes.append("text-gray")
|
||||||
|
elif asset.status == BookmarkAsset.STATUS_FAILURE:
|
||||||
|
icon_classes.append("text-error")
|
||||||
|
text_classes.append("text-error")
|
||||||
|
else:
|
||||||
|
icon_classes.append("text-primary")
|
||||||
|
|
||||||
|
self.icon_classes = " ".join(icon_classes)
|
||||||
|
self.text_classes = " ".join(text_classes)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkDetailsContext:
|
||||||
|
def __init__(self, request: WSGIRequest, bookmark: Bookmark):
|
||||||
|
user = request.user
|
||||||
|
user_profile = request.user_profile
|
||||||
|
|
||||||
|
self.edit_return_url = utils.get_safe_return_url(
|
||||||
|
request.GET.get("return_url"),
|
||||||
|
reverse("bookmarks:details", args=[bookmark.id]),
|
||||||
|
)
|
||||||
|
self.delete_return_url = utils.get_safe_return_url(
|
||||||
|
request.GET.get("return_url"), reverse("bookmarks:index")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.bookmark = bookmark
|
||||||
|
self.profile = request.user_profile
|
||||||
|
self.is_editable = bookmark.owner == user
|
||||||
|
self.sharing_enabled = user_profile.enable_sharing
|
||||||
|
self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file
|
||||||
|
# For now hide files section if snapshots are not supported
|
||||||
|
self.show_files = settings.LD_ENABLE_SNAPSHOTS
|
||||||
|
|
||||||
|
self.assets = [
|
||||||
|
BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()
|
||||||
|
]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from django.shortcuts import render
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, BookmarkSearch, UserProfileForm, FeedToken
|
from bookmarks.models import Bookmark, UserProfileForm, FeedToken
|
||||||
from bookmarks.services import exporter, tasks
|
from bookmarks.services import exporter, tasks
|
||||||
from bookmarks.services import importer
|
from bookmarks.services import importer
|
||||||
from bookmarks.utils import app_version
|
from bookmarks.utils import app_version
|
||||||
@@ -24,6 +24,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def general(request):
|
def general(request):
|
||||||
profile_form = None
|
profile_form = None
|
||||||
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
|
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
|
||||||
|
has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
|
||||||
update_profile_success_message = None
|
update_profile_success_message = None
|
||||||
refresh_favicons_success_message = None
|
refresh_favicons_success_message = None
|
||||||
import_success_message = _find_message_with_tag(
|
import_success_message = _find_message_with_tag(
|
||||||
@@ -53,6 +54,7 @@ def general(request):
|
|||||||
{
|
{
|
||||||
"form": profile_form,
|
"form": profile_form,
|
||||||
"enable_refresh_favicons": enable_refresh_favicons,
|
"enable_refresh_favicons": enable_refresh_favicons,
|
||||||
|
"has_snapshot_support": has_snapshot_support,
|
||||||
"update_profile_success_message": update_profile_success_message,
|
"update_profile_success_message": update_profile_success_message,
|
||||||
"refresh_favicons_success_message": refresh_favicons_success_message,
|
"refresh_favicons_success_message": refresh_favicons_success_message,
|
||||||
"import_success_message": import_success_message,
|
"import_success_message": import_success_message,
|
||||||
@@ -114,6 +116,12 @@ def integrations(request):
|
|||||||
unread_feed_url = request.build_absolute_uri(
|
unread_feed_url = request.build_absolute_uri(
|
||||||
reverse("bookmarks:feeds.unread", args=[feed_token.key])
|
reverse("bookmarks:feeds.unread", args=[feed_token.key])
|
||||||
)
|
)
|
||||||
|
shared_feed_url = request.build_absolute_uri(
|
||||||
|
reverse("bookmarks:feeds.shared", args=[feed_token.key])
|
||||||
|
)
|
||||||
|
public_shared_feed_url = request.build_absolute_uri(
|
||||||
|
reverse("bookmarks:feeds.public_shared")
|
||||||
|
)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"settings/integrations.html",
|
"settings/integrations.html",
|
||||||
@@ -122,6 +130,8 @@ def integrations(request):
|
|||||||
"api_token": api_token.key,
|
"api_token": api_token.key,
|
||||||
"all_feed_url": all_feed_url,
|
"all_feed_url": all_feed_url,
|
||||||
"unread_feed_url": unread_feed_url,
|
"unread_feed_url": unread_feed_url,
|
||||||
|
"shared_feed_url": shared_feed_url,
|
||||||
|
"public_shared_feed_url": public_shared_feed_url,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
|
|||||||
mkdir -p data
|
mkdir -p data
|
||||||
# Create favicon folder if it does not exist
|
# Create favicon folder if it does not exist
|
||||||
mkdir -p data/favicons
|
mkdir -p data/favicons
|
||||||
|
# Create assets folder if it does not exist
|
||||||
|
mkdir -p data/assets
|
||||||
|
|
||||||
# Generate secret key file if it does not exist
|
# Generate secret key file if it does not exist
|
||||||
python manage.py generate_secret_key
|
python manage.py generate_secret_key
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
FROM node:18.18.0-alpine AS node-build
|
FROM node:18-alpine AS node-build
|
||||||
WORKDIR /etc/linkding
|
WORKDIR /etc/linkding
|
||||||
# install build dependencies
|
# install build dependencies
|
||||||
COPY rollup.config.js package.json package-lock.json ./
|
COPY rollup.config.mjs package.json package-lock.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
# copy files needed for JS build
|
# copy files needed for JS build
|
||||||
COPY bookmarks/frontend ./bookmarks/frontend
|
COPY bookmarks/frontend ./bookmarks/frontend
|
||||||
# run build
|
# run build
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.10.13-alpine3.18 AS python-base
|
# Use 3.11 for now, as django4-background-tasks doesn't work with 3.12 yet
|
||||||
RUN apk update && apk add alpine-sdk linux-headers libpq-dev pkgconfig icu-dev sqlite-dev
|
FROM python:3.11.8-alpine3.19 AS python-base
|
||||||
|
# Add required packages
|
||||||
|
# alpine-sdk linux-headers pkgconfig: build Python packages from source
|
||||||
|
# libpq-dev: build Postgres client from source
|
||||||
|
# icu-dev sqlite-dev: build Sqlite ICU extension
|
||||||
|
# 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
|
WORKDIR /etc/linkding
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +38,7 @@ RUN python manage.py compilescss && \
|
|||||||
|
|
||||||
FROM python-base AS prod-deps
|
FROM python-base AS prod-deps
|
||||||
COPY requirements.txt ./requirements.txt
|
COPY requirements.txt ./requirements.txt
|
||||||
# replace psycopg2-binary with psycopg2
|
# Need to build psycopg2 from source for ARM platforms
|
||||||
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
|
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
|
||||||
RUN mkdir /opt/venv && \
|
RUN mkdir /opt/venv && \
|
||||||
python -m venv --upgrade-deps --copies /opt/venv && \
|
python -m venv --upgrade-deps --copies /opt/venv && \
|
||||||
@@ -61,9 +67,9 @@ 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
|
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.10.13-alpine3.18 AS final
|
FROM python:3.11.8-alpine3.19 AS linkding
|
||||||
# install runtime dependencies
|
# install runtime dependencies
|
||||||
RUN apk update && apk add bash curl icu libpq mailcap
|
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
||||||
# create www-data user and group
|
# create www-data user and group
|
||||||
RUN set -x ; \
|
RUN set -x ; \
|
||||||
addgroup -g 82 -S www-data ; \
|
addgroup -g 82 -S www-data ; \
|
||||||
@@ -90,3 +96,10 @@ HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
|||||||
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
||||||
|
|
||||||
CMD ["./bootstrap.sh"]
|
CMD ["./bootstrap.sh"]
|
||||||
|
|
||||||
|
|
||||||
|
FROM linkding AS linkding-plus
|
||||||
|
# install node, chromium and single-file
|
||||||
|
RUN apk update && apk add nodejs npm chromium && npm install -g single-file-cli
|
||||||
|
# enable snapshot support
|
||||||
|
ENV LD_ENABLE_SNAPSHOTS=True
|
||||||
|
|||||||