Add reader mode (#703)

* Add reader mode view

* Show link for latest snapshot instead
This commit is contained in:
Sascha Ißbrücker 2024-04-20 09:18:57 +02:00 committed by GitHub
parent 0586983602
commit 0cbaf927e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2551 additions and 16 deletions

2314
bookmarks/static/vendor/Readability.js vendored Normal file

File diff suppressed because it is too large Load diff

View 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;
}
}

View file

@ -12,6 +12,7 @@
@import "bookmark-form"; @import "bookmark-form";
@import "settings"; @import "settings";
@import "markdown"; @import "markdown";
@import "reader-mode";
/* Dark theme overrides */ /* Dark theme overrides */

View file

@ -12,3 +12,4 @@
@import "bookmark-form"; @import "bookmark-form";
@import "settings"; @import "settings";
@import "markdown"; @import "markdown";
@import "reader-mode";

View file

@ -12,6 +12,17 @@
{% endif %} {% endif %}
<span>{{ details.bookmark.url }}</span> <span>{{ details.bookmark.url }}</span>
</a> </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 %} {% if details.bookmark.web_archive_snapshot_url %}
<a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}" <a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}"
target="{{ details.profile.bookmark_link_target }}"> target="{{ details.profile.bookmark_link_target }}">
@ -22,7 +33,7 @@
fill="currentColor" fill-rule="evenodd"/> fill="currentColor" fill-rule="evenodd"/>
</svg> </svg>
{% endif %} {% endif %}
<span>View on Internet Archive</span> <span>Internet Archive</span>
</a> </a>
{% endif %} {% endif %}
</div> </div>

View 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>

View file

@ -34,12 +34,12 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
asset = self.setup_asset(bookmark=bookmark, file=filename) asset = self.setup_asset(bookmark=bookmark, file=filename)
return asset return asset
def test_view_access(self): def view_access_test(self, view_name: str):
# own bookmark # own bookmark
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# other user's bookmark # other user's bookmark
@ -47,14 +47,14 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(user=other_user) bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing disabled # shared, sharing disabled
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# unshared, sharing enabled # unshared, sharing enabled
@ -64,31 +64,31 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(user=other_user, shared=False) bookmark = self.setup_bookmark(user=other_user, shared=False)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing enabled # shared, sharing enabled
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_view_access_guest_user(self): def view_access_guest_user_test(self, view_name: str):
self.client.logout() self.client.logout()
# unshared, sharing disabled # unshared, sharing disabled
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing disabled # shared, sharing disabled
bookmark = self.setup_bookmark(shared=True) bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# unshared, sharing enabled # unshared, sharing enabled
@ -98,14 +98,14 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(shared=False) bookmark = self.setup_bookmark(shared=False)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, sharing enabled # shared, sharing enabled
bookmark = self.setup_bookmark(shared=True) bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# unshared, public sharing enabled # unshared, public sharing enabled
@ -114,12 +114,24 @@ class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark(shared=False) bookmark = self.setup_bookmark(shared=False)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
# shared, public sharing enabled # shared, public sharing enabled
bookmark = self.setup_bookmark(shared=True) bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark) asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id])) response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 200) 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")

View file

@ -46,6 +46,9 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def find_weblink(self, soup, url): def find_weblink(self, soup, url):
return soup.find("a", {"class": "weblink", "href": 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): def find_asset(self, soup, asset):
return soup.find("div", {"data-asset-id": asset.id}) return soup.find("div", {"data-asset-id": asset.id})
@ -172,6 +175,48 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNotNone(image) self.assertIsNotNone(image)
self.assertEqual(image["src"], "/static/example.png") 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): def test_internet_archive_link(self):
# without snapshot url # without snapshot url
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@ -185,7 +230,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url) link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
self.assertIsNotNone(link) self.assertIsNotNone(link)
self.assertEqual(link["href"], bookmark.web_archive_snapshot_url) self.assertEqual(link["href"], bookmark.web_archive_snapshot_url)
self.assertEqual(link.text.strip(), "View on Internet Archive") self.assertEqual(link.text.strip(), "Internet Archive")
# favicons disabled # favicons disabled
bookmark = self.setup_bookmark( bookmark = self.setup_bookmark(

View file

@ -55,6 +55,11 @@ urlpatterns = [
views.assets.view, views.assets.view,
name="assets.view", name="assets.view",
), ),
path(
"assets/<int:asset_id>/read",
views.assets.read,
name="assets.read",
),
# Partials # Partials
path( path(
"bookmarks/partials/bookmark-list/active", "bookmarks/partials/bookmark-list/active",

View file

@ -6,11 +6,12 @@ from django.http import (
HttpResponse, HttpResponse,
Http404, Http404,
) )
from django.shortcuts import render
from bookmarks.models import BookmarkAsset from bookmarks.models import BookmarkAsset
def view(request, asset_id: int): def _access_asset(request, asset_id: int):
try: try:
asset = BookmarkAsset.objects.get(pk=asset_id) asset = BookmarkAsset.objects.get(pk=asset_id)
except BookmarkAsset.DoesNotExist: except BookmarkAsset.DoesNotExist:
@ -28,6 +29,10 @@ def view(request, asset_id: int):
if not is_owner and not is_shared and not is_public_shared: if not is_owner and not is_shared and not is_public_shared:
raise Http404("Bookmark does not exist") raise Http404("Bookmark does not exist")
return asset
def _get_asset_content(asset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file) filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
if not os.path.exists(filepath): if not os.path.exists(filepath):
@ -40,4 +45,25 @@ def view(request, asset_id: int):
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
content = f.read() content = f.read()
return content
def view(request, asset_id: int):
asset = _access_asset(request, asset_id)
content = _get_asset_content(asset)
return HttpResponse(content, content_type=asset.content_type) return HttpResponse(content, content_type=asset.content_type)
def read(request, asset_id: int):
asset = _access_asset(request, asset_id)
content = _get_asset_content(asset)
content = content.decode("utf-8")
return render(
request,
"bookmarks/read.html",
{
"content": content,
},
)

View file

@ -346,6 +346,7 @@ class BookmarkAssetItem:
self.id = asset.id self.id = asset.id
self.display_name = asset.display_name self.display_name = asset.display_name
self.asset_type = asset.asset_type
self.content_type = asset.content_type self.content_type = asset.content_type
self.file = asset.file self.file = asset.file
self.file_size = asset.file_size self.file_size = asset.file_size
@ -393,3 +394,12 @@ class BookmarkDetailsContext:
self.has_pending_assets = any( self.has_pending_assets = any(
asset.status == BookmarkAsset.STATUS_PENDING for asset in self.assets asset.status == BookmarkAsset.STATUS_PENDING for asset in self.assets
) )
self.latest_snapshot = next(
(
asset
for asset in self.assets
if asset.asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
and asset.status == BookmarkAsset.STATUS_COMPLETE
),
None,
)