mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-02-28 06:53:12 +08:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
695b0dc300 | ||
|
|
fe40139838 | ||
|
|
44b49a4cfe | ||
|
|
469883a674 | ||
|
|
fa5f78cf71 | ||
|
|
e03f536925 | ||
|
|
a92a35cfb8 | ||
|
|
ff334e0888 | ||
|
|
0f9ba57fef | ||
|
|
b4376a9ff1 | ||
|
|
87cd4061cb | ||
|
|
e2415f652b | ||
|
|
9cf5eb5ec0 | ||
|
|
023a213ba6 | ||
|
|
23d97db016 | ||
|
|
0fb1bbd0e2 | ||
|
|
5d2acca122 | ||
|
|
0cbaf927e4 | ||
|
|
0586983602 | ||
|
|
9dc3521d5e | ||
|
|
a1822e2091 | ||
|
|
22ffecbb9d | ||
|
|
d9096eacd6 | ||
|
|
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
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -191,3 +191,8 @@ typings/
|
||||
/tmp
|
||||
# Database file
|
||||
/data
|
||||
# ublock + chromium
|
||||
/uBlock0.chromium
|
||||
/chromium-profile
|
||||
# direnv
|
||||
/.direnv
|
||||
|
||||
119
CHANGELOG.md
119
CHANGELOG.md
@@ -1,5 +1,124 @@
|
||||
# Changelog
|
||||
|
||||
## v1.30.0 (20/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Add reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/703
|
||||
* Allow uploading custom files for bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/713
|
||||
* Add option for marking bookmarks as unread by default by @ab623 in https://github.com/sissbruecker/linkding/pull/706
|
||||
* Make blocking cookie banners more reliable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/699
|
||||
* Close bookmark details with escape by @sissbruecker in https://github.com/sissbruecker/linkding/pull/702
|
||||
* Show proper name for bookmark assets in admin by @ab623 in https://github.com/sissbruecker/linkding/pull/708
|
||||
* Bump sqlparse from 0.4.4 to 0.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/704
|
||||
|
||||
### New Contributors
|
||||
* @ab623 made their first contribution in https://github.com/sissbruecker/linkding/pull/706
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.29.0...v1.30.0
|
||||
|
||||
---
|
||||
|
||||
## v1.29.0 (14/04/2024)
|
||||
|
||||
### What's Changed
|
||||
* Remove ads and cookie banners from HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/695
|
||||
* Add button for creating missing HTML snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/696
|
||||
* Refresh file list when there are queued snapshots by @sissbruecker in https://github.com/sissbruecker/linkding/pull/697
|
||||
* Bump idna from 3.6 to 3.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/694
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.28.0...v1.29.0
|
||||
|
||||
---
|
||||
|
||||
## 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,19 @@ class AdminBookmark(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class AdminBookmarkAsset(admin.ModelAdmin):
|
||||
@admin.display(description="Display Name")
|
||||
def custom_display_name(self, obj):
|
||||
return str(obj)
|
||||
|
||||
list_display = ("custom_display_name", "date_created", "status")
|
||||
search_fields = (
|
||||
"custom_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 +287,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)
|
||||
|
||||
@@ -11,6 +11,7 @@ from bookmarks.api.serializers import (
|
||||
UserProfileSerializer,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.bookmarks import (
|
||||
archive_bookmark,
|
||||
unarchive_bookmark,
|
||||
@@ -99,13 +100,26 @@ class BookmarkViewSet(
|
||||
# Either return metadata from existing bookmark, or scrape from URL
|
||||
if bookmark:
|
||||
metadata = WebsiteMetadata(
|
||||
url, bookmark.website_title, bookmark.website_description
|
||||
url,
|
||||
bookmark.website_title,
|
||||
bookmark.website_description,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
|
||||
# Return tags that would be automatically applied to the bookmark
|
||||
profile = request.user.profile
|
||||
auto_tags = []
|
||||
if profile.auto_tagging_rules:
|
||||
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
|
||||
|
||||
return Response(
|
||||
{"bookmark": existing_bookmark_data, "metadata": metadata.to_dict()},
|
||||
{
|
||||
"bookmark": existing_bookmark_data,
|
||||
"metadata": metadata.to_dict(),
|
||||
"auto_tags": auto_tags,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
176
bookmarks/e2e/e2e_test_bookmark_details_modal.py
Normal file
176
bookmarks/e2e/e2e_test_bookmark_details_modal.py
Normal file
@@ -0,0 +1,176 @@
|
||||
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()
|
||||
|
||||
# close with escape
|
||||
details_modal = self.open_details_modal(bookmark)
|
||||
self.page.keyboard.press("Escape")
|
||||
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()
|
||||
@@ -85,3 +85,25 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
page.get_by_label("URL").fill(bookmark.url)
|
||||
expect(details).to_have_attribute("open", value="")
|
||||
|
||||
def test_create_should_preview_auto_tags(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = "github.com dev github"
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# Open page with URL that should have auto tags
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
url = self.live_server_url + reverse("bookmarks:new")
|
||||
url += f"?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
|
||||
page.goto(url)
|
||||
|
||||
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
|
||||
expect(auto_tags_hint).to_be_visible()
|
||||
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
|
||||
|
||||
# Change to URL without auto tags
|
||||
page.get_by_label("URL").fill("https://example.com")
|
||||
|
||||
expect(auto_tags_hint).to_be_hidden()
|
||||
|
||||
@@ -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);
|
||||
64
bookmarks/frontend/behaviors/form.js
Normal file
64
bookmarks/frontend/behaviors/form.js
Normal file
@@ -0,0 +1,64 @@
|
||||
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 }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class UploadButton extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
const fileInput = element.nextElementSibling;
|
||||
|
||||
element.addEventListener("click", () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
const form = fileInput.closest("form");
|
||||
const event = new Event("submit", { cancelable: true });
|
||||
event.submitter = element;
|
||||
form.dispatchEvent(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-form", FormBehavior);
|
||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||
registerBehavior("ld-upload-button", UploadButton);
|
||||
@@ -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,50 @@
|
||||
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);
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
const modalOverlay = element.querySelector(".modal-overlay");
|
||||
const closeButton = element.querySelector("button.close");
|
||||
modalOverlay.addEventListener("click", this.onClose);
|
||||
closeButton.addEventListener("click", this.onClose);
|
||||
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onToggleClick() {
|
||||
const contentSelector = this.toggle.getAttribute("modal-content");
|
||||
const content = document.querySelector(contentSelector);
|
||||
if (!content) {
|
||||
destroy() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
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");
|
||||
modalOverlay.addEventListener("click", this.onClose.bind(this));
|
||||
closeButton.addEventListener("click", this.onClose.bind(this));
|
||||
|
||||
document.body.append(modal);
|
||||
this.modal = modal;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
// Teleport content back
|
||||
this.contentOwner.append(this.content);
|
||||
|
||||
// Remove modal
|
||||
this.modal.remove();
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
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()
|
||||
75
bookmarks/management/commands/full_backup.py
Normal file
75
bookmarks/management/commands/full_backup.py
Normal file
@@ -0,0 +1,75 @@
|
||||
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))
|
||||
|
||||
# Backup the previews folder
|
||||
if not os.path.exists(os.path.join("data", "previews")):
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No previews folder found. Skipping...")
|
||||
)
|
||||
else:
|
||||
self.stdout.write("Backup bookmark previews...")
|
||||
previews_folder = os.path.join("data", "previews")
|
||||
for root, _, files in os.walk(previews_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
zip_file.write(file_path, os.path.join("previews", 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),
|
||||
]
|
||||
18
bookmarks/migrations/0033_userprofile_default_mark_unread.py
Normal file
18
bookmarks/migrations/0033_userprofile_default_mark_unread.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-17 19:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0032_html_snapshots_hint_toast"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="default_mark_unread",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-10 07:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0033_userprofile_default_mark_unread"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="preview_image_file",
|
||||
field=models.CharField(blank=True, max_length=512),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="enable_preview_images",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
22
bookmarks/migrations/0035_userprofile_tag_grouping.py
Normal file
22
bookmarks/migrations/0035_userprofile_tag_grouping.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-14 08:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0034_bookmark_preview_image_file_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="tag_grouping",
|
||||
field=models.CharField(
|
||||
choices=[("alphabetical", "Alphabetical"), ("disabled", "Disabled")],
|
||||
default="alphabetical",
|
||||
max_length=12,
|
||||
),
|
||||
),
|
||||
]
|
||||
18
bookmarks/migrations/0036_userprofile_auto_tagging_rules.py
Normal file
18
bookmarks/migrations/0036_userprofile_auto_tagging_rules.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-17 07:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0035_userprofile_tag_grouping"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="auto_tagging_rules",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -55,6 +59,7 @@ class Bookmark(models.Model):
|
||||
website_description = models.TextField(blank=True, null=True)
|
||||
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
|
||||
favicon_file = models.CharField(max_length=512, blank=True)
|
||||
preview_image_file = models.CharField(max_length=512, blank=True)
|
||||
unread = models.BooleanField(default=False)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
shared = models.BooleanField(default=False)
|
||||
@@ -85,6 +90,51 @@ class Bookmark(models.Model):
|
||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||
|
||||
|
||||
class BookmarkAsset(models.Model):
|
||||
TYPE_SNAPSHOT = "snapshot"
|
||||
TYPE_UPLOAD = "upload"
|
||||
|
||||
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)
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name or f"Bookmark Asset #{self.pk}"
|
||||
|
||||
|
||||
@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 +328,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 = [
|
||||
@@ -296,6 +352,12 @@ class UserProfile(models.Model):
|
||||
(TAG_SEARCH_STRICT, "Strict"),
|
||||
(TAG_SEARCH_LAX, "Lax"),
|
||||
]
|
||||
TAG_GROUPING_ALPHABETICAL = "alphabetical"
|
||||
TAG_GROUPING_DISABLED = "disabled"
|
||||
TAG_GROUPING_CHOICES = [
|
||||
(TAG_GROUPING_ALPHABETICAL, "Alphabetical"),
|
||||
(TAG_GROUPING_DISABLED, "Disabled"),
|
||||
]
|
||||
user = models.OneToOneField(
|
||||
get_user_model(), related_name="profile", on_delete=models.CASCADE
|
||||
)
|
||||
@@ -308,6 +370,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,
|
||||
@@ -326,13 +398,27 @@ class UserProfile(models.Model):
|
||||
blank=False,
|
||||
default=TAG_SEARCH_STRICT,
|
||||
)
|
||||
tag_grouping = models.CharField(
|
||||
max_length=12,
|
||||
choices=TAG_GROUPING_CHOICES,
|
||||
blank=False,
|
||||
default=TAG_GROUPING_ALPHABETICAL,
|
||||
)
|
||||
enable_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_public_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_favicons = models.BooleanField(default=False, null=False)
|
||||
enable_preview_images = 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)
|
||||
auto_tagging_rules = models.TextField(blank=True, null=False)
|
||||
search_preferences = models.JSONField(default=dict, null=False)
|
||||
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||
default_mark_unread = models.BooleanField(default=False, null=False)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
@@ -341,15 +427,26 @@ class UserProfileForm(forms.ModelForm):
|
||||
fields = [
|
||||
"theme",
|
||||
"bookmark_date_display",
|
||||
"bookmark_description_display",
|
||||
"bookmark_description_max_lines",
|
||||
"bookmark_link_target",
|
||||
"web_archive_integration",
|
||||
"tag_search",
|
||||
"tag_grouping",
|
||||
"enable_sharing",
|
||||
"enable_public_sharing",
|
||||
"enable_favicons",
|
||||
"enable_preview_images",
|
||||
"enable_automatic_html_snapshots",
|
||||
"display_url",
|
||||
"display_view_bookmark_action",
|
||||
"display_edit_bookmark_action",
|
||||
"display_archive_bookmark_action",
|
||||
"display_remove_bookmark_action",
|
||||
"permanent_notes",
|
||||
"default_mark_unread",
|
||||
"custom_css",
|
||||
"auto_tagging_rules",
|
||||
]
|
||||
|
||||
|
||||
|
||||
70
bookmarks/services/auto_tagging.py
Normal file
70
bookmarks/services/auto_tagging.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import re
|
||||
import idna
|
||||
|
||||
|
||||
def get_tags(script: str, url: str):
|
||||
parsed_url = urlparse(url.lower())
|
||||
result = set()
|
||||
|
||||
for line in script.lower().split("\n"):
|
||||
if "#" in line:
|
||||
i = line.index("#")
|
||||
line = line[:i]
|
||||
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
domain_pattern = re.sub("^https?://", "", parts[0])
|
||||
path_pattern = None
|
||||
qs_pattern = None
|
||||
|
||||
if "/" in domain_pattern:
|
||||
i = domain_pattern.index("/")
|
||||
path_pattern = domain_pattern[i:]
|
||||
domain_pattern = domain_pattern[:i]
|
||||
|
||||
if path_pattern and "?" in path_pattern:
|
||||
i = path_pattern.index("?")
|
||||
qs_pattern = path_pattern[i + 1 :]
|
||||
path_pattern = path_pattern[:i]
|
||||
|
||||
if not _domains_matches(domain_pattern, parsed_url.netloc):
|
||||
continue
|
||||
|
||||
if path_pattern and not _path_matches(path_pattern, parsed_url.path):
|
||||
continue
|
||||
|
||||
if qs_pattern and not _qs_matches(qs_pattern, parsed_url.query):
|
||||
continue
|
||||
|
||||
for tag in parts[1:]:
|
||||
result.add(tag)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _path_matches(expected_path: str, actual_path: str) -> bool:
|
||||
return actual_path.startswith(expected_path)
|
||||
|
||||
|
||||
def _domains_matches(expected_domain: str, actual_domain: str) -> bool:
|
||||
expected_domain = idna.encode(expected_domain)
|
||||
actual_domain = idna.encode(actual_domain)
|
||||
|
||||
return actual_domain.endswith(expected_domain)
|
||||
|
||||
|
||||
def _qs_matches(expected_qs: str, actual_qs: str) -> bool:
|
||||
expected_qs = parse_qs(expected_qs, keep_blank_values=True)
|
||||
actual_qs = parse_qs(actual_qs, keep_blank_values=True)
|
||||
|
||||
for key in expected_qs:
|
||||
if key not in actual_qs:
|
||||
return False
|
||||
for value in expected_qs[key]:
|
||||
if value != "" and value not in actual_qs[key]:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -1,12 +1,19 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, parse_tag_string
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
@@ -34,6 +41,11 @@ 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)
|
||||
# Load preview image
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
# Create HTML snapshot
|
||||
if current_user.profile.enable_automatic_html_snapshots:
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
|
||||
return bookmark
|
||||
|
||||
@@ -49,6 +61,8 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
bookmark.save()
|
||||
# Update favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
# Update preview image
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
|
||||
if has_url_changed:
|
||||
# Update web archive snapshot, if URL changed
|
||||
@@ -173,6 +187,46 @@ def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
)
|
||||
|
||||
|
||||
def _generate_upload_asset_filename(asset: BookmarkAsset, filename: str):
|
||||
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
|
||||
return f"{asset.asset_type}_{formatted_datetime}_{filename}"
|
||||
|
||||
|
||||
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile) -> BookmarkAsset:
|
||||
asset = BookmarkAsset(
|
||||
bookmark=bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_UPLOAD,
|
||||
content_type=upload_file.content_type,
|
||||
display_name=upload_file.name,
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
gzip=False,
|
||||
)
|
||||
asset.save()
|
||||
|
||||
try:
|
||||
filename = _generate_upload_asset_filename(asset, upload_file.name)
|
||||
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
|
||||
with open(filepath, "wb") as f:
|
||||
for chunk in upload_file.chunks():
|
||||
f.write(chunk)
|
||||
asset.status = BookmarkAsset.STATUS_COMPLETE
|
||||
asset.file = filename
|
||||
asset.file_size = upload_file.size
|
||||
logger.info(
|
||||
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
|
||||
exc_info=e,
|
||||
)
|
||||
asset.status = BookmarkAsset.STATUS_FAILURE
|
||||
|
||||
asset.save()
|
||||
|
||||
return asset
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
to_bookmark.title = from_bookmark.title
|
||||
to_bookmark.description = from_bookmark.description
|
||||
@@ -189,6 +243,15 @@ def _update_website_metadata(bookmark: Bookmark):
|
||||
|
||||
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
|
||||
if user.profile.auto_tagging_rules:
|
||||
auto_tag_names = auto_tagging.get_tags(
|
||||
user.profile.auto_tagging_rules, bookmark.url
|
||||
)
|
||||
for auto_tag_name in auto_tag_names:
|
||||
if auto_tag_name not in tag_names:
|
||||
tag_names.append(auto_tag_name)
|
||||
|
||||
tags = get_or_create_tags(tag_names, user)
|
||||
bookmark.tags.set(tags)
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ def import_netscape_html(
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
# Load favicons for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_favicons(user)
|
||||
# Load previews for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_previews(user)
|
||||
|
||||
end = timezone.now()
|
||||
logger.debug(f"Import duration: {end - import_start}")
|
||||
|
||||
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}")
|
||||
88
bookmarks/services/preview_image_loader.py
Normal file
88
bookmarks/services/preview_image_loader.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
import mimetypes
|
||||
import os.path
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from bookmarks.services import website_loader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_preview_folder():
|
||||
Path(settings.LD_PREVIEW_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _url_to_filename(preview_image: str) -> str:
|
||||
return hashlib.md5(preview_image.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_image_path(preview_image_file: str) -> Path:
|
||||
return Path(os.path.join(settings.LD_PREVIEW_FOLDER, preview_image_file))
|
||||
|
||||
|
||||
def load_preview_image(url: str) -> str | None:
|
||||
_ensure_preview_folder()
|
||||
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
if not metadata.preview_image:
|
||||
logger.debug(f"Could not find preview image in metadata: {url}")
|
||||
return None
|
||||
|
||||
image_url = metadata.preview_image
|
||||
|
||||
logger.debug(f"Loading preview image: {image_url}")
|
||||
with requests.get(image_url, stream=True) as response:
|
||||
if response.status_code < 200 or response.status_code >= 300:
|
||||
logger.debug(
|
||||
f"Bad response status code for preview image: {image_url} status_code={response.status_code}"
|
||||
)
|
||||
return None
|
||||
|
||||
if "Content-Length" not in response.headers:
|
||||
logger.debug(f"Empty Content-Length for preview image: {image_url}")
|
||||
return None
|
||||
|
||||
content_length = int(response.headers["Content-Length"])
|
||||
if content_length > settings.LD_PREVIEW_MAX_SIZE:
|
||||
logger.debug(
|
||||
f"Content-Length exceeds LD_PREVIEW_MAX_SIZE: {image_url} length={content_length}"
|
||||
)
|
||||
return None
|
||||
|
||||
if "Content-Type" not in response.headers:
|
||||
logger.debug(f"Empty Content-Type for preview image: {image_url}")
|
||||
return None
|
||||
|
||||
content_type = response.headers["Content-Type"].split(";", 1)[0]
|
||||
file_extension = mimetypes.guess_extension(content_type)
|
||||
|
||||
if file_extension not in settings.LD_PREVIEW_ALLOWED_EXTENSIONS:
|
||||
logger.debug(
|
||||
f"Unsupported Content-Type for preview image: {image_url} content_type={content_type}"
|
||||
)
|
||||
return None
|
||||
|
||||
preview_image_hash = _url_to_filename(url)
|
||||
preview_image_file = f"{preview_image_hash}{file_extension}"
|
||||
preview_image_path = _get_image_path(preview_image_file)
|
||||
|
||||
with open(preview_image_path, "wb") as file:
|
||||
downloaded = 0
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
downloaded += len(chunk)
|
||||
if downloaded > content_length:
|
||||
logger.debug(
|
||||
f"Content-Length mismatch for preview image: {image_url} length={content_length} downloaded={downloaded}"
|
||||
)
|
||||
file.close()
|
||||
preview_image_path.unlink()
|
||||
return None
|
||||
|
||||
file.write(chunk)
|
||||
|
||||
logger.debug(f"Saved preview image as: {preview_image_path}")
|
||||
|
||||
return preview_image_file
|
||||
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,56 @@
|
||||
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.db.models import Q
|
||||
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, preview_image_loader
|
||||
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 +100,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 +129,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 +147,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
|
||||
@@ -131,12 +167,18 @@ def is_favicon_feature_active(user: User) -> bool:
|
||||
return background_tasks_enabled and user.profile.enable_favicons
|
||||
|
||||
|
||||
def is_preview_feature_active(user: User) -> bool:
|
||||
return (
|
||||
user.profile.enable_preview_images and not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
)
|
||||
|
||||
|
||||
def load_favicon(user: User, bookmark: Bookmark):
|
||||
if is_favicon_feature_active(user):
|
||||
_load_favicon_task(bookmark.id)
|
||||
|
||||
|
||||
@background()
|
||||
@task()
|
||||
def _load_favicon_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
@@ -160,19 +202,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 +218,180 @@ 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 load_preview_image(user: User, bookmark: Bookmark):
|
||||
if is_preview_feature_active(user):
|
||||
_load_preview_image_task(bookmark.id)
|
||||
|
||||
|
||||
@task()
|
||||
def _load_preview_image_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
|
||||
logger.info(f"Load preview image for bookmark. url={bookmark.url}")
|
||||
|
||||
new_preview_image_file = preview_image_loader.load_preview_image(bookmark.url)
|
||||
|
||||
if new_preview_image_file != bookmark.preview_image_file:
|
||||
bookmark.preview_image_file = new_preview_image_file or ""
|
||||
bookmark.save(update_fields=["preview_image_file"])
|
||||
logger.info(
|
||||
f"Successfully updated preview image for bookmark. url={bookmark.url} preview_image_file={new_preview_image_file}"
|
||||
)
|
||||
|
||||
|
||||
def schedule_bookmarks_without_previews(user: User):
|
||||
if is_preview_feature_active(user):
|
||||
_schedule_bookmarks_without_previews_task(user.id)
|
||||
|
||||
|
||||
@task()
|
||||
def _schedule_bookmarks_without_previews_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(
|
||||
Q(preview_image_file__exact=""),
|
||||
owner=user,
|
||||
)
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
for bookmark in bookmarks:
|
||||
try:
|
||||
_load_preview_image_task(bookmark.id)
|
||||
except Exception as exc:
|
||||
logging.exception(exc)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -15,12 +16,14 @@ class WebsiteMetadata:
|
||||
url: str
|
||||
title: str
|
||||
description: str
|
||||
preview_image: str | None
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"preview_image": self.preview_image,
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +33,7 @@ class WebsiteMetadata:
|
||||
def load_website_metadata(url: str):
|
||||
title = None
|
||||
description = None
|
||||
preview_image = None
|
||||
try:
|
||||
start = timezone.now()
|
||||
page_text = load_page(url)
|
||||
@@ -55,10 +59,21 @@ def load_website_metadata(url: str):
|
||||
else None
|
||||
)
|
||||
|
||||
image_tag = soup.find("meta", attrs={"property": "og:image"})
|
||||
preview_image = image_tag["content"].strip() if image_tag else None
|
||||
if (
|
||||
preview_image
|
||||
and not preview_image.startswith("http://")
|
||||
and not preview_image.startswith("https://")
|
||||
):
|
||||
preview_image = urljoin(url, preview_image)
|
||||
|
||||
end = timezone.now()
|
||||
logger.debug(f"Parsing duration: {end - start}")
|
||||
finally:
|
||||
return WebsiteMetadata(url=url, title=title, description=description)
|
||||
return WebsiteMetadata(
|
||||
url=url, title=title, description=description, preview_image=preview_image
|
||||
)
|
||||
|
||||
|
||||
CHUNK_SIZE = 50 * 1024
|
||||
|
||||
2314
bookmarks/static/vendor/Readability.js
vendored
Normal file
2314
bookmarks/static/vendor/Readability.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
141
bookmarks/styles/bookmark-details.scss
Normal file
141
bookmarks/styles/bookmark-details.scss
Normal file
@@ -0,0 +1,141 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
margin: $unit-4 0;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
gap: $unit-2;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.assets .asset-text .truncate {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.assets .asset-text .filesize {
|
||||
color: $gray-color;
|
||||
}
|
||||
|
||||
.assets .asset-actions, .assets-actions {
|
||||
display: flex;
|
||||
gap: $unit-4;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,11 @@
|
||||
.form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: $warning-color;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $warning-color;
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-input-hint.auto-tags {
|
||||
display: none;
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
details.notes textarea {
|
||||
|
||||
@@ -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,38 +128,80 @@ ul.bookmark-list {
|
||||
/* Bookmarks */
|
||||
li[ld-bookmark-item] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: $unit-2;
|
||||
margin-top: $unit-2;
|
||||
|
||||
[ld-bulk-edit-checkbox].form-checkbox {
|
||||
.content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
img.preview-image {
|
||||
flex: 0 0 auto;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
margin-top: $unit-h;
|
||||
object-fit: cover;
|
||||
border-radius: $border-radius;
|
||||
border: solid 1px $border-color-dark;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (pointer:coarse) {
|
||||
.title a[data-tooltip]::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,20 +209,23 @@ li[ld-bookmark-item] {
|
||||
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 +246,8 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
|
||||
.actions {
|
||||
font-size: $font-size-sm;
|
||||
|
||||
a, button.btn-link {
|
||||
color: $gray-color;
|
||||
padding: 0;
|
||||
@@ -211,10 +264,6 @@ li[ld-bookmark-item] {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +272,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 +309,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 +329,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 +346,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 +369,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 +381,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;
|
||||
}
|
||||
}
|
||||
27
bookmarks/styles/reader-mode.scss
Normal file
27
bookmarks/styles/reader-mode.scss
Normal file
@@ -0,0 +1,27 @@
|
||||
html.reader-mode {
|
||||
--font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
|
||||
body {
|
||||
margin: 3rem 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.byline {
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.reading-time {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 @@
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "responsive";
|
||||
@import "bookmark-details";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "markdown";
|
||||
@import "reader-mode";
|
||||
|
||||
/* Dark theme overrides */
|
||||
|
||||
@@ -40,8 +43,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,9 @@
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "responsive";
|
||||
@import "bookmark-details";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "markdown";
|
||||
@import "reader-mode";
|
||||
|
||||
@@ -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,118 +6,147 @@
|
||||
{% 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" >
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<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="">
|
||||
<img class="favicon" 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">
|
||||
{{ bookmark_item.url }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="description truncate">
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span>
|
||||
{% 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>
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="notes-content">
|
||||
{% markdown bookmark_item.notes %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions text-gray text-sm">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }} ∞
|
||||
{% 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">
|
||||
{{ bookmark_item.url }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span class="separator">|</span>
|
||||
{% 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
|
||||
</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
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="separator 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?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="markdown">{% markdown bookmark_item.notes %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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 }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }} ∞
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a 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 #}
|
||||
{% 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 %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if bookmark_list.show_preview_images and bookmark_item.preview_image_file %}
|
||||
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -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 %}
|
||||
48
bookmarks/templates/bookmarks/details/assets.html
Normal file
48
bookmarks/templates/bookmarks/details/assets.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<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 {{ asset.text_classes }}">
|
||||
<span class="truncate">
|
||||
{{ 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>
|
||||
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="button"
|
||||
class="btn btn-link">Upload file
|
||||
</button>
|
||||
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
114
bookmarks/templates/bookmarks/details/form.html
Normal file
114
bookmarks/templates/bookmarks/details/form.html
Normal file
@@ -0,0 +1,114 @@
|
||||
{% 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.latest_snapshot %}
|
||||
<a class="weblink" href="{% url 'bookmarks:assets.read' details.latest_snapshot.id %}"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<svg class="favicon" xmlns="http://www.w3.org/2000/svg">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span>Reader mode</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% 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>Internet Archive</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||
<div class="preview-image">
|
||||
<img src="{% static details.bookmark.preview_image_file %}"/>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
@@ -23,10 +23,10 @@
|
||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.tag_string|add_class:"form-input"|attr:"ld-tag-autocomplete"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
|
||||
exist it will be
|
||||
automatically created.
|
||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#).
|
||||
If a tag does not exist it will be automatically created.
|
||||
</div>
|
||||
<div class="form-input-hint auto-tags"></div>
|
||||
{{ form.tag_string.errors }}
|
||||
</div>
|
||||
<div class="form-group has-icon-right">
|
||||
@@ -197,6 +197,18 @@
|
||||
} else {
|
||||
bookmarkExistsHint.style['display'] = 'none';
|
||||
}
|
||||
|
||||
// Preview auto tags
|
||||
const autoTags = data.auto_tags;
|
||||
const autoTagsHint = document.querySelector('.form-input-hint.auto-tags');
|
||||
|
||||
if (autoTags.length > 0) {
|
||||
autoTags.sort();
|
||||
autoTagsHint.style['display'] = 'block';
|
||||
autoTagsHint.innerHTML = `Auto tags: ${autoTags.join(" ")}`;
|
||||
} else {
|
||||
autoTagsHint.style['display'] = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
83
bookmarks/templates/bookmarks/read.html
Normal file
83
bookmarks/templates/bookmarks/read.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{% load sass_tags %}
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="reader-mode">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Reader view</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||
{% if request.user_profile.theme == 'light' %}
|
||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
{% elif request.user_profile.theme == 'dark' %}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
{% else %}
|
||||
{# Use auto theme as fallback #}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: dark)"/>
|
||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: light)"/>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
<template id="content">{{ content|safe }}</template>
|
||||
<script src="{% static 'vendor/Readability.js' %}" type="application/javascript"></script>
|
||||
<script type="application/javascript">
|
||||
function estimateReadingTime(charCount, wordsPerMinute) {
|
||||
const avgWordLength = 5;
|
||||
const totalWords = charCount / avgWordLength;
|
||||
return Math.ceil(totalWords / wordsPerMinute);
|
||||
}
|
||||
|
||||
function postProcess(articleContent) {
|
||||
articleContent.querySelectorAll('table').forEach(table => {
|
||||
table.classList.add('table');
|
||||
});
|
||||
}
|
||||
|
||||
function makeReadable() {
|
||||
const content = document.getElementById('content');
|
||||
const contentHtml = content.innerHTML;
|
||||
const dom = new DOMParser().parseFromString(contentHtml, 'text/html');
|
||||
const article = new Readability(dom).parse();
|
||||
|
||||
document.title = article.title;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.classList.add('container');
|
||||
|
||||
const articleTitle = document.createElement('h1');
|
||||
articleTitle.textContent = article.title;
|
||||
container.append(articleTitle);
|
||||
|
||||
const byline = [article.byline, article.siteName].filter(Boolean);
|
||||
if (byline.length > 0) {
|
||||
const articleByline = document.createElement('p');
|
||||
articleByline.textContent = byline.join(' | ');
|
||||
articleByline.classList.add('byline');
|
||||
container.append(articleByline);
|
||||
}
|
||||
|
||||
if(article.length) {
|
||||
const minTime = estimateReadingTime(article.length, 225);
|
||||
const maxTime = estimateReadingTime(article.length, 175);
|
||||
|
||||
const articleReadingTime = document.createElement('p');
|
||||
articleReadingTime.textContent = `${minTime}-${maxTime} minutes`;
|
||||
articleReadingTime.classList.add('reading-time');
|
||||
container.append(articleReadingTime);
|
||||
}
|
||||
|
||||
const divider = document.createElement('hr');
|
||||
container.append(divider);
|
||||
|
||||
const articleContent = document.createElement('div');
|
||||
articleContent.innerHTML = article.content;
|
||||
postProcess(articleContent);
|
||||
container.append(articleContent);
|
||||
|
||||
content.replaceWith(container);
|
||||
}
|
||||
makeReadable();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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" }}
|
||||
@@ -65,6 +110,29 @@
|
||||
result will also include bookmarks where a search term matches otherwise.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_grouping.id_for_label }}" class="form-label">Tag grouping</label>
|
||||
{{ form.tag_grouping|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
In alphabetical mode, tags will be grouped by the first letter.
|
||||
If disabled, tags will not be grouped.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<details {% if form.auto_tagging_rules.value %}open{% endif %}>
|
||||
<summary>Auto Tagging</summary>
|
||||
<label for="{{ form.auto_tagging_rules.id_for_label }}" class="text-assistive">Auto Tagging</label>
|
||||
<div class="mt-2">
|
||||
{{ form.auto_tagging_rules|add_class:"form-input custom-css"|attr:"rows:6" }}
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-input-hint">
|
||||
Automatically adds tags to bookmarks based on predefined rules.
|
||||
Each line is a single rule that maps a URL to one or more tags. For example:
|
||||
<pre>youtube.com video
|
||||
reddit.com/r/Music music reddit</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
|
||||
{{ form.enable_favicons }}
|
||||
@@ -72,6 +140,7 @@
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
|
||||
Enabling this feature automatically downloads all missing favicons.
|
||||
By default, this feature uses a <b>Google service</b> to download favicons.
|
||||
If you don't want to use this service, check the <a
|
||||
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_favicon_provider"
|
||||
@@ -81,13 +150,16 @@
|
||||
{% 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.enable_preview_images.id_for_label }}" class="form-checkbox">
|
||||
{{ form.enable_preview_images }}
|
||||
<i class="form-icon"></i> Enable Preview Images
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Automatically loads preview images for bookmarked websites and displays them next to each bookmark.
|
||||
Enabling this feature automatically downloads all missing preview images.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
||||
@@ -124,27 +196,44 @@
|
||||
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">
|
||||
<label for="{{ form.default_mark_unread.id_for_label }}" class="form-checkbox">
|
||||
{{ form.default_mark_unread }}
|
||||
<i class="form-icon"></i> Create bookmarks as unread by default
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Sets the default state for the "Mark as unread" option when creating a new bookmark.
|
||||
Setting this option will make all new bookmarks default to unread.
|
||||
This can be overridden when creating each new bookmark.
|
||||
</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" }}
|
||||
</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 +262,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 +270,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 +308,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 +325,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:
|
||||
@@ -39,6 +39,7 @@ class BookmarkFactoryMixin:
|
||||
website_description: str = "",
|
||||
web_archive_snapshot_url: str = "",
|
||||
favicon_file: str = "",
|
||||
preview_image_file: str = "",
|
||||
added: datetime = None,
|
||||
):
|
||||
if title is None:
|
||||
@@ -67,6 +68,7 @@ class BookmarkFactoryMixin:
|
||||
shared=shared,
|
||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||
favicon_file=favicon_file,
|
||||
preview_image_file=preview_image_file,
|
||||
)
|
||||
bookmark.save()
|
||||
for tag in tags:
|
||||
@@ -133,6 +135,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()
|
||||
|
||||
179
bookmarks/tests/test_auto_tagging.py
Normal file
179
bookmarks/tests/test_auto_tagging.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from bookmarks.services import auto_tagging
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
class AutoTaggingTestCase(TestCase):
|
||||
def test_auto_tag_by_domain(self):
|
||||
script = """
|
||||
example.com example
|
||||
test.com test
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["example"]))
|
||||
|
||||
def test_auto_tag_by_domain_ignores_case(self):
|
||||
script = """
|
||||
EXAMPLE.com example
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["example"]))
|
||||
|
||||
def test_auto_tag_by_domain_should_add_all_tags(self):
|
||||
script = """
|
||||
example.com one two three
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["one", "two", "three"]))
|
||||
|
||||
def test_auto_tag_by_domain_work_with_idn_domains(self):
|
||||
script = """
|
||||
रजिस्ट्री.भारत tag1
|
||||
"""
|
||||
url = "https://www.xn--81bg3cc2b2bk5hb.xn--h2brj9c/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["tag1"]))
|
||||
|
||||
script = """
|
||||
xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1
|
||||
"""
|
||||
url = "https://www.रजिस्ट्री.भारत/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["tag1"]))
|
||||
|
||||
def test_auto_tag_by_domain_and_path(self):
|
||||
script = """
|
||||
example.com/one one
|
||||
example.com/two two
|
||||
test.com test
|
||||
"""
|
||||
url = "https://example.com/one/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["one"]))
|
||||
|
||||
def test_auto_tag_by_domain_and_path_ignores_case(self):
|
||||
script = """
|
||||
example.com/One one
|
||||
"""
|
||||
url = "https://example.com/one/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["one"]))
|
||||
|
||||
def test_auto_tag_by_domain_and_path_matches_path_ltr(self):
|
||||
script = """
|
||||
example.com/one one
|
||||
example.com/two two
|
||||
test.com test
|
||||
"""
|
||||
url = "https://example.com/one/two"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["one"]))
|
||||
|
||||
def test_auto_tag_by_domain_ignores_domain_in_path(self):
|
||||
script = """
|
||||
example.com example
|
||||
"""
|
||||
url = "https://test.com/example.com"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set([]))
|
||||
|
||||
def test_auto_tag_by_domain_includes_subdomains(self):
|
||||
script = """
|
||||
example.com example
|
||||
test.example.com test
|
||||
some.example.com some
|
||||
"""
|
||||
url = "https://test.example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["example", "test"]))
|
||||
|
||||
def test_auto_tag_by_domain_matches_domain_rtl(self):
|
||||
script = """
|
||||
example.com example
|
||||
"""
|
||||
url = "https://example.com.bad-website.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set([]))
|
||||
|
||||
def test_auto_tag_by_domain_ignores_schema(self):
|
||||
script = """
|
||||
https://example.com/ https
|
||||
http://example.com/ http
|
||||
"""
|
||||
url = "http://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["https", "http"]))
|
||||
|
||||
def test_auto_tag_by_domain_ignores_lines_with_no_tags(self):
|
||||
script = """
|
||||
example.com
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set([]))
|
||||
|
||||
def test_auto_tag_by_domain_path_and_qs(self):
|
||||
script = """
|
||||
example.com/page?a=b tag1 # true, matches a=b
|
||||
example.com/page?a=c&c=d tag2 # true, matches both a=c and c=d
|
||||
example.com/page?c=d&l=p tag3 # false, l=p doesn't exists
|
||||
example.com/page?a=bb tag4 # false bb != b
|
||||
example.com/page?a=b&a=c tag5 # true, matches both a=b and a=c
|
||||
example.com/page?a=B tag6 # true, matches a=b because case insensitive
|
||||
example.com/page?A=b tag7 # true, matches a=b because case insensitive
|
||||
"""
|
||||
url = "https://example.com/page/some?z=x&a=b&v=b&c=d&o=p&a=c"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["tag1", "tag2", "tag5", "tag6", "tag7"]))
|
||||
|
||||
def test_auto_tag_by_domain_path_and_qs_with_empty_value(self):
|
||||
script = """
|
||||
example.com/page?a= tag1
|
||||
example.com/page?b= tag2
|
||||
"""
|
||||
url = "https://example.com/page/some?a=value"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["tag1"]))
|
||||
|
||||
def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self):
|
||||
script = """
|
||||
example.com/page?a=йцу tag1
|
||||
example.com/page?a=%D0%B9%D1%86%D1%83 tag2
|
||||
"""
|
||||
url = "https://example.com/page?a=%D0%B9%D1%86%D1%83"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["tag1", "tag2"]))
|
||||
@@ -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(
|
||||
|
||||
137
bookmarks/tests/test_bookmark_asset_view.py
Normal file
137
bookmarks/tests/test_bookmark_asset_view.py
Normal file
@@ -0,0 +1,137 @@
|
||||
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 view_access_test(self, view_name: str):
|
||||
# own bookmark
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset_with_file(bookmark)
|
||||
|
||||
response = self.client.get(reverse(view_name, 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(view_name, 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(view_name, 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(view_name, 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(view_name, args=[asset.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def view_access_guest_user_test(self, view_name: str):
|
||||
self.client.logout()
|
||||
|
||||
# unshared, sharing disabled
|
||||
bookmark = self.setup_bookmark()
|
||||
asset = self.setup_asset_with_file(bookmark)
|
||||
|
||||
response = self.client.get(reverse(view_name, 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(view_name, 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(view_name, 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(view_name, 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(view_name, 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(view_name, args=[asset.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_view_access(self):
|
||||
self.view_access_test("bookmarks:assets.view")
|
||||
|
||||
def test_view_access_guest_user(self):
|
||||
self.view_access_guest_user_test("bookmarks:assets.view")
|
||||
|
||||
def test_reader_view_access(self):
|
||||
self.view_access_test("bookmarks:assets.read")
|
||||
|
||||
def test_reader_view_access_guest_user(self):
|
||||
self.view_access_guest_user_test("bookmarks:assets.read")
|
||||
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)
|
||||
926
bookmarks/tests/test_bookmark_details_modal.py
Normal file
926
bookmarks/tests/test_bookmark_details_modal.py
Normal file
@@ -0,0 +1,926 @@
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
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 bookmarks, 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 count_weblinks(self, soup):
|
||||
return len(soup.find_all("a", {"class": "weblink"}))
|
||||
|
||||
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_reader_mode_link(self):
|
||||
# no latest snapshot
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 1)
|
||||
|
||||
# snapshot is not complete
|
||||
self.setup_asset(
|
||||
bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_PENDING,
|
||||
)
|
||||
self.setup_asset(
|
||||
bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_FAILURE,
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 1)
|
||||
|
||||
# not a snapshot
|
||||
self.setup_asset(
|
||||
bookmark,
|
||||
asset_type="upload",
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 1)
|
||||
|
||||
# snapshot is complete
|
||||
asset = self.setup_asset(
|
||||
bookmark,
|
||||
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 2)
|
||||
|
||||
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
|
||||
link = self.find_weblink(soup, reader_mode_url)
|
||||
self.assertIsNotNone(link)
|
||||
|
||||
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(), "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_preview_image(self):
|
||||
# without image
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# with image
|
||||
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# preview images enabled, no image
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_preview_images = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# preview images enabled, image present
|
||||
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNotNone(image)
|
||||
self.assertEqual(image["src"], "/static/example.png")
|
||||
|
||||
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"))
|
||||
|
||||
def test_upload_file(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
file_content = b"file content"
|
||||
upload_file = SimpleUploadedFile("test.txt", file_content)
|
||||
|
||||
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark),
|
||||
{"upload_asset": "", "upload_asset_file": upload_file},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
mock_upload_asset.assert_called_once()
|
||||
|
||||
args, kwargs = mock_upload_asset.call_args
|
||||
self.assertEqual(args[0], bookmark)
|
||||
|
||||
upload_file = args[1]
|
||||
self.assertEqual(upload_file.name, "test.txt")
|
||||
|
||||
def test_upload_file_without_file(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
|
||||
response = self.client.post(
|
||||
self.get_base_url(bookmark),
|
||||
{"upload_asset": ""},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
mock_upload_asset.assert_not_called()
|
||||
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(
|
||||
|
||||
@@ -210,3 +210,25 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
|
||||
self.assertContains(response, '<details class="notes">', count=1)
|
||||
|
||||
def test_should_not_check_unread_by_default(self):
|
||||
response = self.client.get(reverse("bookmarks:new"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="checkbox" name="unread" id="id_unread">',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_should_check_unread_when_configured_in_profile(self):
|
||||
self.user.profile.default_mark_unread = True
|
||||
self.user.profile.save()
|
||||
|
||||
response = self.client.get(reverse("bookmarks:new"))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="checkbox" name="unread" value="true" '
|
||||
'id="id_unread" checked="">',
|
||||
html,
|
||||
)
|
||||
|
||||
@@ -440,6 +440,20 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertFalse(bookmark.shared)
|
||||
|
||||
def test_create_bookmark_should_add_tags_from_auto_tagging(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.authenticate()
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = f"example.com {tag2.name}"
|
||||
profile.save()
|
||||
|
||||
data = {"url": "https://example.com/", "tag_names": [tag1.name]}
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_get_bookmark(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -512,6 +526,22 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertEqual(updated_bookmark.shared, True)
|
||||
|
||||
def test_update_bookmark_adds_tags_from_auto_tagging(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.authenticate()
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = f"example.com {tag2.name}"
|
||||
profile.save()
|
||||
|
||||
data = {"url": "https://example.com/", "tag_names": [tag1.name]}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_patch_bookmark(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -583,6 +613,22 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(updated_bookmark.description, bookmark.description)
|
||||
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
|
||||
|
||||
def test_patch_bookmark_adds_tags_from_auto_tagging(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.authenticate()
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = f"example.com {tag2.name}"
|
||||
profile.save()
|
||||
|
||||
data = {"tag_names": [tag1.name]}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_delete_bookmark(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -628,7 +674,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
website_loader, "load_website_metadata"
|
||||
) as mock_load_website_metadata:
|
||||
expected_metadata = WebsiteMetadata(
|
||||
"https://example.com", "Scraped metadata", "Scraped description"
|
||||
"https://example.com",
|
||||
"Scraped metadata",
|
||||
"Scraped description",
|
||||
"https://example.com/preview.png",
|
||||
)
|
||||
mock_load_website_metadata.return_value = expected_metadata
|
||||
|
||||
@@ -640,9 +689,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
metadata = response.data["metadata"]
|
||||
|
||||
self.assertIsNotNone(metadata)
|
||||
self.assertIsNotNone(expected_metadata.url, metadata["url"])
|
||||
self.assertIsNotNone(expected_metadata.title, metadata["title"])
|
||||
self.assertIsNotNone(expected_metadata.description, metadata["description"])
|
||||
self.assertEqual(expected_metadata.url, metadata["url"])
|
||||
self.assertEqual(expected_metadata.title, metadata["title"])
|
||||
self.assertEqual(expected_metadata.description, metadata["description"])
|
||||
self.assertEqual(expected_metadata.preview_image, metadata["preview_image"])
|
||||
|
||||
def test_check_returns_bookmark_if_url_is_bookmarked(self):
|
||||
self.authenticate()
|
||||
@@ -687,9 +737,38 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
mock_load_website_metadata.assert_not_called()
|
||||
self.assertIsNotNone(metadata)
|
||||
self.assertIsNotNone(bookmark.url, metadata["url"])
|
||||
self.assertIsNotNone(bookmark.website_title, metadata["title"])
|
||||
self.assertIsNotNone(bookmark.website_description, metadata["description"])
|
||||
self.assertEqual(bookmark.url, metadata["url"])
|
||||
self.assertEqual(bookmark.website_title, metadata["title"])
|
||||
self.assertEqual(bookmark.website_description, metadata["description"])
|
||||
self.assertIsNone(metadata["preview_image"])
|
||||
|
||||
def test_check_returns_no_auto_tags_if_none_configured(self):
|
||||
self.authenticate()
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
auto_tags = response.data["auto_tags"]
|
||||
|
||||
self.assertCountEqual(auto_tags, [])
|
||||
|
||||
def test_check_returns_matching_auto_tags(self):
|
||||
self.authenticate()
|
||||
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = "example.com tag1 tag2"
|
||||
profile.save()
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
auto_tags = response.data["auto_tags"]
|
||||
|
||||
self.assertCountEqual(auto_tags, ["tag1", "tag2"])
|
||||
|
||||
def test_can_only_access_own_bookmarks(self):
|
||||
self.authenticate()
|
||||
|
||||
@@ -10,26 +10,26 @@ 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"
|
||||
):
|
||||
favicon_img = (
|
||||
f'<img src="/static/{bookmark.favicon_file}" alt="">'
|
||||
f'<img class="favicon" src="/static/{bookmark.favicon_file}" alt="">'
|
||||
if bookmark.favicon_file
|
||||
else ""
|
||||
)
|
||||
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
|
||||
@@ -112,19 +148,41 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
|
||||
def assertFaviconVisible(self, html: str, bookmark: Bookmark):
|
||||
self.assertFaviconCount(html, bookmark, 1)
|
||||
self.assertFavicon(html, bookmark, True)
|
||||
|
||||
def assertFaviconHidden(self, html: str, bookmark: Bookmark):
|
||||
self.assertFaviconCount(html, bookmark, 0)
|
||||
self.assertFavicon(html, bookmark, False)
|
||||
|
||||
def assertFaviconCount(self, html: str, bookmark: Bookmark, count=1):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<img src="/static/{bookmark.favicon_file}" alt="">
|
||||
""",
|
||||
html,
|
||||
count=count,
|
||||
)
|
||||
def assertFavicon(self, html: str, bookmark: Bookmark, visible=True):
|
||||
soup = self.make_soup(html)
|
||||
|
||||
favicon = soup.select_one(".favicon")
|
||||
|
||||
if not visible:
|
||||
self.assertIsNone(favicon)
|
||||
return
|
||||
|
||||
url = f"/static/{bookmark.favicon_file}"
|
||||
self.assertIsNotNone(favicon)
|
||||
self.assertEqual(favicon["src"], url)
|
||||
|
||||
def assertPreviewImageVisible(self, html: str, bookmark: Bookmark):
|
||||
self.assertPreviewImage(html, bookmark, True)
|
||||
|
||||
def assertPreviewImageHidden(self, html: str, bookmark: Bookmark):
|
||||
self.assertPreviewImage(html, bookmark, False)
|
||||
|
||||
def assertPreviewImage(self, html: str, bookmark: Bookmark, visible=True):
|
||||
soup = self.make_soup(html)
|
||||
preview_image = soup.select_one(".preview-image")
|
||||
|
||||
if not visible:
|
||||
self.assertIsNone(preview_image)
|
||||
return
|
||||
|
||||
url = f"/static/{bookmark.preview_image_file}"
|
||||
self.assertIsNotNone(preview_image)
|
||||
self.assertEqual(preview_image["src"], url)
|
||||
|
||||
def assertBookmarkURLCount(
|
||||
self, html: str, bookmark: Bookmark, link_target: str = "_blank", count=0
|
||||
@@ -133,7 +191,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 +212,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 +240,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 +256,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 +299,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, without leading/trailing whitespace
|
||||
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, without leading/trailing whitespace
|
||||
description = soup.select_one(".description.separate")
|
||||
self.assertIsNotNone(description)
|
||||
self.assertEqual(description.text, 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 +575,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 +637,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)
|
||||
|
||||
@@ -388,6 +662,36 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
html,
|
||||
)
|
||||
|
||||
def test_preview_image_should_be_visible_when_preview_images_enabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_preview_images = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(preview_image_file="preview.png")
|
||||
html = self.render_template()
|
||||
|
||||
self.assertPreviewImageVisible(html, bookmark)
|
||||
|
||||
def test_preview_image_should_be_hidden_when_preview_images_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_preview_images = False
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(preview_image_file="preview.png")
|
||||
html = self.render_template()
|
||||
|
||||
self.assertPreviewImageHidden(html, bookmark)
|
||||
|
||||
def test_preview_image_should_be_hidden_when_there_is_no_preview_image(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_preview_images = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
html = self.render_template()
|
||||
|
||||
self.assertPreviewImageHidden(html, bookmark)
|
||||
|
||||
def test_favicon_should_be_visible_when_favicons_enabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_favicons = True
|
||||
@@ -532,16 +836,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 +861,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 +873,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 +929,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)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, Tag
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services.bookmarks import (
|
||||
@@ -21,6 +24,7 @@ from bookmarks.services.bookmarks import (
|
||||
mark_bookmarks_as_unread,
|
||||
share_bookmarks,
|
||||
unshare_bookmarks,
|
||||
upload_asset,
|
||||
)
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
@@ -38,7 +42,10 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
website_loader, "load_website_metadata"
|
||||
) as mock_load_website_metadata:
|
||||
expected_metadata = WebsiteMetadata(
|
||||
"https://example.com", "Website title", "Website description"
|
||||
"https://example.com",
|
||||
"Website title",
|
||||
"Website description",
|
||||
"https://example.com/preview.png",
|
||||
)
|
||||
mock_load_website_metadata.return_value = expected_metadata
|
||||
|
||||
@@ -105,6 +112,36 @@ 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_create_should_add_tags_from_auto_tagging(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = f"example.com {tag2.name}"
|
||||
profile.save()
|
||||
|
||||
bookmark_data = Bookmark(url="https://example.com")
|
||||
bookmark = create_bookmark(bookmark_data, tag1.name, self.user)
|
||||
|
||||
self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
|
||||
with patch.object(
|
||||
tasks, "create_web_archive_snapshot"
|
||||
@@ -135,6 +172,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"https://example.com/updated",
|
||||
"Updated website title",
|
||||
"Updated website description",
|
||||
"https://example.com/preview.png",
|
||||
)
|
||||
mock_load_website_metadata.return_value = expected_metadata
|
||||
|
||||
@@ -167,6 +205,26 @@ 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_update_should_add_tags_from_auto_tagging(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = f"example.com {tag2.name}"
|
||||
profile.save()
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
|
||||
update_bookmark(bookmark, tag1.name, self.user)
|
||||
|
||||
self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_archive_bookmark(self):
|
||||
bookmark = Bookmark(
|
||||
url="https://example.com",
|
||||
@@ -809,3 +867,50 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_upload_asset_should_save_file(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
with tempfile.TemporaryDirectory() as temp_assets:
|
||||
with override_settings(LD_ASSET_FOLDER=temp_assets):
|
||||
file_content = b"file content"
|
||||
upload_file = SimpleUploadedFile(
|
||||
"test_file.txt", file_content, content_type="text/plain"
|
||||
)
|
||||
upload_asset(bookmark, upload_file)
|
||||
|
||||
assets = bookmark.bookmarkasset_set.all()
|
||||
self.assertEqual(1, len(assets))
|
||||
|
||||
asset = assets[0]
|
||||
self.assertEqual("test_file.txt", asset.display_name)
|
||||
self.assertEqual("text/plain", asset.content_type)
|
||||
self.assertEqual(upload_file.size, asset.file_size)
|
||||
self.assertEqual(BookmarkAsset.STATUS_COMPLETE, asset.status)
|
||||
self.assertTrue(asset.file.startswith("upload_"))
|
||||
self.assertTrue(asset.file.endswith(upload_file.name))
|
||||
|
||||
# check file exists
|
||||
filepath = os.path.join(temp_assets, asset.file)
|
||||
self.assertTrue(os.path.exists(filepath))
|
||||
with open(filepath, "rb") as f:
|
||||
self.assertEqual(file_content, f.read())
|
||||
|
||||
def test_upload_asset_should_be_failed_if_saving_file_fails(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
# Use an invalid path to force an error
|
||||
with override_settings(LD_ASSET_FOLDER="/non/existing/folder"):
|
||||
file_content = b"file content"
|
||||
upload_file = SimpleUploadedFile(
|
||||
"test_file.txt", file_content, content_type="text/plain"
|
||||
)
|
||||
upload_asset(bookmark, upload_file)
|
||||
|
||||
assets = bookmark.bookmarkasset_set.all()
|
||||
self.assertEqual(1, len(assets))
|
||||
|
||||
asset = assets[0]
|
||||
self.assertEqual("test_file.txt", asset.display_name)
|
||||
self.assertEqual("text/plain", asset.content_type)
|
||||
self.assertIsNone(asset.file_size)
|
||||
self.assertEqual(BookmarkAsset.STATUS_FAILURE, asset.status)
|
||||
self.assertEqual("", asset.file)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user