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 %} - -
- {% pagination bookmarks %} -
-{% endhtmlmin %} +
+ {% pagination bookmarks %} +
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 }} +
+
+ + Notes + + + {{ form.notes|add_class:"form-input"|attr:"rows:8" }} +
+ Additional notes, supports Markdown. +
+ {{ form.notes.errors }} +
+
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. +
+ +
+ Whether to show bookmark notes permanently, without having to toggle them individually. + Alternatively the keyboard shortcut e can be used to temporarily show all notes. +
+
{{ 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''' +
+
+ {notes_html} +
+
+ ''', html, count=count) + + def assertNotesToggle(self, html: str, count=1): + self.assertInHTML(f''' + + ''', 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,