Add notes to bookmarks (#472)

* Add basic bookmark notes

* Add bookmark list JS to shared bookmarks page

* Allow testing through ngrok

* Improve CSS

* Set notes through API

* Improve notes editing

* Improve notes icon

* Remove transitions for now

* Update keyboard shortcut

* Add bookmark list tests

* Add setting for showing notes permanently

* Add test for toggling notes

* Update API docs

* Allow searching for notes content

* Skip test
This commit is contained in:
Sascha Ißbrücker
2023-05-20 11:54:26 +02:00
committed by GitHub
parent 67ee896a46
commit 43115fd8f2
31 changed files with 609 additions and 144 deletions

View File

@@ -27,6 +27,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
'url', 'url',
'title', 'title',
'description', 'description',
'notes',
'website_title', 'website_title',
'website_description', 'website_description',
'is_archived', 'is_archived',
@@ -47,6 +48,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
# Override optional char fields to provide default value # Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default='') title = serializers.CharField(required=False, allow_blank=True, default='')
description = serializers.CharField(required=False, allow_blank=True, default='') description = serializers.CharField(required=False, allow_blank=True, default='')
notes = serializers.CharField(required=False, allow_blank=True, default='')
is_archived = serializers.BooleanField(required=False, default=False) is_archived = serializers.BooleanField(required=False, default=False)
unread = serializers.BooleanField(required=False, default=False) unread = serializers.BooleanField(required=False, default=False)
shared = serializers.BooleanField(required=False, default=False) shared = serializers.BooleanField(required=False, default=False)
@@ -58,6 +60,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
bookmark.url = validated_data['url'] bookmark.url = validated_data['url']
bookmark.title = validated_data['title'] bookmark.title = validated_data['title']
bookmark.description = validated_data['description'] bookmark.description = validated_data['description']
bookmark.notes = validated_data['notes']
bookmark.is_archived = validated_data['is_archived'] bookmark.is_archived = validated_data['is_archived']
bookmark.unread = validated_data['unread'] bookmark.unread = validated_data['unread']
bookmark.shared = validated_data['shared'] bookmark.shared = validated_data['shared']
@@ -66,7 +69,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
def update(self, instance: Bookmark, validated_data): def update(self, instance: Bookmark, validated_data):
# Update fields if they were provided in the payload # Update fields if they were provided in the payload
for key in ['url', 'title', 'description', 'unread', 'shared']: for key in ['url', 'title', 'description', 'notes', 'unread', 'shared']:
if key in validated_data: if key in validated_data:
setattr(instance, key, validated_data[key]) setattr(instance, key, validated_data[key])

View File

@@ -1,5 +1,5 @@
from django.urls import reverse from django.urls import reverse
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase from bookmarks.e2e.helpers import LinkdingE2ETestCase
@@ -8,6 +8,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
def test_create_should_check_for_existing_bookmark(self): def test_create_should_check_for_existing_bookmark(self):
existing_bookmark = self.setup_bookmark(title='Existing title', existing_bookmark = self.setup_bookmark(title='Existing title',
description='Existing description', description='Existing description',
notes='Existing notes',
tags=[self.setup_tag(name='tag1'), self.setup_tag(name='tag2')], tags=[self.setup_tag(name='tag1'), self.setup_tag(name='tag2')],
website_title='Existing website title', website_title='Existing website title',
website_description='Existing website description', website_description='Existing website description',
@@ -26,6 +27,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
# Form should be pre-filled with data from existing bookmark # Form should be pre-filled with data from existing bookmark
self.assertEqual(existing_bookmark.title, page.get_by_label('Title').input_value()) self.assertEqual(existing_bookmark.title, page.get_by_label('Title').input_value())
self.assertEqual(existing_bookmark.description, page.get_by_label('Description').input_value()) self.assertEqual(existing_bookmark.description, page.get_by_label('Description').input_value())
self.assertEqual(existing_bookmark.notes, page.get_by_label('Notes').input_value())
self.assertEqual(existing_bookmark.website_title, page.get_by_label('Title').get_attribute('placeholder')) self.assertEqual(existing_bookmark.website_title, page.get_by_label('Title').get_attribute('placeholder'))
self.assertEqual(existing_bookmark.website_description, self.assertEqual(existing_bookmark.website_description,
page.get_by_label('Description').get_attribute('placeholder')) page.get_by_label('Description').get_attribute('placeholder'))
@@ -49,3 +51,17 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
page.wait_for_timeout(timeout=1000) page.wait_for_timeout(timeout=1000)
page.get_by_text('This URL is already bookmarked.').wait_for(state='hidden') page.get_by_text('This URL is already bookmarked.').wait_for(state='hidden')
def test_enter_url_of_existing_bookmark_should_show_notes(self):
bookmark = self.setup_bookmark(notes='Existing notes', description='Existing description')
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:new'))
details = page.locator('details.notes')
expect(details).not_to_have_attribute('open', value='')
page.get_by_label('URL').fill(bookmark.url)
expect(details).to_have_attribute('open', value='')

View File

@@ -0,0 +1,27 @@
from unittest import skip
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
@skip("Fails in CI, needs investigation")
class BookmarkListE2ETestCase(LinkdingE2ETestCase):
def test_toggle_notes_should_show_hide_notes(self):
self.setup_bookmark(notes='Test notes')
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:index'))
notes = page.locator('li .notes')
expect(notes).to_be_hidden()
toggle_notes = page.locator('li button.toggle-notes')
toggle_notes.click()
expect(notes).to_be_visible()
toggle_notes.click()
expect(notes).to_be_hidden()

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-05-19 10:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0021_userprofile_display_url'),
]
operations = [
migrations.AddField(
model_name='bookmark',
name='notes',
field=models.TextField(blank=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.9 on 2023-05-20 08:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0022_bookmark_notes'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='permanent_notes',
field=models.BooleanField(default=False),
),
]

View File

@@ -50,6 +50,7 @@ class Bookmark(models.Model):
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()]) url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
title = models.CharField(max_length=512, blank=True) title = models.CharField(max_length=512, blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
notes = models.TextField(blank=True)
website_title = models.CharField(max_length=512, blank=True, null=True) website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True) website_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True) web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
@@ -110,6 +111,7 @@ class BookmarkForm(forms.ModelForm):
'tag_string', 'tag_string',
'title', 'title',
'description', 'description',
'notes',
'website_title', 'website_title',
'website_description', 'website_description',
'unread', 'unread',
@@ -117,6 +119,10 @@ class BookmarkForm(forms.ModelForm):
'auto_close', 'auto_close',
] ]
@property
def has_notes(self):
return self.instance and self.instance.notes
class BookmarkFilters: class BookmarkFilters:
def __init__(self, request: WSGIRequest): def __init__(self, request: WSGIRequest):
@@ -172,13 +178,14 @@ class UserProfile(models.Model):
enable_sharing = models.BooleanField(default=False, null=False) enable_sharing = models.BooleanField(default=False, null=False)
enable_favicons = models.BooleanField(default=False, null=False) enable_favicons = models.BooleanField(default=False, null=False)
display_url = models.BooleanField(default=False, null=False) display_url = models.BooleanField(default=False, null=False)
permanent_notes = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
class Meta: class Meta:
model = UserProfile model = UserProfile
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search', fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search',
'enable_sharing', 'enable_favicons', 'display_url'] 'enable_sharing', 'enable_favicons', 'display_url', 'permanent_notes']
@receiver(post_save, sender=get_user_model()) @receiver(post_save, sender=get_user_model())

