diff --git a/bookmarks/api/serializers.py b/bookmarks/api/serializers.py
index e9d4d112..b82b4ae3 100644
--- a/bookmarks/api/serializers.py
+++ b/bookmarks/api/serializers.py
@@ -27,6 +27,7 @@ class Meta:
'url',
'title',
'description',
+ 'notes',
'website_title',
'website_description',
'is_archived',
@@ -47,6 +48,7 @@ class Meta:
# Override optional char fields to provide default value
title = 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)
unread = serializers.BooleanField(required=False, default=False)
shared = serializers.BooleanField(required=False, default=False)
@@ -58,6 +60,7 @@ def create(self, validated_data):
bookmark.url = validated_data['url']
bookmark.title = validated_data['title']
bookmark.description = validated_data['description']
+ bookmark.notes = validated_data['notes']
bookmark.is_archived = validated_data['is_archived']
bookmark.unread = validated_data['unread']
bookmark.shared = validated_data['shared']
@@ -66,7 +69,7 @@ def create(self, validated_data):
def update(self, instance: Bookmark, validated_data):
# 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:
setattr(instance, key, validated_data[key])
diff --git a/bookmarks/e2e/test_bookmark_form.py b/bookmarks/e2e/test_bookmark_form.py
index 32f99132..a05639c4 100644
--- a/bookmarks/e2e/test_bookmark_form.py
+++ b/bookmarks/e2e/test_bookmark_form.py
@@ -1,5 +1,5 @@
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
@@ -8,6 +8,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
def test_create_should_check_for_existing_bookmark(self):
existing_bookmark = self.setup_bookmark(title='Existing title',
description='Existing description',
+ notes='Existing notes',
tags=[self.setup_tag(name='tag1'), self.setup_tag(name='tag2')],
website_title='Existing website title',
website_description='Existing website description',
@@ -26,6 +27,7 @@ def test_create_should_check_for_existing_bookmark(self):
# 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.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_description,
page.get_by_label('Description').get_attribute('placeholder'))
@@ -49,3 +51,17 @@ def test_edit_should_not_check_for_existing_bookmark(self):
page.wait_for_timeout(timeout=1000)
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='')
diff --git a/bookmarks/e2e/test_bookmark_list.py b/bookmarks/e2e/test_bookmark_list.py
new file mode 100644
index 00000000..789ac8ff
--- /dev/null
+++ b/bookmarks/e2e/test_bookmark_list.py
@@ -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()
diff --git a/bookmarks/migrations/0022_bookmark_notes.py b/bookmarks/migrations/0022_bookmark_notes.py
new file mode 100644
index 00000000..4670a61a
--- /dev/null
+++ b/bookmarks/migrations/0022_bookmark_notes.py
@@ -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),
+ ),
+ ]
diff --git a/bookmarks/migrations/0023_userprofile_permanent_notes.py b/bookmarks/migrations/0023_userprofile_permanent_notes.py
new file mode 100644
index 00000000..4bfc24e9
--- /dev/null
+++ b/bookmarks/migrations/0023_userprofile_permanent_notes.py
@@ -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),
+ ),
+ ]
diff --git a/bookmarks/models.py b/bookmarks/models.py
index 3d8ec93d..253ec594 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -50,6 +50,7 @@ class Bookmark(models.Model):
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
title = models.CharField(max_length=512, blank=True)
description = models.TextField(blank=True)
+ notes = models.TextField(blank=True)
website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
@@ -110,6 +111,7 @@ class Meta:
'tag_string',
'title',
'description',
+ 'notes',
'website_title',
'website_description',
'unread',
@@ -117,6 +119,10 @@ class Meta:
'auto_close',
]
+ @property
+ def has_notes(self):
+ return self.instance and self.instance.notes
+
class BookmarkFilters:
def __init__(self, request: WSGIRequest):
@@ -172,13 +178,14 @@ class UserProfile(models.Model):
enable_sharing = models.BooleanField(default=False, null=False)
enable_favicons = 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 Meta:
model = UserProfile
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())
diff --git a/bookmarks/queries.py b/bookmarks/queries.py
index 3008d09a..fc072396 100644
--- a/bookmarks/queries.py
+++ b/bookmarks/queries.py
@@ -37,6 +37,7 @@ def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_stri
for term in query['search_terms']:
conditions = Q(title__icontains=term) \
| Q(description__icontains=term) \
+ | Q(notes__icontains=term) \
| Q(website_title__icontains=term) \
| Q(website_description__icontains=term) \
| Q(url__icontains=term)
diff --git a/bookmarks/services/bookmarks.py b/bookmarks/services/bookmarks.py
index 2c6679e5..49039fc8 100644
--- a/bookmarks/services/bookmarks.py
+++ b/bookmarks/services/bookmarks.py
@@ -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):
to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description
+ to_bookmark.notes = from_bookmark.notes
to_bookmark.unread = from_bookmark.unread
to_bookmark.shared = from_bookmark.shared
diff --git a/bookmarks/static/bookmark_list.js b/bookmarks/static/bookmark_list.js
index aa5637d1..9f3b84a6 100644
--- a/bookmarks/static/bookmark_list.js
+++ b/bookmarks/static/bookmark_list.js
@@ -1,5 +1,13 @@
(function () {
+ function allowBulkEdit() {
+ return !!document.getElementById('bulk-edit-mode');
+ }
+
function setupBulkEdit() {
+ if (!allowBulkEdit()) {
+ return;
+ }
+
const bulkEditToggle = document.getElementById('bulk-edit-mode')
const bulkEditBar = document.querySelector('.bulk-edit-bar')
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
@@ -64,6 +72,10 @@
}
function setupBulkEditTagAutoComplete() {
+ if (!allowBulkEdit()) {
+ return;
+ }
+
const wrapper = document.createElement('div');
const tagInput = document.getElementById('bulk-edit-tags-input');
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();
setupBulkEditTagAutoComplete();
setupListNavigation();
+ setupNotes();
})()
diff --git a/bookmarks/styles/base.scss b/bookmarks/styles/base.scss
index 9ab98b81..7c6e396d 100644
--- a/bookmarks/styles/base.scss
+++ b/bookmarks/styles/base.scss
@@ -72,6 +72,12 @@ a:visited:hover {
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
.container > .columns > .column:not(:first-child) {
padding-left: 2rem;
diff --git a/bookmarks/styles/bookmarks.scss b/bookmarks/styles/bookmarks.scss
index 874c056f..1911324a 100644
--- a/bookmarks/styles/bookmarks.scss
+++ b/bookmarks/styles/bookmarks.scss
@@ -43,11 +43,19 @@
}
}
+/* Bookmark list */
ul.bookmark-list {
-
list-style: none;
margin: 0;
padding: 0;
+}
+
+/* Bookmarks */
+ul.bookmark-list li {
+
+ .bulk-edit-toggle {
+ display: none;
+ }
.title a {
display: inline-block;
@@ -76,31 +84,44 @@ ul.bookmark-list {
}
}
- .actions > *:not(:last-child) {
- margin-right: 0.1rem;
+ .actions {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
}
- .actions .date-label a {
- color: $gray-color;
- }
+ .actions {
+ > *:not(:last-child) {
+ margin-right: 0.4rem;
+ }
- .actions .btn-link {
- color: $gray-color;
- padding: 0;
- height: auto;
- vertical-align: unset;
- border: none;
+ a, button {
+ 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;
+ }
+ }
- &:focus,
- &:hover,
- &:active,
- &.active {
- color: $gray-color-dark;
+ .separator {
+ align-self: flex-start;
}
- }
- .bulk-edit-toggle {
- display: none;
+ .toggle-notes {
+ align-self: center;
+ display: flex;
+ align-items: center;
+ gap: 0.1rem;
+ }
}
}
@@ -180,6 +201,68 @@ ul.bookmark-list {
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 */
diff --git a/bookmarks/styles/variables-dark.scss b/bookmarks/styles/variables-dark.scss
index ecaa0e55..56a69c41 100644
--- a/bookmarks/styles/variables-dark.scss
+++ b/bookmarks/styles/variables-dark.scss
@@ -26,5 +26,8 @@ $secondary-link-color: rgba(168, 177, 255, 0.73);
$alternative-color: #59bdb9;
$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 */
$dt-primary-button-color: #5761cb !default;
diff --git a/bookmarks/styles/variables-light.scss b/bookmarks/styles/variables-light.scss
index 7d71d70f..38d8c34e 100644
--- a/bookmarks/styles/variables-light.scss
+++ b/bookmarks/styles/variables-light.scss
@@ -3,4 +3,7 @@ $html-font-size: 18px !default;
$alternative-color: #05a6a3;
$alternative-color-dark: darken($alternative-color, 5%);
-$secondary-link-color: rgba(87, 85, 217, 0.64);
\ No newline at end of file
+$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);
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index b3894c10..8f1b54fb 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -1,110 +1,128 @@
{% load static %}
{% load shared %}
{% load pagination %}
-{% htmlmin %}
-
- {% for bookmark in bookmarks %}
-
-
-
-
-
-
- {% if request.user.profile.display_url %}
+
+ {% for bookmark in bookmarks %}
+
+
+
+
+
+
+ {% if request.user.profile.display_url %}
- {% endif %}
-
- {% if bookmark.tag_names %}
-
+ {% endif %}
+
+ {% if bookmark.tag_names %}
+
{% for tag_name in bookmark.tag_names %}
{{ tag_name|hash_tag }}
{% endfor %}
- {% endif %}
- {% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
- {% if bookmark.resolved_description %}
-
{{ bookmark.resolved_description }}
- {% endif %}
+ {% endif %}
+ {% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
+ {% if bookmark.resolved_description %}
+
{{ bookmark.resolved_description }}
+ {% endif %}
+
+ {% if bookmark.notes %}
+
+
+ {% markdown bookmark.notes %}
+
-
- {% if request.user.profile.bookmark_date_display == 'relative' %}
-
+ {% endif %}
+
+ {% if request.user.profile.bookmark_date_display == 'relative' %}
+
{% if bookmark.web_archive_snapshot_url %}
{% endif %}
- {{ bookmark.date_added|humanize_relative_date }}
- {% if bookmark.web_archive_snapshot_url %}
- ∞
-
- {% endif %}
+ {{ bookmark.date_added|humanize_relative_date }}
+ {% if bookmark.web_archive_snapshot_url %}
+ ∞
+
+ {% endif %}
-
|
- {% endif %}
- {% if request.user.profile.bookmark_date_display == 'absolute' %}
-
+ |
+ {% endif %}
+ {% if request.user.profile.bookmark_date_display == 'absolute' %}
+
{% if bookmark.web_archive_snapshot_url %}
{% endif %}
- {{ bookmark.date_added|humanize_absolute_date }}
- {% if bookmark.web_archive_snapshot_url %}
- ∞
-
- {% endif %}
-
- |
- {% endif %}
- {% if bookmark.owner == request.user %}
- {# Bookmark owner actions #}
- Edit
- {% if bookmark.is_archived %}
- Unarchive
-
- {% else %}
- Archive
-
+ {{ bookmark.date_added|humanize_absolute_date }}
+ {% if bookmark.web_archive_snapshot_url %}
+ ∞
+
{% endif %}
- Remove
+
+
|
+ {% endif %}
+ {% if bookmark.owner == request.user %}
+ {# Bookmark owner actions #}
+
Edit
+ {% if bookmark.is_archived %}
+
Unarchive
- {% if bookmark.unread %}
-
|
-
Mark as read
-
- {% endif %}
{% else %}
- {# Shared bookmark actions #}
-
Shared by
- {{ bookmark.owner.username }}
-
+
Archive
+
{% endif %}
-
-
- {% endfor %}
-
+ Remove
+
+ {% if bookmark.unread %}
+ |
+ Mark as read
+
+ {% endif %}
+ {% else %}
+ {# Shared bookmark actions #}
+ Shared by
+ {{ bookmark.owner.username }}
+
+ {% endif %}
+ {% if bookmark.notes and not request.user.profile.permanent_notes %}
+ |
+
+
+
+
+
+
+
+
+ Notes
+
+ {% endif %}
+
+
+ {% endfor %}
+
-
-{% endhtmlmin %}
+
diff --git a/bookmarks/templates/bookmarks/form.html b/bookmarks/templates/bookmarks/form.html
index 1f6b3fcb..fe075156 100644
--- a/bookmarks/templates/bookmarks/form.html
+++ b/bookmarks/templates/bookmarks/form.html
@@ -67,6 +67,19 @@
{{ form.description.errors }}
+
{{ form.unread }}
@@ -128,6 +141,8 @@
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
const titleInput = document.getElementById('{{ form.title.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 unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
@@ -149,11 +164,17 @@
}
function updateInput(input, value) {
- input.value = value;
+ if (!input) {
+ return;
+ }
+ input.value = value;
}
function updateCheckbox(input, value) {
- input.checked = value;
+ if (!input) {
+ return;
+ }
+ input.checked = value;
}
function checkUrl() {
@@ -179,8 +200,10 @@
if (existingBookmark && !editedBookmarkId) {
bookmarkExistsHint.style['display'] = 'block';
+ notesDetails.open = !!existingBookmark.notes;
updateInput(titleInput, existingBookmark.title);
updateInput(descriptionInput, existingBookmark.description);
+ updateInput(notesInput, existingBookmark.notes);
updateInput(tagsInput, existingBookmark.tag_names.join(" "));
updateCheckbox(unreadCheckbox, existingBookmark.unread);
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
// For existing bookmarks we get the website metadata through hidden inputs
if (urlInput.value && !editedBookmarkId) {
@@ -213,9 +239,6 @@
updatePlaceholder(titleInput, websiteTitleInput.value);
updatePlaceholder(descriptionInput, websiteDescriptionInput.value);
}
-
- setupEditAutoValueButton(titleInput);
- setupEditAutoValueButton(descriptionInput);
})();
diff --git a/bookmarks/templates/bookmarks/shared.html b/bookmarks/templates/bookmarks/shared.html
index ebcffd49..268493db 100644
--- a/bookmarks/templates/bookmarks/shared.html
+++ b/bookmarks/templates/bookmarks/shared.html
@@ -45,4 +45,5 @@ Tags
+
{% endblock %}
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index ab8c095d..1d243df5 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -38,6 +38,16 @@ Profile
When enabled, this setting displays the bookmark URL below the title.
+
Open bookmarks in
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }}
diff --git a/bookmarks/templatetags/shared.py b/bookmarks/templatetags/shared.py
index a7afda91..87b761f9 100644
--- a/bookmarks/templatetags/shared.py
+++ b/bookmarks/templatetags/shared.py
@@ -1,6 +1,10 @@
import re
+import bleach
+import markdown
+from bleach_allowlist import markdown_tags, markdown_attrs
from django import template
+from django.utils.safestring import mark_safe
from bookmarks import utils
from bookmarks.models import UserProfile
@@ -113,3 +117,19 @@ def render(self, context):
output = re.sub(r'\s+', ' ', 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)
diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py
index e0e87a5b..d459bc54 100644
--- a/bookmarks/tests/helpers.py
+++ b/bookmarks/tests/helpers.py
@@ -30,6 +30,7 @@ def setup_bookmark(self,
url: str = '',
title: str = '',
description: str = '',
+ notes: str = '',
website_title: str = '',
website_description: str = '',
web_archive_snapshot_url: str = '',
@@ -48,6 +49,7 @@ def setup_bookmark(self,
url=url,
title=title,
description=description,
+ notes=notes,
website_title=website_title,
website_description=website_description,
date_added=timezone.now(),
diff --git a/bookmarks/tests/test_bookmark_edit_view.py b/bookmarks/tests/test_bookmark_edit_view.py
index bc8cc6cf..93b4c207 100644
--- a/bookmarks/tests/test_bookmark_edit_view.py
+++ b/bookmarks/tests/test_bookmark_edit_view.py
@@ -20,6 +20,7 @@ def create_form_data(self, overrides=None):
'tag_string': 'editedtag1 editedtag2',
'title': 'edited title',
'description': 'edited description',
+ 'notes': 'edited notes',
'unread': False,
'shared': False,
}
@@ -37,6 +38,7 @@ def test_should_edit_bookmark(self):
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
+ self.assertEqual(bookmark.notes, form_data['notes'])
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
@@ -74,7 +76,8 @@ def test_should_prefill_bookmark_form_fields(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
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]))
html = response.content.decode()
@@ -101,6 +104,12 @@ def test_should_prefill_bookmark_form_fields(self):
''', html)
+ self.assertInHTML(f'''
+
+ ''', html)
+
self.assertInHTML(f'''
@@ -184,3 +193,15 @@ def test_should_respect_share_profile_setting(self):
Share
''', 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, '
', 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, '', count=1)
diff --git a/bookmarks/tests/test_bookmark_index_view.py b/bookmarks/tests/test_bookmark_index_view.py
index cc01206d..b96970a7 100644
--- a/bookmarks/tests/test_bookmark_index_view.py
+++ b/bookmarks/tests/test_bookmark_index_view.py
@@ -227,8 +227,7 @@ def test_edit_link_return_url_should_contain_query_params(self):
return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f'''
- Edit
+ Edit
''', html)
# with query params
@@ -239,6 +238,5 @@ def test_edit_link_return_url_should_contain_query_params(self):
return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f'''
- Edit
+ Edit
''', html)
diff --git a/bookmarks/tests/test_bookmark_new_view.py b/bookmarks/tests/test_bookmark_new_view.py
index 85025022..83ec706b 100644
--- a/bookmarks/tests/test_bookmark_new_view.py
+++ b/bookmarks/tests/test_bookmark_new_view.py
@@ -19,6 +19,7 @@ def create_form_data(self, overrides=None):
'tag_string': 'tag1 tag2',
'title': 'test title',
'description': 'test description',
+ 'notes': 'test notes',
'unread': False,
'shared': False,
'auto_close': '',
@@ -37,6 +38,7 @@ def test_should_create_new_bookmark(self):
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
+ self.assertEqual(bookmark.notes, form_data['notes'])
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
@@ -138,3 +140,9 @@ def test_should_respect_share_profile_setting(self):
Share
''', 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, '', count=1)
diff --git a/bookmarks/tests/test_bookmarks_api.py b/bookmarks/tests/test_bookmarks_api.py
index d1c9362d..d2891ad7 100644
--- a/bookmarks/tests/test_bookmarks_api.py
+++ b/bookmarks/tests/test_bookmarks_api.py
@@ -20,7 +20,7 @@ def setUp(self) -> None:
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
self.tag1 = 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.bookmark3 = self.setup_bookmark(tags=[self.tag2])
self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, self.tag2])
@@ -36,6 +36,7 @@ def assertBookmarkListEqual(self, data_list, bookmarks):
expectation['url'] = bookmark.url
expectation['title'] = bookmark.title
expectation['description'] = bookmark.description
+ expectation['notes'] = bookmark.notes
expectation['website_title'] = bookmark.website_title
expectation['website_description'] = bookmark.website_description
expectation['is_archived'] = bookmark.is_archived
@@ -134,6 +135,7 @@ def test_create_bookmark(self):
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
+ 'notes': 'Test notes',
'is_archived': False,
'unread': False,
'shared': False,
@@ -144,6 +146,7 @@ def test_create_bookmark(self):
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
+ self.assertEqual(bookmark.notes, data['notes'])
self.assertFalse(bookmark.is_archived, data['is_archived'])
self.assertFalse(bookmark.unread, data['unread'])
self.assertFalse(bookmark.shared, data['shared'])
@@ -157,6 +160,7 @@ def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
'url': original_bookmark.url,
'title': 'Updated title',
'description': 'Updated description',
+ 'notes': 'Updated notes',
'unread': True,
'shared': True,
'is_archived': True,
@@ -168,6 +172,7 @@ def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
+ self.assertEqual(bookmark.notes, data['notes'])
# Saving a duplicate bookmark should not modify archive flag - right?
self.assertFalse(bookmark.is_archived)
self.assertEqual(bookmark.unread, data['unread'])
@@ -265,6 +270,7 @@ def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
self.assertEqual(updated_bookmark.url, data['url'])
self.assertEqual(updated_bookmark.title, '')
self.assertEqual(updated_bookmark.description, '')
+ self.assertEqual(updated_bookmark.notes, '')
self.assertEqual(updated_bookmark.tag_names, [])
def test_update_bookmark_unread_flag(self):
@@ -300,6 +306,12 @@ def test_patch_bookmark(self):
self.bookmark1.refresh_from_db()
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}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
diff --git a/bookmarks/tests/test_bookmarks_list_tag.py b/bookmarks/tests/test_bookmarks_list_tag.py
index a8ae700d..fb6ee9bf 100644
--- a/bookmarks/tests/test_bookmarks_list_tag.py
+++ b/bookmarks/tests/test_bookmarks_list_tag.py
@@ -24,22 +24,22 @@ def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str =
def assertDateLabel(self, html: str, label_content: str):
self.assertInHTML(f'''
-
+
{label_content}
- |
+ |
''', html)
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
self.assertInHTML(f'''
-
+
{label_content}
- ∞
+ ∞
- |
+ |
''', html)
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
@@ -52,8 +52,7 @@ def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
# Edit link
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
self.assertInHTML(f'''
- Edit
+ Edit
''', html, count=count)
# Archive link
self.assertInHTML(f'''
@@ -74,8 +73,8 @@ def assertNoShareInfo(self, html: str, bookmark: Bookmark):
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
- Shared by
- {bookmark.owner.username}
+ Shared by
+ {bookmark.owner.username}
''', html, count=count)
@@ -93,21 +92,43 @@ def assertFaviconCount(self, html: str, bookmark: Bookmark, count=1):
def assertBookmarkURLCount(self, html: str, bookmark: Bookmark, link_target: str = '_blank', count=0):
self.assertInHTML(f'''
''', html, count)
def assertBookmarkURLVisible(self, html: str, bookmark: Bookmark):
self.assertBookmarkURLCount(html, bookmark, count=1)
-
def assertBookmarkURLHidden(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
self.assertBookmarkURLCount(html, bookmark, count=0)
-
+ def assertNotes(self, html: str, notes_html: str, count=1):
+ self.assertInHTML(f'''
+
+ ''', html, count=count)
+
+ def assertNotesToggle(self, html: str, count=1):
+ self.assertInHTML(f'''
+
+
+
+
+
+
+
+
+ Notes
+
+ ''', html, count=count)
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
rf = RequestFactory()
@@ -237,8 +258,8 @@ def test_share_info_user_link_keeps_query_params(self):
html = self.render_default_template([bookmark], url='/test?q=foo')
self.assertInHTML(f'''
- Shared by
- {bookmark.owner.username}
+ Shared by
+ {bookmark.owner.username}
''', html)
@@ -279,8 +300,8 @@ def test_bookmark_url_should_be_hidden_by_default(self):
bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark])
- self.assertBookmarkURLHidden(html,bookmark)
-
+ self.assertBookmarkURLHidden(html, bookmark)
+
def test_show_bookmark_url_when_enabled(self):
profile = self.get_or_create_test_user().profile
profile.display_url = True
@@ -289,7 +310,7 @@ def test_show_bookmark_url_when_enabled(self):
bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark])
- self.assertBookmarkURLVisible(html,bookmark)
+ self.assertBookmarkURLVisible(html, bookmark)
def test_hide_bookmark_url_when_disabled(self):
profile = self.get_or_create_test_user().profile
@@ -299,6 +320,85 @@ def test_hide_bookmark_url_when_disabled(self):
bookmark = self.setup_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 = 'Test note
'
+ 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 = 'Example: print("Hello world!")
'
+ self.assertNotes(html, note_html, 1)
+
+ def test_note_cleans_html(self):
+ bookmark = self.setup_bookmark(notes='')
+ html = self.render_default_template([bookmark])
+
+ note_html = '<script>alert("test")</script>'
+ self.assertNotes(html, note_html, 1)
+
+ def test_notes_are_hidden_initially_by_default(self):
+ html = self.render_default_template([])
+
+ self.assertInHTML("""
+
+ """, 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("""
+
+ """, 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("""
+
+ """, 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)
diff --git a/bookmarks/tests/test_bookmarks_service.py b/bookmarks/tests/test_bookmarks_service.py
index df10c84f..b5c1842c 100644
--- a/bookmarks/tests/test_bookmarks_service.py
+++ b/bookmarks/tests/test_bookmarks_service.py
@@ -46,6 +46,7 @@ def test_create_should_update_existing_bookmark_with_same_url(self):
bookmark_data = Bookmark(url='https://example.com',
title='Updated Title',
description='Updated description',
+ notes='Updated notes',
unread=True,
shared=True,
is_archived=True)
@@ -55,6 +56,7 @@ def test_create_should_update_existing_bookmark_with_same_url(self):
self.assertEqual(updated_bookmark.id, original_bookmark.id)
self.assertEqual(updated_bookmark.title, bookmark_data.title)
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.shared, bookmark_data.shared)
# Saving a duplicate bookmark should not modify archive flag - right?
diff --git a/bookmarks/tests/test_queries.py b/bookmarks/tests/test_queries.py
index d0748044..fc791f5d 100644
--- a/bookmarks/tests/test_queries.py
+++ b/bookmarks/tests/test_queries.py
@@ -32,6 +32,8 @@ def setup_bookmark_search_data(self) -> None:
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(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_description=random_sentence(including_word='term1')),
@@ -92,6 +94,8 @@ def setup_tag_search_data(self):
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(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_description=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index 7fe62fb1..6edc1db2 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -30,6 +30,7 @@ def create_profile_form_data(self, overrides=None):
'enable_favicons': False,
'tag_search': UserProfile.TAG_SEARCH_STRICT,
'display_url': False,
+ 'permanent_notes': False,
}
return {**form_data, **overrides}
@@ -56,6 +57,7 @@ def test_update_profile(self):
'enable_favicons': True,
'tag_search': UserProfile.TAG_SEARCH_LAX,
'display_url': True,
+ 'permanent_notes': True,
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode()
@@ -71,6 +73,7 @@ def test_update_profile(self):
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.display_url, form_data['display_url'])
+ self.assertEqual(self.user.profile.permanent_notes, form_data['permanent_notes'])
self.assertInHTML('''
Profile updated
''', html)
diff --git a/docs/API.md b/docs/API.md
index 3e4f50b3..6df34693 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -45,6 +45,7 @@ Example response:
"url": "https://example.com",
"title": "Example title",
"description": "Example description",
+ "notes": "Example notes",
"website_title": "Website title",
"website_description": "Website description",
"is_archived": false,
@@ -96,6 +97,7 @@ Example payload:
"url": "https://example.com",
"title": "Example title",
"description": "Example description",
+ "notes": "Example notes",
"is_archived": false,
"unread": false,
"shared": false,
diff --git a/requirements.prod.txt b/requirements.prod.txt
index c10236a1..a2de7738 100644
--- a/requirements.prod.txt
+++ b/requirements.prod.txt
@@ -1,5 +1,7 @@
asgiref==3.5.2
beautifulsoup4==4.11.1
+bleach==6.0.0
+bleach-allowlist==1.0.3
certifi==2022.12.7
charset-normalizer==2.1.1
click==8.1.3
@@ -12,6 +14,7 @@ django-widget-tweaks==1.4.12
django4-background-tasks==1.2.7
djangorestframework==3.13.1
idna==3.3
+Markdown==3.4.3
psycopg2==2.9.5
python-dateutil==2.8.2
pytz==2022.2.1
@@ -23,3 +26,4 @@ typing-extensions==3.10.0.0
urllib3==1.26.11
uWSGI==2.0.20
waybackpy==3.0.6
+webencodings==0.5.1
diff --git a/requirements.txt b/requirements.txt
index 0cdf2ef8..3f72c5e5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,7 @@
asgiref==3.5.2
beautifulsoup4==4.11.1
+bleach==6.0.0
+bleach-allowlist==1.0.3
certifi==2022.12.7
charset-normalizer==2.1.1
click==8.1.3
@@ -18,6 +20,7 @@ djangorestframework==3.13.1
greenlet==2.0.1
idna==3.3
libsass==0.21.0
+Markdown==3.4.3
playwright==1.29.1
psycopg2-binary==2.9.5
pyee==9.0.4
@@ -32,3 +35,4 @@ sqlparse==0.4.4
typing-extensions==3.10.0.0
urllib3==1.26.11
waybackpy==3.0.6
+webencodings==0.5.1
diff --git a/siteroot/settings/dev.py b/siteroot/settings/dev.py
index 3e85d8f4..e24608b7 100644
--- a/siteroot/settings/dev.py
+++ b/siteroot/settings/dev.py
@@ -19,6 +19,9 @@
'127.0.0.1',
]
+# Allow access through ngrok
+CSRF_TRUSTED_ORIGINS = ['https://*.ngrok-free.app']
+
# Enable debug logging
LOGGING = {
'version': 1,