Restore desktop navigation and refine layouts
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user