Compare commits

...

9 Commits

Author SHA1 Message Date
Sascha Ißbrücker
23d97db016 Bump version 2024-04-20 14:11:14 +02:00
dependabot[bot]
0fb1bbd0e2 Bump sqlparse from 0.4.4 to 0.5.0 (#704)
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.4 to 0.5.0.
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.4...0.5.0)

---
updated-dependencies:
- dependency-name: sqlparse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-20 12:17:01 +02:00
Sascha Ißbrücker
5d2acca122 Allow uploading custom files for bookmarks (#713) 2024-04-20 12:14:11 +02:00
Sascha Ißbrücker
0cbaf927e4 Add reader mode (#703)
* Add reader mode view

* Show link for latest snapshot instead
2024-04-20 09:18:57 +02:00
ab623
0586983602 Show proper name for bookmark assets in admin (#708) 2024-04-17 23:18:23 +02:00
ab623
9dc3521d5e Add option for marking bookmarks as unread by default (#706)
* Added new option to set Mark as unread with a default

* Added additional test

* tweak test a bit

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-04-17 23:08:18 +02:00
Sascha Ißbrücker
a1822e2091 Close bookmark details with escape (#702) 2024-04-15 19:41:18 +02:00
Sascha Ißbrücker
22ffecbb9d Make blocking cookie banners more reliable (#699) 2024-04-15 19:33:25 +02:00
Sascha Ißbrücker
d9096eacd6 Update CHANGELOG.md 2024-04-14 21:10:27 +02:00
34 changed files with 2862 additions and 42 deletions

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## 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) ## v1.28.0 (09/04/2024)
### What's Changed ### What's Changed

View File

@@ -200,9 +200,13 @@ class AdminBookmark(admin.ModelAdmin):
class AdminBookmarkAsset(admin.ModelAdmin): class AdminBookmarkAsset(admin.ModelAdmin):
list_display = ("display_name", "date_created", "status") @admin.display(description="Display Name")
def custom_display_name(self, obj):
return str(obj)
list_display = ("custom_display_name", "date_created", "status")
search_fields = ( search_fields = (
"display_name", "custom_display_name",
"file", "file",
) )
list_filter = ("status",) list_filter = ("status",)

View File

@@ -34,6 +34,11 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
overlay.click(position={"x": 0, "y": 0}) overlay.click(position={"x": 0, "y": 0})
expect(details_modal).to_be_hidden() 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): def test_toggle_archived(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()

View File

@@ -40,5 +40,25 @@ class AutoSubmitBehavior extends Behavior {
} }
} }
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-form", FormBehavior);
registerBehavior("ld-auto-submit", AutoSubmitBehavior); registerBehavior("ld-auto-submit", AutoSubmitBehavior);
registerBehavior("ld-upload-button", UploadButton);

View File

@@ -4,13 +4,41 @@ class ModalBehavior extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
const modalOverlay = element.querySelector(".modal-overlay"); const modalOverlay = element.querySelector(".modal-overlay");
const closeButton = element.querySelector("button.close"); const closeButton = element.querySelector("button.close");
modalOverlay.addEventListener("click", this.onClose.bind(this)); modalOverlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose.bind(this)); closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
}
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;
}
if (event.key === "Escape") {
event.preventDefault();
this.onClose();
}
} }
onClose() { onClose() {
document.removeEventListener("keydown", this.onKeyDown);
this.element.classList.add("closing"); this.element.classList.add("closing");
this.element.addEventListener("animationend", (event) => { this.element.addEventListener("animationend", (event) => {
if (event.animationName === "fade-out") { if (event.animationName === "fade-out") {

View 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),
),
]

View File

@@ -91,6 +91,7 @@ class Bookmark(models.Model):
class BookmarkAsset(models.Model): class BookmarkAsset(models.Model):
TYPE_SNAPSHOT = "snapshot" TYPE_SNAPSHOT = "snapshot"
TYPE_UPLOAD = "upload"
CONTENT_TYPE_HTML = "text/html" CONTENT_TYPE_HTML = "text/html"
@@ -118,6 +119,9 @@ class BookmarkAsset(models.Model):
pass pass
super().save(*args, **kwargs) super().save(*args, **kwargs)
def __str__(self):
return self.display_name or f"Bookmark Asset #{self.pk}"
@receiver(post_delete, sender=BookmarkAsset) @receiver(post_delete, sender=BookmarkAsset)
def bookmark_asset_deleted(sender, instance, **kwargs): def bookmark_asset_deleted(sender, instance, **kwargs):
@@ -399,6 +403,7 @@ class UserProfile(models.Model):
custom_css = models.TextField(blank=True, null=False) custom_css = models.TextField(blank=True, null=False)
search_preferences = models.JSONField(default=dict, null=False) search_preferences = models.JSONField(default=dict, null=False)
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False) enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
default_mark_unread = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
@@ -422,6 +427,7 @@ class UserProfileForm(forms.ModelForm):
"display_archive_bookmark_action", "display_archive_bookmark_action",
"display_remove_bookmark_action", "display_remove_bookmark_action",
"permanent_notes", "permanent_notes",
"default_mark_unread",
"custom_css", "custom_css",
] ]

View File

@@ -1,12 +1,18 @@
import logging
import os
from typing import Union from typing import Union
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import UploadedFile
from django.utils import timezone from django.utils import timezone
from bookmarks.models import Bookmark, parse_tag_string from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
from bookmarks.services.tags import get_or_create_tags
from bookmarks.services import website_loader
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.services import website_loader
from bookmarks.services.tags import get_or_create_tags
logger = logging.getLogger(__name__)
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User): def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
@@ -176,6 +182,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): def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description to_bookmark.description = from_bookmark.description

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

