Compare commits

...

14 Commits

Author SHA1 Message Date
Sascha Ißbrücker
047d3be1b5 Bump version 2023-05-20 13:02:19 +02:00
Sascha Ißbrücker
43115fd8f2 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
2023-05-20 11:54:26 +02:00
Sascha Ißbrücker
67ee896a46 Update CHANGELOG.md 2023-05-18 12:37:01 +02:00
Sascha Ißbrücker
fd3070c6f3 Bump version 2023-05-18 11:15:30 +02:00
bah0
bc374e90a2 Add option to display URL below title (#365)
* Add feature to display URL below title

* updates pre-merging

* Bookmark URL Tests & solving pending migration

* cleanup after rebase

* add test for updating setting

---------

Co-authored-by: Bahadir Parmaksiz <bahadir.parmaksiz@tmconnected.com>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2023-05-18 10:18:15 +02:00
François Ménabé
a94eb5f85a Allow to log real client ip in logs when using a reverse proxy (#398)
* Allow to log real client ip in logs when using a reverse proxy

* rearrange options

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2023-05-18 09:34:55 +02:00
Paul Lockaby
d1819c6503 Add database options (#406)
* adding support for database connection options

* a better default
2023-05-18 09:31:13 +02:00
Daniel Henning
353ba433f0 Prevent zoom-in after focusing an input on small viewports on iOS devices (#440)
* base.scss: Prevent zoom-in on focusing inputs on small viewports

Adding a media query which sets the font-size for `.form-input` inputs
to 1rem. This aims to prevent the zoom-in on small viewports on iOS
devics which automatically zoom-in a website if the font-size in a
focused input is smaller than 16px.

* Update bookmarks/styles/base.scss

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2023-05-18 09:24:55 +02:00
Sascha Ißbrücker
3af4e07eb6 Allow searching for tags without hash character (#449)
* Allow searching for tags without hash character

* Allow removing selected tags without hash

* Add more tests
2023-05-18 09:06:22 +02:00
dependabot[bot]
e9061f373a Bump sqlparse from 0.4.2 to 0.4.4 (#455)
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.2 to 0.4.4.
- [Release notes](https://github.com/andialbrecht/sqlparse/releases)
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.2...0.4.4)

---
updated-dependencies:
- dependency-name: sqlparse
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-18 08:49:31 +02:00
dependabot[bot]
f87398742a Bump django from 4.1.7 to 4.1.9 (#466)
Bumps [django](https://github.com/django/django) from 4.1.7 to 4.1.9.
- [Commits](https://github.com/django/django/compare/4.1.7...4.1.9)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-18 08:49:03 +02:00
Andrew Moscardino
81dc19958c Add LinkThing iOS app to community section (#446)
I've released an iOS app for linkding called LinkThing. This update adds a link to it under the Community section of the readme
2023-03-22 15:21:55 +01:00
Sascha Ißbrücker
5049ff14cf Make search case-insensitive on Postgres (#432) 2023-02-20 22:49:08 +01:00
Sascha Ißbrücker
f9ab3d1f44 Update CHANGELOG.md 2023-02-18 20:37:30 +01:00
48 changed files with 1143 additions and 271 deletions

View File

@@ -45,3 +45,5 @@ LD_DB_HOST=
# Port use to connect to the database server
# Should use the default port if not set
LD_DB_PORT=
# Any additional options to pass to the database (default: {})
LD_DB_OPTIONS=

View File

@@ -1,5 +1,45 @@
# Changelog
## v1.18.0 (18/05/2023)
### What's Changed
* Make search case-insensitive on Postgres by @sissbruecker in https://github.com/sissbruecker/linkding/pull/432
* Allow searching for tags without hash character by @sissbruecker in https://github.com/sissbruecker/linkding/pull/449
* Prevent zoom-in after focusing an input on small viewports on iOS devices by @puresick in https://github.com/sissbruecker/linkding/pull/440
* Add database options by @plockaby in https://github.com/sissbruecker/linkding/pull/406
* Allow to log real client ip in logs when using a reverse proxy by @fmenabe in https://github.com/sissbruecker/linkding/pull/398
* Add option to display URL below title by @bah0 in https://github.com/sissbruecker/linkding/pull/365
* Add LinkThing iOS app to community section by @amoscardino in https://github.com/sissbruecker/linkding/pull/446
* Bump django from 4.1.7 to 4.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/466
* Bump sqlparse from 0.4.2 to 0.4.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/455
### New Contributors
* @amoscardino made their first contribution in https://github.com/sissbruecker/linkding/pull/446
* @puresick made their first contribution in https://github.com/sissbruecker/linkding/pull/440
* @plockaby made their first contribution in https://github.com/sissbruecker/linkding/pull/406
* @fmenabe made their first contribution in https://github.com/sissbruecker/linkding/pull/398
* @bah0 made their first contribution in https://github.com/sissbruecker/linkding/pull/365
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.2...v1.18.0
---
## v1.17.2 (18/02/2023)
### What's Changed
* Escape texts in exported HTML by @sissbruecker in https://github.com/sissbruecker/linkding/pull/429
* Bump django from 4.1.2 to 4.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/427
* Make health check in Dockerfile honor context path setting by @mrex in https://github.com/sissbruecker/linkding/pull/407
* Disable autocapitalization for tag input form by @joshdick in https://github.com/sissbruecker/linkding/pull/395
### New Contributors
* @mrex made their first contribution in https://github.com/sissbruecker/linkding/pull/407
* @joshdick made their first contribution in https://github.com/sissbruecker/linkding/pull/395
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.1...v1.17.2
---
## v1.17.1 (22/01/2023)
### What's Changed

View File

@@ -193,6 +193,7 @@ This section lists community projects around using linkding, in alphabetical ord
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
## Acknowledgements

View File

@@ -23,7 +23,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
# For list action, use query set that applies search and tag projections
if self.action == 'list':
query_string = self.request.GET.get('q')
return queries.query_bookmarks(user, query_string)
return queries.query_bookmarks(user, user.profile, query_string)
# For single entity actions use default query set without projections
return Bookmark.objects.all().filter(owner=user)
@@ -35,7 +35,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
def archived(self, request):
user = request.user
query_string = request.GET.get('q')
query_set = queries.query_archived_bookmarks(user, query_string)
query_set = queries.query_archived_bookmarks(user, user.profile, query_string)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
@@ -45,7 +45,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
def shared(self, request):
filters = BookmarkFilters(request)
user = User.objects.filter(username=filters.user).first()
query_set = queries.query_shared_bookmarks(user, filters.query)
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data

View File

@@ -27,6 +27,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
'url',
'title',
'description',
'notes',
'website_title',
'website_description',
'is_archived',
@@ -47,6 +48,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
# 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 @@ class BookmarkSerializer(serializers.ModelSerializer):
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 @@ class BookmarkSerializer(serializers.ModelSerializer):
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])

View File

@@ -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 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
# 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 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
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='')

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

@@ -18,7 +18,7 @@ class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
query_string = request.GET.get('q')
query_set = queries.query_bookmarks(feed_token.user, query_string)
query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, query_string)
return FeedContext(feed_token, query_set)
def item_title(self, item: Bookmark):

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-04-10 01:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0019_userprofile_enable_favicons'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='tag_search',
field=models.CharField(choices=[('strict', 'Strict'), ('lax', 'Lax')], default='strict', max_length=10),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-05-18 07:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0020_userprofile_tag_search'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='display_url',
field=models.BooleanField(default=False),
),
]

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()])
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 BookmarkForm(forms.ModelForm):
'tag_string',
'title',
'description',
'notes',
'website_title',
'website_description',
'unread',
@@ -117,6 +119,10 @@ class BookmarkForm(forms.ModelForm):
'auto_close',
]
@property
def has_notes(self):
return self.instance and self.instance.notes
class BookmarkFilters:
def __init__(self, request: WSGIRequest):
@@ -153,6 +159,12 @@ class UserProfile(models.Model):
(WEB_ARCHIVE_INTEGRATION_DISABLED, 'Disabled'),
(WEB_ARCHIVE_INTEGRATION_ENABLED, 'Enabled'),
]
TAG_SEARCH_STRICT = 'strict'
TAG_SEARCH_LAX = 'lax'
TAG_SEARCH_CHOICES = [
(TAG_SEARCH_STRICT, 'Strict'),
(TAG_SEARCH_LAX, 'Lax'),
]
user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE)
theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO)
bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False,
@@ -161,14 +173,19 @@ class UserProfile(models.Model):
default=BOOKMARK_LINK_TARGET_BLANK)
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
tag_search = models.CharField(max_length=10, choices=TAG_SEARCH_CHOICES, blank=False,
default=TAG_SEARCH_STRICT)
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', 'enable_sharing', 'enable_favicons']
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search',
'enable_sharing', 'enable_favicons', 'display_url', 'permanent_notes']
@receiver(post_save, sender=get_user_model())