View File

@@ -37,6 +37,7 @@ def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_stri
for term in query['search_terms']: for term in query['search_terms']:
conditions = Q(title__icontains=term) \ conditions = Q(title__icontains=term) \
| Q(description__icontains=term) \ | Q(description__icontains=term) \
| Q(notes__icontains=term) \
| Q(website_title__icontains=term) \ | Q(website_title__icontains=term) \
| Q(website_description__icontains=term) \ | Q(website_description__icontains=term) \
| Q(url__icontains=term) | Q(url__icontains=term)

View File

@@ -122,6 +122,7 @@ def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_us
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
to_bookmark.notes = from_bookmark.notes
to_bookmark.unread = from_bookmark.unread to_bookmark.unread = from_bookmark.unread
to_bookmark.shared = from_bookmark.shared to_bookmark.shared = from_bookmark.shared

View File

@@ -1,5 +1,13 @@
(function () { (function () {
function allowBulkEdit() {
return !!document.getElementById('bulk-edit-mode');
}
function setupBulkEdit() { function setupBulkEdit() {
if (!allowBulkEdit()) {
return;
}
const bulkEditToggle = document.getElementById('bulk-edit-mode') const bulkEditToggle = document.getElementById('bulk-edit-mode')
const bulkEditBar = document.querySelector('.bulk-edit-bar') const bulkEditBar = document.querySelector('.bulk-edit-bar')
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input') const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
@@ -64,6 +72,10 @@
} }
function setupBulkEditTagAutoComplete() { function setupBulkEditTagAutoComplete() {
if (!allowBulkEdit()) {
return;
}
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
const tagInput = document.getElementById('bulk-edit-tags-input'); const tagInput = document.getElementById('bulk-edit-tags-input');
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || ''; const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || '';
@@ -121,7 +133,39 @@
}); });
} }
function setupNotes() {
// Shortcut for toggling all notes
document.addEventListener('keydown', function(event) {
// Filter for shortcut key
if (event.key !== 'e') return;
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
if (isInputTarget) return;
const list = document.querySelector('.bookmark-list');
list.classList.toggle('show-notes');
});
// Toggle notes for single bookmark
const bookmarks = document.querySelectorAll('.bookmark-list li');
bookmarks.forEach(bookmark => {
const toggleButton = bookmark.querySelector('.toggle-notes');
if (toggleButton) {
toggleButton.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
bookmark.classList.toggle('show-notes');
});
}
});
}
setupBulkEdit(); setupBulkEdit();
setupBulkEditTagAutoComplete(); setupBulkEditTagAutoComplete();
setupListNavigation(); setupListNavigation();
setupNotes();
})() })()

View File

@@ -72,6 +72,12 @@ a:visited:hover {
color: $link-color-dark; color: $link-color-dark;
} }
code {
color: $gray-color-dark;
background-color: $code-bg-color;
box-shadow: 1px 1px 0 $code-shadow-color;
}
// Increase spacing between columns // Increase spacing between columns
.container > .columns > .column:not(:first-child) { .container > .columns > .column:not(:first-child) {
padding-left: 2rem; padding-left: 2rem;

View File

@@ -43,11 +43,19 @@
} }
} }
/* Bookmark list */
ul.bookmark-list { ul.bookmark-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
}
/* Bookmarks */
ul.bookmark-list li {
.bulk-edit-toggle {
display: none;
}
.title a { .title a {
display: inline-block; display: inline-block;
@@ -76,31 +84,44 @@ ul.bookmark-list {
} }
} }
.actions > *:not(:last-child) { .actions {
margin-right: 0.1rem; display: flex;
align-items: baseline;
flex-wrap: wrap;
} }
.actions .date-label a { .actions {
color: $gray-color; > *:not(:last-child) {
} margin-right: 0.4rem;
.actions .btn-link {
color: $gray-color;
padding: 0;
height: auto;
vertical-align: unset;
border: none;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
} }
}
.bulk-edit-toggle { a, button {
display: none; color: $gray-color;
padding: 0;
height: auto;
vertical-align: unset;
border: none;
transition: none;
text-decoration: none;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
}
}
.separator {
align-self: flex-start;
}
.toggle-notes {
align-self: center;
display: flex;
align-items: center;
gap: 0.1rem;
}
} }
} }
@@ -180,6 +201,68 @@ ul.bookmark-list {
font-weight: bold; font-weight: bold;
} }
} }
details.notes textarea {
box-sizing: border-box;
}
}
/* Bookmark notes */
ul.bookmark-list {
.notes {
display: none;
max-height: 300px;
margin: 4px 0;
overflow: auto;
}
&.show-notes .notes,
li.show-notes .notes {
display: block;
}
}
/* Bookmark notes markdown styles */
ul.bookmark-list .notes-content {
& {
padding: 0.4rem 0.6rem;
}
p, ul, ol, pre, blockquote {
margin: 0 0 0.4rem 0;
}
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
ul, ol {
margin-left: 0.8rem;
}
ul li, ol li {
margin-top: 0.2rem;
}
pre {
padding: 0.2rem 0.4rem;
background-color: $code-bg-color;
border-radius: 0.2rem;
}
pre code {
background: none;
box-shadow: none;
}
> pre:first-child:last-child {
padding: 0;
background: none;
border-radius: 0;
}
} }
/* Bookmark actions / bulk edit */ /* Bookmark actions / bulk edit */

