Add markdown exports and tighten tag visibility

This commit is contained in:
rucki
2026-09-09 16:31:01 +02:00
parent c655b82c9b
commit 7f203a7117
6 changed files with 294 additions and 15 deletions
+7
View File
@@ -189,6 +189,13 @@ class CollectionForm(forms.ModelForm):
class ApiKeyForm(forms.ModelForm): class ApiKeyForm(forms.ModelForm):
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
if user and user.is_authenticated:
self.fields['tags'].queryset = Tag.objects.filter(items__owner=user).distinct().order_by('name')
else:
self.fields['tags'].queryset = Tag.objects.none()
class Meta: class Meta:
model = ApiKey model = ApiKey
fields = ['name', 'tags'] fields = ['name', 'tags']
+5 -2
View File
@@ -13,7 +13,10 @@
{% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span>{% endfor %} {% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span>{% endfor %}
</div> </div>
</div> </div>
{% if can_edit %}<a class="btn btn-dark" href="{% if collection.mode == 'tags' %}{% url 'collection_edit' collection.id %}{% else %}{% url 'collection_manage' collection.id %}{% endif %}">{% trans "Edit collection" %}</a>{% endif %} <div class="d-flex gap-2">
<a class="btn btn-outline-secondary" href="{% url 'collection_export' collection.id %}" hx-boost="false">{% trans "Export" %}</a>
{% if can_edit %}<a class="btn btn-dark" href="{% if collection.mode == 'tags' %}{% url 'collection_edit' collection.id %}{% else %}{% url 'collection_manage' collection.id %}{% endif %}">{% trans "Edit collection" %}</a>{% endif %}
</div>
</div> </div>
{% if collection.description %}<div class="content lead mt-4">{{ collection.description|markdown }}</div>{% endif %} {% if collection.description %}<div class="content lead mt-4">{{ collection.description|markdown }}</div>{% endif %}
</header> </header>
@@ -27,7 +30,7 @@
<div class="collection-flow content"> <div class="collection-flow content">
{% if collection.mode == 'tags' %} {% if collection.mode == 'tags' %}
{% for item in tag_items %}<section id="item-{{ item.id }}" class="collection-flow-section">{{ item|collection_item_markdown }}{% if item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "No entries match the selected tags and types yet." %}</div>{% endfor %} {% for item in tag_items %}<section id="item-{{ item.id }}" class="collection-flow-section{% if item.kind == 'todo' %} d-flex gap-3 align-items-start{% endif %}">{% if item.kind == 'todo' %}<form method="post" action="{% url 'toggle_done' item.id %}" hx-boost="false" class="pt-1">{% csrf_token %}<input type="hidden" name="next" value="{{ request.get_full_path }}#item-{{ item.id }}"><input class="form-check-input" type="checkbox" {% if item.is_done %}checked{% endif %} {% if item.can_toggle_done %}onchange="this.form.submit()"{% else %}disabled{% endif %} aria-label="{% trans 'Done' %}"></form><div class="flex-grow-1 {% if item.is_done %}text-decoration-line-through text-muted{% endif %}">{% endif %}{{ item|collection_item_markdown }}{% if item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ item.comment|markdown }}</div>{% endif %}{% if item.kind == 'todo' %}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "No entries match the selected tags and types yet." %}</div>{% endfor %}
{% else %} {% else %}
{% for section in sections %}{% include 'core/_collection_section_read.html' with section=section %}{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %} {% for section in sections %}{% include 'core/_collection_section_read.html' with section=section %}{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
{% endif %} {% endif %}
+1 -1
View File
@@ -46,7 +46,7 @@
<div class="row g-4"> <div class="row g-4">
<aside class="col-lg-3 order-lg-2 mobile-filter-sidebar"> <aside class="col-lg-3 order-lg-2 mobile-filter-sidebar">
<div class="filter-panel"> <div class="filter-panel">
<div class="fw-semibold mb-2 filter-panel-title">{% trans "Filter" %}</div> <div class="d-flex justify-content-between align-items-center mb-2 filter-panel-title"><div class="fw-semibold">{% trans "Filter" %}</div><a class="btn btn-sm btn-outline-secondary" href="{% url 'export_items' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-boost="false">{% trans "Export" %}</a></div>
<div class="d-flex flex-wrap gap-2 mb-3 filter-buttons"> <div class="d-flex flex-wrap gap-2 mb-3 filter-buttons">
<a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% if active_kind == 'todo' %}{% query_replace kind=None %}{% else %}{% query_replace kind='todo' %}{% endif %}">Todos{% if active_kind == 'todo' %} ×{% endif %}</a> <a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% if active_kind == 'todo' %}{% query_replace kind=None %}{% else %}{% query_replace kind='todo' %}{% endif %}">Todos{% if active_kind == 'todo' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">{% trans "Notes" %}{% if active_kind == 'note' %} ×{% endif %}</a> <a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">{% trans "Notes" %}{% if active_kind == 'note' %} ×{% endif %}</a>
+122 -1
View File
@@ -241,13 +241,18 @@ class NavigationAndLayoutTests(TestCase):
response = self.client.get(reverse('edit_item', args=[item.id])) response = self.client.get(reverse('edit_item', args=[item.id]))
self.assertContains(response, 'inline-edit-form') self.assertContains(response, 'inline-edit-form')
def test_tag_filter_list_depends_on_current_filters_but_composer_gets_all_tags(self): def test_tag_filter_list_depends_on_current_filters_but_composer_gets_visible_tags(self):
open_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open #openonly') open_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open #openonly')
open_todo.sync_metadata() open_todo.sync_metadata()
done_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Done #archiveonly', is_done=True) done_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Done #archiveonly', is_done=True)
done_todo.sync_metadata() done_todo.sync_metadata()
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Note #noteonly') note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Note #noteonly')
note.sync_metadata() note.sync_metadata()
other = get_user_model().objects.create_user(username='other-tags', password='secret')
foreign = Item.objects.create(owner=other, kind=Item.Kind.NOTE, content='Foreign #foreignonly')
foreign.sync_metadata()
public = Item.objects.create(owner=other, kind=Item.Kind.NOTE, content='Public #publiconly', visibility=Item.Visibility.PUBLIC)
public.sync_metadata()
response = self.client.get(f'{reverse("index")}?status=archive') response = self.client.get(f'{reverse("index")}?status=archive')
html = response.content.decode() html = response.content.decode()
@@ -257,11 +262,30 @@ class NavigationAndLayoutTests(TestCase):
self.assertNotIn('#noteonly', filter_tags) self.assertNotIn('#noteonly', filter_tags)
self.assertIn("'openonly'", html) self.assertIn("'openonly'", html)
self.assertIn("'noteonly'", html) self.assertIn("'noteonly'", html)
self.assertIn("'publiconly'", html)
self.assertNotIn('foreignonly', html)
response = self.client.get(f'{reverse("tag_list")}?status=archive') response = self.client.get(f'{reverse("tag_list")}?status=archive')
self.assertContains(response, '#archiveonly') self.assertContains(response, '#archiveonly')
self.assertNotContains(response, '#openonly') self.assertNotContains(response, '#openonly')
def test_tag_api_returns_only_visible_tags(self):
own = Item.objects.create(owner=self.user, content='Own #ownapi')
own.sync_metadata()
other = get_user_model().objects.create_user(username='other-api-tags', password='secret')
foreign = Item.objects.create(owner=other, content='Foreign #foreignapi')
foreign.sync_metadata()
public = Item.objects.create(owner=other, content='Public #publicapi', visibility=Item.Visibility.PUBLIC)
public.sync_metadata()
response = self.client.get(reverse('api_tags'))
self.assertEqual(response.status_code, 200)
tags = response.json()['tags']
self.assertIn('ownapi', tags)
self.assertIn('publicapi', tags)
self.assertNotIn('foreignapi', tags)
def test_due_filter_also_shows_next_seven_days_separately(self): def test_due_filter_also_shows_next_seven_days_separately(self):
today = timezone.localdate() today = timezone.localdate()
due = timezone.make_aware(datetime.combine(today, time(12, 0))) due = timezone.make_aware(datetime.combine(today, time(12, 0)))
@@ -589,6 +613,36 @@ class CollectionTests(TestCase):
manage_response = self.client.get(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])) self.assertRedirects(manage_response, reverse('collection_edit', args=[collection.id]))
def test_tag_collection_todos_are_rendered_with_checkbox(self):
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Check me #project')
todo.sync_metadata()
collection = Collection.objects.create(
owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS,
include_notes=False, include_todos=True,
)
collection.filter_tags.add(Tag.objects.get(name='project'))
response = self.client.get(collection.get_absolute_url())
self.assertContains(response, 'type="checkbox"')
self.assertContains(response, reverse('toggle_done', args=[todo.id]))
def test_tag_collection_todo_checkbox_toggles_and_returns_to_collection(self):
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Check me #project')
todo.sync_metadata()
collection = Collection.objects.create(
owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS,
include_notes=False, include_todos=True,
)
collection.filter_tags.add(Tag.objects.get(name='project'))
next_url = f'{collection.get_absolute_url()}#item-{todo.id}'
response = self.client.post(reverse('toggle_done', args=[todo.id]), {'next': next_url})
self.assertRedirects(response, next_url, fetch_redirect_response=False)
todo.refresh_from_db()
self.assertTrue(todo.is_done)
def test_tag_collection_can_filter_included_item_types(self): def test_tag_collection_can_filter_included_item_types(self):
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Matching note #project') note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Matching note #project')
note.sync_metadata() note.sync_metadata()
@@ -617,6 +671,35 @@ class CollectionTests(TestCase):
self.assertContains(response, 'name="include_links"') self.assertContains(response, 'name="include_links"')
self.assertContains(response, 'name="include_journal"') self.assertContains(response, 'name="include_journal"')
def test_current_list_can_be_exported_as_markdown(self):
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open task #project').sync_metadata()
Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Other note')
response = self.client.get(f'{reverse("export_items")}?kind=todo&tag=project')
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'text/markdown; charset=utf-8')
body = response.content.decode()
self.assertIn('- [ ] Open task #project', body)
self.assertNotIn('Other note', body)
def test_collection_can_be_exported_as_markdown(self):
collection = Collection.objects.create(owner=self.user, title='Reading view', description='Guide')
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Todo section #project')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Note section #project')
CollectionSection.objects.create(collection=collection, item=todo, position=0)
CollectionSection.objects.create(collection=collection, item=note, position=1)
response = self.client.get(reverse('collection_export', args=[collection.id]))
self.assertEqual(response.status_code, 200)
body = response.content.decode()
self.assertIn('# Reading view', body)
self.assertIn('Guide', body)
self.assertIn('- [ ] Todo section', body)
self.assertIn('## Note section', body)
self.assertNotIn('#project', body)
def test_collection_opens_as_continuous_reading_view(self): def test_collection_opens_as_continuous_reading_view(self):
collection = Collection.objects.create(owner=self.user, title='Reading view') collection = Collection.objects.create(owner=self.user, title='Reading view')
first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First paragraph') first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First paragraph')
@@ -761,6 +844,31 @@ class ApiItemFilterTests(TestCase):
item.sync_metadata() item.sync_metadata()
return item return item
def test_api_key_settings_only_offer_own_tags(self):
self.client.force_login(self.user)
self.create_item('Own tag #own')
other = get_user_model().objects.create_user(username='api-other', password='secret')
other_item = Item.objects.create(owner=other, content='Other tag #foreign')
other_item.sync_metadata()
response = self.client.get(reverse('settings'))
api_section = response.content.decode().split('id="api"', 1)[1]
self.assertIn('#own', api_section)
self.assertNotIn('#foreign', api_section)
def test_api_key_cannot_be_created_with_foreign_tag(self):
self.client.force_login(self.user)
other = get_user_model().objects.create_user(username='api-other', password='secret')
other_item = Item.objects.create(owner=other, content='Other tag #foreign')
other_item.sync_metadata()
foreign_tag = Tag.objects.get(name='foreign')
response = self.client.post(reverse('settings'), {'action': 'apikey', 'name': 'Bad key', 'tags': [foreign_tag.id]})
self.assertEqual(response.status_code, 200)
self.assertFalse(ApiKey.objects.filter(owner=self.user, name='Bad key').exists())
def test_api_key_scope_and_request_tags_are_combined(self): def test_api_key_scope_and_request_tags_are_combined(self):
matching = self.create_item('Keyboard shortcuts #help #tasten') matching = self.create_item('Keyboard shortcuts #help #tasten')
self.create_item('Git help #help #git') self.create_item('Git help #help #git')
@@ -775,6 +883,19 @@ class ApiItemFilterTests(TestCase):
ids = [item['id'] for item in response.json()['items']] ids = [item['id'] for item in response.json()['items']]
self.assertEqual(ids, [matching.id]) self.assertEqual(ids, [matching.id])
def test_api_key_only_returns_owner_items_not_other_public_items(self):
own = self.create_item('Own public help #help')
other = get_user_model().objects.create_user(username='api-public-other', password='secret')
public = Item.objects.create(owner=other, content='Other public help #help', visibility=Item.Visibility.PUBLIC)
public.sync_metadata()
response = self.client.get(reverse('api_items'), HTTP_X_API_KEY=self.api_key.token)
self.assertEqual(response.status_code, 200)
ids = [item['id'] for item in response.json()['items']]
self.assertIn(own.id, ids)
self.assertNotIn(public.id, ids)
def test_api_items_supports_limit_and_search(self): def test_api_items_supports_limit_and_search(self):
self.create_item('First #help #shell') self.create_item('First #help #shell')
second = self.create_item('Second #help #shell keyboard') second = self.create_item('Second #help #shell keyboard')
+2
View File
@@ -8,6 +8,7 @@ urlpatterns = [
path('logout/', auth_views.LogoutView.as_view(), name='logout'), path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('invite/<str:token>/', views.accept_user_invite, name='accept_user_invite'), path('invite/<str:token>/', views.accept_user_invite, name='accept_user_invite'),
path('new/', views.new_item, name='new_item'), path('new/', views.new_item, name='new_item'),
path('export/', views.export_items, name='export_items'),
path('journal/', views.journal, name='journal'), path('journal/', views.journal, name='journal'),
path('kanban/', views.kanban_list, name='kanban_list'), path('kanban/', views.kanban_list, name='kanban_list'),
path('kanban/<int:pk>/', views.kanban_detail, name='kanban_detail'), path('kanban/<int:pk>/', views.kanban_detail, name='kanban_detail'),
@@ -25,6 +26,7 @@ urlpatterns = [
path('collections/<int:pk>/', views.collection_detail, name='collection_detail'), path('collections/<int:pk>/', views.collection_detail, name='collection_detail'),
path('collections/<int:pk>/manage/', views.collection_manage, name='collection_manage'), path('collections/<int:pk>/manage/', views.collection_manage, name='collection_manage'),
path('collections/<int:pk>/edit/', views.collection_edit, name='collection_edit'), path('collections/<int:pk>/edit/', views.collection_edit, name='collection_edit'),
path('collections/<int:pk>/export/', views.collection_export, name='collection_export'),
path('collections/<int:pk>/delete/', views.collection_delete, name='collection_delete'), 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'),
+157 -11
View File
@@ -13,6 +13,7 @@ from django.db import transaction
from django.db.models import Case, Count, IntegerField, ProtectedError, Q, Value, When from django.db.models import Case, Count, 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.http import url_has_allowed_host_and_scheme
from django.utils import timezone from django.utils import timezone
from django.utils import translation from django.utils import translation
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@@ -21,6 +22,7 @@ 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, CollectionForm, 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, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, TAG_RE, without_markdown_code from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, TAG_RE, without_markdown_code
from .templatetags.markdown_extras import strip_tags_outside_code
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)
@@ -209,6 +211,13 @@ def user_can_edit_item(user, item):
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user).exists()) return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user).exists())
def visible_tags(user):
"""Tags from items the user may see; avoids leaking private tags from other users."""
if not user.is_authenticated:
return Tag.objects.filter(items__visibility=Item.Visibility.PUBLIC).distinct().order_by('name')
return Tag.objects.filter(items__in=visible_items(user)).distinct().order_by('name')
def user_can_delete_item(user, item): 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())
@@ -316,6 +325,137 @@ def index_filter_tags(request, user, prefs, active_tags=None):
return Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags)).distinct().order_by('name') return Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags)).distinct().order_by('name')
def index_filtered_items(request, prefs):
active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()]
active_tag_mode = 'all' if request.GET.get('tag_mode') == 'all' else 'any'
tag = active_tags[0] if active_tags else None
kind = request.GET.get('kind')
status = request.GET.get('status')
day = request.GET.get('day')
q = request.GET.get('q', '').strip()
items = visible_items(request.user)
applied_preferences = False
if active_tags:
if active_tag_mode == 'all':
for tag_name in active_tags:
items = items.filter(tags__name=tag_name)
else:
items = items.filter(tags__name__in=active_tags).distinct()
if kind in dict(Item.Kind.choices):
items = items.filter(kind=kind)
elif not any([tag, status, q, day]):
applied_preferences = True
type_filter = Q()
if prefs.show_notes:
type_filter |= Q(kind=Item.Kind.NOTE)
if prefs.show_links:
type_filter |= Q(kind=Item.Kind.LINK)
if prefs.show_journal:
type_filter |= Q(kind=Item.Kind.JOURNAL)
if prefs.show_open_todos:
type_filter |= Q(kind=Item.Kind.TODO, is_done=False)
if prefs.show_done_todos:
type_filter |= Q(kind=Item.Kind.TODO, is_done=True)
items = items.filter(type_filter) if type_filter else items.none()
if day:
items = items.filter(created_at__date=day)
upcoming_end = timezone.make_aware(datetime.combine(timezone.localdate() + timedelta(days=7), time(23, 59, 59)))
if status == 'open':
items = items.filter(kind=Item.Kind.TODO, is_done=False)
elif status == 'due':
items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__isnull=False, due_at__lte=upcoming_end).order_by('due_at', '-created_at')
elif status == 'archive':
items = items.filter(kind=Item.Kind.TODO, is_done=True)
elif not applied_preferences:
items = items.exclude(kind=Item.Kind.TODO, is_done=True)
if q:
items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
return items, active_tags, active_tag_mode, tag, kind, status, day, q
def export_item_line(item, hide_tags=False):
content = strip_tags_outside_code(item.content) if hide_tags else (item.content or '')
lines = [line.rstrip() for line in content.strip().splitlines()]
first = lines[0] if lines else _('Untitled')
prefix = '- [x] ' if item.kind == Item.Kind.TODO and item.is_done else '- [ ] ' if item.kind == Item.Kind.TODO else '- '
output = [f'{prefix}{first}']
output.extend(f' {line}' if line else '' for line in lines[1:])
if item.comment:
output.append('')
output.extend(f' > {line}' if line else ' >' for line in item.comment.strip().splitlines())
return '\n'.join(output)
def markdown_response(filename, markdown_text):
response = HttpResponse(markdown_text, content_type='text/markdown; charset=utf-8')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@login_required
def export_items(request):
prefs, created = UserPreference.objects.get_or_create(user=request.user)
items, active_tags, active_tag_mode, tag, kind, status, day, q = index_filtered_items(request, prefs)
title_parts = [_('my2dos export')]
if kind:
title_parts.append(kind)
if active_tags:
title_parts.append(' '.join(f'#{tag_name}' for tag_name in active_tags))
if status:
title_parts.append(status)
if q:
title_parts.append(f'“{q}”')
lines = [f'# {" – ".join(title_parts)}', '']
lines.extend(export_item_line(item) for item in items)
return markdown_response('my2dos-export.md', '\n\n'.join(lines).strip() + '\n')
def collection_item_export(item, level=2):
content = strip_tags_outside_code(item.content)
if item.kind == Item.Kind.TODO:
return export_item_line(item, hide_tags=True)
if re.match(r'^#{1,6}\s+', content.lstrip()):
body = content
else:
body = f'{"#" * level} {content}'
if item.comment:
body = f'{body}\n\n> ' + item.comment.strip().replace('\n', '\n> ')
return body
def collection_sections_export(sections, level=2):
lines = []
for section in sections:
if section.item:
heading = section.title.strip() if section.title else ''
if heading:
lines.append(f'{"#" * level} {heading}')
lines.append(collection_item_export(section.item, level + 1))
else:
lines.append(collection_item_export(section.item, level))
elif section.sub_collection:
title = section.title or section.sub_collection.title
lines.append(f'{"#" * level} {title}')
if section.sub_collection.description:
lines.append(section.sub_collection.description.strip())
lines.append(collection_sections_export(section.sub_collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments'), level + 1))
return '\n\n'.join(line for line in lines if line).strip()
def collection_export(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
lines = [f'# {collection.title}', '']
if collection.description:
lines.extend([collection.description.strip(), ''])
if collection.mode == Collection.Mode.TAGS:
lines.extend(export_item_line(item, hide_tags=True) for item in collection_tag_items(collection))
else:
body = collection_sections_export(collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection'))
if body:
lines.append(body)
return markdown_response(f'collection-{collection.pk}.md', '\n\n'.join(line for line in lines if line).strip() + '\n')
def index(request): def index(request):
if not request.user.is_authenticated: if not request.user.is_authenticated:
if request.method == 'POST': if request.method == 'POST':
@@ -400,7 +540,7 @@ def index(request):
default=Value(1), output_field=IntegerField(), default=Value(1), output_field=IntegerField(),
)).order_by('due_rank', 'due_at', '-created_at') )).order_by('due_rank', 'due_at', '-created_at')
return render(request, 'core/index.html', { return render(request, 'core/index.html', {
'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'composer_tags': Tag.objects.all(), 'item_kind_choices': Item.Kind.choices, 'selected_item_kind': prefs.default_item_kind, 'due_now_items': due_now_items, 'upcoming_due_items': upcoming_due_items, 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines, 'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'composer_tags': visible_tags(request.user), 'item_kind_choices': Item.Kind.choices, 'selected_item_kind': prefs.default_item_kind, 'due_now_items': due_now_items, 'upcoming_due_items': upcoming_due_items, 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines,
}) })
@@ -435,7 +575,7 @@ def new_item(request):
add_collection_section(collection, item) add_collection_section(collection, item)
return redirect(next_url) return redirect(next_url)
return render(request, 'core/new_item.html', { return render(request, 'core/new_item.html', {
'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection, 'form': QuickItemForm(), 'tags': visible_tags(request.user), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection,
}) })
@@ -474,7 +614,7 @@ def journal(request):
next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year) next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year)
prefs, created = UserPreference.objects.get_or_create(user=request.user) prefs, created = UserPreference.objects.get_or_create(user=request.user)
return render(request, 'core/journal.html', { return render(request, 'core/journal.html', {
'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'weeks': weeks, 'year': year, 'month': month, 'selected_day': selected_day, 'form': QuickItemForm(), 'items': items, 'tags': visible_tags(request.user), 'weeks': weeks, 'year': year, 'month': month, 'selected_day': selected_day,
'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month, 'overview_lines': prefs.overview_lines, 'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month, 'overview_lines': prefs.overview_lines,
}) })
@@ -515,7 +655,7 @@ def settings_view(request):
messages.success(request, _('Default view saved.')) messages.success(request, _('Default view saved.'))
return redirect('settings') return redirect('settings')
elif request.POST.get('action') == 'apikey': elif request.POST.get('action') == 'apikey':
key_form = ApiKeyForm(request.POST) key_form = ApiKeyForm(request.POST, user=request.user)
if key_form.is_valid(): if key_form.is_valid():
api_key = key_form.save(commit=False) api_key = key_form.save(commit=False)
api_key.owner = request.user api_key.owner = request.user
@@ -535,7 +675,7 @@ def settings_view(request):
return render(request, 'core/settings.html', { return render(request, 'core/settings.html', {
'profile_form': UserSettingsForm(instance=request.user), 'profile_form': UserSettingsForm(instance=request.user),
'preference_form': UserPreferenceForm(instance=prefs), 'preference_form': UserPreferenceForm(instance=prefs),
'key_form': ApiKeyForm(), 'key_form': ApiKeyForm(user=request.user),
'user_invite_form': UserInviteForm(), 'user_invite_form': UserInviteForm(),
'user_invites': request.user.sent_user_invites.filter(accepted_at__isnull=True), 'user_invites': request.user.sent_user_invites.filter(accepted_at__isnull=True),
'api_keys': request.user.api_keys.prefetch_related('tags'), 'api_keys': request.user.api_keys.prefetch_related('tags'),
@@ -914,10 +1054,13 @@ def collection_edit(request, pk):
def collection_detail(request, pk): def collection_detail(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk) collection = get_object_or_404(visible_collections(request.user), pk=pk)
tag_items = list(collection_tag_items(collection)) if collection.mode == Collection.Mode.TAGS else []
for item in tag_items:
item.can_toggle_done = item.kind == Item.Kind.TODO and user_can_edit_item(request.user, item)
return render(request, 'core/collection_detail.html', { return render(request, 'core/collection_detail.html', {
'collection': collection, '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.is_structured 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 [], 'tag_items': tag_items,
'can_edit': user_can_edit_collection(request.user, collection), 'can_edit': user_can_edit_collection(request.user, collection),
}) })
@@ -1034,6 +1177,9 @@ def toggle_done(request, pk):
item.is_done = not item.is_done item.is_done = not item.is_done
item.completed_at = timezone.now() if item.is_done else None item.completed_at = timezone.now() if item.is_done else None
item.save(update_fields=['is_done', 'completed_at', 'updated_at']) item.save(update_fields=['is_done', 'completed_at', 'updated_at'])
next_url = request.POST.get('next') or request.GET.get('next')
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
return redirect(next_url)
if request.headers.get('HX-Request') and item.is_done and request.GET.get('status') != 'archive': if request.headers.get('HX-Request') and item.is_done and request.GET.get('status') != 'archive':
return HttpResponse('') return HttpResponse('')
return render(request, 'core/_item.html', {'item': item}) return render(request, 'core/_item.html', {'item': item})
@@ -1060,7 +1206,7 @@ def edit_item(request, pk):
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE) 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): 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.')) 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) return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': visible_tags(request.user)}, status=409)
item.visibility, item.team = target_visibility, target_team item.visibility, item.team = target_visibility, target_team
item.save() item.save()
form.save_m2m() form.save_m2m()
@@ -1071,7 +1217,7 @@ def edit_item(request, pk):
response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4}) response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
response['HX-Trigger'] = 'tagsChanged' response['HX-Trigger'] = 'tagsChanged'
return response return response
return render(request, 'core/_edit_form.html', {'item': item, 'tags': Tag.objects.all()}) return render(request, 'core/_edit_form.html', {'item': item, 'tags': visible_tags(request.user)})
@login_required @login_required
@@ -1090,7 +1236,7 @@ def item_editor(request, pk):
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE) 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): 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.')) 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) return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': visible_tags(request.user), 'next_url': next_url}, status=409)
item.visibility, item.team = target_visibility, target_team item.visibility, item.team = target_visibility, target_team
item.save() item.save()
form.save_m2m() form.save_m2m()
@@ -1099,7 +1245,7 @@ def item_editor(request, pk):
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE) apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
return redirect(next_url) return redirect(next_url)
return render(request, 'core/item_editor.html', {'item': item, 'tags': Tag.objects.all(), 'next_url': next_url}) return render(request, 'core/item_editor.html', {'item': item, 'tags': visible_tags(request.user), 'next_url': next_url})
@login_required @login_required
@@ -1188,7 +1334,7 @@ def set_default_item_kind(request):
@login_required @login_required
def api_tags(request): def api_tags(request):
return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))}) return JsonResponse({'tags': list(visible_tags(request.user).values_list('name', flat=True))})
@login_required @login_required