Improve task filters and tag collections

This commit is contained in:
rucki
2026-09-04 11:44:49 +02:00
parent e04385c23d
commit c6309cabd8
12 changed files with 342 additions and 13 deletions
+2
View File
@@ -0,0 +1,2 @@
MY2DOS_API_URL=https://my2dos.rucki.ch
MY2DOS_API_KEY=replace-with-api-key
+25
View File
@@ -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
View File
@@ -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),
),
]
+16
View File
@@ -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 -1
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+9 -1
View File
@@ -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>
+7
View File
@@ -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||[]});
});
+78
View File
@@ -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
View File
@@ -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
+107
View File
@@ -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()