View File

@@ -26,5 +26,8 @@ $secondary-link-color: rgba(168, 177, 255, 0.73);
$alternative-color: #59bdb9; $alternative-color: #59bdb9;
$alternative-color-dark: #73f1eb; $alternative-color-dark: #73f1eb;
$code-bg-color: rgba(255, 255, 255, 0.1);
$code-shadow-color: rgba(255, 255, 255, 0.2);
/* Dark theme specific */ /* Dark theme specific */
$dt-primary-button-color: #5761cb !default; $dt-primary-button-color: #5761cb !default;

View File

@@ -4,3 +4,6 @@ $alternative-color: #05a6a3;
$alternative-color-dark: darken($alternative-color, 5%); $alternative-color-dark: darken($alternative-color, 5%);
$secondary-link-color: rgba(87, 85, 217, 0.64); $secondary-link-color: rgba(87, 85, 217, 0.64);
$code-bg-color: rgba(0, 0, 0, 0.05);
$code-shadow-color: rgba(0, 0, 0, 0.15);

View File

@@ -1,110 +1,128 @@
{% load static %} {% load static %}
{% load shared %} {% load shared %}
{% load pagination %} {% load pagination %}
{% htmlmin %} <ul class="bookmark-list{% if request.user.profile.permanent_notes %} show-notes{% endif %}">
<ul class="bookmark-list"> {% for bookmark in bookmarks %}
{% for bookmark in bookmarks %} <li data-is-bookmark-item>
<li data-is-bookmark-item> <label class="form-checkbox bulk-edit-toggle">
<label class="form-checkbox bulk-edit-toggle"> <input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}"> <i class="form-icon"></i>
<i class="form-icon"></i> </label>
</label> <div class="title">
<div class="title"> <a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener" class="{% if bookmark.unread %}text-italic{% endif %}">
class="{% if bookmark.unread %}text-italic{% endif %}"> {% if bookmark.favicon_file and request.user.profile.enable_favicons %}
{% if bookmark.favicon_file and request.user.profile.enable_favicons %} <img src="{% static bookmark.favicon_file %}" alt="">
<img src="{% static bookmark.favicon_file %}" alt=""> {% endif %}
{% endif %} {{ bookmark.resolved_title }}
{{ bookmark.resolved_title }} </a>
</a> </div>
</div> {% if request.user.profile.display_url %}
{% if request.user.profile.display_url %}
<div class="url-path truncate"> <div class="url-path truncate">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener" <a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="url-display text-sm"> class="url-display text-sm">
{{ bookmark.url }} {{ bookmark.url }}
</a> </a>
</div> </div>
{% endif %} {% endif %}
<div class="description truncate"> <div class="description truncate">
{% if bookmark.tag_names %} {% if bookmark.tag_names %}
<span> <span>
{% for tag_name in bookmark.tag_names %} {% for tag_name in bookmark.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %} {% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
{% if bookmark.resolved_description %} {% if bookmark.resolved_description %}
<span>{{ bookmark.resolved_description }}</span> <span>{{ bookmark.resolved_description }}</span>
{% endif %} {% endif %}
</div>
{% if bookmark.notes %}
<div class="notes bg-gray text-gray-dark">
<div class="notes-content">
{% markdown bookmark.notes %}
</div>
</div> </div>
<div class="actions"> {% endif %}
{% if request.user.profile.bookmark_date_display == 'relative' %} <div class="actions text-gray text-sm">
<span class="date-label text-gray text-sm"> {% if request.user.profile.bookmark_date_display == 'relative' %}
<span>
{% if bookmark.web_archive_snapshot_url %} {% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}" <a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}" title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}"
rel="noopener"> rel="noopener">
{% endif %} {% endif %}
<span>{{ bookmark.date_added|humanize_relative_date }}</span> <span>{{ bookmark.date_added|humanize_relative_date }}</span>
{% if bookmark.web_archive_snapshot_url %} {% if bookmark.web_archive_snapshot_url %}
<span></span>
</a> </a>
{% endif %}
</span>
<span class="text-gray text-sm">|</span>
{% endif %}
{% if request.user.profile.bookmark_date_display == 'absolute' %}
<span class="date-label text-gray text-sm">
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}"
rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
<span></span>
</a>
{% endif %}
</span>
<span class="text-gray text-sm">|</span>
{% endif %}
{% if bookmark.owner == request.user %}
{# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Edit</a>
{% if bookmark.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Unarchive
</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %} {% endif %}
<button type="submit" name="remove" value="{{ bookmark.id }}" </span>
class="btn btn-link btn-sm btn-confirmation">Remove <span class="separator">|</span>
{% endif %}
{% if request.user.profile.bookmark_date_display == 'absolute' %}
<span>
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}"
rel="noopener">
{% endif %}
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
</a>
{% endif %}
</span>
<span class="separator">|</span>
{% endif %}
{% if bookmark.owner == request.user %}
{# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}">Edit</a>
{% if bookmark.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Unarchive
</button> </button>
{% if bookmark.unread %}
<span class="text-gray text-sm">|</span>
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Mark as read
</button>
{% endif %}
{% else %} {% else %}
{# Shared bookmark actions #} <button type="submit" name="archive" value="{{ bookmark.id }}"
<span class="text-gray text-sm">Shared by class="btn btn-link btn-sm">Archive
<a class="text-gray" </button>
href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
</span>
{% endif %} {% endif %}
</div> <button type="submit" name="remove" value="{{ bookmark.id }}"
</li> class="btn btn-link btn-sm btn-confirmation">Remove
{% endfor %} </button>
</ul> {% if bookmark.unread %}
<span class="separator">|</span>
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Mark as read
</button>
{% endif %}
{% else %}
{# Shared bookmark actions #}
<span>Shared by
<a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
</span>
{% endif %}
{% if bookmark.notes and not request.user.profile.permanent_notes %}
<span class="separator">|</span>
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16"
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="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
<path d="M9 7l6 0"></path>
<path d="M9 11l6 0"></path>
<path d="M9 15l4 0"></path>
</svg>
<span>Notes</span>
</button>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
<div class="bookmark-pagination"> <div class="bookmark-pagination">
{% pagination bookmarks %} {% pagination bookmarks %}
</div> </div>
{% endhtmlmin %}

View File

@@ -67,6 +67,19 @@
</div> </div>
{{ form.description.errors }} {{ form.description.errors }}
</div> </div>
<div class="form-group">
<details class="notes"{% if form.has_notes %} open{% endif %}>
<summary>
<span class="form-label d-inline-block">Notes</span>
</summary>
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
<div class="form-input-hint">
Additional notes, supports Markdown.
</div>
{{ form.notes.errors }}
</details>
</div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox"> <label for="{{ form.unread.id_for_label }}" class="form-checkbox">
{{ form.unread }} {{ form.unread }}
@@ -128,6 +141,8 @@
const urlInput = document.getElementById('{{ form.url.id_for_label }}'); const urlInput = document.getElementById('{{ form.url.id_for_label }}');
const titleInput = document.getElementById('{{ form.title.id_for_label }}'); const titleInput = document.getElementById('{{ form.title.id_for_label }}');
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}'); const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
const notesDetails = document.querySelector('form details.notes');
const notesInput = document.getElementById('{{ form.notes.id_for_label }}');
const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}'); const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}'); const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}'); const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
@@ -149,11 +164,17 @@
} }
function updateInput(input, value) { function updateInput(input, value) {
input.value = value; if (!input) {
return;
}
input.value = value;
} }
function updateCheckbox(input, value) { function updateCheckbox(input, value) {
input.checked = value; if (!input) {
return;
}
input.checked = value;
} }
function checkUrl() { function checkUrl() {
@@ -179,8 +200,10 @@
if (existingBookmark && !editedBookmarkId) { if (existingBookmark && !editedBookmarkId) {
bookmarkExistsHint.style['display'] = 'block'; bookmarkExistsHint.style['display'] = 'block';
notesDetails.open = !!existingBookmark.notes;
updateInput(titleInput, existingBookmark.title); updateInput(titleInput, existingBookmark.title);
updateInput(descriptionInput, existingBookmark.description); updateInput(descriptionInput, existingBookmark.description);
updateInput(notesInput, existingBookmark.notes);
updateInput(tagsInput, existingBookmark.tag_names.join(" ")); updateInput(tagsInput, existingBookmark.tag_names.join(" "));
updateCheckbox(unreadCheckbox, existingBookmark.unread); updateCheckbox(unreadCheckbox, existingBookmark.unread);
updateCheckbox(sharedCheckbox, existingBookmark.shared); updateCheckbox(sharedCheckbox, existingBookmark.shared);
@@ -201,6 +224,9 @@
}); });
} }
setupEditAutoValueButton(titleInput);
setupEditAutoValueButton(descriptionInput);
// Fetch initial website data if we have a URL, and we are not editing an existing bookmark // Fetch initial website data if we have a URL, and we are not editing an existing bookmark
// For existing bookmarks we get the website metadata through hidden inputs // For existing bookmarks we get the website metadata through hidden inputs
if (urlInput.value && !editedBookmarkId) { if (urlInput.value && !editedBookmarkId) {
@@ -213,9 +239,6 @@
updatePlaceholder(titleInput, websiteTitleInput.value); updatePlaceholder(titleInput, websiteTitleInput.value);
updatePlaceholder(descriptionInput, websiteDescriptionInput.value); updatePlaceholder(descriptionInput, websiteDescriptionInput.value);
} }
setupEditAutoValueButton(titleInput);
setupEditAutoValueButton(descriptionInput);
})(); })();
</script> </script>
</div> </div>