View File

@@ -1,29 +1,29 @@
from typing import Optional
from django.contrib.auth.models import User
from django.db.models import Q, QuerySet
from django.db.models import Q, QuerySet, Exists, OuterRef
from bookmarks.models import Bookmark, Tag
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.utils import unique
def query_bookmarks(user: User, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
def query_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, profile, query_string) \
.filter(is_archived=False)
def query_archived_bookmarks(user: User, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
def query_archived_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, profile, query_string) \
.filter(is_archived=True)
def query_shared_bookmarks(user: Optional[User], query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, profile, query_string) \
.filter(shared=True) \
.filter(owner__profile__enable_sharing=True)
def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
query_set = Bookmark.objects
# Filter for user
@@ -35,13 +35,17 @@ def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
# Filter for search terms and tags
for term in query['search_terms']:
query_set = query_set.filter(
Q(title__contains=term)
| Q(description__contains=term)
| Q(website_title__contains=term)
| Q(website_description__contains=term)
| Q(url__contains=term)
)
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)
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
conditions = conditions | Exists(Bookmark.objects.filter(id=OuterRef('id'), tags__name__iexact=term))
query_set = query_set.filter(conditions)
for tag_name in query['tag_names']:
query_set = query_set.filter(
@@ -65,32 +69,32 @@ def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
return query_set
def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
bookmarks_query = query_bookmarks(user, query_string)
def query_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
bookmarks_query = query_bookmarks(user, profile, query_string)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
bookmarks_query = query_archived_bookmarks(user, query_string)
def query_archived_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
bookmarks_query = query_archived_bookmarks(user, profile, query_string)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_shared_bookmark_tags(user: Optional[User], query_string: str) -> QuerySet:
bookmarks_query = query_shared_bookmarks(user, query_string)
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
bookmarks_query = query_shared_bookmarks(user, profile, query_string)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_shared_bookmark_users(query_string: str) -> QuerySet:
bookmarks_query = query_shared_bookmarks(None, query_string)
def query_shared_bookmark_users(profile: UserProfile, query_string: str) -> QuerySet:
bookmarks_query = query_shared_bookmarks(None, profile, query_string)
query_set = User.objects.filter(bookmark__in=bookmarks_query)

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

View File

@@ -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();
})()

View File

@@ -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;
@@ -102,3 +108,12 @@ a:visited:hover {
font-weight: bold;
}
}
// Increase input font size on small viewports to prevent zooming on focus the input
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
// viewport size
@media screen and (max-width: 430px) {
.form-input {
font-size: 16px;
}
}

View File

@@ -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;
@@ -64,6 +72,10 @@ ul.bookmark-list {
vertical-align: text-top;
}
.url-display {
color: $secondary-link-color;
}
.description {
color: $gray-color-dark;
@@ -72,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 .btn-link {
color: $gray-color;
padding: 0;
height: auto;
vertical-align: unset;
border: none;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
.actions {
> *:not(:last-child) {
margin-right: 0.4rem;
}
}
.bulk-edit-toggle {
display: 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;
}
}
.separator {
align-self: flex-start;
}
.toggle-notes {
align-self: center;
display: flex;
align-items: center;
gap: 0.1rem;
}
}
}
@@ -176,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 */

View File

@@ -21,8 +21,13 @@ $link-color: $primary-color !default;
$link-color-dark: darken($link-color, 5%) !default;
$link-color-light: $link-color !default;
$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;

View File

