Skip to content

Commit

Permalink
Add reader mode (#703)
Browse files Browse the repository at this point in the history
* Add reader mode view

* Show link for latest snapshot instead
  • Loading branch information
sissbruecker committed Apr 20, 2024
1 parent 0586983 commit 0cbaf92
Show file tree
Hide file tree
Showing 11 changed files with 2,551 additions and 16 deletions.
2,314 changes: 2,314 additions & 0 deletions bookmarks/static/vendor/Readability.js

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions bookmarks/styles/reader-mode.scss
@@ -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;
}
}

1 change: 1 addition & 0 deletions bookmarks/styles/theme-dark.scss
Expand Up @@ -12,6 +12,7 @@
@import "bookmark-form";
@import "settings";
@import "markdown";
@import "reader-mode";

/* Dark theme overrides */

Expand Down
1 change: 1 addition & 0 deletions bookmarks/styles/theme-light.scss
Expand Up @@ -12,3 +12,4 @@
@import "bookmark-form";
@import "settings";
@import "markdown";
@import "reader-mode";
13 changes: 12 additions & 1 deletion bookmarks/templates/bookmarks/details/form.html
Expand Up @@ -12,6 +12,17 @@
{% 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 }}">
Expand All @@ -22,7 +33,7 @@
fill="currentColor" fill-rule="evenodd"/>
</svg>
{% endif %}
<span>View on Internet Archive</span>
<span>Internet Archive</span>
</a>
{% endif %}
</div>
Expand Down
83 changes: 83 additions & 0 deletions bookmarks/templates/bookmarks/read.html
@@ -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>
38 changes: 25 additions & 13 deletions bookmarks/tests/test_bookmark_asset_view.py
Expand Up @@ -34,27 +34,27 @@ def setup_asset_with_file(self, bookmark):
asset = self.setup_asset(bookmark=bookmark, file=filename)
return asset

def test_view_access(self):
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("bookmarks:assets.view", args=[asset.id]))
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("bookmarks:assets.view", args=[asset.id]))
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("bookmarks:assets.view", args=[asset.id]))
response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404)

# unshared, sharing enabled
Expand All @@ -64,31 +64,31 @@ def test_view_access(self):
bookmark = self.setup_bookmark(user=other_user, shared=False)
asset = self.setup_asset_with_file(bookmark)

response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
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("bookmarks:assets.view", args=[asset.id]))
response = self.client.get(reverse(view_name, args=[asset.id]))
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()

# unshared, sharing disabled
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark)

response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
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("bookmarks:assets.view", args=[asset.id]))
response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404)

# unshared, sharing enabled
Expand All @@ -98,14 +98,14 @@ def test_view_access_guest_user(self):
bookmark = self.setup_bookmark(shared=False)
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)

# shared, sharing enabled
bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark)

response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
response = self.client.get(reverse(view_name, args=[asset.id]))
self.assertEqual(response.status_code, 404)

# unshared, public sharing enabled
Expand All @@ -114,12 +114,24 @@ def test_view_access_guest_user(self):
bookmark = self.setup_bookmark(shared=False)
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)

# shared, public sharing enabled
bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark)

response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
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")
47 changes: 46 additions & 1 deletion bookmarks/tests/test_bookmark_details_modal.py
Expand Up @@ -46,6 +46,9 @@ def get_section(self, soup, section_name):
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})

Expand Down Expand Up @@ -172,6 +175,48 @@ def test_website_link(self):
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()
Expand All @@ -185,7 +230,7 @@ def test_internet_archive_link(self):
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
self.assertIsNotNone(link)
self.assertEqual(link["href"], bookmark.web_archive_snapshot_url)
self.assertEqual(link.text.strip(), "View on Internet Archive")
self.assertEqual(link.text.strip(), "Internet Archive")

# favicons disabled
bookmark = self.setup_bookmark(
Expand Down
5 changes: 5 additions & 0 deletions bookmarks/urls.py
Expand Up @@ -55,6 +55,11 @@
views.assets.view,
name="assets.view",
),
path(
"assets/<int:asset_id>/read",
views.assets.read,
name="assets.read",
),
# Partials
path(
"bookmarks/partials/bookmark-list/active",
Expand Down
28 changes: 27 additions & 1 deletion bookmarks/views/assets.py
Expand Up @@ -6,11 +6,12 @@
HttpResponse,
Http404,
)
from django.shortcuts import render

from bookmarks.models import BookmarkAsset


def view(request, asset_id: int):
def _access_asset(request, asset_id: int):
try:
asset = BookmarkAsset.objects.get(pk=asset_id)
except BookmarkAsset.DoesNotExist:
Expand All @@ -28,6 +29,10 @@ def view(request, asset_id: int):
if not is_owner and not is_shared and not is_public_shared:
raise Http404("Bookmark does not exist")

return asset


def _get_asset_content(asset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)

if not os.path.exists(filepath):
Expand All @@ -40,4 +45,25 @@ def view(request, asset_id: int):
with open(filepath, "rb") as f:
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)


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,
},
)

0 comments on commit 0cbaf92

Please sign in to comment.