View File

@@ -45,4 +45,5 @@
<script src="{% static "bundle.js" %}"></script> <script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script> <script src="{% static "shared.js" %}"></script>
<script src="{% static "bookmark_list.js" %}"></script>
{% endblock %} {% endblock %}

View File

@@ -38,6 +38,16 @@
When enabled, this setting displays the bookmark URL below the title. When enabled, this setting displays the bookmark URL below the title.
</div> </div>
</div> </div>
<div class="form-group">
<label for="{{ form.permanent_notes.id_for_label }}" class="form-checkbox">
{{ form.permanent_notes }}
<i class="form-icon"></i> Show notes permanently
</label>
<div class="form-input-hint">
Whether to show bookmark notes permanently, without having to toggle them individually.
Alternatively the keyboard shortcut <code>e</code> can be used to temporarily show all notes.
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label> <label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }} {{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }}

View File

@@ -1,6 +1,10 @@
import re import re
import bleach
import markdown
from bleach_allowlist import markdown_tags, markdown_attrs
from django import template from django import template
from django.utils.safestring import mark_safe
from bookmarks import utils from bookmarks import utils
from bookmarks.models import UserProfile from bookmarks.models import UserProfile
@@ -113,3 +117,19 @@ class HtmlMinNode(template.Node):
output = re.sub(r'\s+', ' ', output) output = re.sub(r'\s+', ' ', output)
return output return output
@register.simple_tag(name="markdown", takes_context=True)
def render_markdown(context, markdown_text):
# naive approach to reusing the renderer for a single request
# works for bookmark list for now
if not ('markdown_renderer' in context):
renderer = markdown.Markdown(extensions=['fenced_code', 'nl2br'])
context['markdown_renderer'] = renderer
else:
renderer = context['markdown_renderer']
as_html = renderer.convert(markdown_text)
sanitized_html = bleach.clean(as_html, markdown_tags, markdown_attrs)
return mark_safe(sanitized_html)