@@ -2,3 +2,8 @@ $html-font-size: 18px !default;
$alternative-color: #05a6a3;
$alternative-color-dark: darken($alternative-color, 5%);
$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,102 +1,128 @@
{% load static %}
{% load shared %}
{% load pagination %}
{% htmlmin %}
<ul class="bookmark-list">
{% for bookmark in bookmarks %}
<li data-is-bookmark-item>
<label class="form-checkbox bulk-edit-toggle">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<i class="form-icon"></i>
</label>
<div class="title">
<ul class="bookmark-list{% if request.user.profile.permanent_notes %} show-notes{% endif %}">
{% for bookmark in bookmarks %}
<li data-is-bookmark-item>
<label class="form-checkbox bulk-edit-toggle">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<i class="form-icon"></i>
</label>
<div class="title">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="{% if bookmark.unread %}text-italic{% endif %}">
{% if bookmark.favicon_file and request.user.profile.enable_favicons %}
<img src="{% static bookmark.favicon_file %}" alt="">
{% endif %}
{{ bookmark.resolved_title }}
</a>
</div>
{% if request.user.profile.display_url %}
<div class="url-path truncate">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="{% if bookmark.unread %}text-italic{% endif %}">
{% if bookmark.favicon_file and request.user.profile.enable_favicons %}
<img src="{% static bookmark.favicon_file %}" alt="">
{% endif %}
{{ bookmark.resolved_title }}
class="url-display text-sm">
{{ bookmark.url }}
</a>
</div>
<div class="description truncate">
{% if bookmark.tag_names %}
<span>
{% endif %}
<div class="description truncate">
{% if bookmark.tag_names %}
<span>
{% for tag_name in bookmark.tag_names %}
<a href="?{% append_to_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</span>
{% endif %}
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
{% if bookmark.resolved_description %}
<span>{{ bookmark.resolved_description }}</span>
{% endif %}
{% endif %}
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
{% if bookmark.resolved_description %}
<span>{{ bookmark.resolved_description }}</span>
{% endif %}
</div>
{% if bookmark.notes %}
<div class="notes bg-gray text-gray-dark">
<div class="notes-content">
{% markdown bookmark.notes %}
</div>
</div>
<div class="actions">
{% if request.user.profile.bookmark_date_display == 'relative' %}
<span class="date-label text-gray text-sm">
{% endif %}
<div class="actions text-gray text-sm">
{% if request.user.profile.bookmark_date_display == 'relative' %}
<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_relative_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
<span></span>
</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>
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
{% if bookmark.web_archive_snapshot_url %}
</a>
{% endif %}
<button type="submit" name="remove" value="{{ bookmark.id }}"
class="btn btn-link btn-sm btn-confirmation">Remove
</span>
<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>
{% 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 %}
{# Shared bookmark actions #}
<span class="text-gray text-sm">Shared by
<a class="text-gray"
href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
</span>
<button type="submit" name="archive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Archive
</button>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
<button type="submit" name="remove" value="{{ bookmark.id }}"
class="btn btn-link btn-sm btn-confirmation">Remove
</button>
{% 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">
{% pagination bookmarks %}
</div>
{% endhtmlmin %}
<div class="bookmark-pagination">
{% pagination bookmarks %}
</div>

View File

@@ -67,6 +67,19 @@
</div>
{{ form.description.errors }}
</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">
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
{{ 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);
})();
</script>
</div>

View File

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

View File

@@ -4,7 +4,7 @@
{% if has_selected_tags %}
<p class="selected-tags">
{% for tag in selected_tags %}
<a href="?{% remove_from_query_param q=tag.name|hash_tag %}"
<a href="?{% remove_tag_from_query tag.name %}"
class="text-bold mr-2">
<span>-{{ tag.name }}</span>
</a>
@@ -17,14 +17,14 @@
{% for tag in group.tags %}
{# Highlight first char of first tag in group #}
{% if forloop.counter == 1 %}
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
<a href="?{% add_tag_to_query tag.name %}"
class="mr-2" data-is-tag-item>
<span
class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a>
{% else %}
{# Render remaining tags normally #}
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
<a href="?{% add_tag_to_query tag.name %}"
class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>
</a>

View File

@@ -29,6 +29,25 @@
be hidden.
</div>
</div>
<div class="form-group">
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
{{ form.display_url }}
<i class="form-icon"></i> Show bookmark URL
</label>
<div class="form-input-hint">
When enabled, this setting displays the bookmark URL below the title.
</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">
<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" }}
@@ -36,6 +55,15 @@
Whether to open bookmarks a new page or in the same page.
</div>
</div>
<div class="form-group">
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
{{ form.tag_search|add_class:"form-select col-2 col-sm-12" }}
<div class="form-input-hint">
In strict mode, tags must be prefixed with a hash character (#).
In lax mode, tags can also be searched without the hash character.
Note that tags without the hash character are indistinguishable from search terms, which means the search result will also include bookmarks where a search term matches otherwise.
</div>
</div>
<div class="form-group">
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
{{ form.enable_favicons }}

View File

@@ -1,8 +1,13 @@
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
register = template.Library()
@@ -19,36 +24,39 @@ def update_query_string(context, **kwargs):
@register.simple_tag(takes_context=True)
def append_to_query_param(context, **kwargs):
query = context.request.GET.copy()
def add_tag_to_query(context, tag_name: str):
params = context.request.GET.copy()
# Append to or create query param
for key in kwargs:
if query.__contains__(key):
value = query.__getitem__(key) + ' '
else:
value = ''
value = value + kwargs[key]
query.__setitem__(key, value)
# Append to or create query string
if params.__contains__('q'):
query_string = params.__getitem__('q') + ' '
else:
query_string = ''
query_string = query_string + '#' + tag_name
params.__setitem__('q', query_string)
return query.urlencode()
return params.urlencode()
@register.simple_tag(takes_context=True)
def remove_from_query_param(context, **kwargs):
query = context.request.GET.copy()
def remove_tag_from_query(context, tag_name: str):
params = context.request.GET.copy()
if params.__contains__('q'):
# Split query string into parts
query_string = params.__getitem__('q')
query_parts = query_string.split()
# Remove tag with hash
tag_name_with_hash = '#' + tag_name
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name_with_hash)]
# When using lax tag search, also remove tag without hash
profile = context.request.user.profile
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name)]
# Rebuild query string
query_string = ' '.join(query_parts)
params.__setitem__('q', query_string)
# Remove item from query param
for key in kwargs:
if query.__contains__(key):
value = query.__getitem__(key)
parts = value.split()
part_to_remove = kwargs[key]
updated_parts = [part for part in parts if str.lower(part) != str.lower(part_to_remove)]
updated_value = ' '.join(updated_parts)
query.__setitem__(key, updated_value)
return query.urlencode()
return params.urlencode()
@register.simple_tag(takes_context=True)
@@ -109,3 +117,19 @@ class HtmlMinNode(template.Node):
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)

