Improve task filters and tag collections
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
MY2DOS_API_URL=https://my2dos.rucki.ch
|
||||
MY2DOS_API_KEY=replace-with-api-key
|
||||
@@ -60,6 +60,31 @@ Einzelne Testklasse ausführen:
|
||||
.venv/bin/python manage.py test core.tests.TestKlasse
|
||||
```
|
||||
|
||||
## Zugriff auf produktive Aufgaben
|
||||
- Produktive Aufgaben liegen unter `https://my2dos.rucki.ch` und können über die JSON-API abgerufen werden.
|
||||
- Lokale Zugangsdaten stehen in `.env` und dürfen nicht committed oder ausgegeben werden:
|
||||
- `MY2DOS_API_URL`
|
||||
- `MY2DOS_API_KEY`
|
||||
- API-Authentifizierung: Header `X-Api-Key: $MY2DOS_API_KEY` oder `Authorization: Bearer $MY2DOS_API_KEY`.
|
||||
- Offene Remote-Aufgaben anzeigen:
|
||||
|
||||
```bash
|
||||
./scripts/my2dos_remote_tasks.py list
|
||||
```
|
||||
|
||||
- Aufgabe als erledigt markieren:
|
||||
|
||||
```bash
|
||||
./scripts/my2dos_remote_tasks.py done <id>
|
||||
```
|
||||
|
||||
- Direkt per curl abrufen:
|
||||
|
||||
```bash
|
||||
source .env
|
||||
curl -H "X-Api-Key: $MY2DOS_API_KEY" "$MY2DOS_API_URL/api/items/?limit=500"
|
||||
```
|
||||
|
||||
## Arbeitsregeln für Coding-Agenten
|
||||
- Vor Änderungen relevante Dateien lesen.
|
||||
- Änderungen möglichst klein und gezielt halten.
|
||||
|
||||
+13
-2
@@ -156,19 +156,26 @@ class CollectionForm(forms.ModelForm):
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
if cleaned.get('mode') == Collection.Mode.TAGS and not cleaned.get('filter_tags'):
|
||||
if cleaned.get('mode') == Collection.Mode.TAGS:
|
||||
if not cleaned.get('filter_tags'):
|
||||
self.add_error('filter_tags', _('Select at least one tag for a tag collection.'))
|
||||
if not any(cleaned.get(field) for field in ['include_notes', 'include_todos', 'include_links', 'include_journal']):
|
||||
self.add_error('include_notes', _('Select at least one entry type for a tag collection.'))
|
||||
return cleaned
|
||||
|
||||
class Meta:
|
||||
model = Collection
|
||||
fields = ['title', 'description', 'visibility', 'mode', 'team', 'filter_tags']
|
||||
fields = ['title', 'description', 'visibility', 'mode', 'team', 'filter_tags', 'include_notes', 'include_todos', 'include_links', 'include_journal']
|
||||
labels = {
|
||||
'title': _('Title'),
|
||||
'description': _('Description'),
|
||||
'visibility': _('Visibility'),
|
||||
'mode': _('Collection type'),
|
||||
'filter_tags': _('Content tags'),
|
||||
'include_notes': _('Notes'),
|
||||
'include_todos': _('Tasks'),
|
||||
'include_links': _('Links'),
|
||||
'include_journal': _('Journal'),
|
||||
}
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
@@ -176,6 +183,10 @@ class CollectionForm(forms.ModelForm):
|
||||
'visibility': forms.Select(attrs={'class': 'form-select'}),
|
||||
'mode': forms.Select(attrs={'class': 'form-select', 'x-model': 'mode'}),
|
||||
'filter_tags': forms.CheckboxSelectMultiple(),
|
||||
'include_notes': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'include_todos': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'include_links': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'include_journal': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Pi coding agent
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0020_collection_tutorial_modes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='collection',
|
||||
name='include_notes',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='collection',
|
||||
name='include_todos',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='collection',
|
||||
name='include_links',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='collection',
|
||||
name='include_journal',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -215,6 +215,10 @@ class Collection(models.Model):
|
||||
visibility = models.CharField(max_length=10, choices=Item.Visibility.choices, default=Item.Visibility.PRIVATE)
|
||||
mode = models.CharField(max_length=10, choices=Mode.choices, default=Mode.MANUAL)
|
||||
filter_tags = models.ManyToManyField(Tag, blank=True, related_name='tag_collections')
|
||||
include_notes = models.BooleanField(default=True)
|
||||
include_todos = models.BooleanField(default=False)
|
||||
include_links = models.BooleanField(default=False)
|
||||
include_journal = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -227,6 +231,18 @@ class Collection(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse('collection_detail', args=[self.pk])
|
||||
|
||||
def included_item_kinds(self):
|
||||
kinds = []
|
||||
if self.include_notes:
|
||||
kinds.append(Item.Kind.NOTE)
|
||||
if self.include_todos:
|
||||
kinds.append(Item.Kind.TODO)
|
||||
if self.include_links:
|
||||
kinds.append(Item.Kind.LINK)
|
||||
if self.include_journal:
|
||||
kinds.append(Item.Kind.JOURNAL)
|
||||
return kinds or [Item.Kind.NOTE]
|
||||
|
||||
|
||||
class CollectionSection(models.Model):
|
||||
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name='sections')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% load i18n %}
|
||||
<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 inline-edit-form" 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 %}
|
||||
<div class="card-body vstack gap-2">
|
||||
{% if form.errors %}<div class="alert alert-danger mb-0">{{ form.errors }}</div>{% endif %}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<div class="collection-flow content">
|
||||
{% 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 notes match the selected tags yet." %}</div>{% endfor %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
|
||||
@@ -13,7 +13,15 @@
|
||||
<div><label class="form-label">{{ form.mode.label }}</label>{{ form.mode }}{{ form.mode.errors }}<div class="form-text">{% trans "Tag collections update automatically and are sorted by note creation time." %}</div></div>
|
||||
<div><label class="form-label">{{ form.visibility.label }}</label>{{ form.visibility }}{{ form.visibility.errors }}</div>
|
||||
<div><label class="form-label">{{ form.team.label }}</label>{{ form.team }}{{ form.team.errors }}<div class="form-text">{% trans "Optional. Selecting a team makes the collection visible and editable for that team." %}</div></div>
|
||||
<div x-show="mode === 'tags'"><label class="form-label">{{ form.filter_tags.label }}</label><div class="border rounded p-3 collection-tags">{% if form.filter_tags.field.queryset.exists %}{{ form.filter_tags }}{% else %}<span class="text-muted small">{% trans "No content tags are available yet." %}</span>{% endif %}</div>{{ form.filter_tags.errors }}<div class="form-text">{% trans "Notes must contain all selected tags. Team tags are not content filters." %}</div></div>
|
||||
<div x-show="mode === 'tags'" class="vstack gap-3">
|
||||
<div><label class="form-label">{{ form.filter_tags.label }}</label><div class="border rounded p-3 collection-tags">{% if form.filter_tags.field.queryset.exists %}{{ form.filter_tags }}{% else %}<span class="text-muted small">{% trans "No content tags are available yet." %}</span>{% endif %}</div>{{ form.filter_tags.errors }}<div class="form-text">{% trans "Entries must contain all selected tags. Team tags are not content filters." %}</div></div>
|
||||
<div><label class="form-label">{% trans "Entry types" %}</label><div class="border rounded p-3 d-flex flex-wrap gap-3">
|
||||
<label class="form-check mb-0">{{ form.include_notes }} <span class="form-check-label">{{ form.include_notes.label }}</span></label>
|
||||
<label class="form-check mb-0">{{ form.include_todos }} <span class="form-check-label">{{ form.include_todos.label }}</span></label>
|
||||
<label class="form-check mb-0">{{ form.include_links }} <span class="form-check-label">{{ form.include_links.label }}</span></label>
|
||||
<label class="form-check mb-0">{{ form.include_journal }} <span class="form-check-label">{{ form.include_journal.label }}</span></label>
|
||||
</div>{{ form.include_notes.errors }}</div>
|
||||
</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>
|
||||
|
||||
@@ -111,6 +111,8 @@
|
||||
<section class="col-lg-9">
|
||||
<form class="desktop-quick-create position-relative mb-4" method="post" enctype="multipart/form-data" hx-post="{{ request.get_full_path }}" hx-target="#items" hx-swap="afterbegin" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:htmx:after-request="if($event.detail.successful){text='';open=false;$el.reset()}">
|
||||
{% csrf_token %}
|
||||
<fieldset :disabled="editingItem" :class="{'opacity-50': editingItem}">
|
||||
<div class="small text-muted mb-2" x-show="editingItem" x-cloak>{% trans "Inline editor is open; quick entry is disabled." %}</div>
|
||||
<div class="mobile-create-buttons gap-2 mb-2">
|
||||
<button type="button" class="btn btn-sm btn-primary flex-fill" @click="insert('/todo')">{% trans "Task" %}</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary flex-fill" @click="$el.querySelector('textarea').focus()">{% trans "Note" %}</button>
|
||||
@@ -130,6 +132,7 @@
|
||||
<button class="btn btn-dark px-4">{% trans "Save" %}</button>
|
||||
</div>
|
||||
<template x-if="preview"><div class="mt-2 small"><span class="text-muted">{% trans "Image from clipboard:" %}</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:160px"></div></template>
|
||||
</fieldset>
|
||||
</form>
|
||||
<div id="items" class="vstack gap-3">
|
||||
{% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-5">{% trans "Nothing here yet." %}</div>{% endfor %}
|
||||
@@ -150,12 +153,16 @@ function quickComposer(tags, itemId=null){
|
||||
linkSuggestions:[],
|
||||
attachmentSuggestions:[],
|
||||
pendingFiles:[],
|
||||
editingItem:false,
|
||||
itemId:itemId,
|
||||
tags:tags||[],
|
||||
init(){
|
||||
document.body.addEventListener('htmx:afterRequest', (event) => {
|
||||
if(event.detail.successful && event.target === this.$el){ this.preview=null; }
|
||||
});
|
||||
document.body.addEventListener('htmx:afterSwap', () => {
|
||||
this.editingItem=!!document.querySelector('.inline-edit-form');
|
||||
});
|
||||
document.body.addEventListener('tagsChanged', () => {
|
||||
fetch('{% url "api_tags" %}').then(r=>r.json()).then(data=>{this.tags=data.tags||[]});
|
||||
});
|
||||
|
||||
@@ -151,6 +151,31 @@ class MarkdownHeadingTests(TestCase):
|
||||
self.assertEqual(markdown_title('## Ziel von my2dos\n\nText'), 'Ziel von my2dos')
|
||||
|
||||
|
||||
class QuickCreateFilterTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(username='quick-filter', password='secret')
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_active_tag_is_added_only_when_content_has_no_tags(self):
|
||||
self.client.post(f'{reverse("index")}?tag=work', {'content': 'Task without tag'})
|
||||
first = Item.objects.get(content='Task without tag #work')
|
||||
self.assertEqual(list(first.tags.values_list('name', flat=True)), ['work'])
|
||||
|
||||
self.client.post(f'{reverse("index")}?tag=work', {'content': 'Task with own #other'})
|
||||
second = Item.objects.get(content='Task with own #other')
|
||||
self.assertEqual(list(second.tags.values_list('name', flat=True)), ['other'])
|
||||
|
||||
def test_hx_create_response_keeps_active_tag_marked(self):
|
||||
response = self.client.post(
|
||||
f'{reverse("index")}?tag=work', {'content': 'Task without tag'}, HTTP_HX_REQUEST='true',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'hx-swap-oob="true"')
|
||||
self.assertContains(response, '#work ×')
|
||||
self.assertContains(response, 'text-bg-dark')
|
||||
|
||||
|
||||
class FormattingToolbarTests(TestCase):
|
||||
def test_toolbar_and_shortcuts_are_available(self):
|
||||
user = get_user_model().objects.create_user(username='formatting', password='secret')
|
||||
@@ -189,6 +214,31 @@ class NavigationAndLayoutTests(TestCase):
|
||||
self.assertLess(menu_position, toolbar_position)
|
||||
self.assertLess(toolbar_position, file_position)
|
||||
|
||||
def test_quick_create_is_disabled_while_inline_editor_is_open(self):
|
||||
response = self.client.get(reverse('index'))
|
||||
self.assertContains(response, ':disabled="editingItem"')
|
||||
self.assertContains(response, 'inline-edit-form')
|
||||
item = Item.objects.create(owner=self.user, content='Editable')
|
||||
response = self.client.get(reverse('edit_item', args=[item.id]))
|
||||
self.assertContains(response, 'inline-edit-form')
|
||||
|
||||
def test_tag_filter_list_depends_on_current_filters(self):
|
||||
open_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open #openonly')
|
||||
open_todo.sync_metadata()
|
||||
done_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Done #archiveonly', is_done=True)
|
||||
done_todo.sync_metadata()
|
||||
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Note #noteonly')
|
||||
note.sync_metadata()
|
||||
|
||||
response = self.client.get(f'{reverse("index")}?status=archive')
|
||||
self.assertContains(response, '#archiveonly')
|
||||
self.assertNotContains(response, '#openonly')
|
||||
self.assertNotContains(response, '#noteonly')
|
||||
|
||||
response = self.client.get(f'{reverse("tag_list")}?status=archive')
|
||||
self.assertContains(response, '#archiveonly')
|
||||
self.assertNotContains(response, '#openonly')
|
||||
|
||||
|
||||
class AttachmentEditTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -500,6 +550,34 @@ class CollectionTests(TestCase):
|
||||
manage_response = self.client.get(reverse('collection_manage', args=[collection.id]))
|
||||
self.assertRedirects(manage_response, reverse('collection_edit', args=[collection.id]))
|
||||
|
||||
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.sync_metadata()
|
||||
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Matching todo #project')
|
||||
todo.sync_metadata()
|
||||
link = Item.objects.create(owner=self.user, kind=Item.Kind.LINK, content='Matching link #project')
|
||||
link.sync_metadata()
|
||||
collection = Collection.objects.create(
|
||||
owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS,
|
||||
include_notes=False, include_todos=True, include_links=False, include_journal=False,
|
||||
)
|
||||
collection.filter_tags.add(Tag.objects.get(name='project'))
|
||||
|
||||
response = self.client.get(collection.get_absolute_url())
|
||||
|
||||
self.assertContains(response, 'Matching todo')
|
||||
self.assertNotContains(response, 'Matching note')
|
||||
self.assertNotContains(response, 'Matching link')
|
||||
|
||||
def test_tag_collection_form_offers_item_type_selection(self):
|
||||
response = self.client.get(reverse('collection_create'))
|
||||
|
||||
self.assertContains(response, 'Entry types')
|
||||
self.assertContains(response, 'name="include_notes"')
|
||||
self.assertContains(response, 'name="include_todos"')
|
||||
self.assertContains(response, 'name="include_links"')
|
||||
self.assertContains(response, 'name="include_journal"')
|
||||
|
||||
def test_collection_opens_as_continuous_reading_view(self):
|
||||
collection = Collection.objects.create(owner=self.user, title='Reading view')
|
||||
first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First paragraph')
|
||||
|
||||
+49
-7
@@ -20,7 +20,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
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 .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, without_markdown_code
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, TAG_RE, without_markdown_code
|
||||
|
||||
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
|
||||
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I)
|
||||
@@ -276,6 +276,46 @@ def api_auth_required(view):
|
||||
return wrapper
|
||||
|
||||
|
||||
def index_filter_tags(request, user, prefs, active_tags=None):
|
||||
active_tags = active_tags or []
|
||||
kind = request.GET.get('kind')
|
||||
status = request.GET.get('status')
|
||||
day = request.GET.get('day')
|
||||
q = request.GET.get('q', '').strip()
|
||||
items = visible_items(user)
|
||||
applied_preferences = False
|
||||
if kind in dict(Item.Kind.choices):
|
||||
items = items.filter(kind=kind)
|
||||
elif not any([active_tags, 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)
|
||||
if status == 'open':
|
||||
items = items.filter(kind=Item.Kind.TODO, is_done=False)
|
||||
elif status == 'due':
|
||||
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59)))
|
||||
items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end)
|
||||
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 Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags)).distinct().order_by('name')
|
||||
|
||||
|
||||
def index(request):
|
||||
if not request.user.is_authenticated:
|
||||
if request.method == 'POST':
|
||||
@@ -289,14 +329,15 @@ def index(request):
|
||||
raw, due_at, _ = parse_due_command(raw)
|
||||
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
|
||||
active_tag = request.GET.get('tag')
|
||||
if active_tag and f'#{active_tag.lower()}' not in content.lower():
|
||||
content = f'{content} #{active_tag}'
|
||||
if active_tag and not TAG_RE.search(without_markdown_code(content)):
|
||||
content = f'{content} #{active_tag.strip().lower()}'
|
||||
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
|
||||
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 request.headers.get('HX-Request'):
|
||||
response = render(request, 'core/_create_response.html', {'item': item, 'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')})
|
||||
active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()]
|
||||
response = render(request, 'core/_create_response.html', {'item': item, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags})
|
||||
response['HX-Trigger'] = 'tagsChanged'
|
||||
return response
|
||||
return redirect('index')
|
||||
@@ -357,7 +398,7 @@ def index(request):
|
||||
default=Value(1), output_field=IntegerField(),
|
||||
)).order_by('due_rank', 'due_at', '-created_at')
|
||||
return render(request, 'core/index.html', {
|
||||
'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), '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), '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,
|
||||
})
|
||||
|
||||
|
||||
@@ -661,7 +702,7 @@ def collection_target_from_form(form, user):
|
||||
|
||||
|
||||
def collection_tag_items(collection):
|
||||
items = Item.objects.filter(kind=Item.Kind.NOTE).select_related('owner', 'team').prefetch_related('tags', 'attachments')
|
||||
items = Item.objects.filter(kind__in=collection.included_item_kinds()).select_related('owner', 'team').prefetch_related('tags', 'attachments')
|
||||
if collection.visibility == Item.Visibility.PUBLIC:
|
||||
items = items.filter(visibility=Item.Visibility.PUBLIC)
|
||||
elif collection.visibility == Item.Visibility.TEAM:
|
||||
@@ -1120,9 +1161,10 @@ def kanban_move(request, pk, item_pk):
|
||||
|
||||
@login_required
|
||||
def tag_list(request):
|
||||
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||
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'
|
||||
return render(request, 'core/_tags.html', {'tags': Tag.objects.all(), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode})
|
||||
return render(request, 'core/_tags.html', {'tags': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode})
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch and update remote my2dos tasks via the JSON API.
|
||||
|
||||
Configuration is read from environment variables or from .env in the project root:
|
||||
- MY2DOS_API_URL, e.g. https://my2dos.rucki.ch
|
||||
- MY2DOS_API_KEY, API key token
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_dotenv(path: Path = ROOT / ".env") -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
def config() -> tuple[str, str]:
|
||||
load_dotenv()
|
||||
base_url = os.environ.get("MY2DOS_API_URL", "").rstrip("/")
|
||||
api_key = os.environ.get("MY2DOS_API_KEY", "")
|
||||
if not base_url or not api_key:
|
||||
print("Missing MY2DOS_API_URL or MY2DOS_API_KEY. Configure them in .env.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return base_url, api_key
|
||||
|
||||
|
||||
def request_json(method: str, path: str, data: dict[str, Any] | None = None, query: dict[str, str] | None = None) -> Any:
|
||||
base_url, api_key = config()
|
||||
url = f"{base_url}{path}"
|
||||
if query:
|
||||
url = f"{url}?{urlencode(query)}"
|
||||
body = json.dumps(data).encode("utf-8") if data is not None else None
|
||||
request = Request(
|
||||
url,
|
||||
data=body,
|
||||
method=method,
|
||||
headers={"X-Api-Key": api_key, "Accept": "application/json", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=20) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
details = exc.read().decode("utf-8", errors="replace")
|
||||
print(f"HTTP {exc.code} for {method} {url}: {details}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except URLError as exc:
|
||||
print(f"Request failed for {method} {url}: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def list_tasks(args: argparse.Namespace) -> None:
|
||||
payload = request_json("GET", "/api/items/", query={"limit": str(args.limit)})
|
||||
items = payload.get("items", [])
|
||||
tasks = [item for item in items if item.get("kind") == "todo"]
|
||||
if args.open:
|
||||
tasks = [item for item in tasks if not item.get("is_done")]
|
||||
for item in tasks:
|
||||
status = "x" if item.get("is_done") else " "
|
||||
due = f" due:{item['due_at'][:10]}" if item.get("due_at") else ""
|
||||
tags = " ".join(f"#{tag}" for tag in item.get("tags", []))
|
||||
print(f"[{status}] #{item['id']}{due} {item.get('content', '').strip()} {tags}".rstrip())
|
||||
|
||||
|
||||
def mark_done(args: argparse.Namespace) -> None:
|
||||
item = request_json("PATCH", f"/api/items/{args.id}/", data={"is_done": True})
|
||||
print(f"Done: #{item['id']} {item.get('content', '').strip()}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Work with remote my2dos tasks.")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
list_parser = subparsers.add_parser("list", help="List remote todos")
|
||||
list_parser.add_argument("--all", dest="open", action="store_false", help="include completed todos")
|
||||
list_parser.add_argument("--limit", type=int, default=500)
|
||||
list_parser.set_defaults(func=list_tasks, open=True)
|
||||
|
||||
done_parser = subparsers.add_parser("done", help="Mark a todo as done")
|
||||
done_parser.add_argument("id", type=int)
|
||||
done_parser.set_defaults(func=mark_done)
|
||||
|
||||
args = parser.parse_args()
|
||||
if not hasattr(args, "func"):
|
||||
args = parser.parse_args(["list"])
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user