View File

@@ -30,6 +30,7 @@ class BookmarkFactoryMixin:
url: str = '', url: str = '',
title: str = '', title: str = '',
description: str = '', description: str = '',
notes: str = '',
website_title: str = '', website_title: str = '',
website_description: str = '', website_description: str = '',
web_archive_snapshot_url: str = '', web_archive_snapshot_url: str = '',
@@ -48,6 +49,7 @@ class BookmarkFactoryMixin:
url=url, url=url,
title=title, title=title,
description=description, description=description,
notes=notes,
website_title=website_title, website_title=website_title,
website_description=website_description, website_description=website_description,
date_added=timezone.now(), date_added=timezone.now(),

View File

@@ -20,6 +20,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
'tag_string': 'editedtag1 editedtag2', 'tag_string': 'editedtag1 editedtag2',
'title': 'edited title', 'title': 'edited title',
'description': 'edited description', 'description': 'edited description',
'notes': 'edited notes',
'unread': False, 'unread': False,
'shared': False, 'shared': False,
} }
@@ -37,6 +38,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, form_data['url']) self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title']) self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description']) self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.notes, form_data['notes'])
self.assertEqual(bookmark.unread, form_data['unread']) self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared']) self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2) self.assertEqual(bookmark.tags.count(), 2)
@@ -74,7 +76,8 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag() tag1 = self.setup_tag()
tag2 = self.setup_tag() tag2 = self.setup_tag()
bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description', bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description',
website_title='website title', website_description='website description') notes='edited notes', website_title='website title',
website_description='website description')
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id])) response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
html = response.content.decode() html = response.content.decode()
@@ -101,6 +104,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
</textarea> </textarea>
''', html) ''', html)
self.assertInHTML(f'''
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">
{bookmark.notes}
</textarea>
''', html)
self.assertInHTML(f''' self.assertInHTML(f'''
<input type="hidden" name="website_title" id="id_website_title" <input type="hidden" name="website_title" id="id_website_title"
value="{bookmark.website_title}"> value="{bookmark.website_title}">
@@ -184,3 +193,15 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
<span>Share</span> <span>Share</span>
</label> </label>
''', html, count=1) ''', html, count=1)
def test_should_hide_notes_if_there_are_no_notes(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
self.assertContains(response, '<details class="notes">', count=1)
def test_should_show_notes_if_there_are_notes(self):
bookmark = self.setup_bookmark(notes='test notes')
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
self.assertContains(response, '<details class="notes" open>', count=1)

View File

@@ -227,8 +227,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
return_url = urllib.parse.quote_plus(url) return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f''' self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}" <a href="{edit_url}?return_url={return_url}">Edit</a>
class="btn btn-link btn-sm">Edit</a>
''', html) ''', html)
# with query params # with query params
@@ -239,6 +238,5 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
return_url = urllib.parse.quote_plus(url) return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f''' self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}" <a href="{edit_url}?return_url={return_url}">Edit</a>
class="btn btn-link btn-sm">Edit</a>
''', html) ''', html)

View File