View File

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

View File

@@ -158,6 +158,37 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(self):
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(title=tags[0].name, tags=tags, is_archived=True)
response = self.client.get(reverse('bookmarks:archived') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
self.assertSelectedTags(response, [tags[1]])
def test_should_display_search_terms_from_query_as_selected_tags_in_lax_mode(self):
self.user.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.user.profile.save()
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(tags=tags, is_archived=True)
response = self.client.get(reverse('bookmarks:archived') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = [
self.setup_bookmark(is_archived=True),

View File

@@ -20,6 +20,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
'tag_string': 'editedtag1 editedtag2',
'title': 'edited title',
'description': 'edited description',
'notes': 'edited notes',
'unread': False,
'shared': False,
}
@@ -37,6 +38,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
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 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
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 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
</textarea>
''', html)
self.assertInHTML(f'''
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">
{bookmark.notes}
</textarea>
''', html)
self.assertInHTML(f'''
<input type="hidden" name="website_title" id="id_website_title"
value="{bookmark.website_title}">
@@ -184,3 +193,15 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
<span>Share</span>
</label>
''', 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

@@ -155,7 +155,38 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
]
self.setup_bookmark(tags=tags)
response = self.client.get(reverse('bookmarks:index') + f'?q=%23{tags[0].name}+%23{tags[1].name}')
response = self.client.get(reverse('bookmarks:index') + f'?q=%23{tags[0].name}+%23{tags[1].name.upper()}')
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(self):
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(title=tags[0].name, tags=tags)
response = self.client.get(reverse('bookmarks:index') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
self.assertSelectedTags(response, [tags[1]])
def test_should_display_search_terms_from_query_as_selected_tags_in_lax_mode(self):
self.user.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.user.profile.save()
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(tags=tags)
response = self.client.get(reverse('bookmarks:index') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
self.assertSelectedTags(response, [tags[0], tags[1]])
@@ -196,8 +227,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}"
class="btn btn-link btn-sm">Edit</a>
<a href="{edit_url}?return_url={return_url}">Edit</a>
''', html)
# with query params
@@ -208,6 +238,5 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f'''
<a href="{edit_url}?return_url={return_url}"
class="btn btn-link btn-sm">Edit</a>
<a href="{edit_url}?return_url={return_url}">Edit</a>
''', html)

View File

