Restore desktop navigation and refine layouts

This commit is contained in:
rucki
2026-08-27 11:55:23 +02:00
parent ade878a0cd
commit 4b83582e72
9 changed files with 279 additions and 12 deletions
+2 -2
View File
@@ -11,10 +11,10 @@
</div> </div>
<textarea class="form-control" name="content" rows="5" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea> <textarea class="form-control" name="content" rows="5" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea>
<textarea class="form-control" name="comment" rows="3" placeholder="{% trans 'Comment, e.g. same task already captured [[12]]' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea> <textarea class="form-control" name="comment" rows="3" placeholder="{% trans 'Comment, e.g. same task already captured [[12]]' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
{% include 'core/_format_toolbar.html' %} <div class="slash-menu composer-slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template> <template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template>
</div> </div>
{% include 'core/_format_toolbar.html' %}
<div class="row g-2"> <div class="row g-2">
<div class="col-md-4"><select class="form-select" name="visibility"> <div class="col-md-4"><select class="form-select" name="visibility">
<option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option> <option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option>
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -119,10 +119,10 @@
</div> </div>
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details> <details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
{{ form.content }} {{ form.content }}
{% include 'core/_format_toolbar.html' %} <div class="slash-menu composer-slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template> <template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template>
</div> </div>
{% include 'core/_format_toolbar.html' %}
<div class="d-flex align-items-center gap-2 mt-2 quick-actions"> <div class="d-flex align-items-center gap-2 mt-2 quick-actions">
<input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)"> <input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)">
<div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Gallery" %}<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Photo" %}<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div> <div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Gallery" %}<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Photo" %}<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div>
+2 -2
View File
@@ -17,10 +17,10 @@
</div> </div>
<textarea class="form-control" name="content" rows="18" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea> <textarea class="form-control" name="content" rows="18" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea>
<textarea class="form-control" name="comment" rows="5" placeholder="{% trans 'Comment' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea> <textarea class="form-control" name="comment" rows="5" placeholder="{% trans 'Comment' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
{% include 'core/_format_toolbar.html' %} <div class="slash-menu composer-slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template> <template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template>
</div> </div>
{% include 'core/_format_toolbar.html' %}
<div class="row g-2"> <div class="row g-2">
<div class="col-md-4"><select class="form-select" name="visibility"><option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option><option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</option></select></div> <div class="col-md-4"><select class="form-select" name="visibility"><option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option><option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</option></select></div>
{% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}"><datalist id="due-times-{{ item.id }}"><option value="00:00"><option value="00:30"><option value="01:00"><option value="01:30"><option value="02:00"><option value="02:30"><option value="03:00"><option value="03:30"><option value="04:00"><option value="04:30"><option value="05:00"><option value="05:30"><option value="06:00"><option value="06:30"><option value="07:00"><option value="07:30"><option value="08:00"><option value="08:30"><option value="09:00"><option value="09:30"><option value="10:00"><option value="10:30"><option value="11:00"><option value="11:30"><option value="12:00"><option value="12:30"><option value="13:00"><option value="13:30"><option value="14:00"><option value="14:30"><option value="15:00"><option value="15:30"><option value="16:00"><option value="16:30"><option value="17:00"><option value="17:30"><option value="18:00"><option value="18:30"><option value="19:00"><option value="19:30"><option value="20:00"><option value="20:30"><option value="21:00"><option value="21:30"><option value="22:00"><option value="22:30"><option value="23:00"><option value="23:30"></datalist></div>{% endif %} {% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}"><datalist id="due-times-{{ item.id }}"><option value="00:00"><option value="00:30"><option value="01:00"><option value="01:30"><option value="02:00"><option value="02:30"><option value="03:00"><option value="03:30"><option value="04:00"><option value="04:30"><option value="05:00"><option value="05:30"><option value="06:00"><option value="06:30"><option value="07:00"><option value="07:30"><option value="08:00"><option value="08:30"><option value="09:00"><option value="09:30"><option value="10:00"><option value="10:30"><option value="11:00"><option value="11:30"><option value="12:00"><option value="12:30"><option value="13:00"><option value="13:30"><option value="14:00"><option value="14:30"><option value="15:00"><option value="15:30"><option value="16:00"><option value="16:30"><option value="17:00"><option value="17:30"><option value="18:00"><option value="18:30"><option value="19:00"><option value="19:30"><option value="20:00"><option value="20:30"><option value="21:00"><option value="21:30"><option value="22:00"><option value="22:30"><option value="23:00"><option value="23:30"></datalist></div>{% endif %}
+1 -1
View File
@@ -28,8 +28,8 @@
</div> </div>
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details> <details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
{{ form.content }} {{ form.content }}
<div class="slash-menu composer-slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak><template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template></div>
{% include 'core/_format_toolbar.html' %} {% include 'core/_format_toolbar.html' %}
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak><template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template></div>
<div class="d-flex align-items-center gap-2 mt-2 quick-actions"><input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)"><div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Gallery" %}<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Photo" %}<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div><button class="btn btn-dark px-4">{% trans "Save" %}</button></div> <div class="d-flex align-items-center gap-2 mt-2 quick-actions"><input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)"><div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Gallery" %}<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Photo" %}<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div><button class="btn btn-dark px-4">{% trans "Save" %}</button></div>
</form> </form>
<div id="items" class="vstack gap-3"> <div id="items" class="vstack gap-3">
+2 -2
View File
@@ -16,10 +16,10 @@
</div> </div>
</div> </div>
<textarea class="form-control" name="content" rows="14" placeholder="Text … #tag [[ ![[" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)"></textarea> <textarea class="form-control" name="content" rows="14" placeholder="Text … #tag [[ ![[" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)"></textarea>
{% include 'core/_format_toolbar.html' %} <div class="slash-menu composer-slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template> <template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template>
</div> </div>
{% include 'core/_format_toolbar.html' %}
<div> <div>
<label class="form-label">{% trans "Add image" %}</label> <label class="form-label">{% trans "Add image" %}</label>
<input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)"> <input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)">
+1 -1
View File
@@ -36,7 +36,7 @@
<div class="row g-3"> <div class="row g-3">
{% for field in preference_form %} {% for field in preference_form %}
{% if field.name == 'language' or field.name == 'default_item_kind' or field.name == 'overview_lines' %} {% if field.name == 'language' or field.name == 'default_item_kind' or field.name == 'overview_lines' %}
<div class="col-md-4"><label class="form-label">{{ field.label }}</label>{{ field }}</div> <div class="col-12"><label class="form-label">{{ field.label }}</label>{{ field }}</div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div class="col-12"> <div class="col-12">
+29
View File
@@ -118,6 +118,35 @@ class FormattingToolbarTests(TestCase):
self.assertContains(response, "u:'underline'") self.assertContains(response, "u:'underline'")
class NavigationAndLayoutTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='navigation', password='secret')
self.preferences = UserPreference.objects.create(user=self.user)
self.client.force_login(self.user)
def test_desktop_navigation_contains_overview_hidden_links_and_kanban(self):
self.preferences.show_notes = False
self.preferences.save(update_fields=['show_notes'])
response = self.client.get(reverse('index'))
self.assertContains(response, 'd-none d-md-flex')
self.assertContains(response, f'{reverse("index")}?kind=note')
self.assertContains(response, reverse('kanban_list'))
def test_preference_selects_are_stacked(self):
response = self.client.get(reverse('settings'))
preferences = response.content.decode().split('id="preferences"', 1)[1].split('name="due_first"', 1)[0]
self.assertEqual(preferences.count('<div class="col-12"><label class="form-label">'), 3)
def test_composer_menu_is_before_toolbar_and_file_controls(self):
response = self.client.get(reverse('index'))
html = response.content.decode()
menu_position = html.index('composer-slash-menu')
toolbar_position = html.index('format-toolbar', menu_position)
file_position = html.index('desktop-file', toolbar_position)
self.assertLess(menu_position, toolbar_position)
self.assertLess(toolbar_position, file_position)
class CopyButtonTests(TestCase): class CopyButtonTests(TestCase):
def test_item_has_copy_button_with_original_text(self): def test_item_has_copy_button_with_original_text(self):
user = get_user_model().objects.create_user(username='tester', password='secret') user = get_user_model().objects.create_user(username='tester', password='secret')
+224
View File
@@ -0,0 +1,224 @@
# Mögliche Erweiterung: Gitea-Integration
## Status
Konzeptvorschlag – noch nicht implementiert.
## Ziel
my2dos soll Issues in einer Gitea-Instanz erstellen können. Die Erfassung erfolgt direkt über einen Slash-Befehl. In einer ersten Version findet keine automatische bidirektionale Synchronisation statt.
## Vorgeschlagene Bedienung
### Issue im Standard-Repository erstellen
```text
/issue Fehler beim Speichern
Weitere Beschreibung des Problems.
```
### Repository über einen Alias auswählen
```text
/issue my2dos Fehler beim Speichern
Weitere Beschreibung des Problems.
```
Dabei könnte `my2dos` beispielsweise auf `rucki/my2dos` verweisen.
### Mögliche spätere Optionen
```text
/issue my2dos Fehler beim Speichern /labels bug,frontend
```
Weitere denkbare Angaben:
- Labels
- zuständige Person
- Meilenstein
- Priorität
- Gitea-Projekt
Die genaue Syntax sollte erst nach Erfahrungen mit der Basisversion festgelegt werden.
## Verhalten der ersten Version
1. Benutzer gibt `/issue` mit Titel und optionaler Beschreibung ein.
2. my2dos bestimmt das Standard-Repository oder löst den angegebenen Alias auf.
3. Das Issue wird über die Gitea-REST-API erstellt.
4. In my2dos wird ein lokaler Link-Eintrag zum Issue angelegt.
5. Issue-Nummer, Repository und URL werden lokal gespeichert.
6. Schlägt der API-Aufruf fehl, bleibt der eingegebene Text als lokaler Eintrag erhalten und zeigt einen verständlichen Fehlerstatus.
Der Text darf bei Netzwerk- oder Gitea-Fehlern nicht verloren gehen.
## Einstellungen
In den Benutzereinstellungen könnte ein Bereich **Gitea** ergänzt werden:
- URL der Gitea-Instanz
- API-Token
- Standard-Repository
- optionale Repository-Aliase
- Verbindung testen
Beispiel für Aliase:
| Alias | Gitea-Repository |
|---|---|
| `my2dos` | `rucki/my2dos` |
| `website` | `rucki/website` |
Später könnten Verbindungen auch einem Team statt nur einem Benutzer gehören.
## Mögliches Datenmodell
### `GiteaConnection`
- Benutzer oder Team
- Server-URL
- verschlüsselter API-Token
- aktiv/inaktiv
- Erstellungs- und Änderungszeitpunkt
### `GiteaRepository`
- Gitea-Verbindung
- Alias
- Owner
- Repository-Name
- Kennzeichnung als Standard-Repository
### `ExternalIssue`
- lokaler my2dos-Eintrag
- Gitea-Repository
- Issue-Nummer
- externe URL
- Synchronisationsstatus
- letzte Fehlermeldung
- Zeitpunkt der letzten Synchronisation
Alternativ könnte `ExternalIssue` später zu einem allgemeinen Modell für externe Systeme erweitert werden.
## Gitea-API
Zum Erstellen eines Issues wird voraussichtlich folgender Endpunkt verwendet:
```http
POST /api/v1/repos/{owner}/{repository}/issues
Authorization: token <API-TOKEN>
Content-Type: application/json
```
Beispielinhalt:
```json
{
"title": "Fehler beim Speichern",
"body": "Weitere Beschreibung des Problems."
}
```
Die genaue API muss gegen die eingesetzte Gitea-Version geprüft werden.
## Fehlerbehandlung
Zu berücksichtigen sind insbesondere:
- Gitea nicht erreichbar
- Zeitüberschreitung
- ungültiger oder abgelaufener Token
- fehlende Berechtigung für das Repository
- unbekannter Repository-Alias
- Repository nicht gefunden
- ungültige Labels oder Meilensteine
Ein externer API-Aufruf sollte ein kurzes Timeout besitzen. Fehler müssen im lokalen Eintrag nachvollziehbar sein. Für spätere automatische Wiederholungen wäre ein Hintergrundjob sinnvoll.
## Sicherheit
- API-Tokens dürfen nicht im Klartext angezeigt oder protokolliert werden.
- Tokens sollten verschlüsselt gespeichert oder zunächst über Umgebungsvariablen bereitgestellt werden.
- Tokens sollten nur die notwendigen Gitea-Berechtigungen besitzen.
- Benutzerdefinierte Gitea-URLs müssen validiert werden, um SSRF-Zugriffe auf interne Dienste zu verhindern.
- Für produktive Verbindungen sollte HTTPS verlangt werden.
- Spätere Webhooks benötigen eine Signatur- oder Secret-Prüfung.
## Zusammenspiel mit Slash-Befehlen
`/issue` ist eine Aktion und nicht zwingend ein neuer Eintragstyp. Zwei Varianten sind möglich:
1. `/issue` erstellt das externe Issue und lokal einen Link-Eintrag.
2. `/issue` erstellt lokal eine Aufgabe, die mit dem externen Issue verbunden ist.
Für den MVP wird Variante 1 empfohlen, weil der externe Issue-Link das primäre Ergebnis ist. Vor der Implementierung sollte entschieden werden, ob `/issue` – wie die Typbefehle – nur am Anfang eines Eintrags erlaubt ist.
## Tests
Mindestens erforderlich:
- Parser für `/issue`
- Standard-Repository und Alias-Auflösung
- Titel und mehrzeilige Beschreibung
- erfolgreicher API-Aufruf mit gemockter Gitea-API
- Fehlerantworten und Timeouts
- kein Verlust des lokalen Textes bei Fehlern
- Berechtigungsprüfung für Benutzer und Teams
- sichere Behandlung des Tokens
- API- und UI-Erstellung
## Ausbaustufen
### Stufe 1: Issues erstellen
- eine Gitea-Verbindung pro Benutzer
- Standard-Repository
- `/issue Titel`
- lokaler Link zum erstellten Issue
- robuste Fehlerbehandlung
Geschätzter Aufwand: **1–2 Entwicklungstage**.
### Stufe 2: Mehrere Repositories und Labels
- Repository-Aliase
- Label-Zuordnung
- Team-Verbindungen
- Verbindungstest in den Einstellungen
Geschätzter Gesamtaufwand: **3–5 Entwicklungstage**.
### Stufe 3: Bidirektionale Synchronisation
- geschlossenes Gitea-Issue erledigt die lokale Aufgabe
- erledigte lokale Aufgabe schließt das Gitea-Issue
- Kommentare synchronisieren
- Webhooks
- Schutz vor Synchronisationsschleifen
Geschätzter Aufwand: **1–2 Wochen**.
### Stufe 4: Vollständige Integration
- OAuth statt manueller Tokens
- mehrere Gitea-Instanzen
- Hintergrundjobs und automatische Wiederholungen
- Bearbeiter, Meilensteine und Projekte
- Konfliktbehandlung
Geschätzter Aufwand: **2–3 Wochen**.
## Empfohlener MVP
1. Gitea-URL, Token und Standard-Repository konfigurierbar machen.
2. Repository-Aliase unterstützen.
3. `/issue [alias] Titel` parsen.
4. Restlichen mehrzeiligen Text als Beschreibung verwenden.
5. Issue synchron über die Gitea-API erstellen.
6. Einen lokalen Link-Eintrag mit externer Referenz anlegen.
7. Bei Fehlern einen lokalen Eintrag mit Wiederholungsmöglichkeit behalten.
8. Noch keine automatische Synchronisation oder Webhooks implementieren.
Diese Variante liefert schnell einen praktischen Nutzen und hält Sicherheits-, Synchronisations- und Betriebsaufwand zunächst überschaubar.