@@ -19,6 +19,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
'tag_string': 'tag1 tag2', 'tag_string': 'tag1 tag2',
'title': 'test title', 'title': 'test title',
'description': 'test description', 'description': 'test description',
'notes': 'test notes',
'unread': False, 'unread': False,
'shared': False, 'shared': False,
'auto_close': '', 'auto_close': '',
@@ -37,6 +38,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, form_data['url']) self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title']) self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description']) self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.notes, form_data['notes'])
self.assertEqual(bookmark.unread, form_data['unread']) self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared']) self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2) self.assertEqual(bookmark.tags.count(), 2)
@@ -138,3 +140,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
<span>Share</span> <span>Share</span>
</label> </label>
''', html, count=1) ''', html, count=1)
def test_should_hide_notes_if_there_are_no_notes(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
self.assertContains(response, '<details class="notes">', count=1)

View File

@@ -20,7 +20,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
self.tag1 = self.setup_tag() self.tag1 = self.setup_tag()
self.tag2 = self.setup_tag() self.tag2 = self.setup_tag()
self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2]) self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2], notes='Test notes')
self.bookmark2 = self.setup_bookmark() self.bookmark2 = self.setup_bookmark()
self.bookmark3 = self.setup_bookmark(tags=[self.tag2]) self.bookmark3 = self.setup_bookmark(tags=[self.tag2])
self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, self.tag2]) self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, self.tag2])
@@ -36,6 +36,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation['url'] = bookmark.url expectation['url'] = bookmark.url
expectation['title'] = bookmark.title expectation['title'] = bookmark.title
expectation['description'] = bookmark.description expectation['description'] = bookmark.description
expectation['notes'] = bookmark.notes
expectation['website_title'] = bookmark.website_title expectation['website_title'] = bookmark.website_title
expectation['website_description'] = bookmark.website_description expectation['website_description'] = bookmark.website_description
expectation['is_archived'] = bookmark.is_archived expectation['is_archived'] = bookmark.is_archived
@@ -134,6 +135,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
'url': 'https://example.com/', 'url': 'https://example.com/',
'title': 'Test title', 'title': 'Test title',
'description': 'Test description', 'description': 'Test description',
'notes': 'Test notes',
'is_archived': False, 'is_archived': False,
'unread': False, 'unread': False,
'shared': False, 'shared': False,
@@ -144,6 +146,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, data['url']) self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title']) self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description']) self.assertEqual(bookmark.description, data['description'])
self.assertEqual(bookmark.notes, data['notes'])
self.assertFalse(bookmark.is_archived, data['is_archived']) self.assertFalse(bookmark.is_archived, data['is_archived'])
self.assertFalse(bookmark.unread, data['unread']) self.assertFalse(bookmark.unread, data['unread'])
self.assertFalse(bookmark.shared, data['shared']) self.assertFalse(bookmark.shared, data['shared'])
@@ -157,6 +160,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
'url': original_bookmark.url, 'url': original_bookmark.url,
'title': 'Updated title', 'title': 'Updated title',
'description': 'Updated description', 'description': 'Updated description',
'notes': 'Updated notes',
'unread': True, 'unread': True,
'shared': True, 'shared': True,
'is_archived': True, 'is_archived': True,
@@ -168,6 +172,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, data['url']) self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title']) self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description']) self.assertEqual(bookmark.description, data['description'])
self.assertEqual(bookmark.notes, data['notes'])
# Saving a duplicate bookmark should not modify archive flag - right? # Saving a duplicate bookmark should not modify archive flag - right?
self.assertFalse(bookmark.is_archived) self.assertFalse(bookmark.is_archived)
self.assertEqual(bookmark.unread, data['unread']) self.assertEqual(bookmark.unread, data['unread'])
@@ -265,6 +270,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.url, data['url']) self.assertEqual(updated_bookmark.url, data['url'])
self.assertEqual(updated_bookmark.title, '') self.assertEqual(updated_bookmark.title, '')
self.assertEqual(updated_bookmark.description, '') self.assertEqual(updated_bookmark.description, '')
self.assertEqual(updated_bookmark.notes, '')
self.assertEqual(updated_bookmark.tag_names, []) self.assertEqual(updated_bookmark.tag_names, [])
def test_update_bookmark_unread_flag(self): def test_update_bookmark_unread_flag(self):
@@ -300,6 +306,12 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.bookmark1.refresh_from_db() self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.description, data['description']) self.assertEqual(self.bookmark1.description, data['description'])
data = {'notes': 'Updated notes'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.notes, data['notes'])
data = {'unread': True} data = {'unread': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)

View File

