diff --git a/core/migrations/0020_collection_tutorial_modes.py b/core/migrations/0020_collection_tutorial_modes.py new file mode 100644 index 0000000..bcdb598 --- /dev/null +++ b/core/migrations/0020_collection_tutorial_modes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-08-31 08:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0019_remove_collectionsection_unique_collection_item_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='collection', + name='mode', + field=models.CharField(choices=[('manual', 'Manual collection'), ('tags', 'Tag collection'), ('tutorial', 'Tutorial'), ('chapter', 'Chapter')], default='manual', max_length=10), + ), + ] diff --git a/core/models.py b/core/models.py index e1eb0cb..cb5af02 100644 --- a/core/models.py +++ b/core/models.py @@ -199,6 +199,14 @@ class Collection(models.Model): class Mode(models.TextChoices): MANUAL = 'manual', _('Manual collection') TAGS = 'tags', _('Tag collection') + TUTORIAL = 'tutorial', _('Tutorial') + CHAPTER = 'chapter', _('Chapter') + + STRUCTURED_MODES = {Mode.MANUAL, Mode.TUTORIAL, Mode.CHAPTER} + + @property + def is_structured(self): + return self.mode in self.STRUCTURED_MODES 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') diff --git a/core/templates/core/collection_detail.html b/core/templates/core/collection_detail.html index 9f98366..d8f5042 100644 --- a/core/templates/core/collection_detail.html +++ b/core/templates/core/collection_detail.html @@ -1,15 +1,14 @@ {% extends 'core/base.html' %} {% load i18n markdown_extras %} {% block content %} -
{% trans "Collections are currently available on desktop only." %}
-
+
← {% if user.is_authenticated %}{% trans "Collections" %}{% else %}Start{% endif %}

{{ collection.title }}

- {% if collection.mode == 'tags' %}{% trans "Tag collection" %}{% endif %} + {% if collection.mode != 'manual' %}{{ collection.get_mode_display }}{% endif %} {% if collection.team %}{{ collection.team.name }}{% else %}{{ collection.get_visibility_display }}{% endif %} {% for tag in collection.filter_tags.all %}#{{ tag.name }}{% endfor %}
diff --git a/core/templates/core/collection_list.html b/core/templates/core/collection_list.html index 044f23f..f13b41b 100644 --- a/core/templates/core/collection_list.html +++ b/core/templates/core/collection_list.html @@ -1,16 +1,23 @@ {% extends 'core/base.html' %} {% load i18n %} {% block content %} -
{% trans "Collections are currently available on desktop only." %}
-
+

{% trans "Collections" %}

{% trans "Combine existing notes into ordered documents." %}

{% trans "New collection" %}
+ + {% if orphan_chapters %}
{% trans "Orphan chapters" %}
{% trans "These chapters are not used by any tutorial yet:" %} {% for chapter in orphan_chapters %}{{ chapter.title }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% endif %}
{% for entry in entries %}{% with collection=entry.collection %}
{% endif %} -
+
{% for section in sections %} -
+

{% if section.item %}{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}{% else %}{{ section.title|default:section.sub_collection.title|truncatechars:100 }}{% endif %}

@@ -45,6 +46,8 @@ {% if section.item %}
{{ section.item|item_markdown }}
{% if section.item.comment %}
{{ section.item.comment|markdown }}
{% endif %}{% else %}
{% trans "Nested collection" %}: {{ section.sub_collection.title }}
{% endif %} {% if can_edit %}
+ ↕ +
{% csrf_token %}
{% csrf_token %}
{% csrf_token %}
{% csrf_token %}
@@ -57,6 +60,8 @@ {% if can_edit %}

{% trans "Add existing note or collection" %}

+ +
{% csrf_token %}

{% trans "Notes" %}

@@ -69,4 +74,31 @@
{% endif %}
+ {% endblock %} diff --git a/core/templates/core/new_item.html b/core/templates/core/new_item.html index 598d298..4af8a14 100644 --- a/core/templates/core/new_item.html +++ b/core/templates/core/new_item.html @@ -4,9 +4,10 @@
{% csrf_token %} + {% if collection %}{% endif %}
-

{% if requested_kind == 'todo' %}{% trans "Create task" %}{% elif requested_kind == 'link' %}{% trans "Create link" %}{% elif requested_kind == 'journal' %}{% trans "Create journal entry" %}{% else %}{% trans "Create note" %}{% endif %}

+