@@ -19,6 +19,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
'tag_string': 'tag1 tag2',
'title': 'test title',
'description': 'test description',
'notes': 'test notes',
'unread': False,
'shared': False,
'auto_close': '',
@@ -37,6 +38,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
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 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
<span>Share</span>
</label>
''', 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.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 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'notes': 'Test notes',
'is_archived': False,
'unread': False,
'shared': False,
@@ -144,6 +146,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
'url': original_bookmark.url,
'title': 'Updated title',
'description': 'Updated description',
'notes': 'Updated notes',
'unread': True,
'shared': True,
'is_archived': True,
@@ -168,6 +172,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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)

View File

@@ -24,22 +24,22 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def assertDateLabel(self, html: str, label_content: str):
self.assertInHTML(f'''
<span class="date-label text-gray text-sm">
<span>
<span>{label_content}</span>
</span>
<span class="text-gray text-sm">|</span>
<span class="separator">|</span>
''', html)
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
self.assertInHTML(f'''
<span class="date-label text-gray text-sm">
<span>
<a href="{url}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
<span>{label_content}</span>
<span>∞</span>
</a>
</span>
<span class="text-gray text-sm">|</span>
<span class="separator">|</span>
''', html)
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
@@ -52,8 +52,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
# Edit link
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
self.assertInHTML(f'''
<a href="{edit_url}?return_url=/test"
class="btn btn-link btn-sm">Edit</a>
<a href="{edit_url}?return_url=/test">Edit</a>
''', html, count=count)
# Archive link
self.assertInHTML(f'''
@@ -74,8 +73,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
<span class="text-gray text-sm">Shared by
<a class="text-gray" href="?user={bookmark.owner.username}">{bookmark.owner.username}</a>
<span>Shared by
<a href="?user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span>
''', html, count=count)
@@ -90,6 +89,47 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
<img src="/static/{bookmark.favicon_file}" alt="">
''', html, count=count)
def assertBookmarkURLCount(self, html: str, bookmark: Bookmark, link_target: str = '_blank', count=0):
self.assertInHTML(f'''
<div class="url-path truncate">
<a href="{bookmark.url}" target="{link_target}" rel="noopener"
class="url-display text-sm">
{bookmark.url}
</a>
</div>
''', 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'''
<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:
rf = RequestFactory()
request = rf.get(url)
@@ -218,8 +258,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
html = self.render_default_template([bookmark], url='/test?q=foo')
self.assertInHTML(f'''
<span class="text-gray text-sm">Shared by
<a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
<span>Shared by
<a href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span>
''', html)
@@ -252,3 +292,113 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
html = self.render_default_template([bookmark])
self.assertFaviconHidden(html, bookmark)
def test_bookmark_url_should_be_hidden_by_default(self):
profile = self.get_or_create_test_user().profile
profile.save()
bookmark = self.setup_bookmark()
html = self.render_default_template([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
profile.save()
bookmark = self.setup_bookmark()
html = self.render_default_template([bookmark])
self.assertBookmarkURLVisible(html, bookmark)
def test_hide_bookmark_url_when_disabled(self):
profile = self.get_or_create_test_user().profile
profile.display_url = False
profile.save()
bookmark = self.setup_bookmark()
html = self.render_default_template([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',
title='Updated Title',
description='Updated description',
notes='Updated notes',
unread=True,
shared=True,
is_archived=True)
@@ -55,6 +56,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
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?

View File

@@ -5,7 +5,7 @@ from django.db.models import QuerySet
from django.test import TestCase
from bookmarks import queries
from bookmarks.models import Bookmark
from bookmarks.models import Bookmark, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence
from bookmarks.utils import unique
@@ -13,6 +13,8 @@ User = get_user_model()
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.profile = self.get_or_create_test_user().profile
def setup_bookmark_search_data(self) -> None:
tag1 = self.setup_tag(name='tag1')
@@ -27,9 +29,15 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.term1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1'),
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(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')),
self.setup_bookmark(website_description=random_sentence(including_word='TERM1')),
]
self.term1_term2_bookmarks = [
self.setup_bookmark(url='http://example.com/term1/term2'),
@@ -49,6 +57,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(website_title=random_sentence(), tags=[tag1]),
self.setup_bookmark(website_description=random_sentence(), tags=[tag1]),
]
self.tag1_as_term_bookmarks = [
self.setup_bookmark(url='http://example.com/tag1'),
self.setup_bookmark(title=random_sentence(including_word='tag1')),
self.setup_bookmark(description=random_sentence(including_word='tag1')),
self.setup_bookmark(website_title=random_sentence(including_word='tag1')),
self.setup_bookmark(website_description=random_sentence(including_word='tag1')),
]
self.term1_tag1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1', tags=[tag1]),
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[tag1]),
@@ -76,9 +91,15 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.term1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1', tags=[self.setup_tag()]),
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(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()]),
self.setup_bookmark(website_description=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
]
self.term1_term2_bookmarks = [
self.setup_bookmark(url='http://example.com/term1/term2', tags=[self.setup_tag()]),
@@ -102,6 +123,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(website_title=random_sentence(), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(website_description=random_sentence(), tags=[tag1, self.setup_tag()]),
]
self.tag1_as_term_bookmarks = [
self.setup_bookmark(url='http://example.com/tag1'),
self.setup_bookmark(title=random_sentence(including_word='tag1')),
self.setup_bookmark(description=random_sentence(including_word='tag1')),
self.setup_bookmark(website_title=random_sentence(including_word='tag1')),
self.setup_bookmark(website_description=random_sentence(including_word='tag1')),
]
self.term1_tag1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1', tags=[tag1, self.setup_tag()]),
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[tag1, self.setup_tag()]),
@@ -135,12 +163,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmarks_should_return_all_for_empty_query(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
query = queries.query_bookmarks(self.user, self.profile, '')
self.assertQueryResult(query, [
self.other_bookmarks,
self.term1_bookmarks,
self.term1_term2_bookmarks,
self.tag1_bookmarks,
self.tag1_as_term_bookmarks,
self.term1_tag1_bookmarks,
self.tag2_bookmarks,
self.tag1_tag2_bookmarks
@@ -149,7 +178,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmarks_should_search_single_term(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1')
query = queries.query_bookmarks(self.user, self.profile, 'term1')
self.assertQueryResult(query, [
self.term1_bookmarks,
self.term1_term2_bookmarks,
@@ -159,63 +188,101 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmarks_should_search_multiple_terms(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term2 term1')
query = queries.query_bookmarks(self.user, self.profile, 'term2 term1')
self.assertQueryResult(query, [self.term1_term2_bookmarks])
def test_query_bookmarks_should_search_single_tag(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1')
query = queries.query_bookmarks(self.user, self.profile, '#tag1')
self.assertQueryResult(query, [self.tag1_bookmarks, self.tag1_tag2_bookmarks, self.term1_tag1_bookmarks])
def test_query_bookmarks_should_search_multiple_tags(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1 #tag2')
query = queries.query_bookmarks(self.user, self.profile, '#tag1 #tag2')
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
def test_query_bookmarks_should_search_multiple_tags_ignoring_casing(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '#Tag1 #TAG2')
query = queries.query_bookmarks(self.user, self.profile, '#Tag1 #TAG2')
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
def test_query_bookmarks_should_search_terms_and_tags_combined(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #tag1')
query = queries.query_bookmarks(self.user, self.profile, 'term1 #tag1')
self.assertQueryResult(query, [self.term1_tag1_bookmarks])
def test_query_bookmarks_in_strict_mode_should_not_search_tags_as_terms(self):
self.setup_bookmark_search_data()
self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT
self.profile.save()
query = queries.query_bookmarks(self.user, self.profile, 'tag1')
self.assertQueryResult(query, [self.tag1_as_term_bookmarks])
def test_query_bookmarks_in_lax_mode_should_search_tags_as_terms(self):
self.setup_bookmark_search_data()
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.profile.save()
query = queries.query_bookmarks(self.user, self.profile, 'tag1')
self.assertQueryResult(query, [
self.tag1_bookmarks,
self.tag1_as_term_bookmarks,
self.tag1_tag2_bookmarks,
self.term1_tag1_bookmarks
])
query = queries.query_bookmarks(self.user, self.profile, 'tag1 term1')
self.assertQueryResult(query, [
self.term1_tag1_bookmarks,
])
query = queries.query_bookmarks(self.user, self.profile, 'tag1 tag2')
self.assertQueryResult(query, [
self.tag1_tag2_bookmarks,
])
query = queries.query_bookmarks(self.user, self.profile, 'tag1 #tag2')
self.assertQueryResult(query, [
self.tag1_tag2_bookmarks,
])
def test_query_bookmarks_should_return_no_matches(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term3')
query = queries.query_bookmarks(self.user, self.profile, 'term3')
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 term3')
query = queries.query_bookmarks(self.user, self.profile, 'term1 term3')
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #tag2')
query = queries.query_bookmarks(self.user, self.profile, 'term1 #tag2')
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag3')
query = queries.query_bookmarks(self.user, self.profile, '#tag3')
self.assertQueryResult(query, [])
# Unused tag
query = queries.query_bookmarks(self.get_or_create_test_user(), '#unused_tag1')
query = queries.query_bookmarks(self.user, self.profile, '#unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with tag that is used
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1 #unused_tag1')
query = queries.query_bookmarks(self.user, self.profile, '#tag1 #unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with term that is used
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #unused_tag1')
query = queries.query_bookmarks(self.user, self.profile, 'term1 #unused_tag1')
self.assertQueryResult(query, [])
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
@@ -225,7 +292,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
query = queries.query_bookmarks(self.user, self.profile, '')
self.assertQueryResult(query, [[bookmark1, bookmark2]])
@@ -236,7 +303,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark()
query = queries.query_archived_bookmarks(self.get_or_create_test_user(), '')
query = queries.query_archived_bookmarks(self.user, self.profile, '')
self.assertQueryResult(query, [[bookmark1, bookmark2]])
@@ -251,7 +318,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
query = queries.query_bookmarks(self.user, '')
query = queries.query_bookmarks(self.user, self.profile, '')
self.assertQueryResult(query, [owned_bookmarks])
@@ -266,7 +333,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, user=other_user)
self.setup_bookmark(is_archived=True, user=other_user)
query = queries.query_archived_bookmarks(self.user, '')
query = queries.query_archived_bookmarks(self.user, self.profile, '')
self.assertQueryResult(query, [owned_bookmarks])
@@ -276,7 +343,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmarks(self.user, '!untagged')
query = queries.query_bookmarks(self.user, self.profile, '!untagged')
self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_bookmarks_untagged_should_be_combinable_with_search_terms(self):
@@ -285,7 +352,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(title='term2')
self.setup_bookmark(tags=[tag])
query = queries.query_bookmarks(self.user, '!untagged term1')
query = queries.query_bookmarks(self.user, self.profile, '!untagged term1')
self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_bookmarks_untagged_should_not_be_combinable_with_tags(self):
@@ -294,7 +361,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmarks(self.user, f'!untagged #{tag.name}')
query = queries.query_bookmarks(self.user, self.profile, f'!untagged #{tag.name}')
self.assertCountEqual(list(query), [])
def test_query_archived_bookmarks_untagged_should_return_untagged_bookmarks_only(self):
@@ -303,7 +370,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmarks(self.user, '!untagged')
query = queries.query_archived_bookmarks(self.user, self.profile, '!untagged')
self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_archived_bookmarks_untagged_should_be_combinable_with_search_terms(self):
@@ -312,7 +379,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, title='term2')
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmarks(self.user, '!untagged term1')
query = queries.query_archived_bookmarks(self.user, self.profile, '!untagged term1')
self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_archived_bookmarks_untagged_should_not_be_combinable_with_tags(self):
@@ -321,7 +388,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmarks(self.user, f'!untagged #{tag.name}')
query = queries.query_archived_bookmarks(self.user, self.profile, f'!untagged #{tag.name}')
self.assertCountEqual(list(query), [])
def test_query_bookmarks_unread_should_return_unread_bookmarks_only(self):
@@ -334,7 +401,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark()
query = queries.query_bookmarks(self.user, '!unread')
query = queries.query_bookmarks(self.user, self.profile, '!unread')
self.assertCountEqual(list(query), unread_bookmarks)
def test_query_archived_bookmarks_unread_should_return_unread_bookmarks_only(self):
@@ -347,13 +414,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
query = queries.query_archived_bookmarks(self.user, '!unread')
query = queries.query_archived_bookmarks(self.user, self.profile, '!unread')
self.assertCountEqual(list(query), unread_bookmarks)
def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '')
query = queries.query_bookmark_tags(self.user, self.profile, '')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.other_bookmarks),
@@ -368,7 +435,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_single_term(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, 'term1')
query = queries.query_bookmark_tags(self.user, self.profile, 'term1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_bookmarks),
@@ -379,7 +446,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_multiple_terms(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, 'term2 term1')
query = queries.query_bookmark_tags(self.user, self.profile, 'term2 term1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_term2_bookmarks),
@@ -388,7 +455,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_single_tag(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '#tag1')
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_bookmarks),
@@ -399,7 +466,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_multiple_tags(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '#tag1 #tag2')
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1 #tag2')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
@@ -408,7 +475,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_multiple_tags_ignoring_casing(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '#Tag1 #TAG2')
query = queries.query_bookmark_tags(self.user, self.profile, '#Tag1 #TAG2')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
@@ -417,37 +484,75 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_term_and_tag_combined(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, 'term1 #tag1')
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #tag1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
])
def test_query_bookmark_tags_in_strict_mode_should_not_search_tags_as_terms(self):
self.setup_tag_search_data()
self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT
self.profile.save()
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1')
self.assertQueryResult(query, self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks))
def test_query_bookmark_tags_in_lax_mode_should_search_tags_as_terms(self):
self.setup_tag_search_data()
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.profile.save()
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_bookmarks),
self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks),
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks)
])
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 term1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
])
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 tag2')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 #tag2')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
def test_query_bookmark_tags_should_return_no_matches(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term3')
query = queries.query_bookmark_tags(self.user, self.profile, 'term3')
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 term3')
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 term3')
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 #tag2')
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #tag2')
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#tag3')
query = queries.query_bookmark_tags(self.user, self.profile, '#tag3')
self.assertQueryResult(query, [])
# Unused tag
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#unused_tag1')
query = queries.query_bookmark_tags(self.user, self.profile, '#unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with tag that is used
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#tag1 #unused_tag1')
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1 #unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with term that is used
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 #unused_tag1')
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #unused_tag1')
self.assertQueryResult(query, [])
def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self):
@@ -457,7 +562,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_bookmark_tags(self.user, self.profile, '')
self.assertQueryResult(query, [[tag1]])
@@ -467,7 +572,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_bookmark_tags(self.user, self.profile, '')
self.assertQueryResult(query, [[tag]])
@@ -478,7 +583,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
self.assertQueryResult(query, [[tag2]])
@@ -488,7 +593,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
self.assertQueryResult(query, [[tag]])
@@ -503,7 +608,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
query = queries.query_bookmark_tags(self.user, '')
query = queries.query_bookmark_tags(self.user, self.profile, '')
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
@@ -518,7 +623,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
query = queries.query_archived_bookmark_tags(self.user, '')
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
@@ -529,13 +634,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(title='term1', tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmark_tags(self.user, '!untagged')
query = queries.query_bookmark_tags(self.user, self.profile, '!untagged')
self.assertCountEqual(list(query), [])
query = queries.query_bookmark_tags(self.user, '!untagged term1')
query = queries.query_bookmark_tags(self.user, self.profile, '!untagged term1')
self.assertCountEqual(list(query), [])
query = queries.query_bookmark_tags(self.user, f'!untagged #{tag.name}')
query = queries.query_bookmark_tags(self.user, self.profile, f'!untagged #{tag.name}')
self.assertCountEqual(list(query), [])
def test_query_archived_bookmark_tags_untagged_should_never_return_any_tags(self):
@@ -545,13 +650,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, title='term1', tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmark_tags(self.user, '!untagged')
query = queries.query_archived_bookmark_tags(self.user, self.profile, '!untagged')
self.assertCountEqual(list(query), [])
query = queries.query_archived_bookmark_tags(self.user, '!untagged term1')
query = queries.query_archived_bookmark_tags(self.user, self.profile, '!untagged term1')
self.assertCountEqual(list(query), [])
query = queries.query_archived_bookmark_tags(self.user, f'!untagged #{tag.name}')
query = queries.query_archived_bookmark_tags(self.user, self.profile, f'!untagged #{tag.name}')
self.assertCountEqual(list(query), [])
def test_query_shared_bookmarks(self):
@@ -574,14 +679,14 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user4, shared=True, tags=[tag]),
# Should return shared bookmarks from all users
query_set = queries.query_shared_bookmarks(None, '')
query_set = queries.query_shared_bookmarks(None, self.profile, '')
self.assertQueryResult(query_set, [shared_bookmarks])
# Should respect search query
query_set = queries.query_shared_bookmarks(None, 'test title')
query_set = queries.query_shared_bookmarks(None, self.profile, 'test title')
self.assertQueryResult(query_set, [[shared_bookmarks[0]]])
query_set = queries.query_shared_bookmarks(None, '#' + tag.name)
query_set = queries.query_shared_bookmarks(None, self.profile, '#' + tag.name)
self.assertQueryResult(query_set, [[shared_bookmarks[2]]])
def test_query_shared_bookmark_tags(self):
@@ -605,7 +710,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user3, shared=False, tags=[self.setup_tag(user=user3)]),
self.setup_bookmark(user=user4, shared=True, tags=[self.setup_tag(user=user4)]),
query_set = queries.query_shared_bookmark_tags(None, '')
query_set = queries.query_shared_bookmark_tags(None, self.profile, '')
self.assertQueryResult(query_set, [shared_tags])
@@ -630,9 +735,9 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True),
# Should return users with shared bookmarks
query_set = queries.query_shared_bookmark_users('')
query_set = queries.query_shared_bookmark_users(self.profile, '')
self.assertQueryResult(query_set, [users_with_shared_bookmarks])
# Should respect search query
query_set = queries.query_shared_bookmark_users('test title')
query_set = queries.query_shared_bookmark_users(self.profile, 'test title')
self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]])

View File

@@ -28,6 +28,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
'enable_sharing': False,
'enable_favicons': False,
'tag_search': UserProfile.TAG_SEARCH_STRICT,
'display_url': False,
'permanent_notes': False,
}
return {**form_data, **overrides}
@@ -52,6 +55,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
'enable_sharing': True,
'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()
@@ -65,6 +71,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing'])
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('''
<p class="form-input-hint">Profile updated</p>
''', html)

View File

@@ -3,7 +3,7 @@ from typing import List
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from bookmarks.models import Tag
from bookmarks.models import Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
@@ -14,6 +14,7 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
rf = RequestFactory()
request = rf.get(url)
request.user = self.get_or_create_test_user()
context = RequestContext(request, {
'request': request,
'tags': tags,
@@ -118,6 +119,36 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
</a>
''', rendered_template)
def test_selected_tags_with_lax_tag_search(self):
profile = self.get_or_create_test_user().profile
profile.tag_search = UserProfile.TAG_SEARCH_LAX
profile.save()
tags = [
self.setup_tag(name='tag1'),
self.setup_tag(name='tag2'),
]
# Filter by tag name without hash
rendered_template = self.render_template(tags, tags, url='/test?q=tag1 %23tag2')
self.assertNumSelectedTags(rendered_template, 2)
# Tag name should still be removed from query string
self.assertInHTML('''
<a href="?q=%23tag2"
class="text-bold mr-2">
<span>-tag1</span>
</a>
''', rendered_template)
self.assertInHTML('''
<a href="?q=tag1"
class="text-bold mr-2">
<span>-tag2</span>
</a>
''', rendered_template)
def test_selected_tags_ignore_casing_when_removing_query_part(self):
tags = [
self.setup_tag(name='TEST'),

View File

@@ -1,4 +1,5 @@
import urllib.parse
from typing import List
from django.contrib.auth.decorators import login_required
from django.core.handlers.wsgi import WSGIRequest
@@ -9,7 +10,7 @@ from django.shortcuts import render
from django.urls import reverse
from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, User, Tag, build_tag_string
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, User, UserProfile, Tag, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.utils import get_safe_return_url
@@ -20,8 +21,8 @@ _default_page_size = 30
@login_required
def index(request):
filters = BookmarkFilters(request)
query_set = queries.query_bookmarks(request.user, filters.query)
tags = queries.query_bookmark_tags(request.user, filters.query)
query_set = queries.query_bookmarks(request.user, request.user.profile, filters.query)
tags = queries.query_bookmark_tags(request.user, request.user.profile, filters.query)
base_url = reverse('bookmarks:index')
context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
return render(request, 'bookmarks/index.html', context)
@@ -30,8 +31,8 @@ def index(request):
@login_required
def archived(request):
filters = BookmarkFilters(request)
query_set = queries.query_archived_bookmarks(request.user, filters.query)
tags = queries.query_archived_bookmark_tags(request.user, filters.query)
query_set = queries.query_archived_bookmarks(request.user, request.user.profile, filters.query)
tags = queries.query_archived_bookmark_tags(request.user, request.user.profile, filters.query)
base_url = reverse('bookmarks:archived')
context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
return render(request, 'bookmarks/archive.html', context)
@@ -41,27 +42,23 @@ def archived(request):
def shared(request):
filters = BookmarkFilters(request)
user = User.objects.filter(username=filters.user).first()
query_set = queries.query_shared_bookmarks(user, filters.query)
tags = queries.query_shared_bookmark_tags(user, filters.query)
users = queries.query_shared_bookmark_users(filters.query)
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query)
tags = queries.query_shared_bookmark_tags(user, request.user.profile, filters.query)
users = queries.query_shared_bookmark_users(request.user.profile, filters.query)
base_url = reverse('bookmarks:shared')
context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
context['users'] = users
return render(request, 'bookmarks/shared.html', context)
def _get_selected_tags(tags: QuerySet[Tag], query_string: str):
def _get_selected_tags(tags: List[Tag], query_string: str, profile: UserProfile):
parsed_query = queries.parse_query_string(query_string)
tag_names = parsed_query['tag_names']
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
tag_names = tag_names + parsed_query['search_terms']
tag_names = [tag_name.lower() for tag_name in tag_names]
if len(tag_names) == 0:
return []
condition = Q()
for tag_name in parsed_query['tag_names']:
condition = condition | Q(name__iexact=tag_name)
return list(tags.filter(condition))
return [tag for tag in tags if tag.name.lower() in tag_names]
def get_bookmark_view_context(request: WSGIRequest,
@@ -72,7 +69,8 @@ def get_bookmark_view_context(request: WSGIRequest,
page = request.GET.get('page')
paginator = Paginator(query_set, _default_page_size)
bookmarks = paginator.get_page(page)
selected_tags = _get_selected_tags(tags, filters.query)
tags = list(tags)
selected_tags = _get_selected_tags(tags, filters.query, request.user.profile)
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
prefetch_related_objects(bookmarks.object_list, 'owner', 'tags')
return_url = generate_return_url(base_url, page, filters)

View File

@@ -141,7 +141,7 @@ def bookmark_import(request):
def bookmark_export(request):
# noinspection PyBroadException
try:
bookmarks = list(query_bookmarks(request.user, ''))
bookmarks = list(query_bookmarks(request.user, request.user.profile, ''))
# Prefetch tags to prevent n+1 queries
prefetch_related_objects(bookmarks, 'tags')
file_content = exporter.export_netscape_html(bookmarks)

View File

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

View File

@@ -109,6 +109,12 @@ Multiple origins can be specified by separating them with a comma (`,`).
This setting is adopted from the Django framework used by linkding, more information on the setting is available in the [Django documentation](https://docs.djangoproject.com/en/4.0/ref/settings/#std-setting-CSRF_TRUSTED_ORIGINS).
### `LD_LOG_X_FORWARDED_FOR`
Values: `true` or `false` | Default = `false`
Set uWSGI [log-x-forwarded-for](https://uwsgi-docs.readthedocs.io/en/latest/Options.html?#log-x-forwarded-for) parameter allowing to keep the real IP of clients in logs when using a reverse proxy.
### `LD_DB_ENGINE`
Values: `postgres` or `sqlite` | Default = `sqlite`
@@ -150,6 +156,12 @@ Values: `Integer` | Default = None
The port of the database server.
Should use the default port if left empty, for example `5432` for PostgresSQL.
### `LD_DB_OPTIONS`
Values: `String` | Default = `{}`
A json string with additional options for the database. Passed directly to OPTIONS.
### `LD_FAVICON_PROVIDER`
Values: `String` | Default = `https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON`

View File

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

View File

@@ -1,10 +1,12 @@
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
confusable-homoglyphs==3.2.0
Django==4.1.7
Django==4.1.9
django-generate-secret-key==1.0.2
django-registration==3.3
django-sass-processor==1.2.1
@@ -12,14 +14,16 @@ 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
requests==2.28.1
soupsieve==2.3.2.post1
sqlparse==0.4.2
sqlparse==0.4.4
supervisor==4.2.4
typing-extensions==3.10.0.0
urllib3==1.26.11
uWSGI==2.0.20
waybackpy==3.0.6
webencodings==0.5.1

View File

@@ -1,11 +1,13 @@
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
confusable-homoglyphs==3.2.0
coverage==5.5
Django==4.1.7
Django==4.1.9
django-appconf==1.0.5
django-compressor==4.1
django-debug-toolbar==3.6.0
@@ -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
@@ -28,7 +31,8 @@ requests==2.28.1
rjsmin==1.2.0
six==1.16.0
soupsieve==2.3.2.post1
sqlparse==0.4.2
sqlparse==0.4.4
typing-extensions==3.10.0.0
urllib3==1.26.11
waybackpy==3.0.6
webencodings==0.5.1

View File

@@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.2/ref/settings/
"""
import json
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@@ -204,6 +205,7 @@ LD_DB_DATABASE = os.getenv('LD_DB_DATABASE', 'linkding')
LD_DB_USER = os.getenv('LD_DB_USER', 'linkding')
LD_DB_PASSWORD = os.getenv('LD_DB_PASSWORD', None)
LD_DB_PORT = os.getenv('LD_DB_PORT', None)
LD_DB_OPTIONS = json.loads(os.getenv('LD_DB_OPTIONS') or '{}')
if LD_DB_ENGINE == 'postgres':
default_database = {
@@ -213,11 +215,13 @@ if LD_DB_ENGINE == 'postgres':
'PASSWORD': LD_DB_PASSWORD,
'HOST': LD_DB_HOST,
'PORT': LD_DB_PORT,
'OPTIONS': LD_DB_OPTIONS,
}
else:
default_database = {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'data', 'db.sqlite3'),
'OPTIONS': LD_DB_OPTIONS,
}
DATABASES = {

View File

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

View File

@@ -22,4 +22,8 @@ if-env = LD_REQUEST_TIMEOUT
http-timeout = %(_)
socket-timeout = %(_)
harakiri = %(_)
endif =
endif =
if-env = LD_LOG_X_FORWARDED_FOR
log-x-forwarded-for = %(_)
endif =

View File

@@ -1 +1 @@
1.17.2
1.19.0