@@ -24,22 +24,22 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def assertDateLabel(self, html: str, label_content: str): def assertDateLabel(self, html: str, label_content: str):
self.assertInHTML(f''' self.assertInHTML(f'''
<span class="date-label text-gray text-sm"> <span>
<span>{label_content}</span> <span>{label_content}</span>
</span> </span>
<span class="text-gray text-sm">|</span> <span class="separator">|</span>
''', html) ''', html)
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'): def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
self.assertInHTML(f''' self.assertInHTML(f'''
<span class="date-label text-gray text-sm"> <span>
<a href="{url}" <a href="{url}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener"> title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
<span>{label_content}</span> <span>{label_content}</span>
<span>∞</span>
</a> </a>
</span> </span>
<span class="text-gray text-sm">|</span> <span class="separator">|</span>
''', html) ''', html)
def assertBookmarkActions(self, html: str, bookmark: Bookmark): def assertBookmarkActions(self, html: str, bookmark: Bookmark):
@@ -52,8 +52,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
# Edit link # Edit link
edit_url = reverse('bookmarks:edit', args=[bookmark.id]) edit_url = reverse('bookmarks:edit', args=[bookmark.id])
self.assertInHTML(f''' self.assertInHTML(f'''
<a href="{edit_url}?return_url=/test" <a href="{edit_url}?return_url=/test">Edit</a>
class="btn btn-link btn-sm">Edit</a>
''', html, count=count) ''', html, count=count)
# Archive link # Archive link
self.assertInHTML(f''' self.assertInHTML(f'''
@@ -74,8 +73,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1): def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f''' self.assertInHTML(f'''
<span class="text-gray text-sm">Shared by <span>Shared by
<a class="text-gray" href="?user={bookmark.owner.username}">{bookmark.owner.username}</a> <a href="?user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span> </span>
''', html, count=count) ''', html, count=count)
@@ -93,9 +92,9 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def assertBookmarkURLCount(self, html: str, bookmark: Bookmark, link_target: str = '_blank', count=0): def assertBookmarkURLCount(self, html: str, bookmark: Bookmark, link_target: str = '_blank', count=0):
self.assertInHTML(f''' self.assertInHTML(f'''
<div class="url-path truncate"> <div class="url-path truncate">
<a href="{ bookmark.url }" target="{ link_target }" rel="noopener" <a href="{bookmark.url}" target="{link_target}" rel="noopener"
class="url-display text-sm"> class="url-display text-sm">
{ bookmark.url } {bookmark.url}
</a> </a>
</div> </div>
''', html, count) ''', html, count)
@@ -103,11 +102,33 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def assertBookmarkURLVisible(self, html: str, bookmark: Bookmark): def assertBookmarkURLVisible(self, html: str, bookmark: Bookmark):
self.assertBookmarkURLCount(html, bookmark, count=1) self.assertBookmarkURLCount(html, bookmark, count=1)
def assertBookmarkURLHidden(self, html: str, bookmark: Bookmark, link_target: str = '_blank'): def assertBookmarkURLHidden(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
self.assertBookmarkURLCount(html, bookmark, count=0) self.assertBookmarkURLCount(html, bookmark, count=0)
def assertNotes(self, html: str, notes_html: str, count=1):
self.assertInHTML(f'''
<div class="notes bg-gray text-gray-dark">
<div class="notes-content">
{notes_html}
</div>
</div>
''', html, count=count)
def assertNotesToggle(self, html: str, count=1):
self.assertInHTML(f'''
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16"
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="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
<path d="M9 7l6 0"></path>
<path d="M9 11l6 0"></path>
<path d="M9 15l4 0"></path>
</svg>
<span>Notes</span>
</button>
''', html, count=count)
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str: def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
rf = RequestFactory() rf = RequestFactory()
@@ -237,8 +258,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
html = self.render_default_template([bookmark], url='/test?q=foo') html = self.render_default_template([bookmark], url='/test?q=foo')
self.assertInHTML(f''' self.assertInHTML(f'''
<span class="text-gray text-sm">Shared by <span>Shared by
<a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a> <a href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span> </span>
''', html) ''', html)
@@ -279,7 +300,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_default_template([bookmark])
self.assertBookmarkURLHidden(html,bookmark) self.assertBookmarkURLHidden(html, bookmark)
def test_show_bookmark_url_when_enabled(self): def test_show_bookmark_url_when_enabled(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -289,7 +310,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_default_template([bookmark])
self.assertBookmarkURLVisible(html,bookmark) self.assertBookmarkURLVisible(html, bookmark)
def test_hide_bookmark_url_when_disabled(self): def test_hide_bookmark_url_when_disabled(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -299,6 +320,85 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark]) html = self.render_default_template([bookmark])
self.assertBookmarkURLHidden(html,bookmark) self.assertBookmarkURLHidden(html, bookmark)
def test_without_notes(self):
bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark])
self.assertNotes(html, '', 0)
self.assertNotesToggle(html, 0)
def test_with_notes(self):
bookmark = self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark])
note_html = '<p>Test note</p>'
self.assertNotes(html, note_html, 1)
def test_note_renders_markdown(self):
bookmark = self.setup_bookmark(notes='**Example:** `print("Hello world!")`')
html = self.render_default_template([bookmark])
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
self.assertNotes(html, note_html, 1)
def test_note_cleans_html(self):
bookmark = self.setup_bookmark(notes='<script>alert("test")</script>')
html = self.render_default_template([bookmark])
note_html = '&lt;script&gt;alert("test")&lt;/script&gt;'
self.assertNotes(html, note_html, 1)
def test_notes_are_hidden_initially_by_default(self):
html = self.render_default_template([])
self.assertInHTML("""
<ul class="bookmark-list"></ul>
""", html)
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
profile = self.get_or_create_test_user().profile
profile.permanent_notes = False
profile.save()
html = self.render_default_template([])
self.assertInHTML("""
<ul class="bookmark-list"></ul>
""", html)
def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
profile = self.get_or_create_test_user().profile
profile.permanent_notes = True
profile.save()
html = self.render_default_template([])
self.assertInHTML("""
<ul class="bookmark-list show-notes"></ul>
""", html)
def test_toggle_notes_is_visible_by_default(self):
bookmark = self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark])
self.assertNotesToggle(html, 1)
def test_toggle_notes_is_visible_with_permanent_notes_disabled(self):
profile = self.get_or_create_test_user().profile
profile.permanent_notes = False
profile.save()
bookmark = self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark])
self.assertNotesToggle(html, 1)
def test_toggle_notes_is_hidden_with_permanent_notes_enabled(self):
profile = self.get_or_create_test_user().profile
profile.permanent_notes = True
profile.save()
bookmark = self.setup_bookmark(notes='Test note')
html = self.render_default_template([bookmark])
self.assertNotesToggle(html, 0)

View File

@@ -46,6 +46,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark_data = Bookmark(url='https://example.com', bookmark_data = Bookmark(url='https://example.com',
title='Updated Title', title='Updated Title',
description='Updated description', description='Updated description',
notes='Updated notes',
unread=True, unread=True,
shared=True, shared=True,
is_archived=True) is_archived=True)
@@ -55,6 +56,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.id, original_bookmark.id) self.assertEqual(updated_bookmark.id, original_bookmark.id)
self.assertEqual(updated_bookmark.title, bookmark_data.title) self.assertEqual(updated_bookmark.title, bookmark_data.title)
self.assertEqual(updated_bookmark.description, bookmark_data.description) self.assertEqual(updated_bookmark.description, bookmark_data.description)
self.assertEqual(updated_bookmark.notes, bookmark_data.notes)
self.assertEqual(updated_bookmark.unread, bookmark_data.unread) self.assertEqual(updated_bookmark.unread, bookmark_data.unread)
self.assertEqual(updated_bookmark.shared, bookmark_data.shared) self.assertEqual(updated_bookmark.shared, bookmark_data.shared)
# Saving a duplicate bookmark should not modify archive flag - right? # Saving a duplicate bookmark should not modify archive flag - right?

View File