{% if collection %}{% blocktrans with title=collection.title %}Create note for {{ title }}{% endblocktrans %}{% elif requested_kind == 'todo' %}{% trans "Create task" %}{% elif requested_kind == 'link' %}{% trans "Create link" %}{% elif requested_kind == 'journal' %}{% trans "Create journal entry" %}{% else %}{% trans "Create note" %}{% endif %}

×
diff --git a/core/tests.py b/core/tests.py index 7e22070..40910d6 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,5 +1,10 @@ +import shutil +import tempfile +from unittest.mock import patch + from django.contrib.auth import get_user_model -from django.test import TestCase +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings from django.urls import reverse from .models import ApiKey, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference @@ -103,6 +108,20 @@ class SlashMenuContextTests(TestCase): self.assertIn('/public', content) self.assertIn('/private', content) + @patch('core.views.fetch_page_title', return_value='Example page') + def test_url_on_first_line_becomes_link_with_title_and_extra_text(self, _fetch_title): + kind, _, content, url = parse_quick_content('https://example.com/page\nEigener Text #tag') + self.assertEqual(kind, Item.Kind.LINK) + self.assertEqual(url, 'https://example.com/page') + self.assertEqual(content, 'Example page\nEigener Text #tag') + + @patch('core.views.fetch_page_title', return_value='Example page') + def test_explicit_link_command_uses_first_line_url_title(self, _fetch_title): + kind, _, content, url = parse_quick_content('/link https://example.com/page\n#tag') + self.assertEqual(kind, Item.Kind.LINK) + self.assertEqual(url, 'https://example.com/page') + self.assertEqual(content, 'Example page\n#tag') + class MarkdownHeadingTests(TestCase): def test_tag_at_line_start_is_not_a_heading(self): @@ -171,6 +190,31 @@ class NavigationAndLayoutTests(TestCase): self.assertLess(toolbar_position, file_position) +class AttachmentEditTests(TestCase): + def setUp(self): + self.media_root = tempfile.mkdtemp() + self.override = override_settings(MEDIA_ROOT=self.media_root) + self.override.enable() + self.user = get_user_model().objects.create_user(username='attachment-editor', password='secret') + self.client.force_login(self.user) + + def tearDown(self): + self.override.disable() + shutil.rmtree(self.media_root, ignore_errors=True) + + def test_uploaded_file_is_visible_after_first_inline_edit_save(self): + item = Item.objects.create(owner=self.user, content='Original') + uploaded = SimpleUploadedFile('hello.txt', b'hello', content_type='text/plain') + + response = self.client.post(reverse('edit_item', args=[item.id]), { + 'content': 'Original', 'comment': '', 'visibility': Item.Visibility.PRIVATE, 'url': '', 'files': uploaded, + }, HTTP_HX_REQUEST='true') + + self.assertEqual(response.status_code, 200) + self.assertEqual(item.attachments.count(), 1) + self.assertContains(response, 'hello.txt') + + class TeamItemEditTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user(username='team-editor', password='secret') @@ -301,6 +345,107 @@ class CollectionTests(TestCase): self.assertEqual(list(form.fields['team'].queryset), [team]) self.assertEqual(list(form.fields['filter_tags'].queryset.values_list('name', flat=True)), ['ordinary']) + def test_tutorial_is_listed_but_chapter_is_hidden_from_collection_overview(self): + tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL) + chapter = Collection.objects.create(owner=self.user, title='my2dos Typen', mode=Collection.Mode.CHAPTER) + + response = self.client.get(reverse('collection_list')) + + self.assertContains(response, tutorial.title) + self.assertNotIn(chapter, [entry['collection'] for entry in response.context['entries']]) + + def test_collection_list_can_filter_collection_types(self): + Collection.objects.create(owner=self.user, title='Manual', mode=Collection.Mode.MANUAL) + Collection.objects.create(owner=self.user, title='Tutorial', mode=Collection.Mode.TUTORIAL) + Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS) + Collection.objects.create(owner=self.user, title='Chapter', mode=Collection.Mode.CHAPTER) + + response = self.client.get(f'{reverse("collection_list")}?mode=chapter') + + self.assertEqual([entry['collection'].title for entry in response.context['entries']], ['Chapter']) + + def test_orphan_chapters_are_shown_as_warning(self): + Collection.objects.create(owner=self.user, title='Lonely chapter', mode=Collection.Mode.CHAPTER) + + response = self.client.get(reverse('collection_list')) + + self.assertContains(response, 'Verwaiste Kapitel') + self.assertContains(response, 'Lonely chapter') + + def test_tutorial_can_include_chapter_collection(self): + tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL) + chapter = Collection.objects.create(owner=self.user, title='my2dos Typen', mode=Collection.Mode.CHAPTER) + note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Todos') + CollectionSection.objects.create(collection=chapter, item=note, position=0) + manage_url = reverse('collection_manage', args=[tutorial.id]) + + response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': chapter.id}) + + self.assertRedirects(response, manage_url) + self.assertTrue(tutorial.sections.filter(sub_collection=chapter).exists()) + response = self.client.get(tutorial.get_absolute_url()) + self.assertContains(response, '

my2dos Typen

', html=True) + self.assertContains(response, '

Todos

', html=True) + + def test_collection_overview_is_available_on_mobile(self): + response = self.client.get(reverse('collection_list')) + + self.assertNotContains(response, 'Collections are currently available on desktop only.') + self.assertContains(response, 'Sammlungen') + + def test_collection_manage_links_to_note_editor_with_return_target(self): + collection = Collection.objects.create(owner=self.user, title='Reading view') + manage_url = reverse('collection_manage', args=[collection.id]) + + response = self.client.get(manage_url) + + self.assertContains(response, f'{reverse("new_item")}?kind=note&collection={collection.id}&next={manage_url}') + + def test_create_note_for_collection_in_editor_adds_it_and_returns(self): + collection = Collection.objects.create(owner=self.user, title='Reading view') + manage_url = reverse('collection_manage', args=[collection.id]) + + response = self.client.post(f'{reverse("new_item")}?kind=note&collection={collection.id}&next={manage_url}', { + 'collection': collection.id, 'next': manage_url, 'content': '## Fresh note #new', + }) + + note = Item.objects.get(content='## Fresh note #new') + self.assertRedirects(response, manage_url) + self.assertTrue(collection.sections.filter(item=note).exists()) + self.assertEqual(note.visibility, collection.visibility) + self.assertEqual(list(note.tags.values_list('name', flat=True)), ['new']) + + def test_create_chapter_from_tutorial_manage_adds_and_opens_it(self): + tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL) + + response = self.client.post(reverse('collection_manage', args=[tutorial.id]), {'action': 'create_chapter', 'title': 'Tags'}) + + chapter = Collection.objects.get(title='Tags') + self.assertEqual(chapter.mode, Collection.Mode.CHAPTER) + self.assertTrue(tutorial.sections.filter(sub_collection=chapter).exists()) + self.assertRedirects(response, reverse('collection_manage', args=[chapter.id])) + + def test_collection_sections_can_be_reordered_by_position(self): + collection = Collection.objects.create(owner=self.user, title='Reading view') + first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First') + second = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Second') + first_section = CollectionSection.objects.create(collection=collection, item=first, position=0) + second_section = CollectionSection.objects.create(collection=collection, item=second, position=1) + + response = self.client.post(reverse('collection_manage', args=[collection.id]), { + 'action': 'move_to', 'section_id': second_section.id, 'position': 0, + }) + + self.assertRedirects(response, reverse('collection_manage', args=[collection.id])) + self.assertEqual(list(collection.sections.order_by('position').values_list('id', flat=True)), [second_section.id, first_section.id]) + + def test_collection_detail_is_available_on_mobile(self): + collection = Collection.objects.create(owner=self.user, title='Mobile reading') + response = self.client.get(collection.get_absolute_url()) + + self.assertNotContains(response, 'Collections are currently available on desktop only.') + self.assertContains(response, 'collection-document') + 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() diff --git a/core/views.py b/core/views.py index 2714ea9..1e1586a 100644 --- a/core/views.py +++ b/core/views.py @@ -26,6 +26,7 @@ TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I) VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I) URL_RE = re.compile(r'https?://\S+') ONLY_URL_RE = re.compile(r'^https?://\S+$') +FIRST_LINE_URL_RE = re.compile(r'^(https?://\S+)[ \t]*(?:\r?\n|$)') TITLE_RE = re.compile(r']*>(.*?)', re.I | re.S) DATE_COMMAND_RE = re.compile(r'/datum\s+(\d{1,2}\.\d{1,2}\.(?:\d{2}|\d{4}))(?:\s+(\d{1,2}:\d{2}))?\b', re.I) RELATIVE_DATE_COMMAND_RE = re.compile(r'/(morgen|übermorgen|uebermorgen|nächstewoche|naechstewoche)\b', re.I) @@ -88,20 +89,25 @@ def parse_quick_content(raw, default_kind=Item.Kind.NOTE): visibility_spans = [(match.start() - offset, match.end() - offset) for match in visibility_matches if match.start() >= offset] content = remove_spans(content, visibility_spans) only_url = bool(ONLY_URL_RE.match(content)) + first_line_url_match = FIRST_LINE_URL_RE.match(content) if type_command == 'todo': kind = Item.Kind.TODO elif type_command == 'journal': kind = Item.Kind.JOURNAL - elif type_command == 'link' or only_url: + elif type_command == 'link' or only_url or first_line_url_match: kind = Item.Kind.LINK elif type_command == 'note': kind = Item.Kind.NOTE else: kind = default_kind if default_kind in Item.Kind.values else Item.Kind.NOTE visibility = Item.Visibility.PUBLIC if 'public' in visibility_commands else Item.Visibility.PRIVATE - url_match = URL_RE.search(content) - url = url_match.group(0) if kind == Item.Kind.LINK and url_match else '' - if only_url: + url_match = first_line_url_match or URL_RE.search(content) + url = url_match.group(1) if kind == Item.Kind.LINK and first_line_url_match else (url_match.group(0) if kind == Item.Kind.LINK and url_match else '') + if first_line_url_match: + title = fetch_page_title(url) or url + extra_content = content[first_line_url_match.end():].strip() + content = f'{title}\n{extra_content}' if extra_content else title + elif only_url: content = fetch_page_title(url) or url return kind, visibility, content, url @@ -153,9 +159,13 @@ def parse_due_at_from_post(post): def attach_files_and_replace_tokens(item, files): replacements = {} + attached = False for file in files: attachment = Attachment.objects.create(item=item, file=file) + attached = True replacements[f'![[paste:{file.name}]]'] = f'![[file:{attachment.id}]]' + if attached and hasattr(item, '_prefetched_objects_cache'): + item._prefetched_objects_cache.pop('attachments', None) if replacements: content = item.content comment = item.comment @@ -350,6 +360,13 @@ def new_item(request): next_url = request.GET.get('next') or request.POST.get('next') or reverse('index') if next_url == 'index': next_url = reverse('index') + collection = None + collection_id = request.GET.get('collection') or request.POST.get('collection') + if collection_id: + collection = get_object_or_404(visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES), pk=collection_id) + if not user_can_edit_collection(request.user, collection): + return JsonResponse({'error': 'forbidden'}, status=403) + requested_kind = Item.Kind.NOTE if request.method == 'POST': form = QuickItemForm(request.POST, request.FILES) if form.is_valid(): @@ -362,9 +379,12 @@ def new_item(request): attach_files_and_replace_tokens(item, request.FILES.getlist('files')) item.sync_metadata() apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I))) + if collection: + item = adapt_item_for_collection(item, collection) + add_collection_section(collection, item) return redirect(next_url) return render(request, 'core/new_item.html', { - 'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url, + 'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection, }) @@ -715,6 +735,10 @@ def editable_collection_notes(user): ).filter(Q(owner=user) | Q(team_id__in=user_team_ids(user))).distinct() +def collection_is_structured_mode(mode): + return mode in Collection.STRUCTURED_MODES + + def collection_detail_context(request, collection, pending_item=None): can_edit = user_can_edit_collection(request.user, collection) q = request.GET.get('q', '').strip() @@ -724,7 +748,7 @@ def collection_detail_context(request, collection, pending_item=None): used_item_ids = set(collection.sections.filter(item__isnull=False).values_list('item_id', flat=True)) used_collection_ids = set(collection.sections.filter(sub_collection__isnull=False).values_list('sub_collection_id', flat=True)) candidates = editable_collection_notes(request.user).annotate(collection_use_count=Count('collection_sections', distinct=True)) - collection_candidates = visible_collections(request.user).filter(mode=Collection.Mode.MANUAL).exclude(pk=collection.pk).annotate(collection_use_count=Count('containing_sections', distinct=True)) + collection_candidates = visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES).exclude(pk=collection.pk).annotate(collection_use_count=Count('containing_sections', distinct=True)) 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() @@ -750,11 +774,25 @@ def collection_detail_context(request, collection, pending_item=None): @login_required def collection_list(request): + active_mode = request.GET.get('mode', 'listed') + collections = visible_collections(request.user).prefetch_related('filter_tags') + if active_mode == 'manual': + collections = collections.filter(mode=Collection.Mode.MANUAL) + elif active_mode == 'tutorial': + collections = collections.filter(mode=Collection.Mode.TUTORIAL) + elif active_mode == 'tags': + collections = collections.filter(mode=Collection.Mode.TAGS) + elif active_mode == 'chapter': + collections = collections.filter(mode=Collection.Mode.CHAPTER) + else: + active_mode = 'listed' + collections = collections.exclude(mode=Collection.Mode.CHAPTER) entries = [] - for collection in visible_collections(request.user).prefetch_related('filter_tags'): + for collection in collections: 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}) + orphan_chapters = visible_collections(request.user).filter(mode=Collection.Mode.CHAPTER, containing_sections__isnull=True) if active_mode != 'chapter' else Collection.objects.none() + return render(request, 'core/collection_list.html', {'entries': entries, 'active_mode': active_mode, 'orphan_chapters': orphan_chapters}) @login_required @@ -772,7 +810,7 @@ def collection_create(request): collection.team = team collection.save() form.save_m2m() - if collection.mode == Collection.Mode.MANUAL: + if collection.mode != Collection.Mode.TAGS: collection.filter_tags.clear() messages.success(request, _('Collection created.')) return redirect(collection) @@ -791,8 +829,8 @@ def collection_edit(request, pk): if form.is_valid(): 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( + has_manual_sections = collection_is_structured_mode(collection.mode) and collection.sections.exists() + incompatible = collection_is_structured_mode(target_mode) 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, @@ -814,7 +852,7 @@ def collection_edit(request, pk): collection.team = team collection.save() form.save_m2m() - if collection.mode == Collection.Mode.MANUAL: + if collection.mode != Collection.Mode.TAGS: collection.filter_tags.clear() messages.success(request, _('Collection saved.')) return redirect(collection) @@ -827,7 +865,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', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection') 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.is_structured else [], 'tag_items': collection_tag_items(collection) if collection.mode == Collection.Mode.TAGS else [], 'can_edit': user_can_edit_collection(request.user, collection), }) @@ -864,8 +902,19 @@ def collection_manage(request, pk): return redirect(redirect_url) add_collection_section(collection, item) return redirect(redirect_url) + if action == 'create_chapter': + title = request.POST.get('title', '').strip()[:200] + if title: + chapter = Collection.objects.create( + owner=request.user, title=title, mode=Collection.Mode.CHAPTER, + visibility=collection.visibility, team=collection.team, + ) + add_collection_subcollection(collection, chapter) + return redirect(reverse('collection_manage', args=[chapter.pk])) + messages.error(request, _('Enter a chapter title.')) + return redirect(redirect_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) + sub_collection = get_object_or_404(visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES), pk=request.POST.get('collection_id')) 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): @@ -891,6 +940,19 @@ def collection_manage(request, pk): 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) + elif action == 'move_to': + try: + new_position = int(request.POST.get('position', section.position)) + except (TypeError, ValueError): + new_position = section.position + ordered = list(collection.sections.order_by('position', 'id')) + ordered.remove(section) + ordered.insert(max(0, min(new_position, len(ordered))), section) + with transaction.atomic(): + for index, current in enumerate(ordered): + CollectionSection.objects.filter(pk=current.pk).update(position=1000 + index) + for index, current in enumerate(ordered): + CollectionSection.objects.filter(pk=current.pk).update(position=index) return redirect(redirect_url) return render(request, 'core/collection_manage.html', collection_detail_context(request, collection)) diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index f38d714..96c433b 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -425,6 +425,11 @@ msgstr "Journal-Eintrag erstellen" msgid "Create note" msgstr "Notiz erstellen" +#: core/templates/core/new_item.html +#, python-format +msgid "Create note for %(title)s" +msgstr "Notiz für %(title)s erstellen" + #: core/templates/core/new_item.html:24 msgid "Add image" msgstr "Bild hinzufügen" @@ -780,6 +785,42 @@ msgstr "Manuelle Sammlung" msgid "Tag collection" msgstr "Tag-Sammlung" +#: core/models.py +msgid "Tutorial" +msgstr "Tutorial" + +#: core/models.py +msgid "Chapter" +msgstr "Kapitel" + +#: core/templates/core/collection_list.html +msgid "Chapters" +msgstr "Kapitel" + +#: core/templates/core/collection_list.html +msgid "Orphan chapters" +msgstr "Verwaiste Kapitel" + +#: core/templates/core/collection_list.html +msgid "These chapters are not used by any tutorial yet:" +msgstr "Diese Kapitel werden noch in keinem Tutorial verwendet:" + +#: core/templates/core/collection_manage.html +msgid "Drag to reorder" +msgstr "Ziehen zum Sortieren" + +#: core/templates/core/collection_manage.html +msgid "New chapter title" +msgstr "Titel des neuen Kapitels" + +#: core/templates/core/collection_manage.html +msgid "Create chapter" +msgstr "Kapitel erstellen" + +#: core/views.py +msgid "Enter a chapter title." +msgstr "Gib einen Kapiteltitel ein." + #: core/forms.py msgid "Collection type" msgstr "Art der Sammlung"