mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-14 22:19:32 +02:00
Compare commits
41 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e50912df12 | ||
![]() |
393d688247 | ||
![]() |
6e38587174 | ||
![]() |
123c6fe02a | ||
![]() |
1b7731e506 | ||
![]() |
df9f0095cc | ||
![]() |
25470edb2c | ||
![]() |
22a1fc80ad | ||
![]() |
65f0eb2a04 | ||
![]() |
82f86bf537 | ||
![]() |
639629ddfe | ||
![]() |
2b342c0d56 | ||
![]() |
3ffec72d3e | ||
![]() |
edd958fff6 | ||
![]() |
2d22d6871e | ||
![]() |
5e8f5b2c58 | ||
![]() |
d5a83722de | ||
![]() |
5d8fdebb7c | ||
![]() |
f7bd6ccb31 | ||
![]() |
e4ee0171be | ||
![]() |
53d1f0c91b | ||
![]() |
a6f35119cd | ||
![]() |
68c163d943 | ||
![]() |
bb6c5ca29e | ||
![]() |
c919e79759 | ||
![]() |
8ff9b42a79 | ||
![]() |
4280ab40c6 | ||
![]() |
db1906942a | ||
![]() |
69877a32e5 | ||
![]() |
e5a9a772f0 | ||
![]() |
2f56d418cf | ||
![]() |
a4df586a8a | ||
![]() |
d9b7996e06 | ||
![]() |
92f62d3ded | ||
![]() |
9c48085829 | ||
![]() |
77e1525402 | ||
![]() |
9df80e01de | ||
![]() |
ec34cc523f | ||
![]() |
eb0b092d17 | ||
![]() |
39e8f03345 | ||
![]() |
d43b97e0c0 |
@@ -5,7 +5,6 @@
|
||||
!/bookmarks
|
||||
!/siteroot
|
||||
|
||||
!/background-tasks-wrapper.sh
|
||||
!/bootstrap.sh
|
||||
!/LICENSE.txt
|
||||
!/manage.py
|
||||
|
5
.github/workflows/main.yaml
vendored
5
.github/workflows/main.yaml
vendored
@@ -24,7 +24,9 @@ jobs:
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: pip install -r requirements.txt -r requirements.dev.txt
|
||||
run: |
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
mkdir data
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.tests
|
||||
e2e_tests:
|
||||
@@ -47,6 +49,7 @@ jobs:
|
||||
run: |
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
playwright install chromium
|
||||
mkdir data
|
||||
- name: Run build
|
||||
run: |
|
||||
npm run build
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -191,3 +191,6 @@ typings/
|
||||
/tmp
|
||||
# Database file
|
||||
/data
|
||||
# ublock + chromium
|
||||
/uBlock0.chromium
|
||||
/chromium-profile
|
||||
|
88
CHANGELOG.md
88
CHANGELOG.md
@@ -1,5 +1,93 @@
|
||||
# Changelog
|
||||
|
||||
## v1.28.0 (09/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add option to disable SSL verification for OIDC by @akaSyntaax in https://github.com/sissbruecker/linkding/pull/684
|
||||
* Add full backup method by @sissbruecker in https://github.com/sissbruecker/linkding/pull/686
|
||||
* Truncate snapshot filename for long URLs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/687
|
||||
* Add option for customizing single-file timeout by @pettijohn in https://github.com/sissbruecker/linkding/pull/688
|
||||
* Add option for passing arguments to single-file command by @pettijohn in https://github.com/sissbruecker/linkding/pull/691
|
||||
* Fix typo by @tianheg in https://github.com/sissbruecker/linkding/pull/689
|
||||
|
||||
### New Contributors
|
||||
* @akaSyntaax made their first contribution in https://github.com/sissbruecker/linkding/pull/684
|
||||
* @pettijohn made their first contribution in https://github.com/sissbruecker/linkding/pull/688
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.1...v1.28.0
|
||||
|
||||
---
|
||||
|
||||
## v1.27.1 (07/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Fix HTML snapshot errors related to single-file-cli by @sissbruecker in https://github.com/sissbruecker/linkding/pull/683
|
||||
* Replace django-background-tasks with huey by @sissbruecker in https://github.com/sissbruecker/linkding/pull/657
|
||||
* Add Authelia OIDC example to docs by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/675
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.0...v1.27.1
|
||||
|
||||
---
|
||||
|
||||
## v1.27.0 (01/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Archive snapshots of websites locally by @sissbruecker in https://github.com/sissbruecker/linkding/pull/672
|
||||
* Add Railway hosting option by @tianheg in https://github.com/sissbruecker/linkding/pull/661
|
||||
* Add how to for increasing the font size by @sissbruecker in https://github.com/sissbruecker/linkding/pull/667
|
||||
|
||||
### New Contributors
|
||||
* @tianheg made their first contribution in https://github.com/sissbruecker/linkding/pull/661
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.26.0...v1.27.0
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
68
README.md
68
README.md
@@ -33,22 +33,19 @@ The name comes from:
|
||||
**Feature Overview:**
|
||||
- Clean UI optimized for readability
|
||||
- Organize bookmarks with tags
|
||||
- Add notes using Markdown
|
||||
- Read it later functionality
|
||||
- Share bookmarks with other users
|
||||
- Bulk editing
|
||||
- Bulk editing, Markdown notes, read it later functionality
|
||||
- Share bookmarks with other users or guests
|
||||
- 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
|
||||
- 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
|
||||
- Light and dark themes
|
||||
- SSO support via OIDC or authentication proxies
|
||||
- REST API for developing 3rd party apps
|
||||
- Admin panel for user self-service and raw data access
|
||||
|
||||
|
||||
**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/
|
||||
|
||||
**Screenshot:**
|
||||
|
||||
@@ -62,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.
|
||||
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
|
||||
|
||||
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
|
||||
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
||||
```
|
||||
@@ -184,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)
|
||||
- [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
|
||||
- [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
|
||||
|
||||
|
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper script used by supervisord to first clear task locks before starting the background task processor
|
||||
|
||||
python manage.py clean_tasks
|
||||
exec python manage.py process_tasks
|
@@ -1,22 +1,96 @@
|
||||
from background_task.admin import TaskAdmin, CompletedTaskAdmin
|
||||
from background_task.models import Task, CompletedTask
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Count, QuerySet
|
||||
from django.shortcuts import render
|
||||
from django.urls import path
|
||||
from django.utils.translation import ngettext, gettext
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
from rest_framework.authtoken.admin import TokenAdmin
|
||||
from rest_framework.authtoken.models import TokenProxy
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile, Toast, FeedToken
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
# Custom paginator to paginate through Huey tasks
|
||||
class TaskPaginator(Paginator):
|
||||
def __init__(self):
|
||||
super().__init__(self, 100)
|
||||
self.task_count = huey.storage.queue_size()
|
||||
|
||||
@property
|
||||
def count(self):
|
||||
return self.task_count
|
||||
|
||||
def page(self, number):
|
||||
limit = self.per_page
|
||||
offset = (number - 1) * self.per_page
|
||||
return self._get_page(
|
||||
self.enqueued_items(limit, offset),
|
||||
number,
|
||||
self,
|
||||
)
|
||||
|
||||
# Copied from Huey's SqliteStorage with some modifications to allow pagination
|
||||
def enqueued_items(self, limit, offset):
|
||||
to_bytes = lambda b: bytes(b) if not isinstance(b, bytes) else b
|
||||
sql = "select data from task where queue=? order by priority desc, id limit ? offset ?"
|
||||
params = (huey.storage.name, limit, offset)
|
||||
|
||||
serialized_tasks = [
|
||||
to_bytes(i) for i, in huey.storage.sql(sql, params, results=True)
|
||||
]
|
||||
return [huey.deserialize_task(task) for task in serialized_tasks]
|
||||
|
||||
|
||||
# Custom view to display Huey tasks in the admin
|
||||
def background_task_view(request):
|
||||
page_number = int(request.GET.get("p", 1))
|
||||
paginator = TaskPaginator()
|
||||
page = paginator.get_page(page_number)
|
||||
page_range = paginator.get_elided_page_range(page_number, on_each_side=2, on_ends=2)
|
||||
context = {
|
||||
**linkding_admin_site.each_context(request),
|
||||
"title": "Background tasks",
|
||||
"page": page,
|
||||
"page_range": page_range,
|
||||
"tasks": page.object_list,
|
||||
}
|
||||
return render(request, "admin/background_tasks.html", context)
|
||||
|
||||
|
||||
class LinkdingAdminSite(AdminSite):
|
||||
site_header = "linkding administration"
|
||||
site_title = "linkding Admin"
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path("tasks/", background_task_view, name="background_tasks"),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def get_app_list(self, request, app_label=None):
|
||||
app_list = super().get_app_list(request, app_label)
|
||||
app_list += [
|
||||
{
|
||||
"name": "Huey",
|
||||
"app_label": "huey_app",
|
||||
"models": [
|
||||
{
|
||||
"name": "Queued tasks",
|
||||
"object_name": "background_tasks",
|
||||
"admin_url": "/admin/tasks/",
|
||||
"view_only": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
return app_list
|
||||
|
||||
|
||||
class AdminBookmark(admin.ModelAdmin):
|
||||
list_display = ("resolved_title", "url", "is_archived", "owner", "date_added")
|
||||
@@ -125,6 +199,15 @@ class AdminBookmark(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class AdminBookmarkAsset(admin.ModelAdmin):
|
||||
list_display = ("display_name", "date_created", "status")
|
||||
search_fields = (
|
||||
"display_name",
|
||||
"file",
|
||||
)
|
||||
list_filter = ("status",)
|
||||
|
||||
|
||||
class AdminTag(admin.ModelAdmin):
|
||||
list_display = ("name", "bookmarks_count", "owner", "date_added")
|
||||
search_fields = ("name", "owner__username")
|
||||
@@ -200,10 +283,9 @@ class AdminFeedToken(admin.ModelAdmin):
|
||||
|
||||
linkding_admin_site = LinkdingAdminSite()
|
||||
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
|
||||
linkding_admin_site.register(Tag, AdminTag)
|
||||
linkding_admin_site.register(User, AdminCustomUser)
|
||||
linkding_admin_site.register(TokenProxy, TokenAdmin)
|
||||
linkding_admin_site.register(Toast, AdminToast)
|
||||
linkding_admin_site.register(FeedToken, AdminFeedToken)
|
||||
linkding_admin_site.register(Task, TaskAdmin)
|
||||
linkding_admin_site.register(CompletedTask, CompletedTaskAdmin)
|
||||
|
171
bookmarks/e2e/e2e_test_bookmark_details_modal.py
Normal file
171
bookmarks/e2e/e2e_test_bookmark_details_modal.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from django.test import override_settings
|
||||
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()
|
||||
self.assertReloads(0)
|
||||
|
||||
# unarchive
|
||||
url = reverse("bookmarks:archived")
|
||||
self.page.goto(self.live_server_url + url)
|
||||
self.resetReloads()
|
||||
|
||||
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()
|
||||
self.assertReloads(0)
|
||||
|
||||
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()
|
||||
self.assertReloads(0)
|
||||
|
||||
# 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()
|
||||
self.assertReloads(0)
|
||||
|
||||
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()
|
||||
self.assertReloads(0)
|
||||
|
||||
# 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()
|
||||
self.assertReloads(0)
|
||||
|
||||
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)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_snapshot_remove_snapshot(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)
|
||||
asset_list = details_modal.locator(".assets")
|
||||
|
||||
# No snapshots initially
|
||||
snapshot = asset_list.get_by_text("HTML snapshot from", exact=False)
|
||||
expect(snapshot).not_to_be_visible()
|
||||
|
||||
# Create snapshot
|
||||
details_modal.get_by_text("Create HTML snapshot", exact=False).click()
|
||||
self.assertReloads(0)
|
||||
|
||||
# Has new snapshots
|
||||
expect(snapshot).to_be_visible()
|
||||
|
||||
# Create snapshot
|
||||
asset_list.get_by_text("Remove", exact=False).click()
|
||||
asset_list.get_by_text("Confirm", exact=False).click()
|
||||
|
||||
# Snapshot is removed
|
||||
expect(snapshot).not_to_be_visible()
|
||||
self.assertReloads(0)
|
37
bookmarks/e2e/e2e_test_bookmark_details_view.py
Normal file
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:
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -46,6 +47,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
0,
|
||||
@@ -74,6 +77,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -81,6 +85,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
@@ -109,6 +115,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as 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_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -116,6 +123,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
@@ -144,6 +153,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as 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_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -151,6 +161,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
@@ -182,7 +194,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input")
|
||||
checkboxes = page.locator("label.bulk-edit-checkbox input")
|
||||
self.assertEqual(6, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
@@ -252,13 +264,13 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
# Hide select across by toggling a single bookmark
|
||||
self.locate_bookmark("Bookmark 1").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
# Show select across again, verify it is unchecked
|
||||
self.locate_bookmark("Bookmark 1").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
@@ -269,14 +281,13 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
url = reverse("bookmarks:index")
|
||||
page = self.open(url, p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
|
||||
# Select all bookmarks, enable select across
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
# Get reference for bookmark list
|
||||
bookmark_list = page.locator("ul[ld-bookmark-list]")
|
||||
|
||||
# Execute bulk action
|
||||
self.select_bulk_action("Mark as unread")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
@@ -286,7 +297,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
# Verify bulk edit checkboxes are reset
|
||||
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input")
|
||||
checkboxes = page.locator("label.bulk-edit-checkbox input")
|
||||
self.assertEqual(31, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
@@ -302,6 +313,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
@@ -312,6 +324,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
# 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()
|
||||
|
@@ -169,7 +169,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Archive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
@@ -187,7 +187,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
@@ -230,7 +230,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Archived Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Unarchive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
@@ -248,7 +248,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark("Archived Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
"label.bulk-edit-checkbox"
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
|
@@ -2,6 +2,7 @@ from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import UserProfile
|
||||
|
||||
|
||||
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
||||
@@ -39,3 +40,49 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
||||
expect(enable_sharing).not_to_be_checked()
|
||||
expect(enable_public_sharing).not_to_be_checked()
|
||||
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()
|
||||
|
76
bookmarks/e2e/e2e_test_tag_cloud_modal.py
Normal file
76
bookmarks/e2e/e2e_test_tag_cloud_modal.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect, Locator
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_show_modal_close_modal(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# use smaller viewport to make tags button visible
|
||||
page.set_viewport_size({"width": 375, "height": 812})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
)
|
||||
modal_trigger.click()
|
||||
|
||||
# verify modal is visible
|
||||
modal = page.locator(".modal")
|
||||
expect(modal).to_be_visible()
|
||||
expect(modal.locator(".modal-title")).to_have_text("Tags")
|
||||
|
||||
# close with close button
|
||||
modal.locator("button.close").click()
|
||||
expect(modal).to_be_hidden()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
|
||||
# close with backdrop
|
||||
backdrop = modal.locator(".modal-overlay")
|
||||
backdrop.click(position={"x": 0, "y": 0})
|
||||
expect(modal).to_be_hidden()
|
||||
|
||||
def test_select_tag(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
# use smaller viewport to make tags button visible
|
||||
page.set_viewport_size({"width": 375, "height": 812})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
)
|
||||
modal_trigger.click()
|
||||
|
||||
# verify tags are displayed
|
||||
modal = page.locator(".modal")
|
||||
unselected_tags = modal.locator(".unselected-tags")
|
||||
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
||||
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
||||
|
||||
# select tag
|
||||
unselected_tags.get_by_text("cooking").click()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
|
||||
# verify tag is selected, other tag is not visible anymore
|
||||
selected_tags = modal.locator(".selected-tags")
|
||||
expect(selected_tags.get_by_text("cooking")).to_be_visible()
|
||||
|
||||
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()
|
||||
expect(unselected_tags.get_by_text("hiking")).not_to_be_visible()
|
@@ -1,5 +1,6 @@
|
||||
from django.contrib.staticfiles.testing import LiveServerTestCase
|
||||
from playwright.sync_api import BrowserContext, Playwright, Page
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
@@ -38,15 +39,33 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
def assertReloads(self, count: int):
|
||||
self.assertEqual(self.num_loads, count)
|
||||
|
||||
def resetReloads(self):
|
||||
self.num_loads = 0
|
||||
|
||||
def locate_bookmark_list(self):
|
||||
return self.page.locator("ul[ld-bookmark-list]")
|
||||
|
||||
def locate_bookmark(self, title: str):
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
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):
|
||||
return self.page.locator(".bulk-edit-bar")
|
||||
|
||||
def locate_bulk_edit_select_all(self):
|
||||
return self.locate_bulk_edit_bar().locator("label[ld-bulk-edit-checkbox][all]")
|
||||
return self.locate_bulk_edit_bar().locator("label.bulk-edit-checkbox.all")
|
||||
|
||||
def locate_bulk_edit_select_across(self):
|
||||
return self.locate_bulk_edit_bar().locator("label.select-across")
|
||||
|
@@ -1,63 +1,8 @@
|
||||
import { registerBehavior, swap } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class BookmarkPage {
|
||||
class BookmarkItem extends Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.form = element.querySelector("form.bookmark-actions");
|
||||
this.form.addEventListener("submit", this.onFormSubmit.bind(this));
|
||||
|
||||
this.bookmarkList = element.querySelector(".bookmark-list-container");
|
||||
this.tagCloud = element.querySelector(".tag-cloud-container");
|
||||
}
|
||||
|
||||
async onFormSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const url = this.form.action;
|
||||
const formData = new FormData(this.form);
|
||||
formData.append(event.submitter.name, event.submitter.value);
|
||||
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
redirect: "manual", // ignore redirect
|
||||
});
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const query = window.location.search;
|
||||
const bookmarksUrl = this.element.getAttribute("bookmarks-url");
|
||||
const tagsUrl = this.element.getAttribute("tags-url");
|
||||
Promise.all([
|
||||
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
|
||||
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
|
||||
]).then(([bookmarkListHtml, tagCloudHtml]) => {
|
||||
swap(this.bookmarkList, bookmarkListHtml);
|
||||
swap(this.tagCloud, tagCloudHtml);
|
||||
|
||||
// Dispatch list updated event
|
||||
const listElement = this.bookmarkList.querySelector(
|
||||
"ul[data-bookmarks-total]",
|
||||
);
|
||||
const bookmarksTotal =
|
||||
(listElement && listElement.dataset.bookmarksTotal) || 0;
|
||||
|
||||
this.bookmarkList.dispatchEvent(
|
||||
new CustomEvent("bookmark-list-updated", {
|
||||
bubbles: true,
|
||||
detail: { bookmarksTotal },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-bookmark-page", BookmarkPage);
|
||||
|
||||
class BookmarkItem {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
super(element);
|
||||
|
||||
// Toggle notes
|
||||
const notesToggle = element.querySelector(".toggle-notes");
|
||||
@@ -68,9 +13,11 @@ class BookmarkItem {
|
||||
// Add tooltip to title if it is truncated
|
||||
const titleAnchor = element.querySelector(".title > a");
|
||||
const titleSpan = titleAnchor.querySelector("span");
|
||||
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
||||
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
||||
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onToggleNotes(event) {
|
||||
|
@@ -1,46 +1,60 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class BulkEdit {
|
||||
class BulkEdit extends Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
super(element);
|
||||
|
||||
this.active = false;
|
||||
this.actionSelect = element.querySelector("select[name='bulk_action']");
|
||||
this.tagAutoComplete = element.querySelector(".tag-autocomplete");
|
||||
this.selectAcross = element.querySelector("label.select-across");
|
||||
|
||||
element.addEventListener(
|
||||
"bulk-edit-toggle-active",
|
||||
this.onToggleActive.bind(this),
|
||||
);
|
||||
element.addEventListener(
|
||||
"bulk-edit-toggle-all",
|
||||
this.onToggleAll.bind(this),
|
||||
);
|
||||
element.addEventListener(
|
||||
"bulk-edit-toggle-bookmark",
|
||||
this.onToggleBookmark.bind(this),
|
||||
);
|
||||
element.addEventListener(
|
||||
"bookmark-list-updated",
|
||||
this.onListUpdated.bind(this),
|
||||
);
|
||||
this.onToggleActive = this.onToggleActive.bind(this);
|
||||
this.onToggleAll = this.onToggleAll.bind(this);
|
||||
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
||||
this.onActionSelected = this.onActionSelected.bind(this);
|
||||
|
||||
this.actionSelect.addEventListener(
|
||||
"change",
|
||||
this.onActionSelected.bind(this),
|
||||
);
|
||||
this.init();
|
||||
// Reset when bookmarks are refreshed
|
||||
document.addEventListener("refresh-bookmark-list-done", () => this.init());
|
||||
}
|
||||
|
||||
get allCheckbox() {
|
||||
return this.element.querySelector("[ld-bulk-edit-checkbox][all] input");
|
||||
}
|
||||
init() {
|
||||
// Update elements
|
||||
this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
|
||||
this.actionSelect = this.element.querySelector(
|
||||
"select[name='bulk_action']",
|
||||
);
|
||||
this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
|
||||
this.selectAcross = this.element.querySelector("label.select-across");
|
||||
this.allCheckbox = this.element.querySelector(
|
||||
".bulk-edit-checkbox.all input",
|
||||
);
|
||||
this.bookmarkCheckboxes = Array.from(
|
||||
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||
);
|
||||
|
||||
get bookmarkCheckboxes() {
|
||||
return [
|
||||
...this.element.querySelectorAll(
|
||||
"[ld-bulk-edit-checkbox]:not([all]) input",
|
||||
),
|
||||
];
|
||||
// Remove previous listeners if elements are the same
|
||||
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
|
||||
// Update total number of bookmarks
|
||||
const totalHolder = this.element.querySelector("[data-bookmarks-total]");
|
||||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
|
||||
// Add new listeners
|
||||
this.activeToggle.addEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.addEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
}
|
||||
|
||||
onToggleActive() {
|
||||
@@ -81,16 +95,6 @@ class BulkEdit {
|
||||
}
|
||||
}
|
||||
|
||||
onListUpdated(event) {
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
|
||||
// Update total number of bookmarks
|
||||
const total = event.detail.bookmarksTotal;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
}
|
||||
|
||||
updateSelectAcross(allChecked) {
|
||||
if (allChecked) {
|
||||
this.selectAcross.classList.remove("d-none");
|
||||
@@ -109,33 +113,4 @@ class BulkEdit {
|
||||
}
|
||||
}
|
||||
|
||||
class BulkEditActiveToggle {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
element.addEventListener("click", this.onClick.bind(this));
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.element.dispatchEvent(
|
||||
new CustomEvent("bulk-edit-toggle-active", { bubbles: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BulkEditCheckbox {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
element.addEventListener("change", this.onChange.bind(this));
|
||||
}
|
||||
|
||||
onChange() {
|
||||
const type = this.element.hasAttribute("all") ? "all" : "bookmark";
|
||||
this.element.dispatchEvent(
|
||||
new CustomEvent(`bulk-edit-toggle-${type}`, { bubbles: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-bulk-edit", BulkEdit);
|
||||
registerBehavior("ld-bulk-edit-active-toggle", BulkEditActiveToggle);
|
||||
registerBehavior("ld-bulk-edit-checkbox", BulkEditCheckbox);
|
||||
|
@@ -1,25 +1,29 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class ConfirmButtonBehavior {
|
||||
class ConfirmButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
const button = element;
|
||||
button.dataset.type = button.type;
|
||||
button.dataset.name = button.name;
|
||||
button.dataset.value = button.value;
|
||||
button.removeAttribute("type");
|
||||
button.removeAttribute("name");
|
||||
button.removeAttribute("value");
|
||||
button.addEventListener("click", this.onClick.bind(this));
|
||||
this.button = button;
|
||||
super(element);
|
||||
element.dataset.type = element.type;
|
||||
element.dataset.name = element.name;
|
||||
element.dataset.value = element.value;
|
||||
element.removeAttribute("type");
|
||||
element.removeAttribute("name");
|
||||
element.removeAttribute("value");
|
||||
element.addEventListener("click", this.onClick.bind(this));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
Behavior.interacting = false;
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
Behavior.interacting = true;
|
||||
|
||||
const container = document.createElement("span");
|
||||
container.className = "confirmation";
|
||||
|
||||
const icon = this.button.getAttribute("confirm-icon");
|
||||
const icon = this.element.getAttribute("ld-confirm-icon");
|
||||
if (icon) {
|
||||
const iconElement = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
@@ -31,38 +35,43 @@ class ConfirmButtonBehavior {
|
||||
container.append(iconElement);
|
||||
}
|
||||
|
||||
const question = this.button.getAttribute("confirm-question");
|
||||
const question = this.element.getAttribute("ld-confirm-question");
|
||||
if (question) {
|
||||
const questionElement = document.createElement("span");
|
||||
questionElement.innerText = question;
|
||||
container.append(question);
|
||||
}
|
||||
|
||||
const cancelButton = document.createElement(this.button.nodeName);
|
||||
const buttonClasses = Array.from(this.element.classList.values())
|
||||
.filter((cls) => cls.startsWith("btn"))
|
||||
.join(" ");
|
||||
|
||||
const cancelButton = document.createElement(this.element.nodeName);
|
||||
cancelButton.type = "button";
|
||||
cancelButton.innerText = question ? "No" : "Cancel";
|
||||
cancelButton.className = "btn btn-link btn-sm mr-1";
|
||||
cancelButton.className = `${buttonClasses} mr-1`;
|
||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
const confirmButton = document.createElement(this.button.nodeName);
|
||||
confirmButton.type = this.button.dataset.type;
|
||||
confirmButton.name = this.button.dataset.name;
|
||||
confirmButton.value = this.button.dataset.value;
|
||||
const confirmButton = document.createElement(this.element.nodeName);
|
||||
confirmButton.type = this.element.dataset.type;
|
||||
confirmButton.name = this.element.dataset.name;
|
||||
confirmButton.value = this.element.dataset.value;
|
||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||
confirmButton.className = "btn btn-link btn-sm";
|
||||
confirmButton.className = buttonClasses;
|
||||
confirmButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
container.append(cancelButton, confirmButton);
|
||||
this.container = container;
|
||||
|
||||
this.button.before(container);
|
||||
this.button.classList.add("d-none");
|
||||
this.element.before(container);
|
||||
this.element.classList.add("d-none");
|
||||
}
|
||||
|
||||
reset() {
|
||||
setTimeout(() => {
|
||||
Behavior.interacting = false;
|
||||
this.container.remove();
|
||||
this.button.classList.remove("d-none");
|
||||
this.element.classList.remove("d-none");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class DropdownBehavior {
|
||||
class DropdownBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
super(element);
|
||||
this.opened = false;
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
|
||||
|
48
bookmarks/frontend/behaviors/fetch.js
Normal file
48
bookmarks/frontend/behaviors/fetch.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Behavior, fireEvents, registerBehavior, swap } from "./index";
|
||||
|
||||
class FetchBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
const eventName = element.getAttribute("ld-on");
|
||||
const interval = parseInt(element.getAttribute("ld-interval")) * 1000;
|
||||
|
||||
this.onFetch = this.onFetch.bind(this);
|
||||
this.onInterval = this.onInterval.bind(this);
|
||||
|
||||
element.addEventListener(eventName, this.onFetch);
|
||||
if (interval) {
|
||||
this.intervalId = setInterval(this.onInterval, interval);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
async onFetch(maybeEvent) {
|
||||
if (maybeEvent) {
|
||||
maybeEvent.preventDefault();
|
||||
}
|
||||
const url = this.element.getAttribute("ld-fetch");
|
||||
const html = await fetch(url).then((response) => response.text());
|
||||
|
||||
const target = this.element.getAttribute("ld-target");
|
||||
const select = this.element.getAttribute("ld-select");
|
||||
swap(this.element, html, { target, select });
|
||||
|
||||
const events = this.element.getAttribute("ld-fire");
|
||||
fireEvents(events);
|
||||
}
|
||||
|
||||
onInterval() {
|
||||
if (Behavior.interacting) {
|
||||
return;
|
||||
}
|
||||
this.onFetch();
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-fetch", FetchBehavior);
|
44
bookmarks/frontend/behaviors/form.js
Normal file
44
bookmarks/frontend/behaviors/form.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Behavior, fireEvents, registerBehavior } from "./index";
|
||||
|
||||
class FormBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
element.addEventListener("submit", this.onSubmit.bind(this));
|
||||
}
|
||||
|
||||
async onSubmit(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
|
||||
});
|
||||
|
||||
const events = this.element.getAttribute("ld-fire");
|
||||
if (fireEvents) {
|
||||
fireEvents(events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AutoSubmitBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
element.addEventListener("change", () => {
|
||||
const form = element.closest("form");
|
||||
form.dispatchEvent(new Event("submit", { cancelable: true }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-form", FormBehavior);
|
||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
@@ -1,7 +1,9 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class GlobalShortcuts extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
class GlobalShortcuts {
|
||||
constructor() {
|
||||
document.addEventListener("keydown", this.onKeyDown.bind(this));
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,35 @@
|
||||
const behaviorRegistry = {};
|
||||
const debug = false;
|
||||
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && !node.isConnected) {
|
||||
destroyBehaviors(node);
|
||||
}
|
||||
});
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && node.isConnected) {
|
||||
applyBehaviors(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
export class Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
Behavior.interacting = false;
|
||||
|
||||
export function registerBehavior(name, behavior) {
|
||||
behaviorRegistry[name] = behavior;
|
||||
@@ -12,7 +43,14 @@ export function applyBehaviors(container, behaviorNames = null) {
|
||||
|
||||
behaviorNames.forEach((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) => {
|
||||
element.__behaviors = element.__behaviors || [];
|
||||
@@ -26,11 +64,82 @@ export function applyBehaviors(container, behaviorNames = null) {
|
||||
|
||||
const behaviorInstance = new behavior(element);
|
||||
element.__behaviors.push(behaviorInstance);
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[Behavior] ${behaviorInstance.constructor.name} initialized`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function swap(element, html) {
|
||||
element.innerHTML = html;
|
||||
applyBehaviors(element);
|
||||
export function destroyBehaviors(element) {
|
||||
const behaviorNames = Object.keys(behaviorRegistry);
|
||||
|
||||
behaviorNames.forEach((behaviorName) => {
|
||||
const elements = Array.from(element.querySelectorAll(`[${behaviorName}]`));
|
||||
elements.push(element);
|
||||
|
||||
elements.forEach((element) => {
|
||||
if (!element.__behaviors) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.__behaviors.forEach((behavior) => {
|
||||
behavior.destroy();
|
||||
if (debug) {
|
||||
console.log(`[Behavior] ${behavior.constructor.name} destroyed`);
|
||||
}
|
||||
});
|
||||
delete element.__behaviors;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function swap(element, html, options) {
|
||||
const dom = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
let targetElement = element;
|
||||
let strategy = "innerHTML";
|
||||
if (options.target) {
|
||||
const parts = options.target.split("|");
|
||||
targetElement =
|
||||
parts[0] === "self" ? element : document.querySelector(parts[0]);
|
||||
strategy = parts[1] || "innerHTML";
|
||||
}
|
||||
|
||||
let contents = Array.from(dom.body.children);
|
||||
if (options.select) {
|
||||
contents = Array.from(dom.querySelectorAll(options.select));
|
||||
}
|
||||
|
||||
switch (strategy) {
|
||||
case "append":
|
||||
targetElement.append(...contents);
|
||||
break;
|
||||
case "outerHTML":
|
||||
targetElement.parentElement.replaceChild(contents[0], targetElement);
|
||||
break;
|
||||
case "innerHTML":
|
||||
default:
|
||||
Array.from(targetElement.children).forEach((child) => {
|
||||
child.remove();
|
||||
});
|
||||
targetElement.append(...contents);
|
||||
}
|
||||
}
|
||||
|
||||
export function fireEvents(events) {
|
||||
if (!events) {
|
||||
return;
|
||||
}
|
||||
events.split(",").forEach((eventName) => {
|
||||
const targets = Array.from(
|
||||
document.querySelectorAll(`[ld-on='${eventName}']`),
|
||||
);
|
||||
targets.push(document);
|
||||
targets.forEach((target) => {
|
||||
target.dispatchEvent(new CustomEvent(eventName));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -1,64 +1,22 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class ModalBehavior {
|
||||
class ModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
const toggle = element;
|
||||
toggle.addEventListener("click", this.onToggleClick.bind(this));
|
||||
this.toggle = toggle;
|
||||
}
|
||||
super(element);
|
||||
|
||||
onToggleClick() {
|
||||
const contentSelector = this.toggle.getAttribute("modal-content");
|
||||
const content = document.querySelector(contentSelector);
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create modal
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "active");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
<div class="modal-container">
|
||||
<div class="modal-header d-flex justify-between align-center">
|
||||
<div class="modal-title h5">Tags</div>
|
||||
<button class="btn btn-link 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"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Teleport content element
|
||||
const contentOwner = content.parentElement;
|
||||
const contentContainer = modal.querySelector(".content");
|
||||
contentContainer.append(content);
|
||||
this.content = content;
|
||||
this.contentOwner = contentOwner;
|
||||
|
||||
// Register close handlers
|
||||
const modalOverlay = modal.querySelector(".modal-overlay");
|
||||
const closeButton = modal.querySelector(".btn.close");
|
||||
const modalOverlay = element.querySelector(".modal-overlay");
|
||||
const closeButton = element.querySelector("button.close");
|
||||
modalOverlay.addEventListener("click", this.onClose.bind(this));
|
||||
closeButton.addEventListener("click", this.onClose.bind(this));
|
||||
|
||||
document.body.append(modal);
|
||||
this.modal = modal;
|
||||
}
|
||||
|
||||
onClose() {
|
||||
// Teleport content back
|
||||
this.contentOwner.append(this.content);
|
||||
|
||||
// Remove modal
|
||||
this.modal.remove();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener("animationend", (event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
this.element.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
|
||||
import { ApiClient } from "../api";
|
||||
|
||||
class TagAutocomplete {
|
||||
class TagAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
const wrapper = document.createElement("div");
|
||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
|
||||
const apiClient = new ApiClient(apiBaseUrl);
|
||||
|
@@ -150,19 +150,31 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-autocomplete-input {
|
||||
box-sizing: border-box;
|
||||
height: var(--control-size);
|
||||
min-height: var(--control-size);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete-input input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: 1.4rem;
|
||||
min-height: 1.4rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
height: var(--control-size-sm);
|
||||
min-height: var(--control-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-autocomplete.small .menu .menu-item {
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,10 +1,12 @@
|
||||
import './behaviors/bookmark-page';
|
||||
import './behaviors/bulk-edit';
|
||||
import './behaviors/confirm-button';
|
||||
import './behaviors/dropdown';
|
||||
import './behaviors/modal';
|
||||
import './behaviors/global-shortcuts';
|
||||
import './behaviors/tag-autocomplete';
|
||||
export { default as TagAutoComplete } from './components/TagAutocomplete.svelte';
|
||||
export { default as SearchAutoComplete } from './components/SearchAutoComplete.svelte';
|
||||
export { ApiClient } from './api';
|
||||
import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/fetch";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/modal";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
export { ApiClient } from "./api";
|
||||
|
@@ -24,3 +24,8 @@ class Command(BaseCommand):
|
||||
source_db.close()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Backup created at {destination}"))
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"This backup method is deprecated and may be removed in the future. Please use the full_backup command instead, which creates backup zip file with all contents of the data folder."
|
||||
)
|
||||
)
|
||||
|
@@ -1,15 +0,0 @@
|
||||
from background_task.models import Task, CompletedTask
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Remove task locks and clear completed task history"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Remove task locks
|
||||
# If the background task processor exited while executing tasks, these tasks would still be marked as locked,
|
||||
# even though no process is working on them, and would prevent the task processor from picking the next task in
|
||||
# the queue
|
||||
Task.objects.all().update(locked_by=None, locked_at=None)
|
||||
# Clear task history to prevent them from bloating the DB
|
||||
CompletedTask.objects.all().delete()
|
62
bookmarks/management/commands/full_backup.py
Normal file
62
bookmarks/management/commands/full_backup.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Creates a backup of the linkding data folder"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("backup_file", type=str, help="Backup zip file destination")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
backup_file = options["backup_file"]
|
||||
with zipfile.ZipFile(backup_file, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
# Backup the database
|
||||
self.stdout.write("Create database backup...")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
backup_db_file = os.path.join(temp_dir, "db.sqlite3")
|
||||
self.backup_database(backup_db_file)
|
||||
zip_file.write(backup_db_file, "db.sqlite3")
|
||||
|
||||
# Backup the assets folder
|
||||
if not os.path.exists(os.path.join("data", "assets")):
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No assets folder found. Skipping...")
|
||||
)
|
||||
else:
|
||||
self.stdout.write("Backup bookmark assets...")
|
||||
assets_folder = os.path.join("data", "assets")
|
||||
for root, _, files in os.walk(assets_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
zip_file.write(file_path, os.path.join("assets", file))
|
||||
|
||||
# Backup the favicons folder
|
||||
if not os.path.exists(os.path.join("data", "favicons")):
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No favicons folder found. Skipping...")
|
||||
)
|
||||
else:
|
||||
self.stdout.write("Backup bookmark favicons...")
|
||||
favicons_folder = os.path.join("data", "favicons")
|
||||
for root, _, files in os.walk(favicons_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
zip_file.write(file_path, os.path.join("favicons", file))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Backup created at {backup_file}"))
|
||||
|
||||
def backup_database(self, backup_db_file):
|
||||
def progress(status, remaining, total):
|
||||
self.stdout.write(f"Copied {total-remaining} of {total} pages...")
|
||||
|
||||
source_db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
|
||||
backup_db = sqlite3.connect(backup_db_file)
|
||||
with backup_db:
|
||||
source_db.backup(backup_db, pages=50, progress=progress)
|
||||
backup_db.close()
|
||||
source_db.close()
|
75
bookmarks/management/commands/migrate_tasks.py
Normal file
75
bookmarks/management/commands/migrate_tasks.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import importlib
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Migrate tasks from django-background-tasks to Huey"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
|
||||
|
||||
# Check if background_task table exists
|
||||
cursor = db.cursor()
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='background_task'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
self.stdout.write(
|
||||
"Legacy task table does not exist. Skipping task migration"
|
||||
)
|
||||
return
|
||||
|
||||
# Load legacy tasks
|
||||
cursor.execute("SELECT id, task_name, task_params FROM background_task")
|
||||
legacy_tasks = cursor.fetchall()
|
||||
|
||||
if len(legacy_tasks) == 0:
|
||||
self.stdout.write("No legacy tasks found. Skipping task migration")
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
f"Found {len(legacy_tasks)} legacy tasks. Migrating to Huey..."
|
||||
)
|
||||
|
||||
# Migrate tasks to Huey
|
||||
succeeded_tasks = []
|
||||
for task in legacy_tasks:
|
||||
task_id = task[0]
|
||||
task_name = task[1]
|
||||
task_params_json = task[2]
|
||||
try:
|
||||
task_params = json.loads(task_params_json)
|
||||
function_params = task_params[0]
|
||||
|
||||
# Resolve task function
|
||||
module_name, func_name = task_name.rsplit(".", 1)
|
||||
module = importlib.import_module(module_name)
|
||||
func = getattr(module, func_name)
|
||||
|
||||
# Call task function
|
||||
func(*function_params)
|
||||
succeeded_tasks.append(task_id)
|
||||
except Exception:
|
||||
self.stderr.write(f"Error migrating task [{task_id}] {task_name}")
|
||||
|
||||
self.stdout.write(f"Migrated {len(succeeded_tasks)} tasks successfully")
|
||||
|
||||
# Clean up
|
||||
try:
|
||||
placeholders = ", ".join("?" for _ in succeeded_tasks)
|
||||
sql = f"DELETE FROM background_task WHERE id IN ({placeholders})"
|
||||
cursor.execute(sql, succeeded_tasks)
|
||||
db.commit()
|
||||
self.stdout.write(
|
||||
f"Deleted {len(succeeded_tasks)} migrated tasks from legacy table"
|
||||
)
|
||||
except Exception:
|
||||
self.stderr.write("Error cleaning up legacy tasks")
|
||||
|
||||
cursor.close()
|
||||
db.close()
|
@@ -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
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
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
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
|
||||
from typing import List
|
||||
|
||||
import binascii
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
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.http import QueryDict
|
||||
|
||||
from bookmarks.utils import unique
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=64)
|
||||
@@ -85,6 +89,47 @@ class Bookmark(models.Model):
|
||||
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):
|
||||
# Use URLField for URL
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
@@ -278,6 +323,12 @@ class UserProfile(models.Model):
|
||||
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, "Absolute"),
|
||||
(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_SELF = "_self"
|
||||
BOOKMARK_LINK_TARGET_CHOICES = [
|
||||
@@ -308,6 +359,16 @@ class UserProfile(models.Model):
|
||||
blank=False,
|
||||
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(
|
||||
max_length=10,
|
||||
choices=BOOKMARK_LINK_TARGET_CHOICES,
|
||||
@@ -330,9 +391,14 @@ class UserProfile(models.Model):
|
||||
enable_public_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_favicons = 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)
|
||||
custom_css = models.TextField(blank=True, null=False)
|
||||
search_preferences = models.JSONField(default=dict, null=False)
|
||||
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
@@ -341,13 +407,20 @@ class UserProfileForm(forms.ModelForm):
|
||||
fields = [
|
||||
"theme",
|
||||
"bookmark_date_display",
|
||||
"bookmark_description_display",
|
||||
"bookmark_description_max_lines",
|
||||
"bookmark_link_target",
|
||||
"web_archive_integration",
|
||||
"tag_search",
|
||||
"enable_sharing",
|
||||
"enable_public_sharing",
|
||||
"enable_favicons",
|
||||
"enable_automatic_html_snapshots",
|
||||
"display_url",
|
||||
"display_view_bookmark_action",
|
||||
"display_edit_bookmark_action",
|
||||
"display_archive_bookmark_action",
|
||||
"display_remove_bookmark_action",
|
||||
"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)
|
||||
# Load favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
# Create HTML snapshot
|
||||
if current_user.profile.enable_automatic_html_snapshots:
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
|
||||
return bookmark
|
||||
|
||||
|
32
bookmarks/services/monolith.py
Normal file
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}")
|
58
bookmarks/services/singlefile.py
Normal file
58
bookmarks/services/singlefile.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SingeFileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_snapshot(url: str, filepath: str):
|
||||
singlefile_path = settings.LD_SINGLEFILE_PATH
|
||||
# parse options to list of arguments
|
||||
ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS)
|
||||
custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS)
|
||||
temp_filepath = filepath + ".tmp"
|
||||
# concat lists
|
||||
args = [singlefile_path] + ublock_options + custom_options + [url, temp_filepath]
|
||||
try:
|
||||
# Use start_new_session=True to create a new process group
|
||||
process = subprocess.Popen(args, start_new_session=True)
|
||||
process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC)
|
||||
|
||||
# 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.TimeoutExpired:
|
||||
# First try to terminate properly
|
||||
try:
|
||||
logger.error(
|
||||
"Timeout expired while creating snapshot. Terminating process..."
|
||||
)
|
||||
process.terminate()
|
||||
process.wait(timeout=20)
|
||||
raise SingeFileError("Timeout expired while creating snapshot")
|
||||
except subprocess.TimeoutExpired:
|
||||
# Kill the whole process group, which should also clean up any chromium
|
||||
# processes spawned by single-file
|
||||
logger.error("Timeout expired while terminating. Killing process...")
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
raise SingeFileError("Timeout expired while creating snapshot")
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise SingeFileError(f"Failed to create snapshot: {error.stderr}")
|
@@ -1,21 +1,55 @@
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
import waybackpy
|
||||
from background_task import background
|
||||
from background_task.models import Task
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone, formats
|
||||
from huey import crontab
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
from huey.exceptions import TaskLockedException
|
||||
from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound
|
||||
|
||||
import bookmarks.services.wayback
|
||||
from bookmarks.models import Bookmark, UserProfile
|
||||
from bookmarks.services import favicon_loader
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
|
||||
from bookmarks.services import favicon_loader, singlefile
|
||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Create custom decorator for Huey tasks that implements exponential backoff
|
||||
# Taken from: https://huey.readthedocs.io/en/latest/guide.html#tips-and-tricks
|
||||
# Retry 1: 60
|
||||
# Retry 2: 240
|
||||
# Retry 3: 960
|
||||
# Retry 4: 3840
|
||||
# Retry 5: 15360
|
||||
def task(retries=5, retry_delay=15, retry_backoff=4):
|
||||
def deco(fn):
|
||||
@functools.wraps(fn)
|
||||
def inner(*args, **kwargs):
|
||||
task = kwargs.pop("task")
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except TaskLockedException as exc:
|
||||
# Task locks are currently only used as workaround to enforce
|
||||
# running specific types of tasks (e.g. singlefile snapshots)
|
||||
# sequentially. In that case don't reduce the number of retries.
|
||||
task.retries = retries
|
||||
raise exc
|
||||
except Exception as exc:
|
||||
task.retry_delay *= retry_backoff
|
||||
raise exc
|
||||
|
||||
return huey.task(retries=retries, retry_delay=retry_delay, context=True)(inner)
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def is_web_archive_integration_active(user: User) -> bool:
|
||||
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
web_archive_integration_enabled = (
|
||||
@@ -65,7 +99,7 @@ def _create_snapshot(bookmark: Bookmark):
|
||||
logger.info(f"Successfully created new snapshot for bookmark:. url={bookmark.url}")
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
@@ -94,7 +128,7 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
||||
_load_newest_snapshot(bookmark)
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _load_web_archive_snapshot_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
@@ -112,13 +146,14 @@ def schedule_bookmarks_without_snapshots(user: User):
|
||||
_schedule_bookmarks_without_snapshots_task(user.id)
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks_without_snapshots = Bookmark.objects.filter(
|
||||
web_archive_snapshot_url__exact="", owner=user
|
||||
)
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
for bookmark in bookmarks_without_snapshots:
|
||||
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
|
||||
# new ones when processing bookmarks in bulk
|
||||
@@ -136,7 +171,7 @@ def load_favicon(user: User, bookmark: Bookmark):
|
||||
_load_favicon_task(bookmark.id)
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _load_favicon_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
@@ -160,19 +195,15 @@ def schedule_bookmarks_without_favicons(user: User):
|
||||
_schedule_bookmarks_without_favicons_task(user.id)
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _schedule_bookmarks_without_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(favicon_file__exact="", owner=user)
|
||||
tasks = []
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
for bookmark in bookmarks:
|
||||
task = Task.objects.new_task(
|
||||
task_name="bookmarks.services.tasks._load_favicon_task", args=(bookmark.id,)
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
Task.objects.bulk_create(tasks)
|
||||
_load_favicon_task(bookmark.id)
|
||||
pass
|
||||
|
||||
|
||||
def schedule_refresh_favicons(user: User):
|
||||
@@ -180,16 +211,135 @@ def schedule_refresh_favicons(user: User):
|
||||
_schedule_refresh_favicons_task(user.id)
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _schedule_refresh_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(owner=user)
|
||||
tasks = []
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
for bookmark in bookmarks:
|
||||
task = Task.objects.new_task(
|
||||
task_name="bookmarks.services.tasks._load_favicon_task", args=(bookmark.id,)
|
||||
)
|
||||
tasks.append(task)
|
||||
_load_favicon_task(bookmark.id)
|
||||
|
||||
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
|
||||
|
||||
asset = _create_snapshot_asset(bookmark)
|
||||
asset.save()
|
||||
|
||||
|
||||
def create_html_snapshots(bookmark_list: List[Bookmark]):
|
||||
if not is_html_snapshot_feature_active():
|
||||
return
|
||||
|
||||
assets_to_create = []
|
||||
for bookmark in bookmark_list:
|
||||
asset = _create_snapshot_asset(bookmark)
|
||||
assets_to_create.append(asset)
|
||||
|
||||
BookmarkAsset.objects.bulk_create(assets_to_create)
|
||||
|
||||
|
||||
MAX_SNAPSHOT_FILENAME_LENGTH = 192
|
||||
|
||||
|
||||
def _create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
|
||||
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,
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Calculate the length of the non-URL parts of the filename
|
||||
non_url_length = len(f"{asset.asset_type}{formatted_datetime}__.html.gz")
|
||||
# Calculate the maximum length for the URL part
|
||||
max_url_length = MAX_SNAPSHOT_FILENAME_LENGTH - non_url_length
|
||||
# Truncate the URL if necessary
|
||||
sanitized_url = sanitized_url[:max_url_length]
|
||||
|
||||
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_url}.html.gz"
|
||||
|
||||
|
||||
# singe-file does not support running multiple instances in parallel, so we can
|
||||
# not queue up multiple snapshot tasks at once. Instead, schedule a periodic
|
||||
# task that grabs a number of pending assets and creates snapshots for them in
|
||||
# sequence. The task uses a lock to ensure that a new task isn't scheduled
|
||||
# before the previous one has finished.
|
||||
@huey.periodic_task(crontab(minute="*"))
|
||||
@huey.lock_task("schedule-html-snapshots-lock")
|
||||
def _schedule_html_snapshots_task():
|
||||
# Get five pending assets
|
||||
assets = BookmarkAsset.objects.filter(status=BookmarkAsset.STATUS_PENDING).order_by(
|
||||
"date_created"
|
||||
)[:5]
|
||||
|
||||
for asset in assets:
|
||||
_create_html_snapshot_task(asset.id)
|
||||
|
||||
|
||||
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
|
||||
asset.save()
|
||||
logger.info(
|
||||
f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Failed to HTML snapshot for bookmark. url={asset.bookmark.url}",
|
||||
exc_info=error,
|
||||
)
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
asset.save()
|
||||
|
||||
|
||||
def create_missing_html_snapshots(user: User) -> int:
|
||||
if not is_html_snapshot_feature_active():
|
||||
return 0
|
||||
|
||||
bookmarks_without_snapshots = Bookmark.objects.filter(owner=user).exclude(
|
||||
bookmarkasset__asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
bookmarkasset__status__in=[
|
||||
BookmarkAsset.STATUS_PENDING,
|
||||
BookmarkAsset.STATUS_COMPLETE,
|
||||
],
|
||||
)
|
||||
bookmarks_without_snapshots |= Bookmark.objects.filter(owner=user).exclude(
|
||||
bookmarkasset__asset_type=BookmarkAsset.TYPE_SNAPSHOT
|
||||
)
|
||||
|
||||
create_html_snapshots(list(bookmarks_without_snapshots))
|
||||
|
||||
return bookmarks_without_snapshots.count()
|
||||
|
@@ -9,7 +9,7 @@ body {
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: $unit-10;
|
||||
margin-bottom: $unit-9;
|
||||
|
||||
.logo {
|
||||
width: 28px;
|
||||
@@ -50,14 +50,14 @@ section.content-area {
|
||||
border-bottom: solid 1px $border-color;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: $unit-6;
|
||||
padding-bottom: $unit-2;
|
||||
margin-bottom: $unit-4;
|
||||
column-gap: $unit-5;
|
||||
padding-bottom: $unit-1;
|
||||
margin-bottom: $unit-3;
|
||||
|
||||
h2 {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.8rem;
|
||||
margin-bottom: 0;
|
||||
line-height: $unit-9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
@@ -95,10 +95,6 @@ span.confirmation {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.text-gray-dark {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
@@ -124,10 +120,6 @@ span.confirmation {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn.btn-wide {
|
||||
padding-left: $unit-6;
|
||||
padding-right: $unit-6;
|
||||
|
126
bookmarks/styles/bookmark-details.scss
Normal file
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 {
|
||||
grid-gap: $unit-10;
|
||||
grid-gap: $unit-9;
|
||||
}
|
||||
|
||||
/* Bookmark area header controls */
|
||||
.bookmarks-page .content-area-header {
|
||||
--searchbox-max-width: 350px;
|
||||
--searchbox-height: 1.8rem;
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
--searchbox-max-width: initial;
|
||||
@@ -20,18 +19,18 @@
|
||||
|
||||
// Regular input
|
||||
input[type='search'] {
|
||||
height: var(--searchbox-height);
|
||||
height: $control-size;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
// Enhanced auto-complete input
|
||||
// This needs a bit more wrangling to make the CSS component align with the attached button
|
||||
.form-autocomplete {
|
||||
height: var(--searchbox-height);
|
||||
height: $control-size;
|
||||
|
||||
.form-autocomplete-input {
|
||||
width: 100%;
|
||||
height: var(--searchbox-height);
|
||||
height: $control-size;
|
||||
|
||||
input[type='search'] {
|
||||
width: 100%;
|
||||
@@ -72,6 +71,7 @@
|
||||
.menu {
|
||||
padding: $unit-4;
|
||||
min-width: 250px;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.menu .actions {
|
||||
@@ -82,9 +82,11 @@
|
||||
|
||||
.radio-group {
|
||||
margin-bottom: $unit-1;
|
||||
|
||||
.form-label {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.form-radio.form-inline {
|
||||
margin: 0 $unit-2 0 0;
|
||||
padding: 0;
|
||||
@@ -92,6 +94,7 @@
|
||||
align-items: center;
|
||||
column-gap: $unit-1;
|
||||
}
|
||||
|
||||
.form-icon {
|
||||
top: 0;
|
||||
position: relative;
|
||||
@@ -105,6 +108,9 @@ ul.bookmark-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
@@ -122,59 +128,81 @@ ul.bookmark-list {
|
||||
/* Bookmarks */
|
||||
li[ld-bookmark-item] {
|
||||
position: relative;
|
||||
margin-top: $unit-2;
|
||||
|
||||
[ld-bulk-edit-checkbox].form-checkbox {
|
||||
.form-checkbox.bulk-edit-checkbox {
|
||||
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 {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&[data-tooltip]:hover::after, &[data-tooltip]:focus::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
height: fit-content;
|
||||
background-color: #292f62;
|
||||
color: #fff;
|
||||
padding: $unit-1;
|
||||
border-radius: $border-radius;
|
||||
border: 1px solid #424a8c;
|
||||
font-size: $font-size-sm;
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
animation: 0.3s ease 0s appear;
|
||||
}
|
||||
.title a[data-tooltip]:hover::after, .title a[data-tooltip]:focus::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
height: fit-content;
|
||||
background-color: #292f62;
|
||||
color: #fff;
|
||||
padding: $unit-1;
|
||||
border-radius: $border-radius;
|
||||
border: 1px solid #424a8c;
|
||||
font-size: $font-size-sm;
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
animation: 0.3s ease 0s appear;
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.title img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: $unit-h;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.url-display {
|
||||
.url-path, .url-display {
|
||||
font-size: $font-size-sm;
|
||||
color: $secondary-link-color;
|
||||
}
|
||||
|
||||
.description {
|
||||
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 {
|
||||
color: $alternative-color;
|
||||
}
|
||||
@@ -195,6 +223,8 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
|
||||
.actions {
|
||||
font-size: $font-size-sm;
|
||||
|
||||
a, button.btn-link {
|
||||
color: $gray-color;
|
||||
padding: 0;
|
||||
@@ -211,10 +241,6 @@ li[ld-bookmark-item] {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +249,8 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
|
||||
.selected-tags {
|
||||
margin-bottom: $unit-4;
|
||||
@@ -258,55 +286,13 @@ ul.bookmark-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&.show-notes .notes,
|
||||
li.show-notes .notes {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark notes markdown styles */
|
||||
ul.bookmark-list .notes-content {
|
||||
& {
|
||||
.notes .markdown {
|
||||
padding: $unit-2 $unit-3;
|
||||
}
|
||||
|
||||
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;
|
||||
&.show-notes .notes,
|
||||
li.show-notes .notes {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +306,7 @@ $bulk-edit-transition-duration: 400ms;
|
||||
.bulk-edit-bar {
|
||||
margin-top: -1px;
|
||||
margin-left: -$bulk-edit-bar-offset;
|
||||
margin-bottom: $unit-4;
|
||||
margin-bottom: $unit-3;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height $bulk-edit-transition-duration;
|
||||
@@ -337,21 +323,22 @@ $bulk-edit-transition-duration: 400ms;
|
||||
}
|
||||
|
||||
/* All checkbox */
|
||||
[ld-bulk-edit-checkbox][all].form-checkbox {
|
||||
.form-checkbox.bulk-edit-checkbox.all {
|
||||
display: block;
|
||||
width: $bulk-edit-toggle-width;
|
||||
margin: 0 0 0 $bulk-edit-toggle-offset;
|
||||
padding: 0;
|
||||
min-height: 1rem;
|
||||
}
|
||||
|
||||
/* Bookmark checkboxes */
|
||||
li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
|
||||
li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: $bulk-edit-toggle-width;
|
||||
min-height: $bulk-edit-toggle-width;
|
||||
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
|
||||
top: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
@@ -359,11 +346,11 @@ $bulk-edit-transition-duration: 400ms;
|
||||
transition: all $bulk-edit-transition-duration;
|
||||
|
||||
.form-icon {
|
||||
top: $unit-1;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active li[ld-bookmark-item] [ld-bulk-edit-checkbox].form-checkbox {
|
||||
&.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -371,7 +358,7 @@ $bulk-edit-transition-duration: 400ms;
|
||||
/* Actions */
|
||||
.bulk-edit-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
padding: $unit-1 0;
|
||||
border-top: solid 1px $border-color;
|
||||
gap: $unit-2;
|
||||
|
40
bookmarks/styles/markdown.scss
Normal file
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;
|
||||
}
|
||||
|
||||
.columns-2 {
|
||||
--grid-columns: 2;
|
||||
}
|
||||
|
||||
.gap-0 {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.col-1 {
|
||||
grid-column: unquote("span min(1, var(--grid-columns))");
|
||||
}
|
||||
|
@@ -1,9 +1,9 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
margin-bottom: $unit-12;
|
||||
margin-bottom: $unit-10;
|
||||
|
||||
h2 {
|
||||
margin-bottom: $unit-4;
|
||||
margin-bottom: $unit-3;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -3,6 +3,32 @@
|
||||
|
||||
// Variables and mixins
|
||||
@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";
|
||||
|
||||
/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
|
||||
@@ -64,19 +90,6 @@ a:visited:hover {
|
||||
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
|
||||
code {
|
||||
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
|
||||
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
||||
// viewport size
|
||||
|
@@ -7,9 +7,11 @@
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "responsive";
|
||||
@import "bookmark-details";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "markdown";
|
||||
|
||||
/* 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 {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: $dt-primary-button-color;
|
||||
background: $dt-primary-input-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 {
|
||||
|
@@ -7,6 +7,8 @@
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "responsive";
|
||||
@import "bookmark-details";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "markdown";
|
||||
|
@@ -1,5 +1,3 @@
|
||||
$html-font-size: 18px !default;
|
||||
|
||||
$body-bg: #161822 !default;
|
||||
$bg-color: 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);
|
||||
|
||||
/* Dark theme specific */
|
||||
$dt-primary-input-color: #5C68E7 !default;
|
||||
$dt-primary-button-color: #5761cb !default;
|
||||
|
@@ -1,5 +1,3 @@
|
||||
$html-font-size: 18px !default;
|
||||
|
||||
$alternative-color: #05a6a3;
|
||||
$alternative-color-dark: darken($alternative-color, 5%);
|
||||
|
||||
|
2
bookmarks/tasks.py
Normal file
2
bookmarks/tasks.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Expose task modules to Huey Django extension
|
||||
import bookmarks.services.tasks
|
39
bookmarks/templates/admin/background_tasks.html
Normal file
39
bookmarks/templates/admin/background_tasks.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
|
||||
{% block content %}
|
||||
<table style="width: 100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Args</th>
|
||||
<th>Retries</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr>
|
||||
<td>{{ task.id }}</td>
|
||||
<td>{{ task.name }}</td>
|
||||
<td>{{ task.args }}</td>
|
||||
<td>{{ task.retries }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="paginator">
|
||||
{% if page.paginator.num_pages > 1 %}
|
||||
{% for page_number in page_range %}
|
||||
{% if page_number == page.number %}
|
||||
<span class="this-page">{{ page_number }}</span>
|
||||
{% elif page_number == '…' %}
|
||||
<span>…</span>
|
||||
{% else %}
|
||||
<a href="?p={{ page_number }}">{{ page_number }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
{{ page.paginator.count }} tasks
|
||||
</p>
|
||||
{% endblock %}
|
@@ -4,11 +4,7 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page grid columns-md-1"
|
||||
ld-bulk-edit
|
||||
ld-bookmark-page
|
||||
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.archived' %}"
|
||||
tags-url="{% url 'bookmarks:partials.tag_cloud.archived' %}">
|
||||
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
@@ -17,17 +13,22 @@
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
|
||||
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
|
||||
class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="bookmark-actions"
|
||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
|
||||
class="bookmark-actions"
|
||||
action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post" autocomplete="off">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
|
||||
|
||||
<div class="bookmark-list-container">
|
||||
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
|
||||
ld-fire="refresh-bookmark-list-done"
|
||||
class="bookmark-list-container">
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
@@ -38,7 +39,8 @@
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div class="tag-cloud-container">
|
||||
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
|
||||
class="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
@@ -6,50 +6,66 @@
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
<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 }}">
|
||||
{% for bookmark_item in bookmark_list.items %}
|
||||
<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">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener" >
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% 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>
|
||||
</div>
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display text-sm">
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
</a>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<span>
|
||||
<div 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>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="notes-content">
|
||||
<div class="markdown">
|
||||
{% markdown bookmark_item.notes %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions text-gray text-sm">
|
||||
<div class="actions text-gray">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
@@ -61,23 +77,35 @@
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% 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-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
||||
ld-on="click" ld-target="body|append"
|
||||
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
@@ -86,11 +114,11 @@
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="separator hide-sm">|</span>
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
@@ -100,7 +128,7 @@
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
|
@@ -2,7 +2,7 @@
|
||||
{% htmlmin %}
|
||||
<div class="bulk-edit-bar">
|
||||
<div class="bulk-edit-actions bg-gray">
|
||||
<label ld-bulk-edit-checkbox all class="form-checkbox">
|
||||
<label class="form-checkbox bulk-edit-checkbox all">
|
||||
<input type="checkbox">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<button ld-bulk-edit-active-toggle class="btn hide-sm ml-2" title="Bulk edit">
|
||||
<button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
|
||||
height="20px">
|
||||
<path
|
||||
|
13
bookmarks/templates/bookmarks/details.html
Normal file
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
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
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 %}
|
44
bookmarks/templates/bookmarks/details/assets.html
Normal file
44
bookmarks/templates/bookmarks/details/assets.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<div {% if details.has_pending_assets %}
|
||||
ld-fetch="{% url 'bookmarks:details_assets' details.bookmark.id %}"
|
||||
ld-interval="5" ld-target="self|outerHTML"
|
||||
{% endif %}>
|
||||
{% 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"
|
||||
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
98
bookmarks/templates/bookmarks/details/form.html
Normal file
98
bookmarks/templates/bookmarks/details/form.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% load static %}
|
||||
{% load shared %}
|
||||
|
||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud,refresh-details"
|
||||
action="{% url 'bookmarks:details' details.bookmark.id %}"
|
||||
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-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-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-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
3
bookmarks/templates/bookmarks/details/title.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<h2>
|
||||
{{ details.bookmark.resolved_title }}
|
||||
</h2>
|
30
bookmarks/templates/bookmarks/details_modal.html
Normal file
30
bookmarks/templates/bookmarks/details_modal.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<div ld-modal
|
||||
ld-fetch="{% url 'bookmarks:details_modal' details.bookmark.id %}" ld-on="refresh-details"
|
||||
ld-select=".content" ld-target=".modal.bookmark-details .content|outerHTML"
|
||||
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>
|
@@ -4,11 +4,7 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page grid columns-md-1"
|
||||
ld-bulk-edit
|
||||
ld-bookmark-page
|
||||
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.active' %}"
|
||||
tags-url="{% url 'bookmarks:partials.tag_cloud.active' %}">
|
||||
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
@@ -17,17 +13,22 @@
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search tag_cloud.tags %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
|
||||
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
|
||||
class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="bookmark-actions"
|
||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
|
||||
class="bookmark-actions"
|
||||
action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post" autocomplete="off">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
|
||||
|
||||
<div class="bookmark-list-container">
|
||||
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
|
||||
ld-fire="refresh-bookmark-list-done"
|
||||
class="bookmark-list-container">
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
@@ -35,10 +36,11 @@
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header mb-4">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div class="tag-cloud-container">
|
||||
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
|
||||
class="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
@@ -105,9 +105,9 @@
|
||||
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% for toast in toast_messages %}
|
||||
<div class="toast">
|
||||
<div class="toast d-flex">
|
||||
{{ 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>
|
||||
{% endfor %}
|
||||
</form>
|
||||
|
@@ -6,8 +6,8 @@
|
||||
<div class="dropdown">
|
||||
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0" style="padding-right: 0.2rem">
|
||||
Bookmarks
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"
|
||||
style="height:1rem;width:1rem;vertical-align: text-bottom;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||
style="height:1rem;width:1rem;vertical-align: middle;">
|
||||
<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"
|
||||
clip-rule="evenodd"/>
|
||||
|
@@ -25,7 +25,7 @@
|
||||
<path d="M18 9v11"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="menu text-sm" tabindex="0">
|
||||
<div class="menu" tabindex="0">
|
||||
<form id="search_preferences" action="" method="post">
|
||||
{% csrf_token %}
|
||||
{% if 'sort' in preferences_form.editable_fields %}
|
||||
|
@@ -4,10 +4,7 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page grid columns-md-1"
|
||||
ld-bookmark-page
|
||||
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.shared' %}"
|
||||
tags-url="{% url 'bookmarks:partials.tag_cloud.shared' %}">
|
||||
<div class="bookmarks-page grid columns-md-1">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area col-2">
|
||||
@@ -15,15 +12,20 @@
|
||||
<h2>Shared bookmarks</h2>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %}
|
||||
<button ld-modal modal-content=".tag-cloud" class="btn ml-2 show-md">Tags</button>
|
||||
<button ld-fetch="{{ bookmark_list.tag_modal_url }}" ld-target="body|append" ld-on="click"
|
||||
class="btn ml-2 show-md">Tags
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="bookmark-actions" action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post">
|
||||
<form ld-form ld-fire="refresh-bookmark-list,refresh-tag-cloud"
|
||||
class="bookmark-actions"
|
||||
action="{{ bookmark_list.action_url|safe }}"
|
||||
method="post" autocomplete="off">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="bookmark-list-container">
|
||||
<div ld-fetch="{{ bookmark_list.refresh_url }}" ld-on="refresh-bookmark-list"
|
||||
ld-fire="refresh-bookmark-list-done"
|
||||
class="bookmark-list-container">
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
</div>
|
||||
</form>
|
||||
@@ -41,7 +43,8 @@
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div class="tag-cloud-container">
|
||||
<div ld-fetch="{{ tag_cloud.refresh_url }}" ld-on="refresh-tag-cloud"
|
||||
class="tag-cloud-container">
|
||||
{% include 'bookmarks/tag_cloud.html' %}
|
||||
</div>
|
||||
</section>
|
||||
|
21
bookmarks/templates/bookmarks/tag_modal.html
Normal file
21
bookmarks/templates/bookmarks/tag_modal.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div ld-modal class="modal active">
|
||||
<div class="modal-overlay" aria-label="Close"></div>
|
||||
<div class="modal-container">
|
||||
<div class="modal-header d-flex justify-between align-center">
|
||||
<div class="modal-title h5">Tags</div>
|
||||
<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/tag_cloud.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -8,6 +8,12 @@
|
||||
|
||||
{# Profile section #}
|
||||
<section class="content-area">
|
||||
{% if success_message %}
|
||||
<div class="toast toast-success mb-4">{{ success_message }}</div>
|
||||
{% endif %}
|
||||
{% if error_message %}
|
||||
<div class="toast toast-error mb-4">{{ error_message }}</div>
|
||||
{% endif %}
|
||||
<h2>Profile</h2>
|
||||
<p>
|
||||
<a href="{% url 'change_password' %}">Change password</a>
|
||||
@@ -29,6 +35,23 @@
|
||||
be hidden.
|
||||
</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">
|
||||
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
|
||||
{{ form.display_url }}
|
||||
@@ -48,6 +71,28 @@
|
||||
Alternatively the keyboard shortcut <code>e</code> can be used to temporarily show all notes.
|
||||
</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">
|
||||
<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" }}
|
||||
@@ -81,13 +126,6 @@
|
||||
{% if request.user_profile.enable_favicons and enable_refresh_favicons %}
|
||||
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||
{% endif %}
|
||||
{% if refresh_favicons_success_message %}
|
||||
<div class="has-success">
|
||||
<p class="form-input-hint">
|
||||
{{ refresh_favicons_success_message }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
||||
@@ -124,27 +162,33 @@
|
||||
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
|
||||
</div>
|
||||
</div>
|
||||
<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" }}
|
||||
{% 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>
|
||||
<button class="btn mt-2" name="create_missing_html_snapshots">Create missing HTML snapshots</button>
|
||||
</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>
|
||||
</details>
|
||||
<div class="form-input-hint">
|
||||
Allows to add custom CSS to the page.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
|
||||
{% if update_profile_success_message %}
|
||||
<div class="has-success">
|
||||
<p class="form-input-hint">
|
||||
{{ update_profile_success_message }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -173,20 +217,6 @@
|
||||
<input class="form-input" type="file" name="import_file">
|
||||
<input type="submit" class="input-group-btn btn btn-primary" value="Upload">
|
||||
</div>
|
||||
{% if import_success_message %}
|
||||
<div class="has-success">
|
||||
<p class="form-input-hint">
|
||||
{{ import_success_message }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if import_errors_message %}
|
||||
<div class="has-error">
|
||||
<p class="form-input-hint">
|
||||
{{ import_errors_message }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -195,10 +225,6 @@
|
||||
<section class="content-area">
|
||||
<h2>Export</h2>
|
||||
<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>
|
||||
{% if export_error %}
|
||||
<div class="has-error">
|
||||
@@ -237,10 +263,12 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Automatically disable public bookmark sharing if bookmark sharing is disabled
|
||||
const enableSharing = document.getElementById("{{ form.enable_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() {
|
||||
if (enableSharing.checked) {
|
||||
enablePublicSharing.disabled = false;
|
||||
@@ -252,6 +280,18 @@
|
||||
|
||||
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>
|
||||
|
||||
{% endblock %}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import random
|
||||
import logging
|
||||
import datetime
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -10,7 +10,7 @@ from django.utils.crypto import get_random_string
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag
|
||||
|
||||
|
||||
class BookmarkFactoryMixin:
|
||||
@@ -133,6 +133,38 @@ class BookmarkFactoryMixin:
|
||||
def get_numbered_bookmark(self, title: str):
|
||||
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 = ""):
|
||||
if user is None:
|
||||
user = self.get_or_create_test_user()
|
||||
|
@@ -94,15 +94,10 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
)
|
||||
|
||||
def assertBulkActionForm(self, response, url: str):
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
needle = collapse_whitespace(
|
||||
f"""
|
||||
<form class="bookmark-actions"
|
||||
action="{url}"
|
||||
method="post" autocomplete="off">
|
||||
"""
|
||||
)
|
||||
self.assertIn(needle, html)
|
||||
soup = self.make_soup(response.content.decode())
|
||||
form = soup.select_one("form.bookmark-actions")
|
||||
self.assertIsNotNone(form)
|
||||
self.assertEqual(form.attrs["action"], url)
|
||||
|
||||
def test_should_list_archived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user(
|
||||
|
125
bookmarks/tests/test_bookmark_asset_view.py
Normal file
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
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)
|
819
bookmarks/tests/test_bookmark_details_modal.py
Normal file
819
bookmarks/tests/test_bookmark_details_modal.py
Normal file
@@ -0,0 +1,819 @@
|
||||
import re
|
||||
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_assets_access(self):
|
||||
self.details_route_access_test("bookmarks:details_assets", True)
|
||||
|
||||
def test_assets_access_with_sharing(self):
|
||||
self.details_route_sharing_access_test("bookmarks:details_assets", True)
|
||||
|
||||
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_assets_refresh_when_having_pending_asset(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
|
||||
fetch_url = reverse("bookmarks:details_assets", args=[bookmark.id])
|
||||
|
||||
# no pending asset
|
||||
soup = self.get_details(bookmark)
|
||||
files_section = self.find_section(soup, "Files")
|
||||
assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url})
|
||||
self.assertIsNone(assets_wrapper)
|
||||
|
||||
# with pending asset
|
||||
asset.status = BookmarkAsset.STATUS_PENDING
|
||||
asset.save()
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
files_section = self.find_section(soup, "Files")
|
||||
assets_wrapper = files_section.find("div", {"ld-fetch": fetch_url})
|
||||
self.assertIsNotNone(assets_wrapper)
|
||||
|
||||
@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)
|
||||
|
||||
self.assertEqual(bookmark.bookmarkasset_set.count(), 1)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_snapshot_is_disabled_when_having_pending_asset(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_COMPLETE)
|
||||
|
||||
# no pending asset
|
||||
soup = self.get_details(bookmark)
|
||||
files_section = self.find_section(soup, "Files")
|
||||
create_button = files_section.find(
|
||||
"button", string=re.compile("Create HTML snapshot")
|
||||
)
|
||||
self.assertFalse(create_button.has_attr("disabled"))
|
||||
|
||||
# with pending asset
|
||||
asset.status = BookmarkAsset.STATUS_PENDING
|
||||
asset.save()
|
||||
|
||||
soup = self.get_details(bookmark)
|
||||
files_section = self.find_section(soup, "Files")
|
||||
create_button = files_section.find(
|
||||
"button", string=re.compile("Create HTML snapshot")
|
||||
)
|
||||
self.assertTrue(create_button.has_attr("disabled"))
|
6
bookmarks/tests/test_bookmark_details_view.py
Normal file
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"
|
@@ -94,15 +94,10 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
)
|
||||
|
||||
def assertBulkActionForm(self, response, url: str):
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
needle = collapse_whitespace(
|
||||
f"""
|
||||
<form class="bookmark-actions"
|
||||
action="{url}"
|
||||
method="post" autocomplete="off">
|
||||
"""
|
||||
)
|
||||
self.assertIn(needle, html)
|
||||
soup = self.make_soup(response.content.decode())
|
||||
form = soup.select_one("form.bookmark-actions")
|
||||
self.assertIsNotNone(form)
|
||||
self.assertEqual(form.attrs["action"], url)
|
||||
|
||||
def test_should_list_unarchived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user(
|
||||
|
@@ -10,11 +10,11 @@ from django.utils import timezone, formats
|
||||
|
||||
from bookmarks.middlewares import UserProfileMiddleware
|
||||
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
|
||||
|
||||
|
||||
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
def assertBookmarksLink(
|
||||
self, html: str, bookmark: Bookmark, link_target: str = "_blank"
|
||||
@@ -26,10 +26,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
{favicon_img}
|
||||
<a href="{bookmark.url}"
|
||||
target="{link_target}"
|
||||
rel="noopener">
|
||||
{favicon_img}
|
||||
<span>{bookmark.resolved_title}</span>
|
||||
</a>
|
||||
""",
|
||||
@@ -40,7 +40,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<span>{label_content}</span>
|
||||
<span class="separator">|</span>
|
||||
<span>|</span>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
@@ -54,19 +54,41 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||
{label_content} ∞
|
||||
</a>
|
||||
<span class="separator">|</span>
|
||||
<span>|</span>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
|
||||
self.assertBookmarkActionsCount(html, bookmark, count=1)
|
||||
def assertViewLink(
|
||||
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):
|
||||
self.assertBookmarkActionsCount(html, bookmark, count=0)
|
||||
def assertNoViewLink(
|
||||
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):
|
||||
# Edit link
|
||||
def assertViewLinkCount(
|
||||
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-fetch="{details_modal_url}?return_url={return_url}"
|
||||
ld-on="click" ld-target="body|append"
|
||||
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])
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
@@ -75,7 +97,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
html,
|
||||
count=count,
|
||||
)
|
||||
# Archive link
|
||||
|
||||
def assertArchiveLinkCount(self, html: str, bookmark: Bookmark, count=1):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<button type="submit" name="archive" value="{bookmark.id}"
|
||||
@@ -84,7 +107,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
html,
|
||||
count=count,
|
||||
)
|
||||
# Delete link
|
||||
|
||||
def assertDeleteLinkCount(self, html: str, bookmark: Bookmark, count=1):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<button ld-confirm-button type="submit" name="remove" value="{bookmark.id}"
|
||||
@@ -94,6 +118,17 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
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):
|
||||
self.assertShareInfoCount(html, bookmark, 1)
|
||||
|
||||
@@ -101,6 +136,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
self.assertShareInfoCount(html, bookmark, 0)
|
||||
|
||||
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
|
||||
# Shared by link
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<span>Shared by
|
||||
@@ -133,7 +169,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
f"""
|
||||
<div class="url-path truncate">
|
||||
<a href="{bookmark.url}" target="{link_target}" rel="noopener"
|
||||
class="url-display text-sm">
|
||||
class="url-display">
|
||||
{bookmark.url}
|
||||
</a>
|
||||
</div>
|
||||
@@ -154,7 +190,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="notes-content">
|
||||
<div class="markdown">
|
||||
{notes_html}
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,7 +218,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
f"""
|
||||
<button type="submit" name="unshare" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
@@ -198,7 +234,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
f"""
|
||||
<button type="submit" name="mark_as_read" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
@@ -241,6 +277,172 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
user.profile.save()
|
||||
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):
|
||||
bookmark = self.setup_date_format_test(
|
||||
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
|
||||
@@ -351,9 +553,58 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
bookmark = self.setup_bookmark()
|
||||
html = self.render_template()
|
||||
|
||||
self.assertViewLink(html, bookmark)
|
||||
self.assertBookmarkActions(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):
|
||||
other_user = User.objects.create_user(
|
||||
"otheruser", "otheruser@example.com", "password123"
|
||||
@@ -364,6 +615,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
||||
|
||||
self.assertViewLink(html, bookmark, return_url=reverse("bookmarks:shared"))
|
||||
self.assertNoBookmarkActions(html, bookmark)
|
||||
self.assertShareInfo(html, bookmark)
|
||||
|
||||
@@ -532,16 +784,24 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_note_cleans_html(self):
|
||||
self.setup_bookmark(notes='<script>alert("test")</script>')
|
||||
self.setup_bookmark(
|
||||
notes='<b ld-fetch="https://example.com" ld-on="click">bold text</b>'
|
||||
)
|
||||
html = self.render_template()
|
||||
|
||||
note_html = '<script>alert("test")</script>'
|
||||
self.assertNotes(html, note_html, 1)
|
||||
self.assertIn(note_html, html, 1)
|
||||
|
||||
note_html = "<b>bold text</b>"
|
||||
self.assertIn(note_html, html, 1)
|
||||
|
||||
def test_notes_are_hidden_initially_by_default(self):
|
||||
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):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
@@ -549,9 +809,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
profile.save()
|
||||
|
||||
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):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
@@ -559,11 +821,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
profile.save()
|
||||
|
||||
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 show-notes" data-bookmarks-total="1">', html
|
||||
)
|
||||
self.assertIsNotNone(bookmark_list)
|
||||
|
||||
def test_toggle_notes_is_visible_by_default(self):
|
||||
self.setup_bookmark(notes="Test note")
|
||||
@@ -615,6 +877,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
self.assertWebArchiveLink(
|
||||
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.assertShareInfo(html, bookmark)
|
||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||
|
@@ -105,6 +105,24 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
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):
|
||||
with patch.object(
|
||||
tasks, "create_web_archive_snapshot"
|
||||
@@ -167,6 +185,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
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):
|
||||
bookmark = Bookmark(
|
||||
url="https://example.com",
|
||||
|
@@ -1,19 +1,20 @@
|
||||
import datetime
|
||||
import os.path
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import waybackpy
|
||||
from background_task.models import Task
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase, override_settings
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
from waybackpy.exceptions import WaybackError
|
||||
|
||||
import bookmarks.services.favicon_loader
|
||||
import bookmarks.services.wayback
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||
from bookmarks.models import BookmarkAsset, UserProfile
|
||||
from bookmarks.services import tasks, singlefile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
def create_wayback_machine_save_api_mock(
|
||||
@@ -34,27 +35,45 @@ class MockCdxSnapshot:
|
||||
datetime_timestamp: datetime.datetime
|
||||
|
||||
|
||||
def create_cdx_server_api_mock(
|
||||
archive_url: str | None = "https://example.com/newest_snapshot",
|
||||
fail_loading_snapshot=False,
|
||||
):
|
||||
mock_api = mock.Mock()
|
||||
|
||||
if fail_loading_snapshot:
|
||||
mock_api.newest.side_effect = WaybackError
|
||||
elif archive_url:
|
||||
mock_api.newest.return_value = MockCdxSnapshot(
|
||||
archive_url, datetime.datetime.now()
|
||||
)
|
||||
else:
|
||||
mock_api.newest.return_value = None
|
||||
|
||||
return mock_api
|
||||
|
||||
|
||||
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self):
|
||||
huey.immediate = True
|
||||
huey.results = True
|
||||
huey.store_none = True
|
||||
|
||||
self.mock_save_api = mock.Mock(
|
||||
archive_url="https://example.com/created_snapshot"
|
||||
)
|
||||
self.mock_save_api_patcher = mock.patch.object(
|
||||
waybackpy, "WaybackMachineSaveAPI", return_value=self.mock_save_api
|
||||
)
|
||||
self.mock_save_api_patcher.start()
|
||||
|
||||
self.mock_cdx_api = mock.Mock()
|
||||
self.mock_cdx_api.newest.return_value = MockCdxSnapshot(
|
||||
"https://example.com/newest_snapshot", datetime.datetime.now()
|
||||
)
|
||||
self.mock_cdx_api_patcher = mock.patch.object(
|
||||
bookmarks.services.wayback,
|
||||
"CustomWaybackMachineCDXServerAPI",
|
||||
return_value=self.mock_cdx_api,
|
||||
)
|
||||
self.mock_cdx_api_patcher.start()
|
||||
|
||||
self.mock_load_favicon_patcher = mock.patch(
|
||||
"bookmarks.services.favicon_loader.load_favicon"
|
||||
)
|
||||
self.mock_load_favicon = self.mock_load_favicon_patcher.start()
|
||||
self.mock_load_favicon.return_value = "https_example_com.png"
|
||||
|
||||
self.mock_singlefile_create_snapshot_patcher = mock.patch(
|
||||
"bookmarks.services.singlefile.create_snapshot",
|
||||
)
|
||||
self.mock_singlefile_create_snapshot = (
|
||||
self.mock_singlefile_create_snapshot_patcher.start()
|
||||
)
|
||||
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.web_archive_integration = (
|
||||
UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||
@@ -62,157 +81,100 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
user.profile.enable_favicons = True
|
||||
user.profile.save()
|
||||
|
||||
@disable_logging
|
||||
def run_pending_task(self, task_function: Any):
|
||||
func = getattr(task_function, "task_function", None)
|
||||
task = Task.objects.all()[0]
|
||||
self.assertEqual(task_function.name, task.task_name)
|
||||
args, kwargs = task.params()
|
||||
func(*args, **kwargs)
|
||||
task.delete()
|
||||
def tearDown(self):
|
||||
self.mock_save_api_patcher.stop()
|
||||
self.mock_cdx_api_patcher.stop()
|
||||
self.mock_load_favicon_patcher.stop()
|
||||
self.mock_singlefile_create_snapshot_patcher.stop()
|
||||
huey.storage.flush_results()
|
||||
huey.immediate = False
|
||||
|
||||
@disable_logging
|
||||
def run_all_pending_tasks(self, task_function: Any):
|
||||
func = getattr(task_function, "task_function", None)
|
||||
tasks = Task.objects.all()
|
||||
|
||||
for task in tasks:
|
||||
self.assertEqual(task_function.name, task.task_name)
|
||||
args, kwargs = task.params()
|
||||
func(*args, **kwargs)
|
||||
task.delete()
|
||||
def executed_count(self):
|
||||
return len(huey.all_results())
|
||||
|
||||
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_save_api = create_wayback_machine_save_api_mock()
|
||||
|
||||
with mock.patch.object(
|
||||
waybackpy, "WaybackMachineSaveAPI", return_value=mock_save_api
|
||||
):
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
mock_save_api.save.assert_called_once()
|
||||
self.assertEqual(
|
||||
bookmark.web_archive_snapshot_url,
|
||||
"https://example.com/created_snapshot",
|
||||
)
|
||||
self.mock_save_api.save.assert_called_once()
|
||||
self.assertEqual(self.executed_count(), 1)
|
||||
self.assertEqual(
|
||||
bookmark.web_archive_snapshot_url,
|
||||
"https://example.com/created_snapshot",
|
||||
)
|
||||
|
||||
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
||||
mock_save_api = create_wayback_machine_save_api_mock()
|
||||
tasks._create_web_archive_snapshot_task(123, False)
|
||||
|
||||
with mock.patch.object(
|
||||
waybackpy, "WaybackMachineSaveAPI", return_value=mock_save_api
|
||||
):
|
||||
tasks._create_web_archive_snapshot_task(123, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
mock_save_api.save.assert_not_called()
|
||||
self.assertEqual(self.executed_count(), 1)
|
||||
self.mock_save_api.save.assert_not_called()
|
||||
|
||||
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com")
|
||||
mock_save_api = create_wayback_machine_save_api_mock()
|
||||
|
||||
with mock.patch.object(
|
||||
waybackpy, "WaybackMachineSaveAPI", return_value=mock_save_api
|
||||
):
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
self.mock_save_api.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
|
||||
mock_save_api.assert_not_called()
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
self.mock_save_api.assert_not_called()
|
||||
|
||||
def test_create_web_archive_snapshot_should_force_update_snapshot(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com")
|
||||
mock_save_api = create_wayback_machine_save_api_mock(
|
||||
archive_url="https://other.com"
|
||||
self.mock_save_api.archive_url = "https://other.com"
|
||||
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, True
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
with mock.patch.object(
|
||||
waybackpy, "WaybackMachineSaveAPI", return_value=mock_save_api
|
||||
):
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, True
|
||||
)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, "https://other.com")
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, "https://other.com")
|
||||
|
||||
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
|
||||
mock_cdx_api = create_cdx_server_api_mock()
|
||||
self.mock_save_api.save.side_effect = WaybackError
|
||||
|
||||
with mock.patch.object(
|
||||
waybackpy, "WaybackMachineSaveAPI", return_value=mock_save_api
|
||||
):
|
||||
with mock.patch.object(
|
||||
bookmarks.services.wayback,
|
||||
"CustomWaybackMachineCDXServerAPI",
|
||||
return_value=mock_cdx_api,
|
||||
):
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
mock_cdx_api.newest.assert_called_once()
|
||||
self.assertEqual(
|
||||
"https://example.com/newest_snapshot",
|
||||
bookmark.web_archive_snapshot_url,
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
self.mock_cdx_api.newest.assert_called_once()
|
||||
self.assertEqual(
|
||||
"https://example.com/newest_snapshot",
|
||||
bookmark.web_archive_snapshot_url,
|
||||
)
|
||||
|
||||
def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
|
||||
mock_cdx_api = create_cdx_server_api_mock(archive_url=None)
|
||||
self.mock_save_api.save.side_effect = WaybackError
|
||||
self.mock_cdx_api.newest.return_value = None
|
||||
|
||||
with mock.patch.object(
|
||||
waybackpy, "WaybackMachineSaveAPI", return_value=mock_save_api
|
||||
):
|
||||
with mock.patch.object(
|
||||
bookmarks.services.wayback,
|
||||
"CustomWaybackMachineCDXServerAPI",
|
||||
return_value=mock_cdx_api,
|
||||
):
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
|
||||
mock_cdx_api = create_cdx_server_api_mock(fail_loading_snapshot=True)
|
||||
self.mock_save_api.save.side_effect = WaybackError
|
||||
self.mock_cdx_api.newest.side_effect = WaybackError
|
||||
|
||||
with mock.patch.object(
|
||||
waybackpy, "WaybackMachineSaveAPI", return_value=mock_save_api
|
||||
):
|
||||
with mock.patch.object(
|
||||
bookmarks.services.wayback,
|
||||
"CustomWaybackMachineCDXServerAPI",
|
||||
return_value=mock_cdx_api,
|
||||
):
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_create_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_save_api = create_wayback_machine_save_api_mock()
|
||||
|
||||
# update bookmark during API call to check that saving
|
||||
# the snapshot does not overwrite updated bookmark data
|
||||
@@ -220,99 +182,64 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark.title = "Updated title"
|
||||
bookmark.save()
|
||||
|
||||
mock_save_api.save.side_effect = mock_save_impl
|
||||
self.mock_save_api.save.side_effect = mock_save_impl
|
||||
|
||||
with mock.patch.object(
|
||||
waybackpy, "WaybackMachineSaveAPI", return_value=mock_save_api
|
||||
):
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.title, "Updated title")
|
||||
self.assertEqual(
|
||||
"https://example.com/created_snapshot",
|
||||
bookmark.web_archive_snapshot_url,
|
||||
)
|
||||
self.assertEqual(bookmark.title, "Updated title")
|
||||
self.assertEqual(
|
||||
"https://example.com/created_snapshot",
|
||||
bookmark.web_archive_snapshot_url,
|
||||
)
|
||||
|
||||
def test_load_web_archive_snapshot_should_update_snapshot_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_cdx_api = create_cdx_server_api_mock()
|
||||
|
||||
with mock.patch.object(
|
||||
bookmarks.services.wayback,
|
||||
"CustomWaybackMachineCDXServerAPI",
|
||||
return_value=mock_cdx_api,
|
||||
):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
mock_cdx_api.newest.assert_called_once()
|
||||
self.assertEqual(
|
||||
"https://example.com/newest_snapshot", bookmark.web_archive_snapshot_url
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.executed_count(), 1)
|
||||
self.mock_cdx_api.newest.assert_called_once()
|
||||
self.assertEqual(
|
||||
"https://example.com/newest_snapshot", bookmark.web_archive_snapshot_url
|
||||
)
|
||||
|
||||
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
||||
mock_cdx_api = create_cdx_server_api_mock()
|
||||
tasks._load_web_archive_snapshot_task(123)
|
||||
|
||||
with mock.patch.object(
|
||||
bookmarks.services.wayback,
|
||||
"CustomWaybackMachineCDXServerAPI",
|
||||
return_value=mock_cdx_api,
|
||||
):
|
||||
tasks._load_web_archive_snapshot_task(123)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
|
||||
mock_cdx_api.newest.assert_not_called()
|
||||
self.assertEqual(self.executed_count(), 1)
|
||||
self.mock_cdx_api.newest.assert_not_called()
|
||||
|
||||
def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com")
|
||||
mock_cdx_api = create_cdx_server_api_mock()
|
||||
|
||||
with mock.patch.object(
|
||||
bookmarks.services.wayback,
|
||||
"CustomWaybackMachineCDXServerAPI",
|
||||
return_value=mock_cdx_api,
|
||||
):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
|
||||
mock_cdx_api.newest.assert_not_called()
|
||||
self.assertEqual(self.executed_count(), 1)
|
||||
self.mock_cdx_api.newest.assert_not_called()
|
||||
|
||||
def test_load_web_archive_snapshot_should_handle_missing_snapshot(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_cdx_api = create_cdx_server_api_mock(archive_url=None)
|
||||
self.mock_cdx_api.newest.return_value = None
|
||||
|
||||
with mock.patch.object(
|
||||
bookmarks.services.wayback,
|
||||
"CustomWaybackMachineCDXServerAPI",
|
||||
return_value=mock_cdx_api,
|
||||
):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
|
||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_cdx_api = create_cdx_server_api_mock(fail_loading_snapshot=True)
|
||||
self.mock_cdx_api.newest.side_effect = WaybackError
|
||||
|
||||
with mock.patch.object(
|
||||
bookmarks.services.wayback,
|
||||
"CustomWaybackMachineCDXServerAPI",
|
||||
return_value=mock_cdx_api,
|
||||
):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
|
||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_load_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_cdx_api = create_cdx_server_api_mock()
|
||||
|
||||
# update bookmark during API call to check that saving
|
||||
# the snapshot does not overwrite updated bookmark data
|
||||
@@ -321,32 +248,26 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark.save()
|
||||
return mock.DEFAULT
|
||||
|
||||
mock_cdx_api.newest.side_effect = mock_newest_impl
|
||||
self.mock_cdx_api.newest.side_effect = mock_newest_impl
|
||||
|
||||
with mock.patch.object(
|
||||
bookmarks.services.wayback,
|
||||
"CustomWaybackMachineCDXServerAPI",
|
||||
return_value=mock_cdx_api,
|
||||
):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual("Updated title", bookmark.title)
|
||||
self.assertEqual(
|
||||
"https://example.com/newest_snapshot", bookmark.web_archive_snapshot_url
|
||||
)
|
||||
self.assertEqual("Updated title", bookmark.title)
|
||||
self.assertEqual(
|
||||
"https://example.com/newest_snapshot", bookmark.web_archive_snapshot_url
|
||||
)
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(
|
||||
self,
|
||||
):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
tasks.create_web_archive_snapshot(
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_create_web_archive_snapshot_should_not_run_when_web_archive_integration_is_disabled(
|
||||
self,
|
||||
@@ -361,7 +282,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.get_or_create_test_user(), bookmark, False
|
||||
)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_schedule_bookmarks_without_snapshots_should_load_snapshot_for_all_bookmarks_without_snapshot(
|
||||
self,
|
||||
@@ -375,16 +296,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(web_archive_snapshot_url="https://example.com")
|
||||
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
|
||||
|
||||
task_list = Task.objects.all()
|
||||
self.assertEqual(task_list.count(), 3)
|
||||
|
||||
for task in task_list:
|
||||
self.assertEqual(
|
||||
task.task_name,
|
||||
"bookmarks.services.tasks._load_web_archive_snapshot_task",
|
||||
)
|
||||
self.assertEqual(self.executed_count(), 4)
|
||||
self.assertEqual(self.mock_cdx_api.newest.call_count, 3)
|
||||
|
||||
def test_schedule_bookmarks_without_snapshots_should_only_update_user_owned_bookmarks(
|
||||
self,
|
||||
@@ -401,10 +315,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=other_user)
|
||||
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_snapshots_task)
|
||||
|
||||
task_list = Task.objects.all()
|
||||
self.assertEqual(task_list.count(), 3)
|
||||
self.assertEqual(self.mock_cdx_api.newest.call_count, 3)
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_schedule_bookmarks_without_snapshots_should_not_run_when_background_tasks_are_disabled(
|
||||
@@ -412,7 +324,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
):
|
||||
tasks.schedule_bookmarks_without_snapshots(self.user)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_schedule_bookmarks_without_snapshots_should_not_run_when_web_archive_integration_is_disabled(
|
||||
self,
|
||||
@@ -423,44 +335,32 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.user.profile.save()
|
||||
tasks.schedule_bookmarks_without_snapshots(self.user)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_load_favicon_should_create_favicon_file(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with mock.patch(
|
||||
"bookmarks.services.favicon_loader.load_favicon"
|
||||
) as mock_load_favicon:
|
||||
mock_load_favicon.return_value = "https_example_com.png"
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
self.run_pending_task(tasks._load_favicon_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.favicon_file, "https_example_com.png")
|
||||
self.assertEqual(self.executed_count(), 1)
|
||||
self.assertEqual(bookmark.favicon_file, "https_example_com.png")
|
||||
|
||||
def test_load_favicon_should_update_favicon_file(self):
|
||||
bookmark = self.setup_bookmark(favicon_file="https_example_com.png")
|
||||
|
||||
with mock.patch(
|
||||
"bookmarks.services.favicon_loader.load_favicon"
|
||||
) as mock_load_favicon:
|
||||
mock_load_favicon.return_value = "https_example_updated_com.png"
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
self.run_pending_task(tasks._load_favicon_task)
|
||||
self.mock_load_favicon.return_value = "https_example_updated_com.png"
|
||||
|
||||
mock_load_favicon.assert_called()
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.favicon_file, "https_example_updated_com.png")
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
self.mock_load_favicon.assert_called_once()
|
||||
self.assertEqual(bookmark.favicon_file, "https_example_updated_com.png")
|
||||
|
||||
def test_load_favicon_should_handle_missing_bookmark(self):
|
||||
with mock.patch(
|
||||
"bookmarks.services.favicon_loader.load_favicon"
|
||||
) as mock_load_favicon:
|
||||
tasks._load_favicon_task(123)
|
||||
self.run_pending_task(tasks._load_favicon_task)
|
||||
tasks._load_favicon_task(123)
|
||||
|
||||
mock_load_favicon.assert_not_called()
|
||||
self.mock_load_favicon.assert_not_called()
|
||||
|
||||
def test_load_favicon_should_not_save_stale_bookmark_data(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -472,24 +372,20 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark.save()
|
||||
return "https_example_com.png"
|
||||
|
||||
with mock.patch(
|
||||
"bookmarks.services.favicon_loader.load_favicon"
|
||||
) as mock_load_favicon:
|
||||
mock_load_favicon.side_effect = mock_load_favicon_impl
|
||||
self.mock_load_favicon.side_effect = mock_load_favicon_impl
|
||||
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
self.run_pending_task(tasks._load_favicon_task)
|
||||
bookmark.refresh_from_db()
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.title, "Updated title")
|
||||
self.assertEqual(bookmark.favicon_file, "https_example_com.png")
|
||||
self.assertEqual(bookmark.title, "Updated title")
|
||||
self.assertEqual(bookmark.favicon_file, "https_example_com.png")
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_load_favicon_should_not_run_when_background_tasks_are_disabled(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_load_favicon_should_not_run_when_favicon_feature_is_disabled(self):
|
||||
self.user.profile.enable_favicons = False
|
||||
@@ -498,7 +394,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_schedule_bookmarks_without_favicons_should_load_favicon_for_all_bookmarks_without_favicon(
|
||||
self,
|
||||
@@ -512,15 +408,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(favicon_file="https_example_com.png")
|
||||
|
||||
tasks.schedule_bookmarks_without_favicons(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task)
|
||||
|
||||
task_list = Task.objects.all()
|
||||
self.assertEqual(task_list.count(), 3)
|
||||
|
||||
for task in task_list:
|
||||
self.assertEqual(
|
||||
task.task_name, "bookmarks.services.tasks._load_favicon_task"
|
||||
)
|
||||
self.assertEqual(self.executed_count(), 4)
|
||||
self.assertEqual(self.mock_load_favicon.call_count, 3)
|
||||
|
||||
def test_schedule_bookmarks_without_favicons_should_only_update_user_owned_bookmarks(
|
||||
self,
|
||||
@@ -537,19 +427,17 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=other_user)
|
||||
|
||||
tasks.schedule_bookmarks_without_favicons(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task)
|
||||
|
||||
task_list = Task.objects.all()
|
||||
self.assertEqual(task_list.count(), 3)
|
||||
self.assertEqual(self.mock_load_favicon.call_count, 3)
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_schedule_bookmarks_without_favicons_should_not_run_when_background_tasks_are_disabled(
|
||||
self,
|
||||
):
|
||||
bookmark = self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_schedule_bookmarks_without_favicons_should_not_run_when_favicon_feature_is_disabled(
|
||||
self,
|
||||
@@ -557,10 +445,10 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.user.profile.enable_favicons = False
|
||||
self.user.profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_schedule_refresh_favicons_should_update_favicon_for_all_bookmarks(self):
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -572,15 +460,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(favicon_file="https_example_com.png")
|
||||
|
||||
tasks.schedule_refresh_favicons(user)
|
||||
self.run_pending_task(tasks._schedule_refresh_favicons_task)
|
||||
|
||||
task_list = Task.objects.all()
|
||||
self.assertEqual(task_list.count(), 6)
|
||||
|
||||
for task in task_list:
|
||||
self.assertEqual(
|
||||
task.task_name, "bookmarks.services.tasks._load_favicon_task"
|
||||
)
|
||||
self.assertEqual(self.executed_count(), 7)
|
||||
self.assertEqual(self.mock_load_favicon.call_count, 6)
|
||||
|
||||
def test_schedule_refresh_favicons_should_only_update_user_owned_bookmarks(self):
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -595,10 +477,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=other_user)
|
||||
|
||||
tasks.schedule_refresh_favicons(user)
|
||||
self.run_pending_task(tasks._schedule_refresh_favicons_task)
|
||||
|
||||
task_list = Task.objects.all()
|
||||
self.assertEqual(task_list.count(), 3)
|
||||
self.assertEqual(self.mock_load_favicon.call_count, 3)
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_schedule_refresh_favicons_should_not_run_when_background_tasks_are_disabled(
|
||||
@@ -607,14 +487,14 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark()
|
||||
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
@override_settings(LD_ENABLE_REFRESH_FAVICONS=False)
|
||||
def test_schedule_refresh_favicons_should_not_run_when_refresh_is_disabled(self):
|
||||
self.setup_bookmark()
|
||||
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_schedule_refresh_favicons_should_not_run_when_favicon_feature_is_disabled(
|
||||
self,
|
||||
@@ -625,4 +505,195 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark()
|
||||
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_html_snapshot_should_create_pending_asset(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
# Mock the task function to avoid running it immediately
|
||||
with mock.patch("bookmarks.services.tasks._create_html_snapshot_task"):
|
||||
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.tasks._generate_snapshot_filename"
|
||||
) as mock_generate:
|
||||
expected_filename = "snapshot_2021-01-02_034455_https___example.com.html.gz"
|
||||
mock_generate.return_value = expected_filename
|
||||
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
BookmarkAsset.objects.get(bookmark=bookmark)
|
||||
|
||||
# Run periodic task to process the snapshot
|
||||
tasks._schedule_html_snapshots_task()
|
||||
|
||||
self.mock_singlefile_create_snapshot.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_truncate_filename(self):
|
||||
# Create a bookmark with a very long URL
|
||||
long_url = "http://" + "a" * 300 + ".com"
|
||||
bookmark = self.setup_bookmark(url=long_url)
|
||||
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
BookmarkAsset.objects.get(bookmark=bookmark)
|
||||
|
||||
# Run periodic task to process the snapshot
|
||||
tasks._schedule_html_snapshots_task()
|
||||
|
||||
asset = BookmarkAsset.objects.get(bookmark=bookmark)
|
||||
self.assertEqual(len(asset.file), 192)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_html_snapshot_should_handle_error(self):
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
|
||||
self.mock_singlefile_create_snapshot.side_effect = singlefile.SingeFileError(
|
||||
"Error"
|
||||
)
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
|
||||
# Run periodic task to process the snapshot
|
||||
tasks._schedule_html_snapshots_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):
|
||||
tasks._create_html_snapshot_task(123)
|
||||
|
||||
self.mock_singlefile_create_snapshot.assert_not_called()
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=False)
|
||||
def test_create_html_snapshot_should_not_create_asset_when_single_file_is_disabled(
|
||||
self,
|
||||
):
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
|
||||
self.assertEqual(BookmarkAsset.objects.count(), 0)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True, LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_create_html_snapshot_should_not_create_asset_when_background_tasks_are_disabled(
|
||||
self,
|
||||
):
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
|
||||
self.assertEqual(BookmarkAsset.objects.count(), 0)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_missing_html_snapshots(self):
|
||||
bookmarks_with_snapshots = []
|
||||
bookmarks_without_snapshots = []
|
||||
|
||||
# setup bookmarks with snapshots
|
||||
bookmark = self.setup_bookmark()
|
||||
self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
bookmarks_with_snapshots.append(bookmark)
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
)
|
||||
bookmarks_with_snapshots.append(bookmark)
|
||||
|
||||
# setup bookmarks without snapshots
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmarks_without_snapshots.append(bookmark)
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_FAILURE,
|
||||
)
|
||||
bookmarks_without_snapshots.append(bookmark)
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type="some_other_type",
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
)
|
||||
bookmarks_without_snapshots.append(bookmark)
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
self.setup_asset(
|
||||
bookmark=bookmark,
|
||||
asset_type="some_other_type",
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
bookmarks_without_snapshots.append(bookmark)
|
||||
|
||||
initial_assets = list(BookmarkAsset.objects.all())
|
||||
initial_assets_count = len(initial_assets)
|
||||
initial_asset_ids = [asset.id for asset in initial_assets]
|
||||
count = tasks.create_missing_html_snapshots(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(count, 4)
|
||||
self.assertEqual(BookmarkAsset.objects.count(), initial_assets_count + count)
|
||||
|
||||
for bookmark in bookmarks_without_snapshots:
|
||||
new_assets = BookmarkAsset.objects.filter(bookmark=bookmark).exclude(
|
||||
id__in=initial_asset_ids
|
||||
)
|
||||
self.assertEqual(new_assets.count(), 1)
|
||||
|
||||
for bookmark in bookmarks_with_snapshots:
|
||||
new_assets = BookmarkAsset.objects.filter(bookmark=bookmark).exclude(
|
||||
id__in=initial_asset_ids
|
||||
)
|
||||
self.assertEqual(new_assets.count(), 0)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_missing_html_snapshots_respects_current_user(self):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
|
||||
other_user = self.setup_user()
|
||||
self.setup_bookmark(user=other_user)
|
||||
self.setup_bookmark(user=other_user)
|
||||
self.setup_bookmark(user=other_user)
|
||||
|
||||
count = tasks.create_missing_html_snapshots(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(count, 3)
|
||||
self.assertEqual(BookmarkAsset.objects.count(), count)
|
||||
|
@@ -23,37 +23,37 @@ class MetadataViewTestCase(TestCase):
|
||||
"src": "/static/logo.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any"
|
||||
"purpose": "any",
|
||||
},
|
||||
{
|
||||
"src": "/static/logo-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any"
|
||||
"purpose": "any",
|
||||
},
|
||||
{
|
||||
"src": "/static/logo-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any"
|
||||
"purpose": "any",
|
||||
},
|
||||
{
|
||||
"src": "/static/maskable-logo.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": "/static/maskable-logo-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": "/static/maskable-logo-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable",
|
||||
},
|
||||
],
|
||||
"shortcuts": [
|
||||
@@ -76,14 +76,14 @@ class MetadataViewTestCase(TestCase):
|
||||
{
|
||||
"name": "Shared",
|
||||
"url": "/bookmarks/shared",
|
||||
}
|
||||
},
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/static/linkding-screenshot.png",
|
||||
"type": "image/png",
|
||||
"sizes": "2158x1160",
|
||||
"form_factor": "wide"
|
||||
"form_factor": "wide",
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
@@ -94,8 +94,8 @@ class MetadataViewTestCase(TestCase):
|
||||
"url": "url",
|
||||
"text": "url",
|
||||
"title": "title",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertDictEqual(response_body, expected_body)
|
||||
|
||||
@@ -120,37 +120,37 @@ class MetadataViewTestCase(TestCase):
|
||||
"src": "/linkding/static/logo.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any"
|
||||
"purpose": "any",
|
||||
},
|
||||
{
|
||||
"src": "/linkding/static/logo-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any"
|
||||
"purpose": "any",
|
||||
},
|
||||
{
|
||||
"src": "/linkding/static/logo-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any"
|
||||
"purpose": "any",
|
||||
},
|
||||
{
|
||||
"src": "/linkding/static/maskable-logo.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": "/linkding/static/maskable-logo-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": "/linkding/static/maskable-logo-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable",
|
||||
},
|
||||
],
|
||||
"shortcuts": [
|
||||
@@ -173,14 +173,14 @@ class MetadataViewTestCase(TestCase):
|
||||
{
|
||||
"name": "Shared",
|
||||
"url": "/linkding/bookmarks/shared",
|
||||
}
|
||||
},
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/linkding/static/linkding-screenshot.png",
|
||||
"type": "image/png",
|
||||
"sizes": "2158x1160",
|
||||
"form_factor": "wide"
|
||||
"form_factor": "wide",
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
@@ -191,7 +191,7 @@ class MetadataViewTestCase(TestCase):
|
||||
"url": "url",
|
||||
"text": "url",
|
||||
"title": "title",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertDictEqual(response_body, expected_body)
|
||||
|
44
bookmarks/tests/test_monolith_service.py
Normal file
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)
|
@@ -49,3 +49,15 @@ class OidcSupportTest(TestCase):
|
||||
base_settings.AUTHENTICATION_BACKENDS,
|
||||
)
|
||||
del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable
|
||||
|
||||
def test_default_settings(self):
|
||||
os.environ["LD_ENABLE_OIDC"] = "True"
|
||||
base_settings = importlib.import_module("siteroot.settings.base")
|
||||
importlib.reload(base_settings)
|
||||
|
||||
self.assertEqual(
|
||||
True,
|
||||
base_settings.OIDC_VERIFY_SSL,
|
||||
)
|
||||
|
||||
del os.environ["LD_ENABLE_OIDC"]
|
||||
|
@@ -24,19 +24,44 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
form_data = {
|
||||
"theme": UserProfile.THEME_AUTO,
|
||||
"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,
|
||||
"web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
|
||||
"enable_sharing": False,
|
||||
"enable_public_sharing": False,
|
||||
"enable_favicons": False,
|
||||
"enable_automatic_html_snapshots": True,
|
||||
"tag_search": UserProfile.TAG_SEARCH_STRICT,
|
||||
"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,
|
||||
"custom_css": "",
|
||||
}
|
||||
|
||||
return {**form_data, **overrides}
|
||||
|
||||
def assertSuccessMessage(self, html, message: str, count=1):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<div class="toast toast-success mb-4">{ message }</div>
|
||||
""",
|
||||
html,
|
||||
count=count,
|
||||
)
|
||||
|
||||
def assertErrorMessage(self, html, message: str, count=1):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<div class="toast toast-error mb-4">{ message }</div>
|
||||
""",
|
||||
html,
|
||||
count=count,
|
||||
)
|
||||
|
||||
def test_should_render_successfully(self):
|
||||
response = self.client.get(reverse("bookmarks:settings.general"))
|
||||
|
||||
@@ -56,13 +81,20 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"update_profile": "",
|
||||
"theme": UserProfile.THEME_DARK,
|
||||
"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,
|
||||
"web_archive_integration": UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
|
||||
"enable_sharing": True,
|
||||
"enable_public_sharing": True,
|
||||
"enable_favicons": True,
|
||||
"enable_automatic_html_snapshots": False,
|
||||
"tag_search": UserProfile.TAG_SEARCH_LAX,
|
||||
"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,
|
||||
"custom_css": "body { background-color: #000; }",
|
||||
}
|
||||
@@ -76,6 +108,14 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(
|
||||
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.user.profile.bookmark_link_target, form_data["bookmark_link_target"]
|
||||
)
|
||||
@@ -90,18 +130,33 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(
|
||||
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.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.user.profile.permanent_notes, form_data["permanent_notes"]
|
||||
)
|
||||
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<p class="form-input-hint">Profile updated</p>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
self.assertSuccessMessage(html, "Profile updated")
|
||||
|
||||
def test_update_profile_should_not_be_called_without_respective_form_action(self):
|
||||
form_data = {
|
||||
@@ -114,13 +169,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<p class="form-input-hint">Profile updated</p>
|
||||
""",
|
||||
html,
|
||||
count=0,
|
||||
)
|
||||
self.assertSuccessMessage(html, "Profile updated", count=0)
|
||||
|
||||
def test_enable_favicons_should_schedule_icon_update(self):
|
||||
with patch.object(
|
||||
@@ -168,13 +217,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = response.content.decode()
|
||||
|
||||
mock_schedule_refresh_favicons.assert_called_once()
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<p class="form-input-hint">
|
||||
Scheduled favicon update. This may take a while...
|
||||
</p>
|
||||
""",
|
||||
html,
|
||||
self.assertSuccessMessage(
|
||||
html, "Scheduled favicon update. This may take a while..."
|
||||
)
|
||||
|
||||
def test_refresh_favicons_should_not_be_called_without_respective_form_action(self):
|
||||
@@ -188,14 +232,8 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = response.content.decode()
|
||||
|
||||
mock_schedule_refresh_favicons.assert_not_called()
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<p class="form-input-hint">
|
||||
Scheduled favicon update. This may take a while...
|
||||
</p>
|
||||
""",
|
||||
html,
|
||||
count=0,
|
||||
self.assertSuccessMessage(
|
||||
html, "Scheduled favicon update. This may take a while...", count=0
|
||||
)
|
||||
|
||||
def test_refresh_favicons_should_be_visible_when_favicons_enabled_in_profile(self):
|
||||
@@ -249,6 +287,35 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
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):
|
||||
response = self.client.get(reverse("bookmarks:settings.general"))
|
||||
html = response.content.decode()
|
||||
@@ -294,3 +361,57 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
with patch.object(requests, "get", return_value=latest_version_response_mock):
|
||||
version_info = get_version_info(random.random())
|
||||
self.assertEqual(version_info, app_version)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_missing_html_snapshots(self):
|
||||
with patch.object(
|
||||
tasks, "create_missing_html_snapshots"
|
||||
) as mock_create_missing_html_snapshots:
|
||||
mock_create_missing_html_snapshots.return_value = 5
|
||||
form_data = {
|
||||
"create_missing_html_snapshots": "",
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:settings.general"), form_data
|
||||
)
|
||||
html = response.content.decode()
|
||||
|
||||
mock_create_missing_html_snapshots.assert_called_once()
|
||||
self.assertSuccessMessage(
|
||||
html, "Queued 5 missing snapshots. This may take a while..."
|
||||
)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_missing_html_snapshots_no_missing_snapshots(self):
|
||||
with patch.object(
|
||||
tasks, "create_missing_html_snapshots"
|
||||
) as mock_create_missing_html_snapshots:
|
||||
mock_create_missing_html_snapshots.return_value = 0
|
||||
form_data = {
|
||||
"create_missing_html_snapshots": "",
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:settings.general"), form_data
|
||||
)
|
||||
html = response.content.decode()
|
||||
|
||||
mock_create_missing_html_snapshots.assert_called_once()
|
||||
self.assertSuccessMessage(html, "No missing snapshots found.")
|
||||
|
||||
def test_create_missing_html_snapshots_should_not_be_called_without_respective_form_action(
|
||||
self,
|
||||
):
|
||||
with patch.object(
|
||||
tasks, "create_missing_html_snapshots"
|
||||
) as mock_create_missing_html_snapshots:
|
||||
mock_create_missing_html_snapshots.return_value = 5
|
||||
form_data = {}
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:settings.general"), form_data
|
||||
)
|
||||
html = response.content.decode()
|
||||
|
||||
mock_create_missing_html_snapshots.assert_not_called()
|
||||
self.assertSuccessMessage(
|
||||
html, "Queued 5 missing snapshots. This may take a while...", count=0
|
||||
)
|
||||
|
@@ -11,19 +11,27 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def assertFormSuccessHint(self, response, text: str):
|
||||
self.assertContains(response, '<div class="has-success">')
|
||||
self.assertContains(response, text)
|
||||
def assertSuccessMessage(self, response, message: str):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<div class="toast toast-success mb-4">{ message }</div>
|
||||
""",
|
||||
response.content.decode("utf-8"),
|
||||
)
|
||||
|
||||
def assertNoFormSuccessHint(self, response):
|
||||
self.assertNotContains(response, '<div class="has-success">')
|
||||
def assertNoSuccessMessage(self, response):
|
||||
self.assertNotContains(response, '<div class="toast toast-success mb-4">')
|
||||
|
||||
def assertFormErrorHint(self, response, text: str):
|
||||
self.assertContains(response, '<div class="has-error">')
|
||||
self.assertContains(response, text)
|
||||
def assertErrorMessage(self, response, message: str):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<div class="toast toast-error mb-4">{ message }</div>
|
||||
""",
|
||||
response.content.decode("utf-8"),
|
||||
)
|
||||
|
||||
def assertNoFormErrorHint(self, response):
|
||||
self.assertNotContains(response, '<div class="has-error">')
|
||||
def assertNoErrorMessage(self, response):
|
||||
self.assertNotContains(response, '<div class="toast toast-error mb-4">')
|
||||
|
||||
def test_should_import_successfully(self):
|
||||
with open(
|
||||
@@ -36,10 +44,10 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("bookmarks:settings.general"))
|
||||
self.assertFormSuccessHint(
|
||||
response, "3 bookmarks were successfully imported"
|
||||
self.assertSuccessMessage(
|
||||
response, "3 bookmarks were successfully imported."
|
||||
)
|
||||
self.assertNoFormErrorHint(response)
|
||||
self.assertNoErrorMessage(response)
|
||||
|
||||
def test_should_check_authentication(self):
|
||||
self.client.logout()
|
||||
@@ -53,8 +61,8 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response = self.client.post(reverse("bookmarks:settings.import"), follow=True)
|
||||
|
||||
self.assertRedirects(response, reverse("bookmarks:settings.general"))
|
||||
self.assertNoFormSuccessHint(response)
|
||||
self.assertFormErrorHint(response, "Please select a file to import.")
|
||||
self.assertNoSuccessMessage(response)
|
||||
self.assertErrorMessage(response, "Please select a file to import.")
|
||||
|
||||
@disable_logging
|
||||
def test_should_show_hint_if_import_raises_exception(self):
|
||||
@@ -68,8 +76,8 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("bookmarks:settings.general"))
|
||||
self.assertNoFormSuccessHint(response)
|
||||
self.assertFormErrorHint(
|
||||
self.assertNoSuccessMessage(response)
|
||||
self.assertErrorMessage(
|
||||
response, "An error occurred during bookmark import."
|
||||
)
|
||||
|
||||
@@ -87,10 +95,13 @@ class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("bookmarks:settings.general"))
|
||||
self.assertFormSuccessHint(
|
||||
response, "2 bookmarks were successfully imported"
|
||||
self.assertSuccessMessage(
|
||||
response, "2 bookmarks were successfully imported."
|
||||
)
|
||||
self.assertErrorMessage(
|
||||
response,
|
||||
"1 bookmarks could not be imported. Please check the logs for more details.",
|
||||
)
|
||||
self.assertFormErrorHint(response, "1 bookmarks could not be imported")
|
||||
|
||||
def test_should_respect_map_private_flag_option(self):
|
||||
with open(
|
||||
|
119
bookmarks/tests/test_singlefile_service.py
Normal file
119
bookmarks/tests/test_singlefile_service.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import gzip
|
||||
import os
|
||||
import subprocess
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
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):
|
||||
mock_process = mock.Mock()
|
||||
mock_process.wait.return_value = 0
|
||||
self.create_test_file()
|
||||
|
||||
with mock.patch("subprocess.Popen", return_value=mock_process):
|
||||
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.Popen") as mock_popen:
|
||||
mock_popen.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.Popen"):
|
||||
with self.assertRaises(singlefile.SingeFileError):
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
|
||||
def test_create_snapshot_empty_options(self):
|
||||
mock_process = mock.Mock()
|
||||
mock_process.wait.return_value = 0
|
||||
self.create_test_file()
|
||||
|
||||
with mock.patch("subprocess.Popen") as mock_popen:
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
|
||||
expected_args = [
|
||||
"single-file",
|
||||
'--browser-arg="--headless=new"',
|
||||
'--browser-arg="--user-data-dir=./chromium-profile"',
|
||||
'--browser-arg="--no-sandbox"',
|
||||
'--browser-arg="--load-extension=uBlock0.chromium"',
|
||||
"http://example.com",
|
||||
self.html_filepath + ".tmp",
|
||||
]
|
||||
mock_popen.assert_called_with(expected_args, start_new_session=True)
|
||||
|
||||
@override_settings(
|
||||
LD_SINGLEFILE_OPTIONS='--some-option "some value" --another-option "another value" --third-option="third value"'
|
||||
)
|
||||
def test_create_snapshot_custom_options(self):
|
||||
mock_process = mock.Mock()
|
||||
mock_process.wait.return_value = 0
|
||||
self.create_test_file()
|
||||
|
||||
with mock.patch("subprocess.Popen") as mock_popen:
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
|
||||
expected_args = [
|
||||
"single-file",
|
||||
'--browser-arg="--headless=new"',
|
||||
'--browser-arg="--user-data-dir=./chromium-profile"',
|
||||
'--browser-arg="--no-sandbox"',
|
||||
'--browser-arg="--load-extension=uBlock0.chromium"',
|
||||
"--some-option",
|
||||
"some value",
|
||||
"--another-option",
|
||||
"another value",
|
||||
"--third-option=third value",
|
||||
"http://example.com",
|
||||
self.html_filepath + ".tmp",
|
||||
]
|
||||
mock_popen.assert_called_with(expected_args, start_new_session=True)
|
||||
|
||||
def test_create_snapshot_default_timeout_setting(self):
|
||||
mock_process = mock.Mock()
|
||||
mock_process.wait.return_value = 0
|
||||
self.create_test_file()
|
||||
|
||||
with mock.patch("subprocess.Popen", return_value=mock_process):
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
|
||||
mock_process.wait.assert_called_with(timeout=120)
|
||||
|
||||
@override_settings(LD_SINGLEFILE_TIMEOUT_SEC=180)
|
||||
def test_create_snapshot_custom_timeout_setting(self):
|
||||
mock_process = mock.Mock()
|
||||
mock_process.wait.return_value = 0
|
||||
self.create_test_file()
|
||||
|
||||
with mock.patch("subprocess.Popen", return_value=mock_process):
|
||||
singlefile.create_snapshot("http://example.com", self.html_filepath)
|
||||
|
||||
mock_process.wait.assert_called_with(timeout=180)
|
@@ -40,7 +40,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
# Should render toasts container
|
||||
self.assertContains(response, '<div class="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):
|
||||
self.create_toast(acknowledged=True)
|
||||
@@ -81,9 +81,9 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_toast_content(self):
|
||||
toast = self.create_toast()
|
||||
expected_toast = f"""
|
||||
<div class="toast">
|
||||
<div class="toast d-flex">
|
||||
{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>
|
||||
"""
|
||||
|
||||
|
@@ -34,6 +34,27 @@ urlpatterns = [
|
||||
path("bookmarks/new", views.bookmarks.new, name="new"),
|
||||
path("bookmarks/close", views.bookmarks.close, name="close"),
|
||||
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",
|
||||
),
|
||||
path(
|
||||
"bookmarks/<int:bookmark_id>/details_assets",
|
||||
views.bookmarks.details_assets,
|
||||
name="details_assets",
|
||||
),
|
||||
# Assets
|
||||
path(
|
||||
"assets/<int:asset_id>",
|
||||
views.assets.view,
|
||||
name="assets.view",
|
||||
),
|
||||
# Partials
|
||||
path(
|
||||
"bookmarks/partials/bookmark-list/active",
|
||||
@@ -45,6 +66,11 @@ urlpatterns = [
|
||||
partials.active_tag_cloud,
|
||||
name="partials.tag_cloud.active",
|
||||
),
|
||||
path(
|
||||
"bookmarks/partials/tag-modal/active",
|
||||
partials.active_tag_modal,
|
||||
name="partials.tag_modal.active",
|
||||
),
|
||||
path(
|
||||
"bookmarks/partials/bookmark-list/archived",
|
||||
partials.archived_bookmark_list,
|
||||
@@ -55,6 +81,11 @@ urlpatterns = [
|
||||
partials.archived_tag_cloud,
|
||||
name="partials.tag_cloud.archived",
|
||||
),
|
||||
path(
|
||||
"bookmarks/partials/tag-modal/archived",
|
||||
partials.archived_tag_modal,
|
||||
name="partials.tag_modal.archived",
|
||||
),
|
||||
path(
|
||||
"bookmarks/partials/bookmark-list/shared",
|
||||
partials.shared_bookmark_list,
|
||||
@@ -65,6 +96,11 @@ urlpatterns = [
|
||||
partials.shared_tag_cloud,
|
||||
name="partials.tag_cloud.shared",
|
||||
),
|
||||
path(
|
||||
"bookmarks/partials/tag-modal/shared",
|
||||
partials.shared_tag_modal,
|
||||
name="partials.tag_modal.shared",
|
||||
),
|
||||
# Settings
|
||||
path("settings", views.settings.general, name="settings.index"),
|
||||
path("settings/general", views.settings.general, name="settings.general"),
|
||||
|
@@ -1,3 +1,4 @@
|
||||
from .assets import *
|
||||
from .bookmarks import *
|
||||
from .settings import *
|
||||
from .toasts import *
|
||||
|
43
bookmarks/views/assets.py
Normal file
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 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 (
|
||||
create_bookmark,
|
||||
update_bookmark,
|
||||
@@ -28,6 +34,7 @@ from bookmarks.services.bookmarks import (
|
||||
share_bookmarks,
|
||||
unshare_bookmarks,
|
||||
)
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.utils import get_safe_return_url
|
||||
from bookmarks.views.partials import contexts
|
||||
|
||||
@@ -104,6 +111,71 @@ def search_action(request):
|
||||
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 details_assets(request, bookmark_id: int):
|
||||
return _details(request, bookmark_id, "bookmarks/details/assets.html")
|
||||
|
||||
|
||||
def convert_tag_string(tag_string: str):
|
||||
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
|
||||
# strings
|
||||
|
@@ -11,43 +11,45 @@ def manifest(request):
|
||||
"display": "standalone",
|
||||
"scope": "/" + settings.LD_CONTEXT_PATH,
|
||||
"theme_color": "#5856e0",
|
||||
"background_color": "#161822" if request.user_profile.theme == "dark" else "#ffffff",
|
||||
"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"
|
||||
"purpose": "any",
|
||||
},
|
||||
{
|
||||
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any"
|
||||
"purpose": "any",
|
||||
},
|
||||
{
|
||||
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any"
|
||||
"purpose": "any",
|
||||
},
|
||||
{
|
||||
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable",
|
||||
},
|
||||
],
|
||||
"shortcuts": [
|
||||
@@ -70,14 +72,16 @@ def manifest(request):
|
||||
{
|
||||
"name": "Shared",
|
||||
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/shared",
|
||||
}
|
||||
},
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/" + settings.LD_CONTEXT_PATH + "static/linkding-screenshot.png",
|
||||
"src": "/"
|
||||
+ settings.LD_CONTEXT_PATH
|
||||
+ "static/linkding-screenshot.png",
|
||||
"type": "image/png",
|
||||
"sizes": "2158x1160",
|
||||
"form_factor": "wide"
|
||||
"form_factor": "wide",
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
@@ -88,8 +92,8 @@ def manifest(request):
|
||||
"url": "url",
|
||||
"text": "url",
|
||||
"title": "title",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return JsonResponse(response, status=200)
|
||||
|
@@ -22,6 +22,13 @@ def active_tag_cloud(request):
|
||||
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
|
||||
|
||||
|
||||
@login_required
|
||||
def active_tag_modal(request):
|
||||
tag_cloud_context = contexts.ActiveTagCloudContext(request)
|
||||
|
||||
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
|
||||
|
||||
|
||||
@login_required
|
||||
def archived_bookmark_list(request):
|
||||
bookmark_list_context = contexts.ArchivedBookmarkListContext(request)
|
||||
@@ -41,6 +48,12 @@ def archived_tag_cloud(request):
|
||||
|
||||
|
||||
@login_required
|
||||
def archived_tag_modal(request):
|
||||
tag_cloud_context = contexts.ArchivedTagCloudContext(request)
|
||||
|
||||
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
|
||||
|
||||
|
||||
def shared_bookmark_list(request):
|
||||
bookmark_list_context = contexts.SharedBookmarkListContext(request)
|
||||
|
||||
@@ -51,8 +64,13 @@ def shared_bookmark_list(request):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def shared_tag_cloud(request):
|
||||
tag_cloud_context = contexts.SharedTagCloudContext(request)
|
||||
|
||||
return render(request, "bookmarks/tag_cloud.html", {"tag_cloud": tag_cloud_context})
|
||||
|
||||
|
||||
def shared_tag_modal(request):
|
||||
tag_cloud_context = contexts.SharedTagCloudContext(request)
|
||||
|
||||
return render(request, "bookmarks/tag_modal.html", {"tag_cloud": tag_cloud_context})
|
||||
|
@@ -6,11 +6,13 @@ from django.core.handlers.wsgi import WSGIRequest
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks import utils
|
||||
from bookmarks.models import (
|
||||
Bookmark,
|
||||
BookmarkAsset,
|
||||
BookmarkSearch,
|
||||
User,
|
||||
UserProfile,
|
||||
@@ -21,6 +23,113 @@ DEFAULT_PAGE_SIZE = 30
|
||||
CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
|
||||
|
||||
|
||||
class RequestContext:
|
||||
index_view = "bookmarks:index"
|
||||
action_view = "bookmarks:index.action"
|
||||
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
|
||||
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
|
||||
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
|
||||
|
||||
def __init__(self, request: WSGIRequest):
|
||||
self.request = request
|
||||
self.index_url = reverse(self.index_view)
|
||||
self.action_url = reverse(self.action_view)
|
||||
self.bookmark_list_partial_url = reverse(self.bookmark_list_partial_view)
|
||||
self.tag_cloud_partial_url = reverse(self.tag_cloud_partial_view)
|
||||
self.tag_modal_partial_url = reverse(self.tag_modal_partial_view)
|
||||
self.query_params = request.GET.copy()
|
||||
self.query_params.pop("details", None)
|
||||
|
||||
def get_url(self, view_url: str, add: dict = None, remove: dict = None) -> str:
|
||||
query_params = self.query_params.copy()
|
||||
if add:
|
||||
query_params.update(add)
|
||||
if remove:
|
||||
for key in remove:
|
||||
query_params.pop(key, None)
|
||||
encoded_params = query_params.urlencode()
|
||||
return view_url + "?" + encoded_params if encoded_params else view_url
|
||||
|
||||
def index(self) -> str:
|
||||
return self.get_url(self.index_url)
|
||||
|
||||
def action(self, return_url: str) -> str:
|
||||
return self.get_url(self.action_url, add={"return_url": return_url})
|
||||
|
||||
def bookmark_list_partial(self) -> str:
|
||||
return self.get_url(self.bookmark_list_partial_url)
|
||||
|
||||
def tag_cloud_partial(self) -> str:
|
||||
return self.get_url(self.tag_cloud_partial_url)
|
||||
|
||||
def tag_modal_partial(self) -> str:
|
||||
return self.get_url(self.tag_modal_partial_url)
|
||||
|
||||
def get_bookmark_query_set(self, search: BookmarkSearch):
|
||||
raise Exception("Must be implemented by subclass")
|
||||
|
||||
def get_tag_query_set(self, search: BookmarkSearch):
|
||||
raise Exception("Must be implemented by subclass")
|
||||
|
||||
|
||||
class ActiveBookmarksContext(RequestContext):
|
||||
index_view = "bookmarks:index"
|
||||
action_view = "bookmarks:index.action"
|
||||
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.active"
|
||||
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.active"
|
||||
tag_modal_partial_view = "bookmarks:partials.tag_modal.active"
|
||||
|
||||
def get_bookmark_query_set(self, search: BookmarkSearch):
|
||||
return queries.query_bookmarks(
|
||||
self.request.user, self.request.user_profile, search
|
||||
)
|
||||
|
||||
def get_tag_query_set(self, search: BookmarkSearch):
|
||||
return queries.query_bookmark_tags(
|
||||
self.request.user, self.request.user_profile, search
|
||||
)
|
||||
|
||||
|
||||
class ArchivedBookmarksContext(RequestContext):
|
||||
index_view = "bookmarks:archived"
|
||||
action_view = "bookmarks:archived.action"
|
||||
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.archived"
|
||||
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.archived"
|
||||
tag_modal_partial_view = "bookmarks:partials.tag_modal.archived"
|
||||
|
||||
def get_bookmark_query_set(self, search: BookmarkSearch):
|
||||
return queries.query_archived_bookmarks(
|
||||
self.request.user, self.request.user_profile, search
|
||||
)
|
||||
|
||||
def get_tag_query_set(self, search: BookmarkSearch):
|
||||
return queries.query_archived_bookmark_tags(
|
||||
self.request.user, self.request.user_profile, search
|
||||
)
|
||||
|
||||
|
||||
class SharedBookmarksContext(RequestContext):
|
||||
index_view = "bookmarks:shared"
|
||||
action_view = "bookmarks:shared.action"
|
||||
bookmark_list_partial_view = "bookmarks:partials.bookmark_list.shared"
|
||||
tag_cloud_partial_view = "bookmarks:partials.tag_cloud.shared"
|
||||
tag_modal_partial_view = "bookmarks:partials.tag_modal.shared"
|
||||
|
||||
def get_bookmark_query_set(self, search: BookmarkSearch):
|
||||
user = User.objects.filter(username=search.user).first()
|
||||
public_only = not self.request.user.is_authenticated
|
||||
return queries.query_shared_bookmarks(
|
||||
user, self.request.user_profile, search, public_only
|
||||
)
|
||||
|
||||
def get_tag_query_set(self, search: BookmarkSearch):
|
||||
user = User.objects.filter(username=search.user).first()
|
||||
public_only = not self.request.user.is_authenticated
|
||||
return queries.query_shared_bookmark_tags(
|
||||
user, self.request.user_profile, search, public_only
|
||||
)
|
||||
|
||||
|
||||
class BookmarkItem:
|
||||
def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None:
|
||||
self.bookmark = bookmark
|
||||
@@ -65,7 +174,10 @@ class BookmarkItem:
|
||||
|
||||
|
||||
class BookmarkListContext:
|
||||
request_context = RequestContext
|
||||
|
||||
def __init__(self, request: WSGIRequest) -> None:
|
||||
request_context = self.request_context(request)
|
||||
user = request.user
|
||||
user_profile = request.user_profile
|
||||
|
||||
@@ -74,7 +186,7 @@ class BookmarkListContext:
|
||||
self.request.GET, user_profile.search_preferences
|
||||
)
|
||||
|
||||
query_set = self.get_bookmark_query_set()
|
||||
query_set = request_context.get_bookmark_query_set(self.search)
|
||||
page_number = request.GET.get("page")
|
||||
paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
|
||||
bookmarks_page = paginator.get_page(page_number)
|
||||
@@ -84,19 +196,24 @@ class BookmarkListContext:
|
||||
self.items = [
|
||||
BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page
|
||||
]
|
||||
|
||||
self.is_empty = paginator.count == 0
|
||||
self.bookmarks_page = bookmarks_page
|
||||
self.bookmarks_total = paginator.count
|
||||
self.return_url = self.generate_return_url(
|
||||
self.search, self.get_base_url(), page_number
|
||||
)
|
||||
self.action_url = self.generate_action_url(
|
||||
self.search, self.get_base_action_url(), self.return_url
|
||||
)
|
||||
|
||||
self.return_url = request_context.index()
|
||||
self.action_url = request_context.action(return_url=self.return_url)
|
||||
self.refresh_url = request_context.bookmark_list_partial()
|
||||
self.tag_modal_url = request_context.tag_modal_partial()
|
||||
|
||||
self.link_target = user_profile.bookmark_link_target
|
||||
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_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_notes = user_profile.permanent_notes
|
||||
|
||||
@@ -123,55 +240,17 @@ class BookmarkListContext:
|
||||
else base_action_url + "?" + query_string
|
||||
)
|
||||
|
||||
def get_base_url(self):
|
||||
raise Exception("Must be implemented by subclass")
|
||||
|
||||
def get_base_action_url(self):
|
||||
raise Exception("Must be implemented by subclass")
|
||||
|
||||
def get_bookmark_query_set(self):
|
||||
raise Exception("Must be implemented by subclass")
|
||||
|
||||
|
||||
class ActiveBookmarkListContext(BookmarkListContext):
|
||||
def get_base_url(self):
|
||||
return reverse("bookmarks:index")
|
||||
|
||||
def get_base_action_url(self):
|
||||
return reverse("bookmarks:index.action")
|
||||
|
||||
def get_bookmark_query_set(self):
|
||||
return queries.query_bookmarks(
|
||||
self.request.user, self.request.user_profile, self.search
|
||||
)
|
||||
request_context = ActiveBookmarksContext
|
||||
|
||||
|
||||
class ArchivedBookmarkListContext(BookmarkListContext):
|
||||
def get_base_url(self):
|
||||
return reverse("bookmarks:archived")
|
||||
|
||||
def get_base_action_url(self):
|
||||
return reverse("bookmarks:archived.action")
|
||||
|
||||
def get_bookmark_query_set(self):
|
||||
return queries.query_archived_bookmarks(
|
||||
self.request.user, self.request.user_profile, self.search
|
||||
)
|
||||
request_context = ArchivedBookmarksContext
|
||||
|
||||
|
||||
class SharedBookmarkListContext(BookmarkListContext):
|
||||
def get_base_url(self):
|
||||
return reverse("bookmarks:shared")
|
||||
|
||||
def get_base_action_url(self):
|
||||
return reverse("bookmarks:shared.action")
|
||||
|
||||
def get_bookmark_query_set(self):
|
||||
user = User.objects.filter(username=self.search.user).first()
|
||||
public_only = not self.request.user.is_authenticated
|
||||
return queries.query_shared_bookmarks(
|
||||
user, self.request.user_profile, self.search, public_only
|
||||
)
|
||||
request_context = SharedBookmarksContext
|
||||
|
||||
|
||||
class TagGroup:
|
||||
@@ -210,7 +289,10 @@ class TagGroup:
|
||||
|
||||
|
||||
class TagCloudContext:
|
||||
request_context = RequestContext
|
||||
|
||||
def __init__(self, request: WSGIRequest) -> None:
|
||||
request_context = self.request_context(request)
|
||||
user_profile = request.user_profile
|
||||
|
||||
self.request = request
|
||||
@@ -218,7 +300,7 @@ class TagCloudContext:
|
||||
self.request.GET, user_profile.search_preferences
|
||||
)
|
||||
|
||||
query_set = self.get_tag_query_set()
|
||||
query_set = request_context.get_tag_query_set(self.search)
|
||||
tags = list(query_set)
|
||||
selected_tags = self.get_selected_tags(tags)
|
||||
unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name))
|
||||
@@ -234,8 +316,7 @@ class TagCloudContext:
|
||||
self.selected_tags = unique_selected_tags
|
||||
self.has_selected_tags = has_selected_tags
|
||||
|
||||
def get_tag_query_set(self):
|
||||
raise Exception("Must be implemented by subclass")
|
||||
self.refresh_url = request_context.tag_cloud_partial()
|
||||
|
||||
def get_selected_tags(self, tags: List[Tag]):
|
||||
parsed_query = queries.parse_query_string(self.search.q)
|
||||
@@ -248,23 +329,67 @@ class TagCloudContext:
|
||||
|
||||
|
||||
class ActiveTagCloudContext(TagCloudContext):
|
||||
def get_tag_query_set(self):
|
||||
return queries.query_bookmark_tags(
|
||||
self.request.user, self.request.user_profile, self.search
|
||||
)
|
||||
request_context = ActiveBookmarksContext
|
||||
|
||||
|
||||
class ArchivedTagCloudContext(TagCloudContext):
|
||||
def get_tag_query_set(self):
|
||||
return queries.query_archived_bookmark_tags(
|
||||
self.request.user, self.request.user_profile, self.search
|
||||
)
|
||||
request_context = ArchivedBookmarksContext
|
||||
|
||||
|
||||
class SharedTagCloudContext(TagCloudContext):
|
||||
def get_tag_query_set(self):
|
||||
user = User.objects.filter(username=self.search.user).first()
|
||||
public_only = not self.request.user.is_authenticated
|
||||
return queries.query_shared_bookmark_tags(
|
||||
user, self.request.user_profile, self.search, public_only
|
||||
request_context = SharedBookmarksContext
|
||||
|
||||
|
||||
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()
|
||||
]
|
||||
self.has_pending_assets = any(
|
||||
asset.status == BookmarkAsset.STATUS_PENDING for asset in self.assets
|
||||
)
|
||||
|
@@ -12,7 +12,7 @@ from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
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 importer
|
||||
from bookmarks.utils import app_version
|
||||
@@ -24,12 +24,11 @@ logger = logging.getLogger(__name__)
|
||||
def general(request):
|
||||
profile_form = None
|
||||
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
|
||||
update_profile_success_message = None
|
||||
refresh_favicons_success_message = None
|
||||
import_success_message = _find_message_with_tag(
|
||||
has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
|
||||
success_message = _find_message_with_tag(
|
||||
messages.get_messages(request), "bookmark_import_success"
|
||||
)
|
||||
import_errors_message = _find_message_with_tag(
|
||||
error_message = _find_message_with_tag(
|
||||
messages.get_messages(request), "bookmark_import_errors"
|
||||
)
|
||||
version_info = get_version_info(get_ttl_hash())
|
||||
@@ -37,12 +36,18 @@ def general(request):
|
||||
if request.method == "POST":
|
||||
if "update_profile" in request.POST:
|
||||
profile_form = update_profile(request)
|
||||
update_profile_success_message = "Profile updated"
|
||||
success_message = "Profile updated"
|
||||
if "refresh_favicons" in request.POST:
|
||||
tasks.schedule_refresh_favicons(request.user)
|
||||
refresh_favicons_success_message = (
|
||||
"Scheduled favicon update. This may take a while..."
|
||||
)
|
||||
success_message = "Scheduled favicon update. This may take a while..."
|
||||
if "create_missing_html_snapshots" in request.POST:
|
||||
count = tasks.create_missing_html_snapshots(request.user)
|
||||
if count > 0:
|
||||
success_message = (
|
||||
f"Queued {count} missing snapshots. This may take a while..."
|
||||
)
|
||||
else:
|
||||
success_message = "No missing snapshots found."
|
||||
|
||||
if not profile_form:
|
||||
profile_form = UserProfileForm(instance=request.user_profile)
|
||||
@@ -53,10 +58,9 @@ def general(request):
|
||||
{
|
||||
"form": profile_form,
|
||||
"enable_refresh_favicons": enable_refresh_favicons,
|
||||
"update_profile_success_message": update_profile_success_message,
|
||||
"refresh_favicons_success_message": refresh_favicons_success_message,
|
||||
"import_success_message": import_success_message,
|
||||
"import_errors_message": import_errors_message,
|
||||
"has_snapshot_support": has_snapshot_support,
|
||||
"success_message": success_message,
|
||||
"error_message": error_message,
|
||||
"version_info": version_info,
|
||||
},
|
||||
)
|
||||
|
@@ -7,6 +7,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
|
||||
mkdir -p data
|
||||
# Create favicon folder if it does not exist
|
||||
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
|
||||
python manage.py generate_secret_key
|
||||
@@ -16,8 +18,10 @@ python manage.py migrate
|
||||
python manage.py enable_wal
|
||||
# Create initial superuser if defined in options / environment variables
|
||||
python manage.py create_initial_superuser
|
||||
# Migrate legacy background tasks to Huey
|
||||
python manage.py migrate_tasks
|
||||
|
||||
# Ensure the DB folder is owned by the right user
|
||||
# Ensure folders are owned by the right user
|
||||
chown -R www-data: /etc/linkding/data
|
||||
|
||||
# Start background task processor using supervisord, unless explicitly disabled
|
||||
|
@@ -31,7 +31,8 @@ RUN pip install -U pip && pip install -r requirements.txt -r requirements.dev.tx
|
||||
COPY . .
|
||||
COPY --from=node-build /etc/linkding .
|
||||
# run Django part of the build
|
||||
RUN python manage.py compilescss && \
|
||||
RUN mkdir data && \
|
||||
python manage.py compilescss && \
|
||||
python manage.py collectstatic --ignore=*.scss && \
|
||||
python manage.py compilescss --delete-files
|
||||
|
||||
@@ -67,7 +68,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
||||
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||
|
||||
|
||||
FROM python:3.11.8-alpine3.19 AS final
|
||||
FROM python:3.11.8-alpine3.19 AS linkding
|
||||
# install runtime dependencies
|
||||
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
||||
# create www-data user and group
|
||||
@@ -96,3 +97,33 @@ HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
||||
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
||||
|
||||
CMD ["./bootstrap.sh"]
|
||||
|
||||
|
||||
FROM node:18-alpine AS ublock-build
|
||||
WORKDIR /etc/linkding
|
||||
# Install necessary tools
|
||||
RUN apk add --no-cache curl jq unzip
|
||||
# Fetch the latest release tag
|
||||
# Download the library
|
||||
# Unzip the library
|
||||
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
|
||||
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
|
||||
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
|
||||
unzip uBlock0.zip
|
||||
# Patch assets.json to enable easylist-cookies by default
|
||||
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
|
||||
RUN jq '."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
|
||||
mv temp.json ./uBlock0.chromium/assets/assets.json
|
||||
|
||||
|
||||
FROM linkding AS linkding-plus
|
||||
# install node, chromium
|
||||
RUN apk update && apk add nodejs npm chromium
|
||||
# install single-file from fork for now, which contains several hotfixes
|
||||
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
|
||||
# copy uBlock0
|
||||
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
|
||||
# create chromium profile folder for user running background tasks
|
||||
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
|
||||
# enable snapshot support
|
||||
ENV LD_ENABLE_SNAPSHOTS=True
|
||||
|
@@ -33,7 +33,8 @@ RUN pip install -U pip && pip install -r requirements.txt -r requirements.dev.tx
|
||||
COPY . .
|
||||
COPY --from=node-build /etc/linkding .
|
||||
# run Django part of the build
|
||||
RUN python manage.py compilescss && \
|
||||
RUN mkdir data && \
|
||||
python manage.py compilescss && \
|
||||
python manage.py collectstatic --ignore=*.scss && \
|
||||
python manage.py compilescss --delete-files
|
||||
|
||||
@@ -69,7 +70,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
||||
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
|
||||
|
||||
|
||||
FROM python:3.11.8-slim-bookworm as final
|
||||
FROM python:3.11.8-slim-bookworm as linkding
|
||||
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
|
||||
WORKDIR /etc/linkding
|
||||
# copy prod dependencies
|
||||
@@ -94,3 +95,39 @@ HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
||||
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
||||
|
||||
CMD ["./bootstrap.sh"]
|
||||
|
||||
|
||||
FROM node:18-alpine AS ublock-build
|
||||
WORKDIR /etc/linkding
|
||||
# Install necessary tools
|
||||
RUN apk add --no-cache curl jq unzip
|
||||
# Fetch the latest release tag
|
||||
# Download the library
|
||||
# Unzip the library
|
||||
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
|
||||
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
|
||||
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
|
||||
unzip uBlock0.zip
|
||||
# Patch assets.json to enable easylist-cookies by default
|
||||
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
|
||||
RUN jq '."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
|
||||
mv temp.json ./uBlock0.chromium/assets/assets.json
|
||||
|
||||
|
||||
FROM linkding AS linkding-plus
|
||||
# install chromium
|
||||
RUN apt-get update && apt-get -y install chromium
|
||||
# install node
|
||||
ENV NODE_MAJOR=20
|
||||
RUN apt-get install -y gnupg2 apt-transport-https ca-certificates && \
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \
|
||||
apt-get update && apt-get install -y nodejs
|
||||
# install single-file from fork for now, which contains several hotfixes
|
||||
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
|
||||
# create chromium profile folder for user running background tasks
|
||||
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
|
||||
# copy uBlock0
|
||||
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
|
||||
# enable snapshot support
|
||||
ENV LD_ENABLE_SNAPSHOTS=True
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user