File diff suppressed because it is too large Load Diff

View File

@@ -61,16 +61,22 @@
.assets .asset-text { .assets .asset-text {
flex: 1 1 0; flex: 1 1 0;
gap: $unit-2;
min-width: 0;
display: flex;
}
.assets .asset-text .truncate {
flex-shrink: 1;
} }
.assets .asset-text .filesize { .assets .asset-text .filesize {
color: $gray-color; color: $gray-color;
margin-left: $unit-2;
} }
.assets .asset-actions, .assets-actions { .assets .asset-actions, .assets-actions {
display: flex; display: flex;
gap: $unit-3; gap: $unit-4;
align-items: center; align-items: center;
} }

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

@@ -9,12 +9,12 @@
<div class="asset-icon {{ asset.icon_classes }}"> <div class="asset-icon {{ asset.icon_classes }}">
{% include 'bookmarks/details/asset_icon.html' %} {% include 'bookmarks/details/asset_icon.html' %}
</div> </div>
<div class="asset-text truncate {{ asset.text_classes }}"> <div class="asset-text {{ asset.text_classes }}">
<span> <span class="truncate">
{{ asset.display_name }} {{ asset.display_name }}
{% if asset.status == 'pending' %}(queued){% endif %} {% if asset.status == 'pending' %}(queued){% endif %}
{% if asset.status == 'failure' %}(failed){% endif %} {% if asset.status == 'failure' %}(failed){% endif %}
</span> </span>
{% if asset.file_size %} {% if asset.file_size %}
<span class="filesize">{{ asset.file_size|filesizeformat }}</span> <span class="filesize">{{ asset.file_size|filesizeformat }}</span>
{% endif %} {% endif %}
@@ -39,6 +39,10 @@
<button type="submit" name="create_snapshot" class="btn btn-link" <button type="submit" name="create_snapshot" class="btn btn-link"
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot {% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
</button> </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> </div>
{% endif %} {% endif %}
</div> </div>

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

@@ -175,6 +175,17 @@
<button class="btn mt-2" name="create_missing_html_snapshots">Create missing HTML snapshots</button> <button class="btn mt-2" name="create_missing_html_snapshots">Create missing HTML snapshots</button>
</div> </div>
{% endif %} {% 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"> <div class="form-group">
<details {% if form.custom_css.value %}open{% endif %}> <details {% if form.custom_css.value %}open{% endif %}>
<summary>Custom CSS</summary> <summary>Custom CSS</summary>

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

@@ -1,12 +1,13 @@
import re import re
from unittest.mock import patch from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import formats from django.utils import formats
from bookmarks.models import BookmarkAsset, UserProfile from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import tasks from bookmarks.services import bookmarks, tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
@@ -46,6 +47,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 +176,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 +231,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(
@@ -817,3 +863,34 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
"button", string=re.compile("Create HTML snapshot") "button", string=re.compile("Create HTML snapshot")
) )
self.assertTrue(create_button.has_attr("disabled")) 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()

View File

@@ -210,3 +210,25 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id])) response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
self.assertContains(response, '<details class="notes">', count=1) 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,
)

View File

@@ -1,10 +1,13 @@
import os
import tempfile
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth import get_user_model 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 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 tasks
from bookmarks.services import website_loader from bookmarks.services import website_loader
from bookmarks.services.bookmarks import ( from bookmarks.services.bookmarks import (
@@ -21,6 +24,7 @@ from bookmarks.services.bookmarks import (
mark_bookmarks_as_unread, mark_bookmarks_as_unread,
share_bookmarks, share_bookmarks,
unshare_bookmarks, unshare_bookmarks,
upload_asset,
) )
from bookmarks.services.website_loader import WebsiteMetadata from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -835,3 +839,50 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared) self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared) self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.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)

View File

@@ -96,6 +96,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"display_archive_bookmark_action": False, "display_archive_bookmark_action": False,
"display_remove_bookmark_action": False, "display_remove_bookmark_action": False,
"permanent_notes": True, "permanent_notes": True,
"default_mark_unread": True,
"custom_css": "body { background-color: #000; }", "custom_css": "body { background-color: #000; }",
} }
response = self.client.post(reverse("bookmarks:settings.general"), form_data) response = self.client.post(reverse("bookmarks:settings.general"), form_data)
@@ -155,6 +156,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual( self.assertEqual(
self.user.profile.permanent_notes, form_data["permanent_notes"] self.user.profile.permanent_notes, form_data["permanent_notes"]
) )
self.assertEqual(
self.user.profile.default_mark_unread, form_data["default_mark_unread"]
)
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"]) self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
self.assertSuccessMessage(html, "Profile updated") self.assertSuccessMessage(html, "Profile updated")

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

