diff --git a/core/admin.py b/core/admin.py
index 82fed48..1bc4593 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -9,8 +9,9 @@ class CollectionSectionInline(admin.TabularInline):
@admin.register(Collection)
class CollectionAdmin(admin.ModelAdmin):
- list_display = ('title', 'visibility', 'team', 'owner', 'updated_at')
- list_filter = ('visibility', 'team')
+ list_display = ('title', 'mode', 'visibility', 'team', 'owner', 'updated_at')
+ list_filter = ('mode', 'visibility', 'team')
+ filter_horizontal = ('filter_tags',)
inlines = [CollectionSectionInline]
diff --git a/core/forms.py b/core/forms.py
index 4474513..fc8e5bf 100644
--- a/core/forms.py
+++ b/core/forms.py
@@ -1,6 +1,7 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
+from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference
@@ -134,34 +135,47 @@ class KanbanForm(forms.ModelForm):
class CollectionForm(forms.ModelForm):
+ team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, label=_('Team'), widget=forms.Select(attrs={'class': 'form-select'}))
+
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
+ self.fields['visibility'].choices = [
+ (Item.Visibility.PRIVATE, _('Private')),
+ (Item.Visibility.PUBLIC, _('Public')),
+ ]
if user and user.is_authenticated:
- team_slugs = user.team_memberships.values_list('team__slug', flat=True)
- self.fields['tags'].queryset = Tag.objects.filter(name__in=team_slugs).order_by('name')
+ team_ids = user.team_memberships.values_list('team_id', flat=True)
+ self.fields['team'].queryset = Team.objects.filter(pk__in=team_ids).order_by('name')
+ self.fields['team'].initial = self.instance.team_id if self.instance and self.instance.pk else None
+ item_scope = Q(items__owner=user) | Q(items__team_id__in=team_ids)
+ team_slugs = Team.objects.values_list('slug', flat=True)
+ self.fields['filter_tags'].queryset = Tag.objects.filter(item_scope).exclude(name__in=team_slugs).distinct().order_by('name')
else:
- self.fields['tags'].queryset = Tag.objects.none()
+ self.fields['team'].queryset = Team.objects.none()
+ self.fields['filter_tags'].queryset = Tag.objects.none()
- def clean_tags(self):
- tags = self.cleaned_data['tags']
- if tags.count() > 1:
- raise forms.ValidationError(_('Select at most one team.'))
- return tags
+ def clean(self):
+ cleaned = super().clean()
+ if cleaned.get('mode') == Collection.Mode.TAGS and not cleaned.get('filter_tags'):
+ self.add_error('filter_tags', _('Select at least one tag for a tag collection.'))
+ return cleaned
class Meta:
model = Collection
- fields = ['title', 'description', 'visibility', 'tags']
+ fields = ['title', 'description', 'visibility', 'mode', 'team', 'filter_tags']
labels = {
'title': _('Title'),
'description': _('Description'),
'visibility': _('Visibility'),
- 'tags': _('Team'),
+ 'mode': _('Collection type'),
+ 'filter_tags': _('Content tags'),
}
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
'visibility': forms.Select(attrs={'class': 'form-select'}),
- 'tags': forms.CheckboxSelectMultiple(),
+ 'mode': forms.Select(attrs={'class': 'form-select', 'x-model': 'mode'}),
+ 'filter_tags': forms.CheckboxSelectMultiple(),
}
diff --git a/core/migrations/0018_remove_collection_tags_collection_filter_tags_and_more.py b/core/migrations/0018_remove_collection_tags_collection_filter_tags_and_more.py
new file mode 100644
index 0000000..3c31ffe
--- /dev/null
+++ b/core/migrations/0018_remove_collection_tags_collection_filter_tags_and_more.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.2.17 on 2026-08-27 14:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0017_collection_collectionsection'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='collection',
+ name='tags',
+ ),
+ migrations.AddField(
+ model_name='collection',
+ name='filter_tags',
+ field=models.ManyToManyField(blank=True, related_name='tag_collections', to='core.tag'),
+ ),
+ migrations.AddField(
+ model_name='collection',
+ name='mode',
+ field=models.CharField(choices=[('manual', 'Manual collection'), ('tags', 'Tag collection')], default='manual', max_length=10),
+ ),
+ ]
diff --git a/core/models.py b/core/models.py
index 27ee95b..0eba004 100644
--- a/core/models.py
+++ b/core/models.py
@@ -35,7 +35,7 @@ def without_markdown_code(text):
def delete_unused_tags(tags=None):
"""Delete tags that are no longer used by items or tag-based features."""
candidates = Tag.objects.all() if tags is None else Tag.objects.filter(pk__in=[tag.pk for tag in tags])
- candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, collections__isnull=True).exclude(
+ candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True).exclude(
name__in=Team.objects.values_list('slug', flat=True),
).delete()
@@ -196,12 +196,17 @@ class Kanban(models.Model):
class Collection(models.Model):
+ class Mode(models.TextChoices):
+ MANUAL = 'manual', _('Manual collection')
+ TAGS = 'tags', _('Tag collection')
+
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='collections')
team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='collections')
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
visibility = models.CharField(max_length=10, choices=Item.Visibility.choices, default=Item.Visibility.PRIVATE)
- tags = models.ManyToManyField(Tag, blank=True, related_name='collections')
+ mode = models.CharField(max_length=10, choices=Mode.choices, default=Mode.MANUAL)
+ filter_tags = models.ManyToManyField(Tag, blank=True, related_name='tag_collections')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
diff --git a/core/templates/core/collection_detail.html b/core/templates/core/collection_detail.html
index f2b7c5f..069e0ef 100644
--- a/core/templates/core/collection_detail.html
+++ b/core/templates/core/collection_detail.html
@@ -9,30 +9,29 @@
← {% if user.is_authenticated %}{% trans "Collections" %}{% else %}Start{% endif %}
{{ collection.title }}
+ {% if collection.mode == 'tags' %}{% trans "Tag collection" %} {% endif %}
{% if collection.team %}{{ collection.team.name }} {% else %}{{ collection.get_visibility_display }} {% endif %}
- {% for tag in collection.tags.all %}#{{ tag.name }} {% endfor %}
+ {% for tag in collection.filter_tags.all %}#{{ tag.name }} {% endfor %}
- {% if can_edit %}{% trans "Edit collection" %} {% endif %}
+ {% if can_edit %}{% trans "Edit collection" %} {% endif %}
{% if collection.description %}{{ collection.description|markdown }}
{% endif %}
- {% if sections %}
+ {% if sections or tag_items %}
{% trans "Contents" %}
- {% for section in sections %}{{ section.title|default:section.item.content|truncatechars:90 }} {% endfor %}
+ {% if collection.mode == 'tags' %}{% for item in tag_items %}{{ item.content|truncatechars:90 }} {% endfor %}{% else %}{% for section in sections %}{{ section.title|default:section.item.content|truncatechars:90 }} {% endfor %}{% endif %}
{% endif %}
- {% for section in sections %}
-
- {% if section.title %}{{ section.title }} {% endif %}
- {{ section.item|item_markdown }}
- {% if section.item.comment %}{{ section.item.comment|markdown }}
{% endif %}
-
- {% empty %}
{% trans "This collection has no sections yet." %}
{% endfor %}
+ {% if collection.mode == 'tags' %}
+ {% for item in tag_items %}
{{ item|item_markdown }}{% if item.comment %}{{ item.comment|markdown }}
{% endif %} {% empty %}
{% trans "No notes match the selected tags yet." %}
{% endfor %}
+ {% else %}
+ {% for section in sections %}
{% if section.title %}{{ section.title }} {% endif %}{{ section.item|item_markdown }}{% if section.item.comment %}{{ section.item.comment|markdown }}
{% endif %} {% empty %}
{% trans "This collection has no sections yet." %}
{% endfor %}
+ {% endif %}
{% endblock %}
diff --git a/core/templates/core/collection_form.html b/core/templates/core/collection_form.html
index 52f6007..a4d39c9 100644
--- a/core/templates/core/collection_form.html
+++ b/core/templates/core/collection_form.html
@@ -4,14 +4,16 @@
{% trans "Collections are currently available on desktop only." %}
- {% for collection in collections %}
+ {% for entry in entries %}{% with collection=entry.collection %}
- {% empty %}
{% trans "No collections yet." %}
{% endfor %}
+ {% endwith %}{% empty %}
{% trans "No collections yet." %}
{% endfor %}
{% endblock %}
diff --git a/core/templates/core/collection_manage.html b/core/templates/core/collection_manage.html
index fb483ae..bb22d1e 100644
--- a/core/templates/core/collection_manage.html
+++ b/core/templates/core/collection_manage.html
@@ -9,7 +9,7 @@
{{ collection.title }}
{% if collection.team %}{{ collection.team.name }} {% else %}{{ collection.get_visibility_display }} {% endif %}
- {% for tag in collection.tags.all %}#{{ tag.name }} {% endfor %}
+ {% for tag in collection.filter_tags.all %}#{{ tag.name }} {% endfor %}
{% if can_edit %}{% endif %}
diff --git a/core/tests.py b/core/tests.py
index f371fab..af767c7 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -154,7 +154,7 @@ class CollectionTests(TestCase):
def test_create_collection_and_add_compatible_note(self):
response = self.client.post(reverse('collection_create'), {
- 'title': 'Documentation', 'description': 'Guide', 'visibility': Item.Visibility.PRIVATE,
+ 'title': 'Documentation', 'description': 'Guide', 'visibility': Item.Visibility.PRIVATE, 'mode': Collection.Mode.MANUAL,
})
collection = Collection.objects.get()
self.assertRedirects(response, collection.get_absolute_url())
@@ -199,12 +199,12 @@ class CollectionTests(TestCase):
self.assertEqual(response.status_code, 409)
self.assertTrue(Item.objects.filter(pk=note.id).exists())
- def test_team_tag_creates_team_collection(self):
+ def test_team_selection_creates_team_collection(self):
team = Team.objects.create(name='Docs team', slug='docs', owner=self.user)
TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER)
- tag = Tag.objects.create(name='docs')
response = self.client.post(reverse('collection_create'), {
- 'title': 'Team guide', 'visibility': Item.Visibility.PRIVATE, 'tags': [tag.id],
+ 'title': 'Team guide', 'visibility': Item.Visibility.PRIVATE,
+ 'mode': Collection.Mode.MANUAL, 'team': team.id,
})
collection = Collection.objects.get()
self.assertRedirects(response, collection.get_absolute_url())
@@ -218,13 +218,36 @@ class CollectionTests(TestCase):
response = self.client.get(reverse('collection_manage', args=[collection.id]))
self.assertNotContains(response, f'name="item_id" value="{public_note.id}"')
- def test_collection_form_only_offers_team_tags(self):
+ def test_collection_form_separates_teams_and_content_tags(self):
team = Team.objects.create(name='Docs team', slug='docs', owner=self.user)
TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER)
Tag.objects.create(name='docs')
- Tag.objects.create(name='ordinary')
+ note = Item.objects.create(owner=self.user, content='#ordinary')
+ note.sync_metadata()
response = self.client.get(reverse('collection_create'))
- self.assertEqual(list(response.context['form'].fields['tags'].queryset.values_list('name', flat=True)), ['docs'])
+ form = response.context['form']
+ self.assertEqual(list(form.fields['team'].queryset), [team])
+ self.assertEqual(list(form.fields['filter_tags'].queryset.values_list('name', flat=True)), ['ordinary'])
+
+ def test_tag_collection_is_dynamic_and_sorted_by_creation(self):
+ first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First #guide #basics')
+ first.sync_metadata()
+ Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Ignored #guide').sync_metadata()
+ second = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Second #guide #basics')
+ second.sync_metadata()
+ collection = Collection.objects.create(owner=self.user, title='Automatic', mode=Collection.Mode.TAGS)
+ collection.filter_tags.set(Tag.objects.filter(name__in=['guide', 'basics']))
+
+ response = self.client.get(collection.get_absolute_url())
+ html = response.content.decode()
+ self.assertContains(response, 'Tag-Sammlung')
+ self.assertLess(html.index('First #guide'), html.index('Second #guide'))
+ self.assertNotContains(response, 'Ignored #guide')
+ self.assertContains(response, reverse('collection_edit', args=[collection.id]))
+ self.assertNotContains(response, reverse('collection_manage', args=[collection.id]))
+
+ manage_response = self.client.get(reverse('collection_manage', args=[collection.id]))
+ self.assertRedirects(manage_response, reverse('collection_edit', args=[collection.id]))
def test_collection_opens_as_continuous_reading_view(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
@@ -247,8 +270,8 @@ class CollectionTests(TestCase):
def test_collection_tag_is_not_removed_as_unused(self):
tag = Tag.objects.create(name='documentation')
- collection = Collection.objects.create(owner=self.user, title='Tagged')
- collection.tags.add(tag)
+ collection = Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS)
+ collection.filter_tags.add(tag)
note = Item.objects.create(owner=self.user, content='#temporary')
note.sync_metadata()
note.content = ''
diff --git a/core/views.py b/core/views.py
index 09656ec..9d8087e 100644
--- a/core/views.py
+++ b/core/views.py
@@ -536,7 +536,7 @@ def team_accept_invite(request, token):
def visible_collections(user):
- qs = Collection.objects.select_related('owner', 'team').prefetch_related('tags', 'sections__item')
+ qs = Collection.objects.select_related('owner', 'team').prefetch_related('filter_tags', 'sections__item')
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)
@@ -569,14 +569,25 @@ def collection_item_is_compatible(collection, item):
def collection_target_from_form(form, user):
- tags = form.cleaned_data.get('tags')
- tag_names = list(tags.values_list('name', flat=True)) if tags is not None else []
- membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
- if membership:
- return Item.Visibility.TEAM, membership.team
+ team = form.cleaned_data.get('team')
+ if team:
+ return Item.Visibility.TEAM, team
return form.cleaned_data['visibility'], None
+def collection_tag_items(collection):
+ items = Item.objects.filter(kind=Item.Kind.NOTE).select_related('owner', 'team').prefetch_related('tags', 'attachments')
+ if collection.visibility == Item.Visibility.PUBLIC:
+ items = items.filter(visibility=Item.Visibility.PUBLIC)
+ elif collection.visibility == Item.Visibility.TEAM:
+ items = items.filter(Q(visibility=Item.Visibility.PUBLIC) | Q(visibility=Item.Visibility.TEAM, team=collection.team))
+ else:
+ items = items.filter(Q(visibility=Item.Visibility.PUBLIC) | Q(visibility=Item.Visibility.PRIVATE, owner=collection.owner))
+ for tag in collection.filter_tags.all():
+ items = items.filter(tags=tag)
+ return items.distinct().order_by('created_at', 'id')
+
+
def item_can_change_for_collection(user, item, collection):
if not user_can_edit_item(user, item):
return False
@@ -659,8 +670,11 @@ def collection_detail_context(request, collection, pending_item=None):
@login_required
def collection_list(request):
- collections = visible_collections(request.user)
- return render(request, 'core/collection_list.html', {'collections': collections})
+ entries = []
+ for collection in visible_collections(request.user).prefetch_related('filter_tags'):
+ count = collection_tag_items(collection).count() if collection.mode == Collection.Mode.TAGS else collection.sections.count()
+ entries.append({'collection': collection, 'section_count': count})
+ return render(request, 'core/collection_list.html', {'entries': entries})
@login_required
@@ -670,7 +684,7 @@ def collection_create(request):
if form.is_valid():
visibility, team = collection_target_from_form(form, request.user)
if visibility == Item.Visibility.TEAM and not team:
- form.add_error('visibility', _('Select a team tag for a team collection.'))
+ form.add_error('team', _('Select a team for a team collection.'))
else:
collection = form.save(commit=False)
collection.owner = request.user
@@ -678,6 +692,8 @@ def collection_create(request):
collection.team = team
collection.save()
form.save_m2m()
+ if collection.mode == Collection.Mode.MANUAL:
+ collection.filter_tags.clear()
messages.success(request, _('Collection created.'))
return redirect(collection)
else:
@@ -694,20 +710,26 @@ def collection_edit(request, pk):
form = CollectionForm(request.POST, instance=collection, user=request.user)
if form.is_valid():
visibility, team = collection_target_from_form(form, request.user)
- incompatible = any(not collection_scope_allows_item(
+ 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'))
- if incompatible:
+ 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:
form.add_error(None, _('The collection visibility cannot change while it contains incompatible notes.'))
elif visibility == Item.Visibility.TEAM and not team:
- form.add_error('visibility', _('Select a team tag for a team collection.'))
+ form.add_error('team', _('Select a team for a team collection.'))
else:
collection = form.save(commit=False)
collection.visibility = visibility
collection.team = team
collection.save()
form.save_m2m()
+ if collection.mode == Collection.Mode.MANUAL:
+ collection.filter_tags.clear()
messages.success(request, _('Collection saved.'))
return redirect(collection)
else:
@@ -719,7 +741,8 @@ 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'),
+ 'sections': collection.sections.select_related('item').prefetch_related('item__tags', 'item__attachments') 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),
})
@@ -729,6 +752,8 @@ def collection_manage(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403)
+ if collection.mode == Collection.Mode.TAGS:
+ return redirect('collection_edit', pk=collection.pk)
manage_url = reverse('collection_manage', args=[collection.pk])
if request.method == 'POST':
action = request.POST.get('action')
diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po
index 86ecfe5..67870f0 100644
--- a/locale/de/LC_MESSAGES/django.po
+++ b/locale/de/LC_MESSAGES/django.po
@@ -772,6 +772,50 @@ msgstr "Wähle höchstens ein Team aus."
msgid "Combine existing notes into ordered documents."
msgstr "Fasse bestehende Notizen zu geordneten Dokumenten zusammen."
+#: core/models.py
+msgid "Manual collection"
+msgstr "Manuelle Sammlung"
+
+#: core/models.py core/templates/core/collection_detail.html core/templates/core/collection_list.html
+msgid "Tag collection"
+msgstr "Tag-Sammlung"
+
+#: core/forms.py
+msgid "Collection type"
+msgstr "Art der Sammlung"
+
+#: core/forms.py
+msgid "Content tags"
+msgstr "Inhalts-Tags"
+
+#: core/forms.py
+msgid "Select at least one tag for a tag collection."
+msgstr "Wähle mindestens einen Tag für eine Tag-Sammlung aus."
+
+#: core/templates/core/collection_form.html
+msgid "Tag collections update automatically and are sorted by note creation time."
+msgstr "Tag-Sammlungen aktualisieren sich automatisch und werden nach dem Erstellungszeitpunkt der Notizen sortiert."
+
+#: core/templates/core/collection_form.html
+msgid "No content tags are available yet."
+msgstr "Es sind noch keine Inhalts-Tags verfügbar."
+
+#: core/templates/core/collection_form.html
+msgid "Notes must contain all selected tags. Team tags are not content filters."
+msgstr "Notizen müssen alle ausgewählten Tags enthalten. Team-Tags sind keine Inhaltsfilter."
+
+#: core/templates/core/collection_detail.html
+msgid "No notes match the selected tags yet."
+msgstr "Noch keine Notizen entsprechen den ausgewählten Tags."
+
+#: core/views.py
+msgid "Select a team for a team collection."
+msgstr "Wähle für eine Team-Sammlung ein Team aus."
+
+#: core/views.py
+msgid "Remove all manual sections before changing to a tag collection."
+msgstr "Entferne alle manuellen Abschnitte, bevor du zu einer Tag-Sammlung wechselst."
+
#: core/templates/core/collection_list.html
msgid "New collection"
msgstr "Neue Sammlung"