Add desktop note collections

This commit is contained in:
rucki
2026-08-27 14:06:06 +02:00
parent 4b83582e72
commit cd97a6cb62
14 changed files with 731 additions and 10 deletions
+13 -1
View File
@@ -1,5 +1,17 @@
from django.contrib import admin from django.contrib import admin
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
class CollectionSectionInline(admin.TabularInline):
model = CollectionSection
extra = 0
@admin.register(Collection)
class CollectionAdmin(admin.ModelAdmin):
list_display = ('title', 'visibility', 'team', 'owner', 'updated_at')
list_filter = ('visibility', 'team')
inlines = [CollectionSectionInline]
class AttachmentInline(admin.TabularInline): class AttachmentInline(admin.TabularInline):
+19 -1
View File
@@ -2,7 +2,7 @@ from django import forms
from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference
class MultipleFileInput(forms.ClearableFileInput): class MultipleFileInput(forms.ClearableFileInput):
@@ -133,6 +133,24 @@ class KanbanForm(forms.ModelForm):
return super().save(commit=commit) return super().save(commit=commit)
class CollectionForm(forms.ModelForm):
class Meta:
model = Collection
fields = ['title', 'description', 'visibility', 'tags']
labels = {
'title': _('Title'),
'description': _('Description'),
'visibility': _('Visibility'),
'tags': _('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(),
}
class ApiKeyForm(forms.ModelForm): class ApiKeyForm(forms.ModelForm):
class Meta: class Meta:
model = ApiKey model = ApiKey
@@ -0,0 +1,48 @@
# Generated by Django 5.2.17 on 2026-08-27 12:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_userpreference_default_item_kind'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Collection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('visibility', models.CharField(choices=[('private', 'Private'), ('team', 'Team'), ('public', 'Public')], default='private', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collections', to=settings.AUTH_USER_MODEL)),
('tags', models.ManyToManyField(blank=True, related_name='collections', to='core.tag')),
('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='collections', to='core.team')),
],
options={
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='CollectionSection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(blank=True, max_length=200)),
('position', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='core.collection')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='collection_sections', to='core.item')),
],
options={
'ordering': ['position', 'id'],
'constraints': [models.UniqueConstraint(fields=('collection', 'item'), name='unique_collection_item'), models.UniqueConstraint(fields=('collection', 'position'), name='unique_collection_position')],
},
),
]
+39 -1
View File
@@ -35,7 +35,7 @@ def without_markdown_code(text):
def delete_unused_tags(tags=None): def delete_unused_tags(tags=None):
"""Delete tags that are no longer used by items or tag-based features.""" """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 = 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).exclude( candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, collections__isnull=True).exclude(
name__in=Team.objects.values_list('slug', flat=True), name__in=Team.objects.values_list('slug', flat=True),
).delete() ).delete()
@@ -195,6 +195,44 @@ class Kanban(models.Model):
return self.name return self.name
class Collection(models.Model):
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')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-updated_at']
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse('collection_detail', args=[self.pk])
class CollectionSection(models.Model):
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name='sections')
item = models.ForeignKey(Item, on_delete=models.PROTECT, related_name='collection_sections')
title = models.CharField(max_length=200, blank=True)
position = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['position', 'id']
constraints = [
models.UniqueConstraint(fields=['collection', 'item'], name='unique_collection_item'),
models.UniqueConstraint(fields=['collection', 'position'], name='unique_collection_position'),
]
def __str__(self):
return f'{self.collection}: {self.title or self.item}'
class UserPreference(models.Model): class UserPreference(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences') user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences')
language = models.CharField(max_length=10, blank=True, default='', choices=[('', _('Browser default')), ('de', 'Deutsch'), ('en', 'English')]) language = models.CharField(max_length=10, blank=True, default='', choices=[('', _('Browser default')), ('de', 'Deutsch'), ('en', 'English')])
+1
View File
@@ -2,6 +2,7 @@
<form id="item-{{ item.id }}" class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" hx-post="{% url 'edit_item' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}], {{ item.id }})" x-init="text=$el.querySelector('textarea').value"> <form id="item-{{ item.id }}" class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" hx-post="{% url 'edit_item' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}], {{ item.id }})" x-init="text=$el.querySelector('textarea').value">
{% csrf_token %} {% csrf_token %}
<div class="card-body vstack gap-2"> <div class="card-body vstack gap-2">
{% if form.errors %}<div class="alert alert-danger mb-0">{{ form.errors }}</div>{% endif %}
<div class="small text-muted">{% trans "Type:" %} {{ item.get_kind_display }}</div> <div class="small text-muted">{% trans "Type:" %} {{ item.get_kind_display }}</div>
<div class="mobile-tag-picker"> <div class="mobile-tag-picker">
<div class="small text-muted mb-1">{% trans "Add tags" %}</div> <div class="small text-muted mb-1">{% trans "Add tags" %}</div>
+1 -1
View File
@@ -26,7 +26,7 @@
<div class="d-none d-md-flex align-items-center gap-2"> <div class="d-none d-md-flex align-items-center gap-2">
<a class="small nav-soft fw-semibold" href="/">{% for label in nav_overview_labels %}{{ label }}{% if not forloop.last %} · {% endif %}{% endfor %}</a> <a class="small nav-soft fw-semibold" href="/">{% for label in nav_overview_labels %}{{ label }}{% if not forloop.last %} · {% endif %}{% endfor %}</a>
{% for link in nav_hidden_links %}<a class="small nav-soft" href="{{ link.url }}">{{ link.label }}</a>{% endfor %} {% for link in nav_hidden_links %}<a class="small nav-soft" href="{{ link.url }}">{{ link.label }}</a>{% endfor %}
{% if user.is_authenticated %}<a class="small nav-soft" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small nav-soft" href="{% url 'kanban_list' %}">{% trans "Kanban" %}</a><a class="small nav-soft" href="{% url 'team_list' %}">{% trans "Teams" %}</a>{% endif %} {% if user.is_authenticated %}<a class="small nav-soft" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small nav-soft" href="{% url 'kanban_list' %}">{% trans "Kanban" %}</a><a class="small nav-soft" href="{% url 'collection_list' %}">{% trans "Collections" %}</a><a class="small nav-soft" href="{% url 'team_list' %}">{% trans "Teams" %}</a>{% endif %}
</div> </div>
<div class="d-flex d-md-none align-items-center gap-1"><a class="small nav-soft fw-semibold" href="/">Start</a>{% if user.is_authenticated %}<a class="small nav-soft" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small nav-soft" href="{% url 'team_list' %}">{% trans "Teams" %}</a>{% endif %}</div> <div class="d-flex d-md-none align-items-center gap-1"><a class="small nav-soft fw-semibold" href="/">Start</a>{% if user.is_authenticated %}<a class="small nav-soft" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small nav-soft" href="{% url 'team_list' %}">{% trans "Teams" %}</a>{% endif %}</div>
{% if user.is_authenticated %} {% if user.is_authenticated %}
@@ -0,0 +1,67 @@
{% extends 'core/base.html' %}
{% load i18n markdown_extras %}
{% block content %}
<div class="d-md-none alert alert-info">{% trans "Collections are currently available on desktop only." %}</div>
<div class="d-none d-md-block">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
<div>
<a class="btn btn-sm btn-outline-secondary mb-3" href="{% url 'collection_list' %}">← {% trans "Collections" %}</a>
<h1 class="display-6 mb-2">{{ collection.title }}</h1>
<div class="d-flex flex-wrap gap-2 align-items-center">
{% if collection.team %}<span class="badge text-bg-warning">{{ collection.team.name }}</span>{% else %}<span class="badge text-bg-light">{{ collection.get_visibility_display }}</span>{% endif %}
{% for tag in collection.tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span>{% endfor %}
</div>
</div>
{% if can_edit %}<div class="d-flex gap-2"><a class="btn btn-outline-secondary" href="{% url 'collection_edit' collection.id %}">{% trans "Edit collection" %}</a>{% if can_delete %}<form method="post" action="{% url 'collection_delete' collection.id %}" onsubmit="return confirm('{% trans "Delete collection? The notes remain available." %}')">{% csrf_token %}<button class="btn btn-outline-danger">{% trans "Delete" %}</button></form>{% endif %}</div>{% endif %}
</div>
{% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %}
{% if sections %}
<div class="card item-card mb-4"><div class="card-body"><div class="fw-semibold mb-2">{% trans "Contents" %}</div><ol class="mb-0">{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|truncatechars:90 }}</a></li>{% endfor %}</ol></div></div>
{% endif %}
{% if pending_item %}
<div class="alert alert-warning">
<h2 class="h5">{% trans "The note has a different visibility" %}</h2>
<p>{% trans "To add it, change its visibility or create a separate copy for this collection." %}</p>
<div class="border rounded bg-white p-2 mb-3">{{ pending_item.content|truncatechars:180 }}</div>
<div class="d-flex gap-2">
{% if pending_can_convert %}<form method="post">{% csrf_token %}<input type="hidden" name="action" value="confirm_add"><input type="hidden" name="item_id" value="{{ pending_item.id }}"><input type="hidden" name="mode" value="convert"><button class="btn btn-warning">{% if collection.visibility == 'public' %}{% trans "Make note public" %}{% elif collection.team %}{% trans "Share note with team" %}{% else %}{% trans "Make note private" %}{% endif %}</button></form>{% endif %}
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="confirm_add"><input type="hidden" name="item_id" value="{{ pending_item.id }}"><input type="hidden" name="mode" value="copy"><button class="btn btn-outline-secondary">{% trans "Create compatible copy" %}</button></form>
<a class="btn btn-link" href="{{ collection.get_absolute_url }}">{% trans "Cancel" %}</a>
</div>
{% if not pending_can_convert %}<div class="small text-muted mt-2">{% trans "The original cannot be changed because it is used elsewhere or you cannot edit it." %}</div>{% endif %}
</div>
{% endif %}
<div class="vstack gap-4 mb-5">
{% for section in sections %}
<section id="section-{{ section.id }}" class="card item-card"><div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<h2 class="h3 mb-0">{{ section.title|default:section.item.content|truncatechars:100 }}</h2>
<a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a>
</div>
<div class="content">{{ section.item|item_markdown }}</div>
{% if section.item.comment %}<div class="content text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}
{% if can_edit %}<div class="border-top mt-4 pt-3 d-flex align-items-center gap-2">
<form method="post" class="d-flex gap-2 flex-grow-1">{% csrf_token %}<input type="hidden" name="action" value="title"><input type="hidden" name="section_id" value="{{ section.id }}"><input class="form-control form-control-sm" name="title" value="{{ section.title }}" placeholder="{% trans 'Optional section title' %}"><button class="btn btn-sm btn-outline-secondary">{% trans "Save title" %}</button></form>
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="up"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move up' %}">↑</button></form>
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="down"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move down' %}">↓</button></form>
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="remove"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-danger">{% trans "Remove" %}</button></form>
</div>{% endif %}
</div></section>
{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
</div>
{% if can_edit %}
<div class="card item-card"><div class="card-body">
<h2 class="h4">{% trans "Add existing note" %}</h2>
<form method="get" class="input-group mb-3"><input class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search notes' %}"><button class="btn btn-outline-secondary">{% trans "Search" %}</button></form>
<div class="vstack gap-2">
{% for item in candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3"><div><div>{{ item.content|truncatechars:180 }}</div><div class="small text-muted">#{{ item.id }} · {{ item.get_visibility_display }}{% if item.team %} · {{ item.team.name }}{% endif %}</div></div><form method="post">{% csrf_token %}<input type="hidden" name="action" value="add"><input type="hidden" name="item_id" value="{{ item.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form></div>{% empty %}<div class="text-muted">{% trans "No matching notes." %}</div>{% endfor %}
</div>
</div></div>
{% endif %}
</div>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% block content %}
<div class="d-md-none alert alert-info">{% trans "Collections are currently available on desktop only." %}</div>
<div class="d-none d-md-block">
<a class="btn btn-sm btn-outline-secondary mb-3" href="{% if collection %}{{ collection.get_absolute_url }}{% else %}{% url 'collection_list' %}{% endif %}">← {% trans "back" %}</a>
<form method="post" class="card item-card"><div class="card-body vstack gap-3">
<h1 class="h3 mb-0">{{ heading }}</h1>
{% csrf_token %}
{% if form.non_field_errors %}<div class="alert alert-danger mb-0">{{ form.non_field_errors }}</div>{% endif %}
<div><label class="form-label">{{ form.title.label }}</label>{{ form.title }}{{ form.title.errors }}</div>
<div><label class="form-label">{{ form.description.label }}</label>{{ form.description }}{{ form.description.errors }}</div>
<div><label class="form-label">{{ form.visibility.label }}</label>{{ form.visibility }}{{ form.visibility.errors }}<div class="form-text">{% trans "Adding one of your team tags turns this into a team collection." %}</div></div>
<div><label class="form-label">{{ form.tags.label }}</label><div class="border rounded p-3 collection-tags">{{ form.tags }}</div>{{ form.tags.errors }}</div>
<div class="d-flex gap-2"><button class="btn btn-dark">{% trans "Save" %}</button><a class="btn btn-outline-secondary" href="{% if collection %}{{ collection.get_absolute_url }}{% else %}{% url 'collection_list' %}{% endif %}">{% trans "Cancel" %}</a></div>
</div></form>
</div>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% block content %}
<div class="d-md-none alert alert-info">{% trans "Collections are currently available on desktop only." %}</div>
<div class="d-none d-md-block">
<div class="d-flex justify-content-between align-items-center mb-4">
<div><h1 class="h3 mb-1">{% trans "Collections" %}</h1><p class="text-muted mb-0">{% trans "Combine existing notes into ordered documents." %}</p></div>
<a class="btn btn-dark" href="{% url 'collection_create' %}">{% trans "New collection" %}</a>
</div>
<div class="row g-3">
{% for collection in collections %}
<div class="col-md-6"><a class="card item-card h-100 text-decoration-none text-dark" href="{{ collection.get_absolute_url }}"><div class="card-body">
<div class="d-flex justify-content-between gap-2"><h2 class="h5 mb-1">{{ collection.title }}</h2>{% if collection.team %}<span class="badge text-bg-warning">{{ collection.team.name }}</span>{% else %}<span class="badge text-bg-light">{{ collection.get_visibility_display }}</span>{% endif %}</div>
{% if collection.description %}<p class="text-muted mt-2 mb-2">{{ collection.description|truncatechars:140 }}</p>{% endif %}
<div class="small text-muted">{{ collection.sections.count }} {% trans "sections" %} · {{ collection.updated_at|date:'d.m.Y H:i' }}</div>
</div></a></div>
{% empty %}<div class="col-12 text-center text-muted py-5">{% trans "No collections yet." %}</div>{% endfor %}
</div>
</div>
{% endblock %}
+1
View File
@@ -4,6 +4,7 @@
<form class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}], {{ item.id }})" x-init="text=$el.querySelector('textarea[name=content]').value"> <form class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}], {{ item.id }})" x-init="text=$el.querySelector('textarea[name=content]').value">
{% csrf_token %} {% csrf_token %}
<div class="card-body vstack gap-3"> <div class="card-body vstack gap-3">
{% if form.errors %}<div class="alert alert-danger mb-0">{{ form.errors }}</div>{% endif %}
<div class="d-flex justify-content-between align-items-center gap-2 sticky-top bg-white py-2" style="z-index:5"> <div class="d-flex justify-content-between align-items-center gap-2 sticky-top bg-white py-2" style="z-index:5">
<h1 class="h5 m-0">{% blocktrans with id=item.id %}Edit item #{{ id }}{% endblocktrans %}</h1> <h1 class="h5 m-0">{% blocktrans with id=item.id %}Edit item #{{ id }}{% endblocktrans %}</h1>
<div class="d-flex gap-2"><button class="btn btn-success px-3 py-2" title="{% trans 'Save' %}">✓</button><a class="btn btn-danger px-3 py-2" href="{{ next_url }}" title="{% trans 'Cancel' %}">×</a></div> <div class="d-flex gap-2"><button class="btn btn-success px-3 py-2" title="{% trans 'Save' %}">✓</button><a class="btn btn-danger px-3 py-2" href="{{ next_url }}" title="{% trans 'Cancel' %}">×</a></div>
+82 -1
View File
@@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from .models import Item, Tag, UserPreference from .models import Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
from .templatetags.markdown_extras import markdown from .templatetags.markdown_extras import markdown
from .views import parse_due_command, parse_quick_content from .views import parse_due_command, parse_quick_content
@@ -147,6 +147,87 @@ class NavigationAndLayoutTests(TestCase):
self.assertLess(toolbar_position, file_position) self.assertLess(toolbar_position, file_position)
class CollectionTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='collector', password='secret')
self.client.force_login(self.user)
def test_create_collection_and_add_compatible_note(self):
response = self.client.post(reverse('collection_create'), {
'title': 'Documentation', 'description': 'Guide', 'visibility': Item.Visibility.PRIVATE,
})
collection = Collection.objects.get()
self.assertRedirects(response, collection.get_absolute_url())
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Introduction')
response = self.client.post(collection.get_absolute_url(), {'action': 'add', 'item_id': note.id})
self.assertRedirects(response, collection.get_absolute_url())
self.assertTrue(CollectionSection.objects.filter(collection=collection, item=note).exists())
def test_public_collection_asks_before_copying_private_note(self):
collection = Collection.objects.create(owner=self.user, title='Public guide', visibility=Item.Visibility.PUBLIC)
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Private section')
response = self.client.post(collection.get_absolute_url(), {'action': 'add', 'item_id': note.id})
self.assertContains(response, 'name="mode" value="copy"')
response = self.client.post(collection.get_absolute_url(), {
'action': 'confirm_add', 'item_id': note.id, 'mode': 'copy',
})
self.assertRedirects(response, collection.get_absolute_url())
copied = collection.sections.get().item
self.assertNotEqual(copied.id, note.id)
self.assertEqual(copied.visibility, Item.Visibility.PUBLIC)
note.refresh_from_db()
self.assertEqual(note.visibility, Item.Visibility.PRIVATE)
def test_note_used_by_collection_cannot_be_deleted(self):
collection = Collection.objects.create(owner=self.user, title='Protected')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Do not delete')
CollectionSection.objects.create(collection=collection, item=note, position=0)
response = self.client.post(reverse('delete_item', args=[note.id]))
self.assertEqual(response.status_code, 409)
self.assertTrue(Item.objects.filter(pk=note.id).exists())
def test_team_tag_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],
})
collection = Collection.objects.get()
self.assertRedirects(response, collection.get_absolute_url())
self.assertEqual(collection.team, team)
self.assertEqual(collection.visibility, Item.Visibility.TEAM)
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()
self.assertEqual(self.client.get(collection.get_absolute_url()).status_code, 200)
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)
note = Item.objects.create(owner=self.user, content='#temporary')
note.sync_metadata()
note.content = ''
note.save()
note.sync_metadata()
self.assertTrue(Tag.objects.filter(pk=tag.pk).exists())
def test_api_cannot_make_collection_note_incompatible(self):
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Published', visibility=Item.Visibility.PUBLIC)
CollectionSection.objects.create(collection=collection, item=note, position=0)
response = self.client.patch(
reverse('api_item_detail', args=[note.id]),
data='{"visibility":"private"}', content_type='application/json',
)
self.assertEqual(response.status_code, 409)
note.refresh_from_db()
self.assertEqual(note.visibility, Item.Visibility.PUBLIC)
class CopyButtonTests(TestCase): class CopyButtonTests(TestCase):
def test_item_has_copy_button_with_original_text(self): def test_item_has_copy_button_with_original_text(self):
user = get_user_model().objects.create_user(username='tester', password='secret') user = get_user_model().objects.create_user(username='tester', password='secret')
+5
View File
@@ -19,6 +19,11 @@ urlpatterns = [
path('teams/', views.team_list, name='team_list'), path('teams/', views.team_list, name='team_list'),
path('teams/<int:pk>/', views.team_detail, name='team_detail'), path('teams/<int:pk>/', views.team_detail, name='team_detail'),
path('teams/invite/<str:token>/', views.team_accept_invite, name='team_accept_invite'), path('teams/invite/<str:token>/', views.team_accept_invite, name='team_accept_invite'),
path('collections/', views.collection_list, name='collection_list'),
path('collections/new/', views.collection_create, name='collection_create'),
path('collections/<int:pk>/', views.collection_detail, name='collection_detail'),
path('collections/<int:pk>/edit/', views.collection_edit, name='collection_edit'),
path('collections/<int:pk>/delete/', views.collection_delete, name='collection_delete'),
path('items/<int:pk>/', views.item_detail, name='item_detail'), path('items/<int:pk>/', views.item_detail, name='item_detail'),
path('items/<int:pk>/card/', views.item_card, name='item_card'), path('items/<int:pk>/card/', views.item_card, name='item_card'),
path('items/<int:pk>/edit/', views.edit_item, name='edit_item'), path('items/<int:pk>/edit/', views.edit_item, name='edit_item'),
+261 -5
View File
@@ -9,7 +9,8 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login from django.contrib.auth import login
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Case, IntegerField, Q, Value, When from django.db import transaction
from django.db.models import Case, IntegerField, ProtectedError, Q, Value, When
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@@ -18,8 +19,8 @@ from django.utils.translation import gettext as _
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST from django.views.decorators.http import require_http_methods, require_POST
from .forms import ApiKeyForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I) TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I) VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I)
@@ -159,6 +160,22 @@ def user_can_delete_item(user, item):
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists()) return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists())
def desired_item_scope(item, user, force_private=False):
if force_private:
return Item.Visibility.PRIVATE, None
tag_names = {match.group(1).lower() for match in re.finditer(r'(?<!\w)#([\wäöüÄÖÜß-]+)', item.content)}
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
if membership:
return Item.Visibility.TEAM, membership.team
visibility = Item.Visibility.PRIVATE if item.visibility == Item.Visibility.TEAM else item.visibility
return visibility, None
def item_scope_fits_collections(item, visibility, team):
sections = item.collection_sections.select_related('collection') if item.pk else CollectionSection.objects.none()
return not any(section.collection.visibility != visibility or section.collection.team_id != (team.id if team else None) for section in sections)
def apply_team_from_tags(item, user, force_private=False): def apply_team_from_tags(item, user, force_private=False):
if force_private: if force_private:
item.team = None item.team = None
@@ -514,6 +531,223 @@ def team_accept_invite(request, token):
return redirect('team_detail', pk=invite.team.pk) return redirect('team_detail', pk=invite.team.pk)
def visible_collections(user):
qs = Collection.objects.select_related('owner', 'team').prefetch_related('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)
def user_can_edit_collection(user, collection):
return user.is_authenticated and (collection.owner_id == user.id or (collection.team_id and TeamMembership.objects.filter(team=collection.team, user=user).exists()))
def user_can_delete_collection(user, collection):
return user.is_authenticated and (collection.owner_id == user.id or (collection.team_id and TeamMembership.objects.filter(team=collection.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists()))
def collection_item_is_compatible(collection, item):
if collection.visibility == Item.Visibility.TEAM:
return item.visibility == Item.Visibility.TEAM and item.team_id == collection.team_id
return item.visibility == collection.visibility and item.team_id is None
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
return form.cleaned_data['visibility'], None
def item_can_change_for_collection(user, item, collection):
if not user_can_edit_item(user, item):
return False
for section in item.collection_sections.select_related('collection').exclude(collection=collection):
other = section.collection
if other.visibility != collection.visibility or other.team_id != collection.team_id:
return False
return True
def remove_team_tags(content):
slugs = Team.objects.values_list('slug', flat=True)
for slug in slugs:
content = re.sub(rf'(?<!\w)#{re.escape(slug)}\b', '', content, flags=re.I)
return re.sub(r'[ \t]{2,}', ' ', content).strip()
def adapt_item_for_collection(item, collection):
item.content = remove_team_tags(item.content)
if collection.team:
item.content = f'{item.content} #{collection.team.slug}'.strip()
item.visibility = collection.visibility
item.team = collection.team
item.save(update_fields=['content', 'visibility', 'team', 'updated_at'])
item.sync_metadata()
item.team = collection.team
item.visibility = collection.visibility
item.save(update_fields=['team', 'visibility', 'updated_at'])
return item
def copy_item_for_collection(item, collection, owner):
copied = Item.objects.create(
owner=owner, team=collection.team, kind=Item.Kind.NOTE, content=item.content,
comment=item.comment, visibility=collection.visibility, url=item.url,
)
for attachment in item.attachments.all():
Attachment.objects.create(item=copied, file=attachment.file.name)
return adapt_item_for_collection(copied, collection)
def add_collection_section(collection, item):
last_position = collection.sections.order_by('-position').values_list('position', flat=True).first()
return CollectionSection.objects.get_or_create(
collection=collection, item=item,
defaults={'position': (last_position + 1) if last_position is not None else 0},
)
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()
if can_edit:
candidates = visible_items(request.user).filter(kind=Item.Kind.NOTE).exclude(collection_sections__collection=collection)
if q:
candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
candidates = candidates[:30]
return {
'collection': collection,
'sections': collection.sections.select_related('item').prefetch_related('item__tags', 'item__attachments'),
'can_edit': can_edit,
'can_delete': user_can_delete_collection(request.user, collection),
'candidates': candidates,
'q': q,
'pending_item': pending_item,
'pending_can_convert': pending_item and item_can_change_for_collection(request.user, pending_item, collection),
}
@login_required
def collection_list(request):
collections = visible_collections(request.user)
return render(request, 'core/collection_list.html', {'collections': collections})
@login_required
def collection_create(request):
if request.method == 'POST':
form = CollectionForm(request.POST)
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.'))
else:
collection = form.save(commit=False)
collection.owner = request.user
collection.visibility = visibility
collection.team = team
collection.save()
form.save_m2m()
messages.success(request, _('Collection created.'))
return redirect(collection)
else:
form = CollectionForm()
return render(request, 'core/collection_form.html', {'form': form, 'heading': _('Create collection')})
@login_required
def collection_edit(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 request.method == 'POST':
form = CollectionForm(request.POST, instance=collection)
if form.is_valid():
visibility, team = collection_target_from_form(form, request.user)
incompatible = collection.sections.exclude(item__visibility=visibility)
if team:
incompatible = incompatible | collection.sections.exclude(item__team=team)
else:
incompatible = incompatible | collection.sections.exclude(item__team__isnull=True)
if incompatible.exists():
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.'))
else:
collection = form.save(commit=False)
collection.visibility = visibility
collection.team = team
collection.save()
form.save_m2m()
messages.success(request, _('Collection saved.'))
return redirect(collection)
else:
form = CollectionForm(instance=collection)
return render(request, 'core/collection_form.html', {'form': form, 'collection': collection, 'heading': _('Edit collection')})
def collection_detail(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
if request.method == 'POST':
if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403)
action = request.POST.get('action')
if action in {'add', 'confirm_add'}:
item = get_object_or_404(visible_items(request.user), pk=request.POST.get('item_id'), kind=Item.Kind.NOTE)
if collection_item_is_compatible(collection, item):
add_collection_section(collection, item)
return redirect(collection)
if action == 'add':
return render(request, 'core/collection_detail.html', collection_detail_context(request, collection, item))
mode = request.POST.get('mode')
if mode == 'convert':
if not item_can_change_for_collection(request.user, item, collection):
messages.error(request, _('This note is used by an incompatible collection or cannot be edited. Create a copy instead.'))
return redirect(collection)
item = adapt_item_for_collection(item, collection)
elif mode == 'copy':
item = copy_item_for_collection(item, collection, request.user)
else:
return redirect(collection)
add_collection_section(collection, item)
return redirect(collection)
section = get_object_or_404(CollectionSection, pk=request.POST.get('section_id'), collection=collection)
if action == 'remove':
section.delete()
for index, remaining in enumerate(collection.sections.order_by('position', 'id')):
if remaining.position != index:
CollectionSection.objects.filter(pk=remaining.pk).update(position=index)
elif action == 'title':
section.title = request.POST.get('title', '').strip()[:200]
section.save(update_fields=['title'])
elif action in {'up', 'down'}:
direction = -1 if action == 'up' else 1
other = collection.sections.filter(position=section.position + direction).first()
if other:
with transaction.atomic():
temporary = collection.sections.order_by('-position').values_list('position', flat=True).first() + 1000
CollectionSection.objects.filter(pk=section.pk).update(position=temporary)
CollectionSection.objects.filter(pk=other.pk).update(position=section.position)
CollectionSection.objects.filter(pk=section.pk).update(position=other.position)
return redirect(collection)
return render(request, 'core/collection_detail.html', collection_detail_context(request, collection))
@login_required
@require_POST
def collection_delete(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
if not user_can_delete_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403)
collection.delete()
messages.success(request, _('Collection deleted.'))
return redirect('collection_list')
@login_required @login_required
def item_detail(request, pk): def item_detail(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk) item = get_object_or_404(visible_items(request.user), pk=pk)
@@ -552,6 +786,11 @@ def edit_item(request, pk):
item.content, command_due_at, has_due_command = parse_due_command(item.content) item.content, command_due_at, has_due_command = parse_due_command(item.content)
if item.kind == Item.Kind.TODO: if item.kind == Item.Kind.TODO:
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST) item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team):
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': Tag.objects.all()}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save() item.save()
form.save_m2m() form.save_m2m()
attach_files_and_replace_tokens(item, request.FILES.getlist('files')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
@@ -576,6 +815,11 @@ def item_editor(request, pk):
item.content, command_due_at, has_due_command = parse_due_command(item.content) item.content, command_due_at, has_due_command = parse_due_command(item.content)
if item.kind == Item.Kind.TODO: if item.kind == Item.Kind.TODO:
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST) item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team):
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': Tag.objects.all(), 'next_url': next_url}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save() item.save()
form.save_m2m() form.save_m2m()
attach_files_and_replace_tokens(item, request.FILES.getlist('files')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
@@ -591,7 +835,11 @@ def delete_item(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk) item = get_object_or_404(visible_items(request.user), pk=pk)
if not user_can_delete_item(request.user, item): if not user_can_delete_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
item.delete() try:
item.delete()
except ProtectedError:
collections = list(item.collection_sections.select_related('collection').values_list('collection__title', flat=True))
return JsonResponse({'error': _('This note is used in collections and cannot be deleted.'), 'collections': collections}, status=409)
return JsonResponse({'ok': True}) return JsonResponse({'ok': True})
@@ -721,7 +969,11 @@ def api_item_detail(request, pk):
if request.method == 'DELETE': if request.method == 'DELETE':
if not user_can_delete_item(user, item): if not user_can_delete_item(user, item):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
item.delete() try:
item.delete()
except ProtectedError:
collections = list(item.collection_sections.select_related('collection').values_list('collection__title', flat=True))
return JsonResponse({'error': _('This note is used in collections and cannot be deleted.'), 'collections': collections}, status=409)
return JsonResponse({'ok': True}) return JsonResponse({'ok': True})
if not user_can_edit_item(user, item): if not user_can_edit_item(user, item):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
@@ -735,6 +987,10 @@ def api_item_detail(request, pk):
item.due_at = command_due_at item.due_at = command_due_at
if 'is_done' in data and item.kind == Item.Kind.TODO: if 'is_done' in data and item.kind == Item.Kind.TODO:
item.completed_at = timezone.now() if item.is_done else None item.completed_at = timezone.now() if item.is_done else None
target_visibility, target_team = desired_item_scope(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team):
return JsonResponse({'error': _('This visibility is incompatible with a collection that uses the note.')}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save() item.save()
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE) apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
+156
View File
@@ -644,6 +644,162 @@ msgstr "Team %(team)s erstellt. Nutze #%(slug)s für Team-Einträge."
msgid "You are now a member of team %(team)s." msgid "You are now a member of team %(team)s."
msgstr "Du bist jetzt Mitglied im Team %(team)s." msgstr "Du bist jetzt Mitglied im Team %(team)s."
#: core/forms.py core/templates/core/collection_form.html
msgid "Title"
msgstr "Titel"
#: core/forms.py core/templates/core/collection_form.html
msgid "Description"
msgstr "Beschreibung"
#: core/templates/core/base.html core/templates/core/collection_detail.html core/templates/core/collection_list.html
msgid "Collections"
msgstr "Sammlungen"
#: core/templates/core/collection_detail.html core/templates/core/collection_form.html core/templates/core/collection_list.html
msgid "Collections are currently available on desktop only."
msgstr "Sammlungen sind derzeit nur auf dem Desktop verfügbar."
#: core/templates/core/collection_detail.html
msgid "Edit collection"
msgstr "Sammlung bearbeiten"
#: core/templates/core/collection_detail.html
msgid "Delete collection? The notes remain available."
msgstr "Sammlung löschen? Die Notizen bleiben erhalten."
#: core/templates/core/collection_detail.html
msgid "Contents"
msgstr "Inhalt"
#: core/templates/core/collection_detail.html
msgid "The note has a different visibility"
msgstr "Die Notiz hat eine andere Sichtbarkeit"
#: core/templates/core/collection_detail.html
msgid "To add it, change its visibility or create a separate copy for this collection."
msgstr "Um sie hinzuzufügen, ändere ihre Sichtbarkeit oder erstelle eine eigene Kopie für diese Sammlung."
#: core/templates/core/collection_detail.html
msgid "Make note public"
msgstr "Notiz öffentlich machen"
#: core/templates/core/collection_detail.html
msgid "Share note with team"
msgstr "Notiz mit Team teilen"
#: core/templates/core/collection_detail.html
msgid "Make note private"
msgstr "Notiz privat machen"
#: core/templates/core/collection_detail.html
msgid "Create compatible copy"
msgstr "Passende Kopie erstellen"
#: core/templates/core/collection_detail.html
msgid "The original cannot be changed because it is used elsewhere or you cannot edit it."
msgstr "Das Original kann nicht geändert werden, weil es anderswo verwendet wird oder du es nicht bearbeiten darfst."
#: core/templates/core/collection_detail.html
msgid "Optional section title"
msgstr "Optionaler Abschnittstitel"
#: core/templates/core/collection_detail.html
msgid "Save title"
msgstr "Titel speichern"
#: core/templates/core/collection_detail.html
msgid "Move up"
msgstr "Nach oben"
#: core/templates/core/collection_detail.html
msgid "Move down"
msgstr "Nach unten"
#: core/templates/core/collection_detail.html
msgid "Remove"
msgstr "Entfernen"
#: core/templates/core/collection_detail.html
msgid "This collection has no sections yet."
msgstr "Diese Sammlung hat noch keine Abschnitte."
#: core/templates/core/collection_detail.html
msgid "Add existing note"
msgstr "Bestehende Notiz hinzufügen"
#: core/templates/core/collection_detail.html
msgid "Search notes"
msgstr "Notizen suchen"
#: core/templates/core/collection_detail.html
msgid "Search"
msgstr "Suchen"
#: core/templates/core/collection_detail.html
msgid "Add"
msgstr "Hinzufügen"
#: core/templates/core/collection_detail.html
msgid "No matching notes."
msgstr "Keine passenden Notizen."
#: core/templates/core/collection_form.html
msgid "Adding one of your team tags turns this into a team collection."
msgstr "Durch Hinzufügen eines deiner Team-Tags wird daraus eine Team-Sammlung."
#: core/templates/core/collection_list.html
msgid "Combine existing notes into ordered documents."
msgstr "Fasse bestehende Notizen zu geordneten Dokumenten zusammen."
#: core/templates/core/collection_list.html
msgid "New collection"
msgstr "Neue Sammlung"
#: core/templates/core/collection_list.html
msgid "sections"
msgstr "Abschnitte"
#: core/templates/core/collection_list.html
msgid "No collections yet."
msgstr "Noch keine Sammlungen."
#: core/views.py
msgid "Select a team tag for a team collection."
msgstr "Wähle für eine Team-Sammlung ein Team-Tag aus."
#: core/views.py
msgid "Collection created."
msgstr "Sammlung erstellt."
#: core/views.py
msgid "Create collection"
msgstr "Sammlung erstellen"
#: core/views.py
msgid "The collection visibility cannot change while it contains incompatible notes."
msgstr "Die Sichtbarkeit kann nicht geändert werden, solange die Sammlung unpassende Notizen enthält."
#: core/views.py
msgid "Collection saved."
msgstr "Sammlung gespeichert."
#: core/views.py
msgid "This note is used by an incompatible collection or cannot be edited. Create a copy instead."
msgstr "Diese Notiz wird in einer unpassenden Sammlung verwendet oder kann nicht bearbeitet werden. Erstelle stattdessen eine Kopie."
#: core/views.py
msgid "Collection deleted."
msgstr "Sammlung gelöscht."
#: core/views.py
msgid "This note is used in collections and cannot be deleted."
msgstr "Diese Notiz wird in Sammlungen verwendet und kann nicht gelöscht werden."
#: core/views.py
msgid "This visibility is incompatible with a collection that uses the note."
msgstr "Diese Sichtbarkeit passt nicht zu einer Sammlung, die diese Notiz verwendet."
#: core/templates/core/_format_toolbar.html #: core/templates/core/_format_toolbar.html
msgid "Bold" msgid "Bold"
msgstr "Fett" msgstr "Fett"