@@ -34,7 +34,7 @@ from bookmarks.services.bookmarks import (
share_bookmarks, share_bookmarks,
unshare_bookmarks, unshare_bookmarks,
) )
from bookmarks.services import tasks from bookmarks.services import bookmarks as bookmark_actions, tasks
from bookmarks.utils import get_safe_return_url from bookmarks.utils import get_safe_return_url
from bookmarks.views.partials import contexts from bookmarks.views.partials import contexts
@@ -145,6 +145,11 @@ def _details(request, bookmark_id: int, template: str):
asset.delete() asset.delete()
if "create_snapshot" in request.POST: if "create_snapshot" in request.POST:
tasks.create_html_snapshot(bookmark) tasks.create_html_snapshot(bookmark)
if "upload_asset" in request.POST:
file = request.FILES.get("upload_asset_file")
if not file:
return HttpResponseBadRequest("No file uploaded")
bookmark_actions.upload_asset(bookmark, file)
else: else:
bookmark.is_archived = request.POST.get("is_archived") == "on" bookmark.is_archived = request.POST.get("is_archived") == "on"
bookmark.unread = request.POST.get("unread") == "on" bookmark.unread = request.POST.get("unread") == "on"
@@ -188,6 +193,7 @@ def new(request):
initial_title = request.GET.get("title") initial_title = request.GET.get("title")
initial_description = request.GET.get("description") initial_description = request.GET.get("description")
initial_auto_close = "auto_close" in request.GET initial_auto_close = "auto_close" in request.GET
initial_mark_unread = request.user.profile.default_mark_unread
if request.method == "POST": if request.method == "POST":
form = BookmarkForm(request.POST) form = BookmarkForm(request.POST)
@@ -210,6 +216,8 @@ def new(request):
form.initial["description"] = initial_description form.initial["description"] = initial_description
if initial_auto_close: if initial_auto_close:
form.initial["auto_close"] = "true" form.initial["auto_close"] = "true"
if initial_mark_unread:
form.initial["unread"] = "true"
context = { context = {
"form": form, "form": form,

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

View File

@@ -112,7 +112,7 @@ RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest |
unzip uBlock0.zip unzip uBlock0.zip
# Patch assets.json to enable easylist-cookies by default # Patch assets.json to enable easylist-cookies by default
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
RUN jq '."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \ RUN jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
mv temp.json ./uBlock0.chromium/assets/assets.json mv temp.json ./uBlock0.chromium/assets/assets.json

View File

@@ -110,7 +110,7 @@ RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest |
unzip uBlock0.zip unzip uBlock0.zip
# Patch assets.json to enable easylist-cookies by default # Patch assets.json to enable easylist-cookies by default
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
RUN jq '."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \ RUN jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
mv temp.json ./uBlock0.chromium/assets/assets.json mv temp.json ./uBlock0.chromium/assets/assets.json

View File

@@ -1,6 +1,6 @@
{ {
"name": "linkding", "name": "linkding",
"version": "1.29.0", "version": "1.30.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@@ -54,7 +54,7 @@ rcssmin==1.1.1
# via django-compressor # via django-compressor
rjsmin==1.2.1 rjsmin==1.2.1
# via django-compressor # via django-compressor
sqlparse==0.4.4 sqlparse==0.5.0
# via # via
# django # django
# django-debug-toolbar # django-debug-toolbar

View File

@@ -72,7 +72,7 @@ six==1.16.0
# python-dateutil # python-dateutil
soupsieve==2.5 soupsieve==2.5
# via beautifulsoup4 # via beautifulsoup4
sqlparse==0.4.4 sqlparse==0.5.0
# via django # via django
supervisor==4.2.5 supervisor==4.2.5
# via -r requirements.in # via -r requirements.in

View File

@@ -7,7 +7,7 @@ unzip uBlock0.zip
rm uBlock0.zip rm uBlock0.zip
curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
jq '."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json
mv temp.json ./uBlock0.chromium/assets/assets.json mv temp.json ./uBlock0.chromium/assets/assets.json
mkdir -p chromium-profile mkdir -p chromium-profile

View File

@@ -1 +1 @@
1.29.0 1.30.0

View File

@@ -61,6 +61,13 @@
"required": false "required": false
} }
}, },
{
"name": "ld-upload-button",
"description": "Opens the related file input when clicked, and submits the form when a file is selected",
"value": {
"required": false
}
},
{ {
"name": "ld-modal", "name": "ld-modal",
"description": "Adds Javascript behavior to a modal HTML component", "description": "Adds Javascript behavior to a modal HTML component",