@@ -32,6 +32,8 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(title=random_sentence(including_word='TERM1')), self.setup_bookmark(title=random_sentence(including_word='TERM1')),
self.setup_bookmark(description=random_sentence(including_word='term1')), self.setup_bookmark(description=random_sentence(including_word='term1')),
self.setup_bookmark(description=random_sentence(including_word='TERM1')), self.setup_bookmark(description=random_sentence(including_word='TERM1')),
self.setup_bookmark(notes=random_sentence(including_word='term1')),
self.setup_bookmark(notes=random_sentence(including_word='TERM1')),
self.setup_bookmark(website_title=random_sentence(including_word='term1')), self.setup_bookmark(website_title=random_sentence(including_word='term1')),
self.setup_bookmark(website_title=random_sentence(including_word='TERM1')), self.setup_bookmark(website_title=random_sentence(including_word='TERM1')),
self.setup_bookmark(website_description=random_sentence(including_word='term1')), self.setup_bookmark(website_description=random_sentence(including_word='term1')),
@@ -92,6 +94,8 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(title=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]), self.setup_bookmark(title=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
self.setup_bookmark(description=random_sentence(including_word='term1'), tags=[self.setup_tag()]), self.setup_bookmark(description=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
self.setup_bookmark(description=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]), self.setup_bookmark(description=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
self.setup_bookmark(notes=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
self.setup_bookmark(notes=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
self.setup_bookmark(website_title=random_sentence(including_word='term1'), tags=[self.setup_tag()]), self.setup_bookmark(website_title=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
self.setup_bookmark(website_title=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]), self.setup_bookmark(website_title=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
self.setup_bookmark(website_description=random_sentence(including_word='term1'), tags=[self.setup_tag()]), self.setup_bookmark(website_description=random_sentence(including_word='term1'), tags=[self.setup_tag()]),

View File

@@ -30,6 +30,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
'enable_favicons': False, 'enable_favicons': False,
'tag_search': UserProfile.TAG_SEARCH_STRICT, 'tag_search': UserProfile.TAG_SEARCH_STRICT,
'display_url': False, 'display_url': False,
'permanent_notes': False,
} }
return {**form_data, **overrides} return {**form_data, **overrides}
@@ -56,6 +57,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
'enable_favicons': True, 'enable_favicons': True,
'tag_search': UserProfile.TAG_SEARCH_LAX, 'tag_search': UserProfile.TAG_SEARCH_LAX,
'display_url': True, 'display_url': True,
'permanent_notes': True,
} }
response = self.client.post(reverse('bookmarks:settings.general'), form_data) response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode() html = response.content.decode()
@@ -71,6 +73,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons']) self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons'])
self.assertEqual(self.user.profile.tag_search, form_data['tag_search']) self.assertEqual(self.user.profile.tag_search, form_data['tag_search'])
self.assertEqual(self.user.profile.display_url, form_data['display_url']) self.assertEqual(self.user.profile.display_url, form_data['display_url'])
self.assertEqual(self.user.profile.permanent_notes, form_data['permanent_notes'])
self.assertInHTML(''' self.assertInHTML('''
<p class="form-input-hint">Profile updated</p> <p class="form-input-hint">Profile updated</p>
''', html) ''', html)

View File

@@ -45,6 +45,7 @@ Example response:
"url": "https://example.com", "url": "https://example.com",
"title": "Example title", "title": "Example title",
"description": "Example description", "description": "Example description",
"notes": "Example notes",
"website_title": "Website title", "website_title": "Website title",
"website_description": "Website description", "website_description": "Website description",
"is_archived": false, "is_archived": false,
@@ -96,6 +97,7 @@ Example payload:
"url": "https://example.com", "url": "https://example.com",
"title": "Example title", "title": "Example title",
"description": "Example description", "description": "Example description",
"notes": "Example notes",
"is_archived": false, "is_archived": false,
"unread": false, "unread": false,
"shared": false, "shared": false,

View File

@@ -1,5 +1,7 @@
asgiref==3.5.2 asgiref==3.5.2
beautifulsoup4==4.11.1 beautifulsoup4==4.11.1
bleach==6.0.0
bleach-allowlist==1.0.3
certifi==2022.12.7 certifi==2022.12.7
charset-normalizer==2.1.1 charset-normalizer==2.1.1
click==8.1.3 click==8.1.3
@@ -12,6 +14,7 @@ django-widget-tweaks==1.4.12
django4-background-tasks==1.2.7 django4-background-tasks==1.2.7
djangorestframework==3.13.1 djangorestframework==3.13.1
idna==3.3 idna==3.3
Markdown==3.4.3
psycopg2==2.9.5 psycopg2==2.9.5
python-dateutil==2.8.2 python-dateutil==2.8.2
pytz==2022.2.1 pytz==2022.2.1
@@ -23,3 +26,4 @@ typing-extensions==3.10.0.0
urllib3==1.26.11 urllib3==1.26.11
uWSGI==2.0.20 uWSGI==2.0.20
waybackpy==3.0.6 waybackpy==3.0.6
webencodings==0.5.1

View File

@@ -1,5 +1,7 @@
asgiref==3.5.2 asgiref==3.5.2
beautifulsoup4==4.11.1 beautifulsoup4==4.11.1
bleach==6.0.0
bleach-allowlist==1.0.3
certifi==2022.12.7 certifi==2022.12.7
charset-normalizer==2.1.1 charset-normalizer==2.1.1
click==8.1.3 click==8.1.3
@@ -18,6 +20,7 @@ djangorestframework==3.13.1
greenlet==2.0.1 greenlet==2.0.1
idna==3.3 idna==3.3
libsass==0.21.0 libsass==0.21.0
Markdown==3.4.3
playwright==1.29.1 playwright==1.29.1
psycopg2-binary==2.9.5 psycopg2-binary==2.9.5
pyee==9.0.4 pyee==9.0.4
@@ -32,3 +35,4 @@ sqlparse==0.4.4
typing-extensions==3.10.0.0 typing-extensions==3.10.0.0
urllib3==1.26.11 urllib3==1.26.11
waybackpy==3.0.6 waybackpy==3.0.6
webencodings==0.5.1

View File

@@ -19,6 +19,9 @@ INTERNAL_IPS = [
'127.0.0.1', '127.0.0.1',
] ]
# Allow access through ngrok
CSRF_TRUSTED_ORIGINS = ['https://*.ngrok-free.app']
# Enable debug logging # Enable debug logging
LOGGING = { LOGGING = {
'version': 1, 'version': 1,