@@ -56,11 +56,16 @@
{% if can_edit %}
-
{% trans "Add existing note" %}
-
-
+
{% trans "Add existing note or collection" %}
+
+
{% trans "Notes" %}
+
{% for item in candidates %}
{{ item.content|truncatechars:180 }}
#{{ item.id }} · {{ item.get_visibility_display }}{% if item.team %} · {{ item.team.name }}{% endif %}
{% empty %}
{% trans "No matching notes." %}
{% endfor %}
+
{% trans "Collections" %}
+
+ {% for candidate in collection_candidates %}
{{ candidate.title }}
#{{ candidate.id }} · {{ candidate.get_visibility_display }}{% if candidate.team %} · {{ candidate.team.name }}{% endif %}
{% empty %}
{% trans "No matching collections." %}
{% endfor %}
+
{% endif %}
diff --git a/core/templatetags/markdown_extras.py b/core/templatetags/markdown_extras.py
index 45950fc..a430f81 100644
--- a/core/templatetags/markdown_extras.py
+++ b/core/templatetags/markdown_extras.py
@@ -7,7 +7,7 @@ from django.utils.safestring import mark_safe
register = template.Library()
ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | {
- 'p', 'pre', 'code', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li',
+ 'p', 'pre', 'code', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li',
'strong', 'em', 'u', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'img'
}
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']}
@@ -71,7 +71,18 @@ def markdown_title(text):
return ''
-def render_item_markdown(item, hide_tags=False):
+def offset_heading_tags(html, offset=0):
+ if not offset:
+ return html
+
+ def replace(match):
+ slash, level = match.groups()
+ return f'<{slash}h{min(6, int(level) + offset)}>'
+
+ return re.sub(r'<(/?)h([1-6])>', replace, str(html))
+
+
+def render_item_markdown(item, hide_tags=False, heading_offset=0):
text = strip_tags_outside_code(item.content) if hide_tags else (item.content or '')
attachments = {str(a.id): a for a in item.attachments.all()}
@@ -81,7 +92,7 @@ def render_item_markdown(item, hide_tags=False):
return match.group(0)
return f''
- return markdown(FILE_RE.sub(replace, text))
+ return mark_safe(offset_heading_tags(markdown(FILE_RE.sub(replace, text)), heading_offset))
@register.filter
@@ -94,6 +105,11 @@ def collection_item_markdown(item):
return render_item_markdown(item, hide_tags=True)
+@register.filter
+def collection_sub_item_markdown(item):
+ return render_item_markdown(item, hide_tags=True, heading_offset=1)
+
+
@register.filter
def is_image(file_name):
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'))
diff --git a/core/tests.py b/core/tests.py
index 13b66e6..7323c80 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -341,6 +341,53 @@ class CollectionTests(TestCase):
self.assertContains(response, '>Section')
self.assertNotContains(response, '>## Section')
+ def test_collection_can_include_collection(self):
+ parent = Collection.objects.create(owner=self.user, title='Parent')
+ child = Collection.objects.create(owner=self.user, title='Child')
+ note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Child note #internal')
+ CollectionSection.objects.create(collection=child, item=note, position=0)
+ manage_url = reverse('collection_manage', args=[parent.id])
+
+ response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': child.id})
+
+ self.assertRedirects(response, manage_url)
+ self.assertTrue(parent.sections.filter(sub_collection=child).exists())
+ response = self.client.get(parent.get_absolute_url())
+ self.assertContains(response, '
Child
', html=True)
+ self.assertContains(response, '
Child note
', html=True)
+ self.assertNotContains(response, '#internal')
+
+ def test_collection_loop_is_rejected(self):
+ parent = Collection.objects.create(owner=self.user, title='Parent')
+ child = Collection.objects.create(owner=self.user, title='Child')
+ CollectionSection.objects.create(collection=parent, sub_collection=child, position=0)
+ manage_url = reverse('collection_manage', args=[child.id])
+
+ response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': parent.id}, follow=True)
+
+ self.assertContains(response, 'collection loop')
+ self.assertFalse(child.sections.filter(sub_collection=parent).exists())
+
+ def test_public_collection_cannot_include_private_collection(self):
+ parent = Collection.objects.create(owner=self.user, title='Parent', visibility=Item.Visibility.PUBLIC)
+ child = Collection.objects.create(owner=self.user, title='Child', visibility=Item.Visibility.PRIVATE)
+ manage_url = reverse('collection_manage', args=[parent.id])
+
+ response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': child.id}, follow=True)
+
+ self.assertContains(response, 'incompatible visibility')
+ self.assertFalse(parent.sections.filter(sub_collection=child).exists())
+
+ def test_private_collection_can_include_public_collection(self):
+ parent = Collection.objects.create(owner=self.user, title='Parent', visibility=Item.Visibility.PRIVATE)
+ child = Collection.objects.create(owner=self.user, title='Child', visibility=Item.Visibility.PUBLIC)
+ manage_url = reverse('collection_manage', args=[parent.id])
+
+ response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': child.id})
+
+ self.assertRedirects(response, manage_url)
+ self.assertTrue(parent.sections.filter(sub_collection=child).exists())
+
def test_public_collection_is_visible_without_login(self):
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
self.client.logout()
diff --git a/core/views.py b/core/views.py
index e1c8b28..fad7ed8 100644
--- a/core/views.py
+++ b/core/views.py
@@ -542,7 +542,7 @@ def team_accept_invite(request, token):
def visible_collections(user):
- qs = Collection.objects.select_related('owner', 'team').prefetch_related('filter_tags', 'sections__item')
+ qs = Collection.objects.select_related('owner', 'team').prefetch_related('filter_tags', 'sections__item', 'sections__sub_collection')
if user.is_authenticated:
return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
return qs.filter(visibility=Item.Visibility.PUBLIC)
@@ -574,6 +574,22 @@ def collection_item_is_compatible(collection, item):
)
+def collection_subcollection_is_compatible(collection, sub_collection):
+ return collection_scope_allows_item(
+ collection.visibility, collection.team_id, collection.owner_id,
+ sub_collection.visibility, sub_collection.team_id, sub_collection.owner_id,
+ )
+
+
+def collection_contains_collection(collection, target):
+ if collection.pk == target.pk:
+ return True
+ for section in collection.sections.select_related('sub_collection'):
+ if section.sub_collection and collection_contains_collection(section.sub_collection, target):
+ return True
+ return False
+
+
def collection_target_from_form(form, user):
team = form.cleaned_data.get('team')
if team:
@@ -639,11 +655,22 @@ def copy_item_for_collection(item, collection, owner):
return adapt_item_for_collection(copied, collection)
-def add_collection_section(collection, item):
+def next_collection_section_position(collection):
last_position = collection.sections.order_by('-position').values_list('position', flat=True).first()
+ return (last_position + 1) if last_position is not None else 0
+
+
+def add_collection_section(collection, item):
return CollectionSection.objects.get_or_create(
collection=collection, item=item,
- defaults={'position': (last_position + 1) if last_position is not None else 0},
+ defaults={'position': next_collection_section_position(collection)},
+ )
+
+
+def add_collection_subcollection(collection, sub_collection):
+ return CollectionSection.objects.get_or_create(
+ collection=collection, sub_collection=sub_collection,
+ defaults={'position': next_collection_section_position(collection)},
)
@@ -657,17 +684,23 @@ def collection_detail_context(request, collection, pending_item=None):
can_edit = user_can_edit_collection(request.user, collection)
q = request.GET.get('q', '').strip()
candidates = Item.objects.none()
+ collection_candidates = Collection.objects.none()
if can_edit:
candidates = editable_collection_notes(request.user).exclude(collection_sections__collection=collection)
+ collection_candidates = visible_collections(request.user).filter(mode=Collection.Mode.MANUAL).exclude(pk=collection.pk).exclude(containing_sections__collection=collection)
+ collection_candidates = [candidate for candidate in collection_candidates if collection_subcollection_is_compatible(collection, candidate) and not collection_contains_collection(candidate, collection)]
if q:
candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
+ collection_candidates = [candidate for candidate in collection_candidates if q.lower() in candidate.title.lower()]
candidates = candidates[:30]
+ collection_candidates = collection_candidates[:30]
return {
'collection': collection,
- 'sections': collection.sections.select_related('item').prefetch_related('item__tags', 'item__attachments'),
+ 'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection'),
'can_edit': can_edit,
'can_delete': user_can_delete_collection(request.user, collection),
'candidates': candidates,
+ 'collection_candidates': collection_candidates,
'q': q,
'pending_item': pending_item,
'pending_can_convert': pending_item and item_can_change_for_collection(request.user, pending_item, collection),
@@ -718,10 +751,16 @@ def collection_edit(request, pk):
visibility, team = collection_target_from_form(form, request.user)
target_mode = form.cleaned_data['mode']
has_manual_sections = collection.mode == Collection.Mode.MANUAL and collection.sections.exists()
- incompatible = target_mode == Collection.Mode.MANUAL and any(not collection_scope_allows_item(
- visibility, team.id if team else None, collection.owner_id,
- section.item.visibility, section.item.team_id, section.item.owner_id,
- ) for section in collection.sections.select_related('item'))
+ incompatible = target_mode == Collection.Mode.MANUAL and any(
+ not collection_scope_allows_item(
+ visibility, team.id if team else None, collection.owner_id,
+ section.item.visibility, section.item.team_id, section.item.owner_id,
+ ) if section.item else not collection_scope_allows_item(
+ visibility, team.id if team else None, collection.owner_id,
+ section.sub_collection.visibility, section.sub_collection.team_id, section.sub_collection.owner_id,
+ )
+ for section in collection.sections.select_related('item', 'sub_collection')
+ )
if target_mode == Collection.Mode.TAGS and has_manual_sections:
form.add_error('mode', _('Remove all manual sections before changing to a tag collection.'))
elif incompatible:
@@ -747,7 +786,7 @@ def collection_detail(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
return render(request, 'core/collection_detail.html', {
'collection': collection,
- 'sections': collection.sections.select_related('item').prefetch_related('item__tags', 'item__attachments') if collection.mode == Collection.Mode.MANUAL else [],
+ 'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection') if collection.mode == Collection.Mode.MANUAL else [],
'tag_items': collection_tag_items(collection) if collection.mode == Collection.Mode.TAGS else [],
'can_edit': user_can_edit_collection(request.user, collection),
})
@@ -782,6 +821,15 @@ def collection_manage(request, pk):
return redirect(manage_url)
add_collection_section(collection, item)
return redirect(manage_url)
+ if action == 'add_collection':
+ sub_collection = get_object_or_404(visible_collections(request.user), pk=request.POST.get('collection_id'), mode=Collection.Mode.MANUAL)
+ if sub_collection.pk == collection.pk or collection_contains_collection(sub_collection, collection):
+ messages.error(request, _('This would create a collection loop.'))
+ elif not collection_subcollection_is_compatible(collection, sub_collection):
+ messages.error(request, _('This collection has an incompatible visibility.'))
+ else:
+ add_collection_subcollection(collection, sub_collection)
+ return redirect(manage_url)
section = get_object_or_404(CollectionSection, pk=request.POST.get('section_id'), collection=collection)
if action == 'remove':
section.delete()