Merge dev into main (v0.43.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s
This commit is contained in:
commit
f49d599d12
36 files changed
+1335
-154
No files matched your search
@@ -32,6 +32,11 @@ TZ=Europe/Berlin
|
|||||||
# Alt: true entspricht REGISTRATION_MODE=open; false lässt die Wahl dem Admin.
|
# Alt: true entspricht REGISTRATION_MODE=open; false lässt die Wahl dem Admin.
|
||||||
ALLOW_REGISTRATION=false
|
ALLOW_REGISTRATION=false
|
||||||
|
|
||||||
|
# Aufbewahrung: Darf jede Person „nie“ wählen, also Erledigtes und Gelöschtes dauerhaft behalten?
|
||||||
|
# false = nein (es gilt die Voreinstellung des Bereichs, z. B. 30 Tage), true = ja. Ohne Angabe
|
||||||
|
# entscheidet Administration → Server & Isolation (Standard: erlaubt).
|
||||||
|
# RETENTION_ALLOW_NEVER=false
|
||||||
|
|
||||||
# ─── Sicherheit ────────────────────────────────────────────────────────────────────────────────────
|
# ─── Sicherheit ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Wessen Angaben zur Besucher-Adresse (X-Forwarded-For / X-Real-IP) geglaubt werden. Wichtig für die
|
# Wessen Angaben zur Besucher-Adresse (X-Forwarded-For / X-Real-IP) geglaubt werden. Wichtig für die
|
||||||
|
|||||||
+14
-1
@@ -6,6 +6,18 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.43.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Papierkorb für Aufgaben: Gelöschte Aufgaben, „Erledigte leeren“ und das automatische Aufräumen erledigter Aufgaben landen im Reiter „Papierkorb“ der Aufgaben, statt sofort weg zu sein. Von dort kommen sie unverändert zurück (mit Beschreibung, Unteraufgaben, Zuweisungen) oder werden endgültig gelöscht. Wer eine Liste sieht, sieht auch ihren Papierkorb; Zurückholen und Löschen braucht Bearbeitungsrecht. Auch `delete_task` per MCP legt in den Papierkorb.
|
||||||
|
- Eigener Papierkorb für die Triage: verworfene Gedanken öffnen sich über den Papierkorb-Knopf oben in der Triage (am Handy im Menü).
|
||||||
|
- Kontoeinstellungen → Aufbewahrung: für erledigte Aufgaben (Standard 30 Tage, danach in den Papierkorb), den Papierkorb der Aufgaben (30), der Triage (14), der Notizen (30) und abgehakte Listeneinträge (7) jeweils 7, 14, 30 oder 90 Tage, ein oder zwei Jahre oder nie. In geteilten Aufgabenlisten, Listen und Notizen gilt die Einstellung der Person, der sie gehören. Was beim Update schon erledigt oder gelöscht war, zählt ab dem Update.
|
||||||
|
- Administration: „Dauerhaftes Aufbewahren erlauben“ (bzw. `RETENTION_ALLOW_NEVER`). Ausgeschaltet gibt es „nie“ nicht mehr; dort gilt dann die Voreinstellung des Bereichs.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Die Auswahl „Erledigte Aufgaben löschen nach …“ und ihr Erklärsatz im Reiter „Erledigt“ entfallen; die Fristen stehen gesammelt unter Aufbewahrung. „Erledigte leeren“ fragt nicht mehr nach, weil sich alles zurückholen lässt.
|
||||||
|
- **Achtung:** Der Papierkorb der Notizen und abgehakte Einträge in Einkaufs- und Checklisten werden jetzt nach der Frist automatisch geleert (Standard 30 bzw. 7 Tage ab dem Update). Wer das nicht möchte, stellt unter Aufbewahrung „nie“ ein.
|
||||||
|
|
||||||
## [0.42.0] - 2026-11-01
|
## [0.42.0] - 2026-11-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -692,7 +704,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.43.0...dev
|
||||||
|
[0.43.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...v0.43.0
|
||||||
[0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0
|
[0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0
|
||||||
[0.41.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.1...v0.41.0
|
[0.41.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.1...v0.41.0
|
||||||
[0.40.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.0...v0.40.1
|
[0.40.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.0...v0.40.1
|
||||||
|
|||||||
+9
-5
@@ -34,7 +34,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
|
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
|
||||||
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
|
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
|
||||||
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
||||||
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`, `doneRetentionDays` (0, 7, 30, 90 oder 365; erledigte Aufgaben löschen nach so vielen Tagen, 0 = nie). `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
|
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`, `retention` (`doneTasks`, `taskTrash`, `triageTrash`, `noteTrash`, `listDone`: Tage aus 7, 14, 30, 90, 365, 730 oder 0 = nie; immer alle fünf, `400` bei anderen Werten oder bei 0, wenn der Server „nie“ abgeschaltet hat), `voiceInput` (`""` = Whisper-Server, `browser`), `doneRetentionDays` (älteres Einzelfeld für `retention.doneTasks`). `GET /auth/me` liefert dazu `retention` und `retentionNeverAllowed`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
|
||||||
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
||||||
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
|
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
|
||||||
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
||||||
@@ -92,7 +92,7 @@ MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP,
|
|||||||
| `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` |
|
| `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` |
|
||||||
| `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) |
|
| `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) |
|
||||||
| `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) |
|
| `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) |
|
||||||
| `delete_task` | `tasks:write` | Aufgabe endgültig löschen (z. B. versehentlich angelegte) |
|
| `delete_task` | `tasks:write` | Aufgabe löschen (z. B. versehentlich angelegte); sie kommt in den Papierkorb der Aufgaben |
|
||||||
| `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen |
|
| `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen |
|
||||||
| `create_list` | `lists:write` | Liste anlegen (`title`, `icon`, `listType` shopping/checklist/vacation) |
|
| `create_list` | `lists:write` | Liste anlegen (`title`, `icon`, `listType` shopping/checklist/vacation) |
|
||||||
| `get_list` | `lists:read` | Liste mit Einträgen |
|
| `get_list` | `lists:read` | Liste mit Einträgen |
|
||||||
@@ -132,12 +132,16 @@ Die Ids `webhook:…` stammen aus der Zeit, als Schlüssel nur einliefern konnte
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
|
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
|
||||||
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
||||||
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen: eine Aufgabe kommt in den Papierkorb der Aufgaben, ein Gedanke aus Posteingang oder Triage-Papierkorb ist endgültig weg. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
||||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
| DELETE | `/thoughts/discarded/purge` | Papierkorb der Triage (verworfene Gedanken) leeren |
|
||||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||||
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
||||||
| GET | `/tasks?taskListId=&status=&due=` | Aufgaben (optional einer Liste; Filter siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)) |
|
| GET | `/tasks?taskListId=&status=&due=` | Aufgaben (optional einer Liste; Filter siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)) |
|
||||||
| POST | `/tasks/clear-done` | Erledigte Aufgaben endgültig löschen (`taskListId`, leer = alle bearbeitbaren) → `{deleted}` |
|
| POST | `/tasks/clear-done` | Erledigte Aufgaben in den Papierkorb der Aufgaben (`taskListId`, leer = alle bearbeitbaren) → `{deleted}` |
|
||||||
|
| GET | `/tasks/trash?taskListId=` | Papierkorb der Aufgaben (einer Liste, sonst alle sichtbaren): `id`, `content`, `taskListId`, `isCompleted`, `trashedAt`, `trashedBy` (Name, leer = automatisch), `canEdit` |
|
||||||
|
| POST | `/tasks/trash/{id}/restore` | Aufgabe unverändert zurückholen (mit Zuweisungen; eine gelöschte Liste macht sie zur eigenen Aufgabe ihres Erstellers) → Aufgabe |
|
||||||
|
| DELETE | `/tasks/trash/{id}` | Endgültig löschen (Bearbeitungsrecht auf die Liste, sonst `403`) |
|
||||||
|
| POST | `/tasks/trash/empty` | Papierkorb leeren (`taskListId`, leer = alle bearbeitbaren) → `{deleted}` |
|
||||||
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
||||||
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
|
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
|
||||||
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
|
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB |
|
| `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB |
|
||||||
| `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) |
|
| `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) |
|
||||||
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
|
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
|
||||||
|
| `RETENTION_ALLOW_NEVER` | leer | `false` verbietet „nie“ bei den Aufbewahrungsfristen (Kontoeinstellungen → Aufbewahrung); wer „nie“ gewählt hatte, bekommt die Voreinstellung des Bereichs. `true` erlaubt es fest. Leer: Schalter in Administration → Systemeinstellungen (Standard: erlaubt) |
|
||||||
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
|
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
|
||||||
| `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
|
| `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
|
||||||
| `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus |
|
| `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
|||||||
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
|
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
|
||||||
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste oder über große Knöpfe unter der Karte; die Warteschlange klappt bei Bedarf auf.
|
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste oder über große Knöpfe unter der Karte; die Warteschlange klappt bei Bedarf auf.
|
||||||
* **Teilen aus anderen Apps:** Als installierte App erscheint MindSift im Teilen-Menü des Smartphones; geteilte Texte und Links landen im Posteingang und lassen sich direkt danach mit einem Tipp zur Aufgabe oder auf eine Liste schicken.
|
* **Teilen aus anderen Apps:** Als installierte App erscheint MindSift im Teilen-Menü des Smartphones; geteilte Texte und Links landen im Posteingang und lassen sich direkt danach mit einem Tipp zur Aufgabe oder auf eine Liste schicken.
|
||||||
* Papierkorb mit Wiederherstellen.
|
* Papierkörbe mit Wiederherstellen für verworfene Gedanken, gelöschte Aufgaben und Notizen; unter Kontoeinstellungen → Aufbewahrung legt jede Person fest, nach wie vielen Tagen Erledigtes und Gelöschtes aufgeräumt wird.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/Mobile_Triage.webp" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
|
<img src="screenshots/Mobile_Triage.webp" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ services:
|
|||||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-private}
|
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-private}
|
||||||
|
- RETENTION_ALLOW_NEVER=${RETENTION_ALLOW_NEVER:-}
|
||||||
- LOGIN_PROTECTION=${LOGIN_PROTECTION:-true}
|
- LOGIN_PROTECTION=${LOGIN_PROTECTION:-true}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
- DEBUG=${DEBUG:-false}
|
- DEBUG=${DEBUG:-false}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.42.0",
|
"version": "0.43.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.42.0",
|
"version": "0.43.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.42.0",
|
"version": "0.43.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+14
-1
@@ -10,6 +10,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
Palette,
|
Palette,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -73,6 +74,7 @@ import { CommandPalette } from './components/CommandPalette';
|
|||||||
import { AppFooter } from './components/AppFooter';
|
import { AppFooter } from './components/AppFooter';
|
||||||
import { ThemeQuickPicker } from './components/ThemeQuickPicker';
|
import { ThemeQuickPicker } from './components/ThemeQuickPicker';
|
||||||
import { AboutModal } from './components/AboutModal';
|
import { AboutModal } from './components/AboutModal';
|
||||||
|
import { TriageTrash } from './features/triage/TriageTrash';
|
||||||
import { UpdateAvailableModal } from './components/UpdateAvailableModal';
|
import { UpdateAvailableModal } from './components/UpdateAvailableModal';
|
||||||
import { useUpdateCheck } from './lib/useUpdateCheck';
|
import { useUpdateCheck } from './lib/useUpdateCheck';
|
||||||
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
||||||
@@ -191,6 +193,7 @@ export const App: React.FC = () => {
|
|||||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
const serverOnline = useServerOnline();
|
const serverOnline = useServerOnline();
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
const [showTriageTrash, setShowTriageTrash] = useState(false);
|
||||||
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
|
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -314,6 +317,13 @@ export const App: React.FC = () => {
|
|||||||
badge: stats.inboxCount,
|
badge: stats.inboxCount,
|
||||||
onClick: () => refreshAll(),
|
onClick: () => refreshAll(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'triage-trash',
|
||||||
|
label: 'Papierkorb',
|
||||||
|
icon: <Trash2 className="w-4 h-4" />,
|
||||||
|
badge: stats.discardedCount,
|
||||||
|
onClick: () => setShowTriageTrash(true),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -394,7 +404,7 @@ export const App: React.FC = () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount]);
|
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount, stats.discardedCount]);
|
||||||
|
|
||||||
// Mobile Web-App (PWA) Installation State
|
// Mobile Web-App (PWA) Installation State
|
||||||
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
||||||
@@ -1275,6 +1285,8 @@ export const App: React.FC = () => {
|
|||||||
onDeckChange={refreshAll}
|
onDeckChange={refreshAll}
|
||||||
onOpenDocument={handleOpenDocument}
|
onOpenDocument={handleOpenDocument}
|
||||||
onFocusCapture={handleTriggerAddButton}
|
onFocusCapture={handleTriggerAddButton}
|
||||||
|
discardedCount={stats.discardedCount}
|
||||||
|
onOpenTrash={() => setShowTriageTrash(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1886,6 +1898,7 @@ export const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||||
|
{showTriageTrash && <TriageTrash onClose={() => setShowTriageTrash(false)} onChanged={refreshAll} />}
|
||||||
{availableUpdate && <UpdateAvailableModal update={availableUpdate} onLater={dismissUpdate} />}
|
{availableUpdate && <UpdateAvailableModal update={availableUpdate} onLater={dismissUpdate} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
<strong className="text-main">Rückverweise:</strong> Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">[[Titel]]</code> verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.
|
<strong className="text-main">Rückverweise:</strong> Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">[[Titel]]</code> verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war.
|
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war. Nach der Frist aus Kontoeinstellungen → Aufbewahrung (Standard 30 Tage) wird sie endgültig gelöscht. Gelöschte Aufgaben liegen im Reiter „Papierkorb“ der Aufgaben, verworfene Gedanken im Papierkorb der Triage.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
backupEnabled: Boolean(sys.backupEnabled),
|
backupEnabled: Boolean(sys.backupEnabled),
|
||||||
backupTime: sys.backupTime || '03:00',
|
backupTime: sys.backupTime || '03:00',
|
||||||
backupRetentionDays: sys.backupRetentionDays || 3,
|
backupRetentionDays: sys.backupRetentionDays || 3,
|
||||||
|
retentionAllowNever: sys.retentionAllowNever ?? true,
|
||||||
|
retentionNeverViaEnv: Boolean(sys.retentionNeverViaEnv),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -1315,6 +1317,23 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Retention: may people keep finished and deleted things for good? */}
|
||||||
|
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||||
|
<div className="pr-4">
|
||||||
|
<p className="font-bold text-main">Dauerhaftes Aufbewahren erlauben</p>
|
||||||
|
<p className="text-[11px] text-muted mt-0.5">
|
||||||
|
Eingeschaltet kann jede Person unter Kontoeinstellungen → Aufbewahrung „nie“ wählen, also Erledigtes und
|
||||||
|
Gelöschtes behalten. Ausgeschaltet gilt dort für „nie“ die Voreinstellung des Bereichs (z. B. 30 Tage).
|
||||||
|
{settings.retentionNeverViaEnv && ' Festgelegt per RETENTION_ALLOW_NEVER.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={settings.retentionAllowNever !== false}
|
||||||
|
disabled={Boolean(settings.retentionNeverViaEnv)}
|
||||||
|
onChange={(retentionAllowNever) => handleSaveSystemSettings({ retentionAllowNever })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Who may register without an invite */}
|
{/* Who may register without an invite */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const mode = settings.registrationMode || 'closed';
|
const mode = settings.registrationMode || 'closed';
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Archive,
|
Archive,
|
||||||
Mic,
|
Mic,
|
||||||
|
Clock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
|
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, Retention, User, UserTheme } from '../../lib/types';
|
||||||
import { ApiKeysPanel } from './ApiKeysPanel';
|
import { ApiKeysPanel } from './ApiKeysPanel';
|
||||||
import { ServerBackupsCard } from './ServerBackupsCard';
|
import { ServerBackupsCard } from './ServerBackupsCard';
|
||||||
import { saveBlob } from '../../lib/download';
|
import { saveBlob } from '../../lib/download';
|
||||||
@@ -68,7 +69,25 @@ interface UserSettingsViewProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'voice' | 'webhooks' | 'data';
|
// Kontoeinstellungen → Aufbewahrung: the areas and the delays offered (storage.RetentionChoices)
|
||||||
|
const RETENTION_AREAS: { key: keyof Retention; title: string; text: string; verb: string }[] = [
|
||||||
|
{ key: 'doneTasks', title: 'Erledigte Aufgaben', text: 'Wandern danach in den Papierkorb der Aufgaben.', verb: 'in den Papierkorb nach' },
|
||||||
|
{ key: 'taskTrash', title: 'Papierkorb der Aufgaben', text: 'Gelöschte und geleerte Aufgaben.', verb: 'endgültig löschen nach' },
|
||||||
|
{ key: 'triageTrash', title: 'Papierkorb der Triage', text: 'Beim Sortieren verworfene Gedanken.', verb: 'endgültig löschen nach' },
|
||||||
|
{ key: 'noteTrash', title: 'Papierkorb der Notizen', text: 'Gelöschte Notizen im Workspace.', verb: 'endgültig löschen nach' },
|
||||||
|
{ key: 'listDone', title: 'Abgehakte Listeneinträge', text: 'In Einkaufs- und Checklisten.', verb: 'löschen nach' },
|
||||||
|
];
|
||||||
|
const RETENTION_OPTIONS = [
|
||||||
|
{ days: 7, label: '7 Tagen' },
|
||||||
|
{ days: 14, label: '14 Tagen' },
|
||||||
|
{ days: 30, label: '30 Tagen' },
|
||||||
|
{ days: 90, label: '90 Tagen' },
|
||||||
|
{ days: 365, label: 'einem Jahr' },
|
||||||
|
{ days: 730, label: 'zwei Jahren' },
|
||||||
|
{ days: 0, label: 'nie' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'voice' | 'retention' | 'webhooks' | 'data';
|
||||||
|
|
||||||
export function buildNotificationSettings(user: User): NotificationSettings {
|
export function buildNotificationSettings(user: User): NotificationSettings {
|
||||||
const hasLegacy = Array.isArray(user.alarmChannels);
|
const hasLegacy = Array.isArray(user.alarmChannels);
|
||||||
@@ -140,6 +159,22 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadVoiceConfig().then(() => setVoiceChoice(voiceChoiceAvailable()));
|
void loadVoiceConfig().then(() => setVoiceChoice(voiceChoiceAvailable()));
|
||||||
}, []);
|
}, []);
|
||||||
|
// Retention: one delay per area (storage/retention.go); "nie" only when the server allows it
|
||||||
|
const [retentionSaving, setRetentionSaving] = useState(false);
|
||||||
|
const [retentionError, setRetentionError] = useState<string | null>(null);
|
||||||
|
const handleChangeRetention = async (key: keyof Retention, days: number) => {
|
||||||
|
if (!currentUser.retention) return;
|
||||||
|
setRetentionSaving(true);
|
||||||
|
setRetentionError(null);
|
||||||
|
try {
|
||||||
|
onUserUpdated(await api.updateSettings({ retention: { ...currentUser.retention, [key]: days } }));
|
||||||
|
} catch (err: any) {
|
||||||
|
setRetentionError(err?.message || 'Speichern fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setRetentionSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleChangeVoiceInput = async (next: '' | 'browser') => {
|
const handleChangeVoiceInput = async (next: '' | 'browser') => {
|
||||||
setVoiceSaving(true);
|
setVoiceSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -675,6 +710,21 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSection('retention')}
|
||||||
|
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||||
|
section === 'retention'
|
||||||
|
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
|
||||||
|
: 'text-muted hover:text-main hover:bg-panel-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Aufbewahrung</p>
|
||||||
|
<p className="text-[10px] text-faint">Erledigtes & Papierkörbe aufräumen</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSection('webhooks')}
|
onClick={() => setSection('webhooks')}
|
||||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||||
@@ -1964,6 +2014,55 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ================================================================= */}
|
||||||
|
{/* SECTION: AUFBEWAHRUNG */}
|
||||||
|
{/* ================================================================= */}
|
||||||
|
{section === 'retention' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="border-b border-border pb-3">
|
||||||
|
<h2 className="text-sm font-bold text-main">Aufbewahrung</h2>
|
||||||
|
<p className="text-muted mt-0.5">
|
||||||
|
Wie lange Erledigtes und Gelöschtes bleibt, gezählt ab dem Abhaken bzw. Löschen. In geteilten Aufgabenlisten,
|
||||||
|
Listen und Notizen gilt die Einstellung der Person, der sie gehören.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{retentionError && (
|
||||||
|
<div className="alert-err flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||||
|
<span>{retentionError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentUser.retention && (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{RETENTION_AREAS.map((area) => (
|
||||||
|
<div key={area.key} className="py-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-main">{area.title}</p>
|
||||||
|
<p className="text-muted">{area.text}</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span className="text-muted">{area.verb}</span>
|
||||||
|
<select
|
||||||
|
value={currentUser.retention![area.key]}
|
||||||
|
disabled={retentionSaving}
|
||||||
|
onChange={(e) => handleChangeRetention(area.key, Number(e.target.value))}
|
||||||
|
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||||
|
aria-label={`${area.title}: ${area.verb}`}
|
||||||
|
>
|
||||||
|
{RETENTION_OPTIONS.filter((o) => o.days > 0 || currentUser.retentionNeverAllowed !== false).map((o) => (
|
||||||
|
<option key={o.days} value={o.days}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
{/* SECTION 5: EXPORT & IMPORT */}
|
{/* SECTION 5: EXPORT & IMPORT */}
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Repeat,
|
Repeat,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS, TrashedTask } from '../../lib/types';
|
||||||
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
import { formatDeletedAt, formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
||||||
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||||
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
|
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
@@ -129,7 +129,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
}, [selectedListId, viewMode]);
|
}, [selectedListId, viewMode]);
|
||||||
const [teams, setTeams] = useState<Team[]>([]);
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||||||
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
// Task trash of the selected list (or all): deleted tasks and cleared or cleaned-up completed ones
|
||||||
|
const [taskTrash, setTaskTrash] = useState<TrashedTask[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Drag and Drop state
|
// Drag and Drop state
|
||||||
@@ -215,12 +216,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const listId = selectedListId;
|
const listId = selectedListId;
|
||||||
if (loadedListRef.current !== listId) setLoading(true);
|
if (loadedListRef.current !== listId) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
|
const [allTasks, allLists, allTeams, allCollabs, trash] = await Promise.all([
|
||||||
api.getTasks(listId === 'all' ? undefined : listId).catch(() => []),
|
api.getTasks(listId === 'all' ? undefined : listId).catch(() => []),
|
||||||
api.getTaskLists().catch(() => []),
|
api.getTaskLists().catch(() => []),
|
||||||
api.getUserTeams().catch(() => []),
|
api.getUserTeams().catch(() => []),
|
||||||
api.getCollaborators().catch(() => []),
|
api.getCollaborators().catch(() => []),
|
||||||
api.getThoughts('discarded').catch(() => []),
|
api.getTaskTrash(listId === 'all' ? '' : listId).catch(() => []),
|
||||||
]);
|
]);
|
||||||
if (seq !== loadSeqRef.current) return; // a newer reload is under way
|
if (seq !== loadSeqRef.current) return; // a newer reload is under way
|
||||||
loadedListRef.current = listId;
|
loadedListRef.current = listId;
|
||||||
@@ -232,7 +233,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
}
|
}
|
||||||
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
||||||
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
|
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
|
||||||
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
|
setTaskTrash(Array.isArray(trash) ? trash : []);
|
||||||
} catch {
|
} catch {
|
||||||
// Prevent unhandled promise rejection from crashing view
|
// Prevent unhandled promise rejection from crashing view
|
||||||
} finally {
|
} finally {
|
||||||
@@ -447,7 +448,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
const safeTeams = Array.isArray(teams) ? teams : [];
|
const safeTeams = Array.isArray(teams) ? teams : [];
|
||||||
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
|
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
|
||||||
const safeDiscarded = Array.isArray(discarded) ? discarded : [];
|
|
||||||
|
|
||||||
// Available unique hashtags from current tasks
|
// Available unique hashtags from current tasks
|
||||||
const allAvailableTags = useMemo(() => {
|
const allAvailableTags = useMemo(() => {
|
||||||
@@ -751,13 +751,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestore = async (item: Thought) => {
|
// Deleting a task puts it into the task trash (task_trash.go)
|
||||||
triggerHaptic(15);
|
|
||||||
await api.triageAction(item.id, 'restore');
|
|
||||||
await loadData();
|
|
||||||
onRefreshStats();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeletePermanent = async (id: string) => {
|
const handleDeletePermanent = async (id: string) => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
await api.deleteThought(id);
|
await api.deleteThought(id);
|
||||||
@@ -765,37 +759,32 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
onRefreshStats();
|
onRefreshStats();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Completed tasks: cleared by hand or deleted after the owner's delay (done_cleanup.go)
|
const handleRestoreTrashed = async (item: TrashedTask) => {
|
||||||
const [doneRetentionDays, setDoneRetentionDays] = useState<number>(currentUser?.doneRetentionDays ?? 30);
|
triggerHaptic(15);
|
||||||
useEffect(() => {
|
await api.restoreTrashedTask(item.id);
|
||||||
if (currentUser?.doneRetentionDays !== undefined) setDoneRetentionDays(currentUser.doneRetentionDays);
|
await loadData();
|
||||||
}, [currentUser?.doneRetentionDays]);
|
onRefreshStats();
|
||||||
|
};
|
||||||
|
|
||||||
const handleChangeDoneRetention = async (days: number) => {
|
const handlePurgeTrashed = async (item: TrashedTask) => {
|
||||||
const previous = doneRetentionDays;
|
triggerHaptic(20);
|
||||||
setDoneRetentionDays(days);
|
await api.purgeTrashedTask(item.id);
|
||||||
try {
|
await loadData();
|
||||||
await api.updateSettings({ doneRetentionDays: days });
|
|
||||||
} catch {
|
|
||||||
setDoneRetentionDays(previous);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearDone = async () => {
|
const handleClearDone = async () => {
|
||||||
const scope = selectedListId === 'all' ? 'aller Listen, die du bearbeiten darfst,' : `aus „${currentSelectedList?.title || 'dieser Liste'}“`;
|
|
||||||
if (!window.confirm(`Alle erledigten Aufgaben ${scope} endgültig löschen?`)) return;
|
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
await api.clearDoneTasks(selectedListId === 'all' ? '' : selectedListId);
|
await api.clearDoneTasks(selectedListId === 'all' ? '' : selectedListId);
|
||||||
await loadData();
|
await loadData();
|
||||||
onRefreshStats();
|
onRefreshStats();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePurgeAllDiscarded = async () => {
|
const handleEmptyTaskTrash = async () => {
|
||||||
if (!window.confirm('Alle verworfenen Gedanken endgültig löschen?')) return;
|
const scope = selectedListId === 'all' ? 'aller Listen, die du bearbeiten darfst,' : `von „${currentSelectedList?.title || 'dieser Liste'}“`;
|
||||||
|
if (!window.confirm(`Den Papierkorb ${scope} endgültig leeren?`)) return;
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
await api.purgeDiscarded();
|
await api.emptyTaskTrash(selectedListId === 'all' ? '' : selectedListId);
|
||||||
await loadData();
|
await loadData();
|
||||||
onRefreshStats();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickAddTask = async (e: React.FormEvent) => {
|
const handleQuickAddTask = async (e: React.FormEvent) => {
|
||||||
@@ -1278,7 +1267,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
id: 'filter-discarded',
|
id: 'filter-discarded',
|
||||||
label: 'Papierkorb',
|
label: 'Papierkorb',
|
||||||
icon: <Trash2 className="w-4 h-4" />,
|
icon: <Trash2 className="w-4 h-4" />,
|
||||||
badge: safeDiscarded.length,
|
badge: taskTrash.length,
|
||||||
active: viewMode === 'list' && filter === 'discarded',
|
active: viewMode === 'list' && filter === 'discarded',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setViewMode('list');
|
setViewMode('list');
|
||||||
@@ -1357,7 +1346,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
sortBy,
|
sortBy,
|
||||||
openTasks.length,
|
openTasks.length,
|
||||||
doneTasks.length,
|
doneTasks.length,
|
||||||
safeDiscarded.length,
|
taskTrash.length,
|
||||||
kanbanTasks.length,
|
kanbanTasks.length,
|
||||||
safeTaskLists,
|
safeTaskLists,
|
||||||
openTasksCount,
|
openTasksCount,
|
||||||
@@ -1837,7 +1826,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
? `Offen (${openTasks.length})`
|
? `Offen (${openTasks.length})`
|
||||||
: filter === 'done'
|
: filter === 'done'
|
||||||
? `Erledigt (${doneTasks.length})`
|
? `Erledigt (${doneTasks.length})`
|
||||||
: `Papierkorb (${safeDiscarded.length})`}
|
: `Papierkorb (${taskTrash.length})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{currentSelectedList && (
|
{currentSelectedList && (
|
||||||
@@ -2755,12 +2744,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
filter === 'discarded' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
|
filter === 'discarded' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Papierkorb ({safeDiscarded.length})
|
Papierkorb ({taskTrash.length})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filter === 'discarded' && safeDiscarded.length > 0 && (
|
{filter === 'discarded' && taskTrash.some((t) => t.canEdit) && (
|
||||||
<button onClick={handlePurgeAllDiscarded} className="btn-action min-h-[38px] sm:min-h-0">
|
<button onClick={handleEmptyTaskTrash} className="btn-action min-h-[38px] sm:min-h-0 ml-auto">
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
Papierkorb leeren
|
Papierkorb leeren
|
||||||
</button>
|
</button>
|
||||||
@@ -2773,68 +2762,51 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* What happens to completed tasks; the delay of the list owner counts */}
|
|
||||||
{filter === 'done' && (
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted px-1">
|
|
||||||
{currentSelectedList && currentSelectedList.isOwner === false ? (
|
|
||||||
<span>
|
|
||||||
Erledigte Aufgaben dieser Liste räumt die Einstellung von {currentSelectedList.ownerName || 'der Eigentümerin bzw. dem Eigentümer'} auf.
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span>Erledigte Aufgaben löschen</span>
|
|
||||||
<select
|
|
||||||
value={doneRetentionDays}
|
|
||||||
onChange={(e) => handleChangeDoneRetention(Number(e.target.value))}
|
|
||||||
className="h-9 sm:h-6 px-1.5 input-field text-xs bg-panel"
|
|
||||||
aria-label="Erledigte Aufgaben löschen nach"
|
|
||||||
>
|
|
||||||
<option value={7}>nach 7 Tagen</option>
|
|
||||||
<option value={30}>nach 30 Tagen</option>
|
|
||||||
<option value={90}>nach 90 Tagen</option>
|
|
||||||
<option value={365}>nach einem Jahr</option>
|
|
||||||
<option value={0}>nie</option>
|
|
||||||
</select>
|
|
||||||
<span className="text-faint">
|
|
||||||
{doneRetentionDays > 0 ? '(gezählt ab dem Abhaken; gilt für deine eigenen Listen)' : '(bleiben, bis du sie leerst)'}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-6 text-center text-faint text-sm">Lade Aufgaben…</div>
|
<div className="p-6 text-center text-faint text-sm">Lade Aufgaben…</div>
|
||||||
) : filter === 'discarded' ? (
|
) : filter === 'discarded' ? (
|
||||||
<div className="panel-surface divide-y divide-border">
|
<div className="panel-surface divide-y divide-border">
|
||||||
{safeDiscarded.length === 0 ? (
|
{taskTrash.length === 0 ? (
|
||||||
<div className="px-4 py-6 text-center text-muted text-sm">
|
<div className="px-4 py-6 text-center text-muted text-sm">Der Papierkorb ist leer.</div>
|
||||||
Keine verworfenen Gedanken im Papierkorb.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
safeDiscarded.map((item) => (
|
taskTrash.map((item) => {
|
||||||
<div
|
const listOfItem = item.taskListId ? safeTaskLists.find((l) => l.id === item.taskListId) : undefined;
|
||||||
key={item.id}
|
return (
|
||||||
className="px-3 py-2 flex items-center justify-between gap-3 hover:bg-panel-hover"
|
<div key={item.id} className="px-3 py-2 flex items-center justify-between gap-3 hover:bg-panel-hover">
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
<Trash2 className="w-4 h-4 text-faint flex-shrink-0" />
|
<Trash2 className="w-4 h-4 text-faint flex-shrink-0" />
|
||||||
<p className="text-sm text-muted line-through truncate">{item.content}</p>
|
<div className="min-w-0">
|
||||||
|
<p className={`text-sm truncate ${item.isCompleted ? 'text-muted line-through' : 'text-main'}`}>{item.content}</p>
|
||||||
|
<p className="text-[11px] text-faint truncate">
|
||||||
|
{selectedListId === 'all' && (
|
||||||
|
<>
|
||||||
|
{listOfItem ? `${listOfItem.icon || '📋'} ${listOfItem.title}` : 'Persönlich'}
|
||||||
|
{' · '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
gelöscht {formatDeletedAt(item.trashedAt)}
|
||||||
|
{item.trashedBy ? ` von ${item.trashedBy}` : ' (automatisch)'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{item.canEdit && (
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<button onClick={() => handleRestore(item)} className="btn-action">
|
<button onClick={() => handleRestoreTrashed(item)} className="btn-action min-h-[38px] sm:min-h-0">
|
||||||
<RotateCcw className="w-3 h-3 text-accent" /> Zurück
|
<RotateCcw className="w-3 h-3 text-accent" /> Zurück
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeletePermanent(item.id)}
|
onClick={() => handlePurgeTrashed(item)}
|
||||||
className="btn-icon btn-icon-danger"
|
className="btn-icon btn-icon-danger min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0"
|
||||||
title="Endgültig löschen"
|
title="Endgültig löschen"
|
||||||
|
aria-label="Endgültig löschen"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SharedList, Thought } from '../../lib/types';
|
import { SharedList, Thought } from '../../lib/types';
|
||||||
import { addDays, formatDue } from '../../lib/due';
|
import { addDays, formatDue } from '../../lib/due';
|
||||||
@@ -32,6 +33,9 @@ interface TriageDeckProps {
|
|||||||
onDeckChange: () => void;
|
onDeckChange: () => void;
|
||||||
onOpenDocument: (docId: string) => void;
|
onOpenDocument: (docId: string) => void;
|
||||||
onFocusCapture: () => void;
|
onFocusCapture: () => void;
|
||||||
|
// The triage's own trash (TriageTrash): thoughts thrown away while sorting
|
||||||
|
discardedCount: number;
|
||||||
|
onOpenTrash: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SwipeableQueueItemProps {
|
interface SwipeableQueueItemProps {
|
||||||
@@ -219,6 +223,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
lists,
|
lists,
|
||||||
onDeckChange,
|
onDeckChange,
|
||||||
onFocusCapture,
|
onFocusCapture,
|
||||||
|
discardedCount,
|
||||||
|
onOpenTrash,
|
||||||
}) => {
|
}) => {
|
||||||
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
||||||
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
|
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
|
||||||
@@ -469,6 +475,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
Letzte Aktion rückgängig
|
Letzte Aktion rückgängig
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{discardedCount > 0 && (
|
||||||
|
<button onClick={onOpenTrash} className="btn-action h-10 px-4 text-sm">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Papierkorb ({discardedCount})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -488,6 +501,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{lastTriaged && (
|
{lastTriaged && (
|
||||||
<button
|
<button
|
||||||
onClick={handleUndo}
|
onClick={handleUndo}
|
||||||
@@ -497,6 +511,14 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
Rückgängig
|
Rückgängig
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Phones reach the trash through the menu */}
|
||||||
|
{discardedCount > 0 && (
|
||||||
|
<button onClick={onOpenTrash} className="hidden sm:inline-flex btn-action h-8 px-3 text-xs" title="Papierkorb der Triage">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
{discardedCount}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card Stack Container */}
|
{/* Card Stack Container */}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { formatDeletedAt } from '../../lib/due';
|
||||||
|
import type { Thought } from '../../lib/types';
|
||||||
|
import { ReadingModal } from '../../components/ReadingModal';
|
||||||
|
|
||||||
|
interface TriageTrashProps {
|
||||||
|
onClose: () => void;
|
||||||
|
// Something was restored or deleted: counters and the deck change
|
||||||
|
onChanged: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The triage's own trash: thoughts thrown away while sorting. Back into the Quick Dump, or deleted for
|
||||||
|
// good (one by one, all at once, or after the delay from Kontoeinstellungen → Aufbewahrung). Deleting
|
||||||
|
// for good takes a second tap on the same button, like the notes trash.
|
||||||
|
export const TriageTrash: React.FC<TriageTrashProps> = ({ onClose, onChanged }) => {
|
||||||
|
const [items, setItems] = useState<Thought[] | null>(null);
|
||||||
|
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const list = await api.getThoughts('discarded').catch(() => []);
|
||||||
|
setItems(Array.isArray(list) ? list : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restore = async (item: Thought) => {
|
||||||
|
triggerHaptic(15);
|
||||||
|
await api.triageAction(item.id, 'restore');
|
||||||
|
await load();
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
const purge = async (id: string) => {
|
||||||
|
if (confirmId !== id) {
|
||||||
|
setConfirmId(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerHaptic(20);
|
||||||
|
setConfirmId(null);
|
||||||
|
if (id === '__all__') await api.purgeDiscarded();
|
||||||
|
else await api.deleteThought(id);
|
||||||
|
await load();
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReadingModal title="Papierkorb der Triage" onClose={onClose}>
|
||||||
|
{items === null ? (
|
||||||
|
<div className="py-6 flex justify-center text-muted">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-muted">Keine verworfenen Gedanken.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="divide-y divide-border border border-border rounded-card overflow-hidden">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="px-3 py-2 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm text-main truncate">{item.content}</p>
|
||||||
|
{item.triagedAt && <p className="text-[11px] text-faint">verworfen {formatDeletedAt(item.triagedAt)}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button type="button" onClick={() => restore(item)} className="btn-action min-h-[38px] sm:min-h-0">
|
||||||
|
<RotateCcw className="w-3 h-3 text-accent" /> Zurück
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => purge(item.id)}
|
||||||
|
className={`btn-icon btn-icon-danger min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ${confirmId === item.id ? 'text-status-err' : ''}`}
|
||||||
|
title={confirmId === item.id ? 'Wirklich endgültig löschen?' : 'Endgültig löschen'}
|
||||||
|
aria-label="Endgültig löschen"
|
||||||
|
>
|
||||||
|
{confirmId === item.id ? <span className="text-[11px] font-semibold px-1">Wirklich?</span> : <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button type="button" onClick={() => purge('__all__')} className="btn-action min-h-[38px] sm:min-h-0 text-status-err">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
{confirmId === '__all__' ? `Wirklich ${items.length} endgültig löschen?` : 'Papierkorb leeren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ReadingModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -71,6 +71,8 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
onDeckChange={onDeckChange}
|
onDeckChange={onDeckChange}
|
||||||
onOpenDocument={vi.fn()}
|
onOpenDocument={vi.fn()}
|
||||||
onFocusCapture={vi.fn()}
|
onFocusCapture={vi.fn()}
|
||||||
|
discardedCount={0}
|
||||||
|
onOpenTrash={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,6 +102,8 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
onDeckChange={onDeckChange}
|
onDeckChange={onDeckChange}
|
||||||
onOpenDocument={vi.fn()}
|
onOpenDocument={vi.fn()}
|
||||||
onFocusCapture={vi.fn()}
|
onFocusCapture={vi.fn()}
|
||||||
|
discardedCount={0}
|
||||||
|
onOpenTrash={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -123,6 +127,8 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
onDeckChange={onDeckChange}
|
onDeckChange={onDeckChange}
|
||||||
onOpenDocument={vi.fn()}
|
onOpenDocument={vi.fn()}
|
||||||
onFocusCapture={vi.fn()}
|
onFocusCapture={vi.fn()}
|
||||||
|
discardedCount={0}
|
||||||
|
onOpenTrash={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -149,6 +155,8 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
onDeckChange={onDeckChange}
|
onDeckChange={onDeckChange}
|
||||||
onOpenDocument={vi.fn()}
|
onOpenDocument={vi.fn()}
|
||||||
onFocusCapture={vi.fn()}
|
onFocusCapture={vi.fn()}
|
||||||
|
discardedCount={0}
|
||||||
|
onOpenTrash={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -171,6 +179,8 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
onDeckChange={onDeckChange}
|
onDeckChange={onDeckChange}
|
||||||
onOpenDocument={vi.fn()}
|
onOpenDocument={vi.fn()}
|
||||||
onFocusCapture={vi.fn()}
|
onFocusCapture={vi.fn()}
|
||||||
|
discardedCount={0}
|
||||||
|
onOpenTrash={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { ArrowLeft, Folder, RotateCcw, Trash2 } from 'lucide-react';
|
import { ArrowLeft, Folder, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
import { TrashedDocument } from '../../lib/types';
|
import { TrashedDocument } from '../../lib/types';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { formatDeletedAt } from '../../lib/due';
|
||||||
|
|
||||||
interface TrashViewProps {
|
interface TrashViewProps {
|
||||||
items: TrashedDocument[];
|
items: TrashedDocument[];
|
||||||
@@ -12,12 +13,6 @@ interface TrashViewProps {
|
|||||||
onBackMobile: () => void;
|
onBackMobile: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDeletedAt = (iso: string) => {
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (Number.isNaN(d.getTime())) return '';
|
|
||||||
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second
|
// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second
|
||||||
// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch.
|
// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch.
|
||||||
export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => {
|
export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
DashboardStats,
|
DashboardStats,
|
||||||
Document,
|
Document,
|
||||||
TrashedDocument,
|
TrashedDocument,
|
||||||
|
TrashedTask,
|
||||||
|
Retention,
|
||||||
JournalDay,
|
JournalDay,
|
||||||
PushDevice,
|
PushDevice,
|
||||||
BackupFile,
|
BackupFile,
|
||||||
@@ -302,6 +304,7 @@ export const api = {
|
|||||||
notificationSettings?: NotificationSettings;
|
notificationSettings?: NotificationSettings;
|
||||||
doneRetentionDays?: number;
|
doneRetentionDays?: number;
|
||||||
voiceInput?: '' | 'browser';
|
voiceInput?: '' | 'browser';
|
||||||
|
retention?: Retention;
|
||||||
}): Promise<User> {
|
}): Promise<User> {
|
||||||
return request<User>('/api/v1/auth/settings', {
|
return request<User>('/api/v1/auth/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -479,6 +482,24 @@ export const api = {
|
|||||||
return request<{ deleted: number }>('/api/v1/tasks/clear-done', { method: 'POST', body: JSON.stringify({ taskListId }) });
|
return request<{ deleted: number }>('/api/v1/tasks/clear-done', { method: 'POST', body: JSON.stringify({ taskListId }) });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Task trash: deleted tasks, cleared and automatically cleaned-up completed ones
|
||||||
|
async getTaskTrash(taskListId = ''): Promise<TrashedTask[]> {
|
||||||
|
const q = taskListId ? `?taskListId=${encodeURIComponent(taskListId)}` : '';
|
||||||
|
return request<TrashedTask[]>(`/api/v1/tasks/trash${q}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async restoreTrashedTask(id: string): Promise<Thought> {
|
||||||
|
return request<Thought>(`/api/v1/tasks/trash/${id}/restore`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async purgeTrashedTask(id: string): Promise<{ deleted: boolean }> {
|
||||||
|
return request<{ deleted: boolean }>(`/api/v1/tasks/trash/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async emptyTaskTrash(taskListId: string): Promise<{ deleted: number }> {
|
||||||
|
return request<{ deleted: number }>('/api/v1/tasks/trash/empty', { method: 'POST', body: JSON.stringify({ taskListId }) });
|
||||||
|
},
|
||||||
|
|
||||||
async purgeDiscarded(): Promise<{ purged: number }> {
|
async purgeDiscarded(): Promise<{ purged: number }> {
|
||||||
return request<{ purged: number }>('/api/v1/thoughts/discarded/purge', { method: 'DELETE' });
|
return request<{ purged: number }>('/api/v1/thoughts/discarded/purge', { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,3 +48,10 @@ export const formatDue = (due: string | undefined, now: Date = new Date()): stri
|
|||||||
else label = `${pad(d)}.${pad(m)}.${y}`;
|
else label = `${pad(d)}.${pad(m)}.${y}`;
|
||||||
return time ? `${label}, ${time}` : label;
|
return time ? `${label}, ${time}` : label;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** When something went to a trash, e.g. "01.11.2026, 14:05". */
|
||||||
|
export const formatDeletedAt = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
@@ -115,6 +115,9 @@ export interface User {
|
|||||||
doneRetentionDays?: number;
|
doneRetentionDays?: number;
|
||||||
// Voice input: '' = the server's Whisper (default), 'browser' = the browser's recognition
|
// Voice input: '' = the server's Whisper (default), 'browser' = the browser's recognition
|
||||||
voiceInput?: '' | 'browser';
|
voiceInput?: '' | 'browser';
|
||||||
|
// How long finished and deleted things are kept, in days (0 = for good; only when allowed)
|
||||||
|
retention?: Retention;
|
||||||
|
retentionNeverAllowed?: boolean;
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -293,6 +296,9 @@ export interface SystemSettings {
|
|||||||
backupEnabled?: boolean;
|
backupEnabled?: boolean;
|
||||||
backupTime?: string;
|
backupTime?: string;
|
||||||
backupRetentionDays?: number;
|
backupRetentionDays?: number;
|
||||||
|
// People may keep finished and deleted things for good ("nie"); fixed when set via RETENTION_ALLOW_NEVER
|
||||||
|
retentionAllowNever?: boolean;
|
||||||
|
retentionNeverViaEnv?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One file of a server backup listing. */
|
/** One file of a server backup listing. */
|
||||||
@@ -382,6 +388,26 @@ export interface JournalDay {
|
|||||||
empty: boolean;
|
empty: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delays in days per area (Kontoeinstellungen → Aufbewahrung); 0 = keep for good. */
|
||||||
|
export interface Retention {
|
||||||
|
doneTasks: number;
|
||||||
|
taskTrash: number;
|
||||||
|
triageTrash: number;
|
||||||
|
noteTrash: number;
|
||||||
|
listDone: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deleted task waiting in the task trash. */
|
||||||
|
export interface TrashedTask {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
taskListId: string;
|
||||||
|
isCompleted: boolean;
|
||||||
|
trashedAt: string;
|
||||||
|
trashedBy: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrashedDocument {
|
export interface TrashedDocument {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -2,16 +2,19 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/sandrodach/mindsift/internal/auth"
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
"github.com/sandrodach/mindsift/internal/storage"
|
"github.com/sandrodach/mindsift/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// startDoneCleanupTicker deletes completed tasks whose delay ran out (storage/done_cleanup.go), once
|
// startDoneCleanupTicker runs the retention delays (storage/retention.go): completed tasks into the
|
||||||
// shortly after the start and then every hour.
|
// task trash, the trashes and ticked-off list items emptied; once shortly after the start and then
|
||||||
|
// every hour.
|
||||||
func (s *Server) startDoneCleanupTicker() {
|
func (s *Server) startDoneCleanupTicker() {
|
||||||
time.Sleep(2 * time.Minute)
|
time.Sleep(2 * time.Minute)
|
||||||
s.runDoneCleanup(time.Now())
|
s.runDoneCleanup(time.Now())
|
||||||
@@ -22,16 +25,27 @@ func (s *Server) startDoneCleanupTicker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allowNeverRetention: RETENTION_ALLOW_NEVER if set, else the admin's switch in the system settings.
|
||||||
|
func (s *Server) allowNeverRetention() bool {
|
||||||
|
if s.cfg.RetentionAllowNever != nil {
|
||||||
|
return *s.cfg.RetentionAllowNever
|
||||||
|
}
|
||||||
|
return s.store.RetentionAllowNeverSetting()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) runDoneCleanup(now time.Time) {
|
func (s *Server) runDoneCleanup(now time.Time) {
|
||||||
deleted, err := s.store.PurgeExpiredDoneTasks(now)
|
res, err := s.store.PurgeExpired(now, s.allowNeverRetention())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Aufräumen erledigter Aufgaben fehlgeschlagen", "error", err)
|
slog.Error("Aufräumen nach Aufbewahrungsfristen fehlgeschlagen", "error", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if len(deleted) > 0 {
|
if n := len(res.TasksToTrash) + len(res.TrashedTasks) + res.Discarded + len(res.Notes) + len(res.ListItems); n > 0 {
|
||||||
slog.Info("Erledigte Aufgaben aufgeräumt", "count", len(deleted))
|
slog.Info("Aufbewahrungsfristen angewendet", "erledigte_in_papierkorb", len(res.TasksToTrash), "papierkorb_aufgaben", len(res.TrashedTasks),
|
||||||
|
"triage", res.Discarded, "notizen", len(res.Notes), "listeneintraege", len(res.ListItems))
|
||||||
|
}
|
||||||
|
s.broadcastDeletedTasks(res.TasksToTrash)
|
||||||
|
for _, it := range res.ListItems {
|
||||||
|
s.wsHub.BroadcastListEvent(it.ListID, "ITEM_DELETED", map[string]string{"itemId": it.ID, "listId": it.ListID})
|
||||||
}
|
}
|
||||||
s.broadcastDeletedTasks(deleted)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcastDeletedTasks tells everyone who saw the tasks; there are no watcher notifications for this.
|
// broadcastDeletedTasks tells everyone who saw the tasks; there are no watcher notifications for this.
|
||||||
@@ -42,7 +56,8 @@ func (s *Server) broadcastDeletedTasks(deleted []storage.DeletedTask) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleClearDoneTasks: "Erledigte leeren" for one task list (taskListId) or all the user can edit.
|
// handleClearDoneTasks: "Erledigte leeren" (into the task trash) for one task list (taskListId) or all
|
||||||
|
// the user can edit.
|
||||||
func (s *Server) handleClearDoneTasks(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleClearDoneTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -57,3 +72,59 @@ func (s *Server) handleClearDoneTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.broadcastDeletedTasks(deleted)
|
s.broadcastDeletedTasks(deleted)
|
||||||
writeJSON(w, http.StatusOK, map[string]int{"deleted": len(deleted)})
|
writeJSON(w, http.StatusOK, map[string]int{"deleted": len(deleted)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Task trash (storage/task_trash.go) ---
|
||||||
|
|
||||||
|
// handleGetTaskTrash: GET /tasks/trash?taskListId= – the trashed tasks the user can see.
|
||||||
|
func (s *Server) handleGetTaskTrash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := s.store.GetTaskTrash(auth.GetUserID(r.Context()), r.URL.Query().Get("taskListId"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRestoreTrashedTask puts a task back and tells everyone who sees it.
|
||||||
|
func (s *Server) handleRestoreTrashedTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
task, err := s.store.RestoreTrashedTask(auth.GetUserID(r.Context()), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeTaskTrashError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
|
||||||
|
writeJSON(w, http.StatusOK, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePurgeTrashedTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.store.PurgeTrashedTask(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil {
|
||||||
|
writeTaskTrashError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEmptyTaskTrash: POST /tasks/trash/empty {taskListId} – one list or all the user may edit.
|
||||||
|
func (s *Server) handleEmptyTaskTrash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
TaskListID string `json:"taskListId"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
ids, err := s.store.EmptyTaskTrash(auth.GetUserID(r.Context()), req.TaskListID)
|
||||||
|
if err != nil {
|
||||||
|
writeTaskTrashError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]int{"deleted": len(ids)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTaskTrashError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrNotInTaskTrash):
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
case errors.Is(err, storage.ErrTaskListReadOnly):
|
||||||
|
writeError(w, http.StatusForbidden, err.Error())
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -340,6 +340,11 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys
|
|||||||
out.RegistrationVerifyEmail = s.cfg.RegistrationVerifyEmail == "true"
|
out.RegistrationVerifyEmail = s.cfg.RegistrationVerifyEmail == "true"
|
||||||
out.RegistrationVerifyViaEnv = true
|
out.RegistrationVerifyViaEnv = true
|
||||||
}
|
}
|
||||||
|
if s.cfg.RetentionAllowNever != nil {
|
||||||
|
allow := *s.cfg.RetentionAllowNever
|
||||||
|
out.RetentionAllowNever = &allow
|
||||||
|
out.RetentionNeverViaEnv = true
|
||||||
|
}
|
||||||
out.EmailLinksAvailable = s.emailLinksAvailable()
|
out.EmailLinksAvailable = s.emailLinksAvailable()
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
@@ -372,6 +377,10 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
|||||||
settings.RegistrationVerifyEmail = cur.RegistrationVerifyEmail
|
settings.RegistrationVerifyEmail = cur.RegistrationVerifyEmail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.cfg.RetentionAllowNever != nil {
|
||||||
|
settings.RetentionAllowNever = nil // fixed by RETENTION_ALLOW_NEVER, the stored value stays
|
||||||
|
}
|
||||||
|
settings.RetentionNeverViaEnv = false
|
||||||
previousMode := s.store.SharingMode()
|
previousMode := s.store.SharingMode()
|
||||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
@@ -384,7 +393,11 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, s.settingsForBrowser(&settings))
|
saved, _ := s.store.GetSystemSettings()
|
||||||
|
if saved == nil {
|
||||||
|
saved = &settings
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.settingsForBrowser(saved))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -404,8 +404,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
stats, _ := s.store.GetDashboardStats(userID)
|
stats, _ := s.store.GetDashboardStats(userID)
|
||||||
ns := s.store.NotificationSettingsOf(userID)
|
ns := s.store.NotificationSettingsOf(userID)
|
||||||
user.NotificationSettings = &ns
|
user.NotificationSettings = &ns
|
||||||
user.DoneRetentionDays = s.store.DoneRetentionDays(userID)
|
s.fillUserPreferences(user)
|
||||||
user.VoiceInput = s.store.VoiceInput(userID)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"user": user,
|
"user": user,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
@@ -519,6 +518,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
|||||||
DoneRetentionDays *int `json:"doneRetentionDays"`
|
DoneRetentionDays *int `json:"doneRetentionDays"`
|
||||||
// Voice input: "" (Whisper server, the default) or "browser"
|
// Voice input: "" (Whisper server, the default) or "browser"
|
||||||
VoiceInput *string `json:"voiceInput"`
|
VoiceInput *string `json:"voiceInput"`
|
||||||
|
// All retention delays at once (storage/retention.go)
|
||||||
|
Retention *domain.Retention `json:"retention"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
@@ -606,6 +607,10 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.DoneRetentionDays != nil {
|
if req.DoneRetentionDays != nil {
|
||||||
|
if *req.DoneRetentionDays == 0 && !s.allowNeverRetention() {
|
||||||
|
writeError(w, http.StatusBadRequest, storage.ErrRetentionNever.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := s.store.SetDoneRetentionDays(userID, *req.DoneRetentionDays); err != nil {
|
if err := s.store.SetDoneRetentionDays(userID, *req.DoneRetentionDays); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
@@ -617,10 +622,15 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.Retention != nil {
|
||||||
|
if err := s.store.SetRetention(userID, *req.Retention, s.allowNeverRetention()); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
ns := s.store.NotificationSettingsOf(userID)
|
ns := s.store.NotificationSettingsOf(userID)
|
||||||
u.NotificationSettings = &ns
|
u.NotificationSettings = &ns
|
||||||
u.DoneRetentionDays = s.store.DoneRetentionDays(userID)
|
s.fillUserPreferences(u)
|
||||||
u.VoiceInput = s.store.VoiceInput(userID)
|
|
||||||
writeJSON(w, http.StatusOK, u)
|
writeJSON(w, http.StatusOK, u)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,3 +766,13 @@ func (s *Server) handleGetUsersDirectory(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, users)
|
writeJSON(w, http.StatusOK, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fillUserPreferences adds the settings kept outside the user row's own fields.
|
||||||
|
func (s *Server) fillUserPreferences(u *domain.User) {
|
||||||
|
allowNever := s.allowNeverRetention()
|
||||||
|
r := s.store.RetentionOf(u.ID, allowNever)
|
||||||
|
u.Retention = &r
|
||||||
|
u.RetentionNeverAllowed = allowNever
|
||||||
|
u.DoneRetentionDays = r.DoneTasks
|
||||||
|
u.VoiceInput = s.store.VoiceInput(u.ID)
|
||||||
|
}
|
||||||
+1
-1
@@ -550,7 +550,7 @@ var mcpTools = []mcpTool{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "delete_task",
|
Name: "delete_task",
|
||||||
Description: "Delete a task for good, e.g. one created by mistake. Prefer complete_task for work that is done.",
|
Description: "Delete a task, e.g. one created by mistake: it goes to the task trash, where the user can restore it. Prefer complete_task for work that is done.",
|
||||||
Scope: storage.ScopeTasksWrite,
|
Scope: storage.ScopeTasksWrite,
|
||||||
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
||||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
|
|||||||
@@ -207,6 +207,10 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask)
|
r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask)
|
||||||
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
|
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
|
||||||
r.Post("/api/v1/tasks/clear-done", s.handleClearDoneTasks)
|
r.Post("/api/v1/tasks/clear-done", s.handleClearDoneTasks)
|
||||||
|
r.Get("/api/v1/tasks/trash", s.handleGetTaskTrash)
|
||||||
|
r.Post("/api/v1/tasks/trash/empty", s.handleEmptyTaskTrash)
|
||||||
|
r.Post("/api/v1/tasks/trash/{id}/restore", s.handleRestoreTrashedTask)
|
||||||
|
r.Delete("/api/v1/tasks/trash/{id}", s.handlePurgeTrashedTask)
|
||||||
r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks)
|
r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks)
|
||||||
|
|
||||||
// Task Lists & Shares
|
// Task Lists & Shares
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ type Config struct {
|
|||||||
// Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login
|
// Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login
|
||||||
Legal LegalTexts
|
Legal LegalTexts
|
||||||
|
|
||||||
|
// RetentionAllowNever: RETENTION_ALLOW_NEVER=true/false fixes whether people may keep finished and
|
||||||
|
// deleted things for good; unset (nil) leaves it to the system settings
|
||||||
|
RetentionAllowNever *bool
|
||||||
// Speech recognition, configured only by environment (STT_*)
|
// Speech recognition, configured only by environment (STT_*)
|
||||||
Speech SpeechConfig
|
Speech SpeechConfig
|
||||||
|
|
||||||
@@ -254,6 +257,7 @@ func Load() *Config {
|
|||||||
DemoMode: demoMode,
|
DemoMode: demoMode,
|
||||||
DemoResetMinutes: demoResetMinutes(),
|
DemoResetMinutes: demoResetMinutes(),
|
||||||
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
|
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
|
||||||
|
RetentionAllowNever: envBoolPtr("RETENTION_ALLOW_NEVER"),
|
||||||
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
|
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
|
||||||
SMTPHost: smtpHost,
|
SMTPHost: smtpHost,
|
||||||
SMTPPort: smtpPort,
|
SMTPPort: smtpPort,
|
||||||
@@ -369,3 +373,16 @@ func boolEnvOrEmpty(key string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envBoolPtr reads true/false; nil when unset or anything else.
|
||||||
|
func envBoolPtr(key string) *bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||||
|
case "true", "1", "yes":
|
||||||
|
v := true
|
||||||
|
return &v
|
||||||
|
case "false", "0", "no":
|
||||||
|
v := false
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -145,6 +145,10 @@ type User struct {
|
|||||||
DoneRetentionDays int `json:"doneRetentionDays"`
|
DoneRetentionDays int `json:"doneRetentionDays"`
|
||||||
// VoiceInput: "" uses the server's Whisper (when set up), "browser" the browser's recognition
|
// VoiceInput: "" uses the server's Whisper (when set up), "browser" the browser's recognition
|
||||||
VoiceInput string `json:"voiceInput"`
|
VoiceInput string `json:"voiceInput"`
|
||||||
|
// Retention: how long finished and deleted things are kept (storage/retention.go);
|
||||||
|
// RetentionNeverAllowed: whether "nie" may be chosen on this server
|
||||||
|
Retention *Retention `json:"retention,omitempty"`
|
||||||
|
RetentionNeverAllowed bool `json:"retentionNeverAllowed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationSettings: which events reach a user over which channel, the daily summary and a pause.
|
// NotificationSettings: which events reach a user over which channel, the daily summary and a pause.
|
||||||
@@ -410,6 +414,10 @@ type SystemSettings struct {
|
|||||||
BackupEnabled bool `json:"backupEnabled"`
|
BackupEnabled bool `json:"backupEnabled"`
|
||||||
BackupTime string `json:"backupTime"`
|
BackupTime string `json:"backupTime"`
|
||||||
BackupRetentionDays int `json:"backupRetentionDays"`
|
BackupRetentionDays int `json:"backupRetentionDays"`
|
||||||
|
// RetentionAllowNever: people may keep finished and deleted things for good ("nie"); nil in an
|
||||||
|
// update leaves it unchanged. RetentionNeverViaEnv: fixed by RETENTION_ALLOW_NEVER, not editable.
|
||||||
|
RetentionAllowNever *bool `json:"retentionAllowNever,omitempty"`
|
||||||
|
RetentionNeverViaEnv bool `json:"retentionNeverViaEnv,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalJSON accepts smtpPort as text or number: older clients sent a number, which made the whole
|
// UnmarshalJSON accepts smtpPort as text or number: older clients sent a number, which made the whole
|
||||||
@@ -682,3 +690,13 @@ type FolderShare struct {
|
|||||||
CreatedBy string `json:"createdBy"`
|
CreatedBy string `json:"createdBy"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retention: delays in days for completed tasks, the task, triage and notes trash and ticked-off list
|
||||||
|
// items; 0 = keep for good.
|
||||||
|
type Retention struct {
|
||||||
|
DoneTasks int `json:"doneTasks"`
|
||||||
|
TaskTrash int `json:"taskTrash"`
|
||||||
|
TriageTrash int `json:"triageTrash"`
|
||||||
|
NoteTrash int `json:"noteTrash"`
|
||||||
|
ListDone int `json:"listDone"`
|
||||||
|
}
|
||||||
@@ -1,23 +1,18 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Completed tasks are deleted a while after they were ticked off, so the "Erledigt" tab does not fill
|
// Completed tasks move to the task trash (task_trash.go) a while after they were ticked off, so the
|
||||||
// up for good. Every user chooses the delay (done_retention_days, 0 = never); for tasks in a task list
|
// "Erledigt" tab does not fill up for good. Every user chooses the delay (done_retention_days, see
|
||||||
// the owner's choice counts, for personal tasks the user's own. thoughts.completed_at is kept by two
|
// retention.go); for tasks in a task list the owner's choice counts, for personal tasks the user's own.
|
||||||
// triggers, so every way of completing a task (app, Kanban, note checkbox, API) sets it.
|
// thoughts.completed_at is kept by two triggers, so every way of completing a task (app, Kanban, note
|
||||||
|
// checkbox, API) sets it.
|
||||||
// DoneRetentionChoices are the delays offered in the app, in days; 0 keeps completed tasks.
|
|
||||||
var DoneRetentionChoices = []int{0, 7, 30, 90, 365}
|
|
||||||
|
|
||||||
const DefaultDoneRetentionDays = 30
|
const DefaultDoneRetentionDays = 30
|
||||||
|
|
||||||
var ErrInvalidRetention = errors.New("Ungültige Frist für erledigte Aufgaben (erlaubt: 0, 7, 30, 90, 365 Tage)")
|
|
||||||
|
|
||||||
func (s *Store) migrateDoneCleanup() error {
|
func (s *Store) migrateDoneCleanup() error {
|
||||||
if err := s.addColumnIfMissing("users", "done_retention_days", "INTEGER NOT NULL DEFAULT 30"); err != nil {
|
if err := s.addColumnIfMissing("users", "done_retention_days", "INTEGER NOT NULL DEFAULT 30"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -47,20 +42,16 @@ func (s *Store) DoneRetentionDays(userID string) int {
|
|||||||
return days
|
return days
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDoneRetentionDays stores one of DoneRetentionChoices; other values are refused.
|
// SetDoneRetentionDays stores one of RetentionChoices; other values are refused.
|
||||||
func (s *Store) SetDoneRetentionDays(userID string, days int) error {
|
func (s *Store) SetDoneRetentionDays(userID string, days int) error {
|
||||||
ok := false
|
if !validRetention(days) {
|
||||||
for _, d := range DoneRetentionChoices {
|
|
||||||
ok = ok || d == days
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return ErrInvalidRetention
|
return ErrInvalidRetention
|
||||||
}
|
}
|
||||||
_, err := s.db.Exec(`UPDATE users SET done_retention_days = ? WHERE id = ?`, days, userID)
|
_, err := s.db.Exec(`UPDATE users SET done_retention_days = ? WHERE id = ?`, days, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeletedTask: what callers need to tell everyone who saw a deleted task.
|
// DeletedTask: what callers need to tell everyone who saw a task that went to the trash.
|
||||||
type DeletedTask struct {
|
type DeletedTask struct {
|
||||||
ID, UserID, TaskListID string
|
ID, UserID, TaskListID string
|
||||||
}
|
}
|
||||||
@@ -68,27 +59,29 @@ type DeletedTask struct {
|
|||||||
// Completed tasks: a kept task, a converted task or a task linked to a note checkbox
|
// Completed tasks: a kept task, a converted task or a task linked to a note checkbox
|
||||||
const completedTaskSQL = `t.is_completed = 1 AND (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))`
|
const completedTaskSQL = `t.is_completed = 1 AND (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))`
|
||||||
|
|
||||||
// PurgeExpiredDoneTasks deletes completed tasks whose delay has run out at now: the task list owner's
|
// PurgeExpiredDoneTasks moves completed tasks whose delay has run out at now into the task trash: the
|
||||||
// delay, or the user's own for personal tasks. A note checkbox linked to such a task stays as a plain
|
// task list owner's delay, or the user's own for personal tasks. A note checkbox linked to such a task
|
||||||
// checkbox (the link lives on the task).
|
// stays as a plain checkbox (the link lives on the task). Without allowNever, "never" counts as the
|
||||||
func (s *Store) PurgeExpiredDoneTasks(now time.Time) ([]DeletedTask, error) {
|
// default delay.
|
||||||
|
func (s *Store) PurgeExpiredDoneTasks(now time.Time, allowNever bool) ([]DeletedTask, error) {
|
||||||
|
def := neverAs(allowNever, DefaultDoneRetentionDays)
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT t.id, t.user_id, t.task_list_id
|
SELECT t.id, t.user_id, t.task_list_id
|
||||||
FROM thoughts t
|
FROM thoughts t
|
||||||
LEFT JOIN task_lists tl ON tl.id = t.task_list_id AND t.task_list_id != ''
|
LEFT JOIN task_lists tl ON tl.id = t.task_list_id AND t.task_list_id != ''
|
||||||
JOIN users u ON u.id = COALESCE(tl.user_id, t.user_id)
|
JOIN users u ON u.id = COALESCE(tl.user_id, t.user_id)
|
||||||
WHERE `+completedTaskSQL+`
|
WHERE `+completedTaskSQL+`
|
||||||
AND t.completed_at != '' AND u.done_retention_days > 0
|
AND t.completed_at != '' AND `+daysSQL("u.done_retention_days")+` > 0
|
||||||
AND t.completed_at < strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || u.done_retention_days || ' days')`,
|
AND t.completed_at < strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || `+daysSQL("u.done_retention_days")+` || ' days')`,
|
||||||
now.UTC().Format("2006-01-02 15:04:05"))
|
def, sqlNow(now), def)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.deleteTasks(rows)
|
return s.trashTaskRows(rows, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearDoneTasks deletes the completed tasks of one task list ("" = all lists the user can see) that
|
// ClearDoneTasks moves the completed tasks of one task list ("" = all lists the user can see) into the
|
||||||
// the user may delete: own personal tasks and tasks in lists they own or edit.
|
// task trash, as far as the user may delete them: own personal tasks and tasks in lists they own or edit.
|
||||||
func (s *Store) ClearDoneTasks(userID, taskListID string) ([]DeletedTask, error) {
|
func (s *Store) ClearDoneTasks(userID, taskListID string) ([]DeletedTask, error) {
|
||||||
if taskListID != "" {
|
if taskListID != "" {
|
||||||
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
|
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
|
||||||
@@ -99,7 +92,7 @@ func (s *Store) ClearDoneTasks(userID, taskListID string) ([]DeletedTask, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.deleteTasks(rows)
|
return s.trashTaskRows(rows, userID)
|
||||||
}
|
}
|
||||||
lists, err := s.GetTaskLists(userID)
|
lists, err := s.GetTaskLists(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,15 +115,15 @@ func (s *Store) ClearDoneTasks(userID, taskListID string) ([]DeletedTask, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.deleteTasks(rows)
|
return s.trashTaskRows(rows, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteTasks deletes the tasks the query rows name and returns them.
|
// trashTaskRows moves the tasks the query rows name into the task trash and returns them.
|
||||||
func (s *Store) deleteTasks(rows interface {
|
func (s *Store) trashTaskRows(rows interface {
|
||||||
Next() bool
|
Next() bool
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
Close() error
|
Close() error
|
||||||
}) ([]DeletedTask, error) {
|
}, byUserID string) ([]DeletedTask, error) {
|
||||||
var out []DeletedTask
|
var out []DeletedTask
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d DeletedTask
|
var d DeletedTask
|
||||||
@@ -140,7 +133,7 @@ func (s *Store) deleteTasks(rows interface {
|
|||||||
}
|
}
|
||||||
_ = rows.Close()
|
_ = rows.Close()
|
||||||
for _, d := range out {
|
for _, d := range out {
|
||||||
if _, err := s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, d.ID); err != nil {
|
if err := s.trashTask(d.ID, byUserID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func TestPurgeExpiredDoneTasks(t *testing.T) {
|
|||||||
benInAnnasList := doneTask(t, s, ben.ID, shared.ID, "in Annas Liste", "2026-11-01T08:00:00Z")
|
benInAnnasList := doneTask(t, s, ben.ID, shared.ID, "in Annas Liste", "2026-11-01T08:00:00Z")
|
||||||
openTask, _ := s.CreateThoughtWithDetails(anna.ID, "offen", "normal", "manual", "", "", "todo", "")
|
openTask, _ := s.CreateThoughtWithDetails(anna.ID, "offen", "normal", "manual", "", "", "todo", "")
|
||||||
|
|
||||||
deleted, err := s.PurgeExpiredDoneTasks(now)
|
deleted, err := s.PurgeExpiredDoneTasks(now, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func TestPurgeExpiredDoneTasks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Edge: exactly at the limit nothing happens yet
|
// Edge: exactly at the limit nothing happens yet
|
||||||
edge := doneTask(t, s, anna.ID, "", "Grenze", "2026-11-15T00:30:00Z")
|
edge := doneTask(t, s, anna.ID, "", "Grenze", "2026-11-15T00:30:00Z")
|
||||||
_, _ = s.PurgeExpiredDoneTasks(now)
|
_, _ = s.PurgeExpiredDoneTasks(now, true)
|
||||||
if !exists(s, edge) {
|
if !exists(s, edge) {
|
||||||
t.Fatal("a task completed exactly 30 days ago stays until the limit has passed")
|
t.Fatal("a task completed exactly 30 days ago stays until the limit has passed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ var migrations = []migration{
|
|||||||
{13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin},
|
{13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin},
|
||||||
{14, "Erledigte Aufgaben aufräumen", (*Store).migrateDoneCleanup},
|
{14, "Erledigte Aufgaben aufräumen", (*Store).migrateDoneCleanup},
|
||||||
{15, "Spracheingabe pro Person", (*Store).migrateVoiceInput},
|
{15, "Spracheingabe pro Person", (*Store).migrateVoiceInput},
|
||||||
|
{16, "Aufbewahrung und Papierkorb für Aufgaben", (*Store).migrateRetention},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) migrate() error {
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// How long finished and deleted things are kept, chosen by everyone in the account settings
|
||||||
|
// ("Aufbewahrung"): completed tasks (then into the task trash), the task trash, the triage trash
|
||||||
|
// (discarded thoughts), the notes trash and ticked-off list items (deleted for good). In shared task
|
||||||
|
// lists, lists and notes the owner's choice counts. 0 keeps things for good; the admin can switch that
|
||||||
|
// off (RETENTION_ALLOW_NEVER or the system settings), then 0 counts as the area's default.
|
||||||
|
//
|
||||||
|
// Things that were already finished or deleted before this feature count from its update on
|
||||||
|
// (retention_since), so nothing disappeared at once.
|
||||||
|
|
||||||
|
// RetentionChoices are the delays offered in the app, in days; 0 = never.
|
||||||
|
var RetentionChoices = []int{0, 7, 14, 30, 90, 365, 730}
|
||||||
|
|
||||||
|
// Retention: the delays of one user, in days.
|
||||||
|
type Retention = domain.Retention
|
||||||
|
|
||||||
|
// DefaultRetention: what everyone starts with, and what "never" becomes when it is not allowed.
|
||||||
|
var DefaultRetention = Retention{DoneTasks: DefaultDoneRetentionDays, TaskTrash: 30, TriageTrash: 14, NoteTrash: 30, ListDone: 7}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidRetention = errors.New("Ungültige Frist (erlaubt: 7, 14, 30, 90, 365, 730 Tage oder nie)")
|
||||||
|
ErrRetentionNever = errors.New("Dauerhaftes Aufbewahren ist auf diesem Server abgeschaltet")
|
||||||
|
)
|
||||||
|
|
||||||
|
// retentionFields maps each area to its users column.
|
||||||
|
var retentionFields = []struct {
|
||||||
|
column string
|
||||||
|
field func(r *Retention) *int
|
||||||
|
}{
|
||||||
|
{"done_retention_days", func(r *Retention) *int { return &r.DoneTasks }},
|
||||||
|
{"task_trash_days", func(r *Retention) *int { return &r.TaskTrash }},
|
||||||
|
{"triage_trash_days", func(r *Retention) *int { return &r.TriageTrash }},
|
||||||
|
{"note_trash_days", func(r *Retention) *int { return &r.NoteTrash }},
|
||||||
|
{"list_done_days", func(r *Retention) *int { return &r.ListDone }},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrateRetention() error {
|
||||||
|
d := DefaultRetention
|
||||||
|
for _, c := range []struct {
|
||||||
|
column string
|
||||||
|
days int
|
||||||
|
}{{"task_trash_days", d.TaskTrash}, {"triage_trash_days", d.TriageTrash}, {"note_trash_days", d.NoteTrash}, {"list_done_days", d.ListDone}} {
|
||||||
|
if err := s.addColumnIfMissing("users", c.column, "INTEGER NOT NULL DEFAULT "+strconv.Itoa(c.days)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.execInTx(
|
||||||
|
stmt(`CREATE TABLE IF NOT EXISTS trashed_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
task_list_id TEXT NOT NULL DEFAULT '',
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_completed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
assignees TEXT NOT NULL DEFAULT '[]',
|
||||||
|
trashed_at TEXT NOT NULL,
|
||||||
|
trashed_by TEXT NOT NULL DEFAULT ''
|
||||||
|
)`),
|
||||||
|
stmt(`CREATE INDEX IF NOT EXISTS idx_trashed_tasks_list ON trashed_tasks(task_list_id, trashed_at)`),
|
||||||
|
stmt(`CREATE INDEX IF NOT EXISTS idx_trashed_tasks_owner ON trashed_tasks(owner_id, trashed_at)`),
|
||||||
|
stmt(`INSERT OR IGNORE INTO system_settings (key, value) VALUES ('retention_since', ?)`, domain.NowISO()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRetention(days int) bool {
|
||||||
|
for _, d := range RetentionChoices {
|
||||||
|
if d == days {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetentionOf returns the user's delays; without allowNever a stored 0 counts as the default.
|
||||||
|
func (s *Store) RetentionOf(userID string, allowNever bool) Retention {
|
||||||
|
r := DefaultRetention
|
||||||
|
_ = s.db.QueryRow(`SELECT done_retention_days, task_trash_days, triage_trash_days, note_trash_days, list_done_days FROM users WHERE id = ?`, userID).
|
||||||
|
Scan(&r.DoneTasks, &r.TaskTrash, &r.TriageTrash, &r.NoteTrash, &r.ListDone)
|
||||||
|
if !allowNever {
|
||||||
|
def := DefaultRetention
|
||||||
|
for _, f := range retentionFields {
|
||||||
|
if v := f.field(&r); *v == 0 {
|
||||||
|
*v = *f.field(&def)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRetention stores all delays; each must be one of RetentionChoices, 0 only when allowed.
|
||||||
|
func (s *Store) SetRetention(userID string, r Retention, allowNever bool) error {
|
||||||
|
stmts := []sqlStmt{}
|
||||||
|
for _, f := range retentionFields {
|
||||||
|
v := *f.field(&r)
|
||||||
|
if !validRetention(v) {
|
||||||
|
return ErrInvalidRetention
|
||||||
|
}
|
||||||
|
if v == 0 && !allowNever {
|
||||||
|
return ErrRetentionNever
|
||||||
|
}
|
||||||
|
stmts = append(stmts, stmt(`UPDATE users SET `+f.column+` = ? WHERE id = ?`, v, userID))
|
||||||
|
}
|
||||||
|
return s.execInTx(stmts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// retentionSince: when retention started; older items count from then on.
|
||||||
|
func (s *Store) retentionSince() string {
|
||||||
|
var v string
|
||||||
|
_ = s.db.QueryRow(`SELECT value FROM system_settings WHERE key = 'retention_since'`).Scan(&v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// daysSQL: the delay in days of an owner column, with "never" (0) replaced by def when it is not allowed.
|
||||||
|
func daysSQL(column string) string {
|
||||||
|
return `COALESCE(NULLIF(` + column + `, 0), ?)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// neverAs: what a stored 0 means in daysSQL: 0 (keep) when allowed, else the area's default.
|
||||||
|
func neverAs(allowNever bool, def int) int {
|
||||||
|
if allowNever {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// cutoffSQL: true when the timestamp column (counted from retention_since at the earliest) lies more
|
||||||
|
// than the delay before now. Arguments: since, since, now (in this order).
|
||||||
|
func cutoffSQL(tsColumn, days string) string {
|
||||||
|
return `(CASE WHEN ` + tsColumn + ` = '' OR ` + tsColumn + ` < ? THEN ? ELSE ` + tsColumn + ` END)
|
||||||
|
< strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || ` + days + ` || ' days')`
|
||||||
|
}
|
||||||
|
|
||||||
|
func sqlNow(now time.Time) string { return now.UTC().Format("2006-01-02 15:04:05") }
|
||||||
|
|
||||||
|
// PurgeResult: what PurgeExpired removed, for the realtime messages.
|
||||||
|
type PurgeResult struct {
|
||||||
|
TasksToTrash []DeletedTask
|
||||||
|
TrashedTasks []string // purged from the task trash for good
|
||||||
|
Discarded int
|
||||||
|
Notes []string
|
||||||
|
ListItems []PurgedListItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgedListItem: a ticked-off list item deleted after its delay.
|
||||||
|
type PurgedListItem struct {
|
||||||
|
ID, ListID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeExpired runs every delay at now: completed tasks go to the task trash, the trashes and ticked-off
|
||||||
|
// list items are emptied for good.
|
||||||
|
func (s *Store) PurgeExpired(now time.Time, allowNever bool) (PurgeResult, error) {
|
||||||
|
var res PurgeResult
|
||||||
|
var err error
|
||||||
|
if res.TasksToTrash, err = s.PurgeExpiredDoneTasks(now, allowNever); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if res.TrashedTasks, err = s.purgeExpiredTaskTrash(now, allowNever); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
since := s.retentionSince()
|
||||||
|
if since == "" {
|
||||||
|
since = domain.NowISO()
|
||||||
|
}
|
||||||
|
d := DefaultRetention
|
||||||
|
|
||||||
|
// Triage trash: discarded thoughts, from the moment they were discarded (triaged_at)
|
||||||
|
r, err := s.db.Exec(`DELETE FROM thoughts WHERE status = 'discarded' AND id IN (
|
||||||
|
SELECT t.id FROM thoughts t JOIN users u ON u.id = t.user_id
|
||||||
|
WHERE t.status = 'discarded' AND `+daysSQL("u.triage_trash_days")+` > 0
|
||||||
|
AND `+cutoffSQL("t.triaged_at", daysSQL("u.triage_trash_days"))+`)`,
|
||||||
|
neverAs(allowNever, d.TriageTrash), since, since, sqlNow(now), neverAs(allowNever, d.TriageTrash))
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
n, _ := r.RowsAffected()
|
||||||
|
res.Discarded = int(n)
|
||||||
|
|
||||||
|
// Notes trash: the note owner's delay, from the deletion
|
||||||
|
rows, err := s.db.Query(`SELECT d.id FROM documents d JOIN users u ON u.id = d.user_id
|
||||||
|
WHERE d.is_archived = 1 AND `+daysSQL("u.note_trash_days")+` > 0
|
||||||
|
AND `+cutoffSQL("d.archived_at", daysSQL("u.note_trash_days")),
|
||||||
|
neverAs(allowNever, d.NoteTrash), since, since, sqlNow(now), neverAs(allowNever, d.NoteTrash))
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
var docIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
docIDs = append(docIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
for _, id := range docIDs {
|
||||||
|
if err := s.hardDeleteDocument(id); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
res.Notes = append(res.Notes, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticked-off list items: the list owner's delay, from ticking them off
|
||||||
|
rows, err = s.db.Query(`SELECT li.id, li.list_id FROM list_items li JOIN lists l ON l.id = li.list_id JOIN users u ON u.id = l.user_id
|
||||||
|
WHERE li.is_completed = 1 AND `+daysSQL("u.list_done_days")+` > 0
|
||||||
|
AND `+cutoffSQL("li.completed_at", daysSQL("u.list_done_days")),
|
||||||
|
neverAs(allowNever, d.ListDone), since, since, sqlNow(now), neverAs(allowNever, d.ListDone))
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var it PurgedListItem
|
||||||
|
if rows.Scan(&it.ID, &it.ListID) == nil {
|
||||||
|
res.ListItems = append(res.ListItems, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
for _, it := range res.ListItems {
|
||||||
|
if _, err := s.db.Exec(`DELETE FROM list_items WHERE id = ?`, it.ID); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetentionAllowNeverSetting: the admin's switch in the system settings (default: allowed).
|
||||||
|
func (s *Store) RetentionAllowNeverSetting() bool {
|
||||||
|
var v string
|
||||||
|
_ = s.db.QueryRow(`SELECT value FROM system_settings WHERE key = 'retention_allow_never'`).Scan(&v)
|
||||||
|
return v != "false"
|
||||||
|
}
|
||||||
@@ -176,6 +176,9 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
|||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
settings.BackupRetentionDays = n
|
settings.BackupRetentionDays = n
|
||||||
}
|
}
|
||||||
|
case "retention_allow_never":
|
||||||
|
allow := v != "false"
|
||||||
|
settings.RetentionAllowNever = &allow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,6 +190,10 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
settings.AllowRegistration = settings.RegistrationMode == RegistrationOpen
|
settings.AllowRegistration = settings.RegistrationMode == RegistrationOpen
|
||||||
|
if settings.RetentionAllowNever == nil {
|
||||||
|
allow := true
|
||||||
|
settings.RetentionAllowNever = &allow
|
||||||
|
}
|
||||||
return settings, nil
|
return settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,5 +276,9 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
|||||||
settings.RegistrationDomains,
|
settings.RegistrationDomains,
|
||||||
strconv.FormatBool(settings.RegistrationVerifyEmail),
|
strconv.FormatBool(settings.RegistrationVerifyEmail),
|
||||||
)
|
)
|
||||||
|
if err == nil && settings.RetentionAllowNever != nil {
|
||||||
|
_, err = s.db.Exec(`INSERT INTO system_settings (key, value) VALUES ('retention_allow_never', ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.FormatBool(*settings.RetentionAllowNever))
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deleted tasks go to a trash first: deleting a task, "Erledigte leeren" and the automatic clean-up of
|
||||||
|
// completed tasks. The task leaves the thoughts table with its whole row and its assignees kept as a
|
||||||
|
// copy (trashed_tasks), so no query on tasks has to know about the trash; restoring puts the row back
|
||||||
|
// as it was. Everyone who sees a task list sees its trashed tasks; restoring and deleting for good need
|
||||||
|
// edit rights, like changing the list. The delay of the list owner (or of the user for personal tasks)
|
||||||
|
// empties the trash.
|
||||||
|
|
||||||
|
var ErrNotInTaskTrash = errors.New("Aufgabe nicht im Papierkorb")
|
||||||
|
|
||||||
|
// TrashedTask is one entry of the task trash.
|
||||||
|
type TrashedTask struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
TaskListID string `json:"taskListId"`
|
||||||
|
IsCompleted bool `json:"isCompleted"`
|
||||||
|
TrashedAt string `json:"trashedAt"`
|
||||||
|
TrashedBy string `json:"trashedBy"` // display name
|
||||||
|
CanEdit bool `json:"canEdit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// trashTasks moves tasks into the trash, each with its row and assignees, in one transaction per task.
|
||||||
|
func (s *Store) trashTasks(ids []string, byUserID string) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := s.trashTask(id, byUserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) trashTask(id, byUserID string) error {
|
||||||
|
rows, err := s.db.Query(`SELECT * FROM thoughts WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cols, _ := rows.Columns()
|
||||||
|
data := map[string]any{}
|
||||||
|
found := false
|
||||||
|
if rows.Next() {
|
||||||
|
vals := make([]any, len(cols))
|
||||||
|
ptrs := make([]any, len(cols))
|
||||||
|
for i := range vals {
|
||||||
|
ptrs[i] = &vals[i]
|
||||||
|
}
|
||||||
|
if err := rows.Scan(ptrs...); err != nil {
|
||||||
|
_ = rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, c := range cols {
|
||||||
|
if b, ok := vals[i].([]byte); ok {
|
||||||
|
data[c] = string(b)
|
||||||
|
} else {
|
||||||
|
data[c] = vals[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
if !found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
str := func(k string) string { v, _ := data[k].(string); return v }
|
||||||
|
userID, listID := str("user_id"), str("task_list_id")
|
||||||
|
owner := userID
|
||||||
|
if listID != "" {
|
||||||
|
_ = s.db.QueryRow(`SELECT user_id FROM task_lists WHERE id = ?`, listID).Scan(&owner)
|
||||||
|
}
|
||||||
|
done := false
|
||||||
|
switch v := data["is_completed"].(type) {
|
||||||
|
case int64:
|
||||||
|
done = v == 1
|
||||||
|
}
|
||||||
|
assignees := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ?`, id)
|
||||||
|
dataJSON, _ := json.Marshal(data)
|
||||||
|
assJSON, _ := json.Marshal(assignees)
|
||||||
|
return s.execInTx(
|
||||||
|
stmt(`INSERT OR REPLACE INTO trashed_tasks (id, user_id, task_list_id, owner_id, content, is_completed, data, assignees, trashed_at, trashed_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, userID, listID, owner, str("content"), boolInt(done), string(dataJSON), string(assJSON), domain.NowISO(), byUserID),
|
||||||
|
stmt(`DELETE FROM thoughts WHERE id = ?`, id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskTrashScope: entries the user can see: own personal tasks and tasks of lists they can see.
|
||||||
|
const taskTrashScope = `((tt.user_id = ? AND tt.task_list_id = '') OR tt.task_list_id IN (` + accessibleTaskListsSQL + `))`
|
||||||
|
|
||||||
|
func taskTrashArgs(userID string) []any {
|
||||||
|
return append([]any{userID}, accessibleTaskListArgs(userID)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskTrash lists the trashed tasks of one task list ("" = all the user can see), newest first.
|
||||||
|
func (s *Store) GetTaskTrash(userID, taskListID string) ([]TrashedTask, error) {
|
||||||
|
q := `SELECT tt.id, tt.content, tt.task_list_id, tt.is_completed, tt.trashed_at, COALESCE(u.display_name, '')
|
||||||
|
FROM trashed_tasks tt LEFT JOIN users u ON u.id = tt.trashed_by
|
||||||
|
WHERE ` + taskTrashScope
|
||||||
|
args := taskTrashArgs(userID)
|
||||||
|
if taskListID != "" {
|
||||||
|
q += ` AND tt.task_list_id = ?`
|
||||||
|
args = append(args, taskListID)
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(q+` ORDER BY tt.trashed_at DESC`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []TrashedTask{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t TrashedTask
|
||||||
|
var done int
|
||||||
|
if rows.Scan(&t.ID, &t.Content, &t.TaskListID, &done, &t.TrashedAt, &t.TrashedBy) == nil {
|
||||||
|
t.IsCompleted = done == 1
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
out[i].CanEdit = out[i].TaskListID == "" || s.CanEditTaskList(userID, out[i].TaskListID)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// trashedTaskFor loads an entry the user may restore or delete.
|
||||||
|
func (s *Store) trashedTaskFor(userID, id string) (data map[string]any, assignees []string, listID string, err error) {
|
||||||
|
var raw, rawAss, owner string
|
||||||
|
err = s.db.QueryRow(`SELECT tt.data, tt.assignees, tt.task_list_id, tt.user_id FROM trashed_tasks tt WHERE tt.id = ? AND `+taskTrashScope,
|
||||||
|
append([]any{id}, taskTrashArgs(userID)...)...).Scan(&raw, &rawAss, &listID, &owner)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil, "", ErrNotInTaskTrash
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", err
|
||||||
|
}
|
||||||
|
if listID != "" && !s.CanEditTaskList(userID, listID) {
|
||||||
|
return nil, nil, "", ErrTaskListReadOnly
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(raw), &data)
|
||||||
|
_ = json.Unmarshal([]byte(rawAss), &assignees)
|
||||||
|
for k, v := range data {
|
||||||
|
if f, ok := v.(float64); ok && f == float64(int64(f)) {
|
||||||
|
data[k] = int64(f) // JSON numbers come back as floats; the columns hold integers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data["task_list_id"] = listID // a deleted list made it a personal task
|
||||||
|
return data, assignees, listID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreTrashedTask puts a task back where it was (a list deleted meanwhile: into its creator's own
|
||||||
|
// tasks, see DeleteTaskList). A
|
||||||
|
// completed task starts its delay anew, so the automatic clean-up does not take it again at once.
|
||||||
|
func (s *Store) RestoreTrashedTask(userID, id string) (*domain.Thought, error) {
|
||||||
|
data, assignees, _, err := s.trashedTaskFor(userID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cols := map[string]bool{}
|
||||||
|
rows, err := s.db.Query(`SELECT name FROM pragma_table_info('thoughts')`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var c string
|
||||||
|
if rows.Scan(&c) == nil {
|
||||||
|
cols[c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
if done, ok := data["is_completed"].(int64); ok && done == 1 {
|
||||||
|
data["completed_at"] = domain.NowISO()
|
||||||
|
}
|
||||||
|
names, marks, args := "", "", []any{}
|
||||||
|
for k, v := range data {
|
||||||
|
if !cols[k] || v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if names != "" {
|
||||||
|
names += ", "
|
||||||
|
marks += ", "
|
||||||
|
}
|
||||||
|
names += k
|
||||||
|
marks += "?"
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
stmts := []sqlStmt{
|
||||||
|
stmt(`INSERT INTO thoughts (`+names+`) VALUES (`+marks+`)`, args...),
|
||||||
|
stmt(`DELETE FROM trashed_tasks WHERE id = ?`, id),
|
||||||
|
}
|
||||||
|
for _, uid := range assignees {
|
||||||
|
stmts = append(stmts, stmt(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at)
|
||||||
|
SELECT ?, id, ? FROM users WHERE id = ?`, id, domain.NowISO(), uid))
|
||||||
|
}
|
||||||
|
if err := s.execInTx(stmts...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.PruneTaskAssignees()
|
||||||
|
return s.GetThoughtByIDUnchecked(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeTrashedTask deletes one trashed task for good.
|
||||||
|
func (s *Store) PurgeTrashedTask(userID, id string) error {
|
||||||
|
if _, _, _, err := s.trashedTaskFor(userID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.purgeTrashedTasks([]string{id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmptyTaskTrash deletes the trashed tasks of one list ("" = all the user may edit) for good.
|
||||||
|
func (s *Store) EmptyTaskTrash(userID, taskListID string) ([]string, error) {
|
||||||
|
if taskListID != "" {
|
||||||
|
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items, err := s.GetTaskTrash(userID, taskListID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := []string{}
|
||||||
|
for _, it := range items {
|
||||||
|
if it.CanEdit {
|
||||||
|
ids = append(ids, it.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, s.purgeTrashedTasks(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) purgeTrashedTasks(ids []string) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := s.execInTx(
|
||||||
|
stmt(`DELETE FROM trashed_tasks WHERE id = ?`, id),
|
||||||
|
stmt(`DELETE FROM task_watchers WHERE task_id = ?`, id),
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeExpiredTaskTrash empties the task trash after the owner's delay (list owner, else the creator).
|
||||||
|
func (s *Store) purgeExpiredTaskTrash(now time.Time, allowNever bool) ([]string, error) {
|
||||||
|
def := neverAs(allowNever, DefaultRetention.TaskTrash)
|
||||||
|
rows, err := s.db.Query(`SELECT tt.id FROM trashed_tasks tt JOIN users u ON u.id = tt.owner_id
|
||||||
|
WHERE `+daysSQL("u.task_trash_days")+` > 0
|
||||||
|
AND tt.trashed_at < strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || `+daysSQL("u.task_trash_days")+` || ' days')`,
|
||||||
|
def, sqlNow(now), def)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
return ids, s.purgeTrashedTasks(ids)
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func inTaskTrash(s *Store, id string) bool {
|
||||||
|
var n int
|
||||||
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM trashed_tasks WHERE id = ?`, id).Scan(&n)
|
||||||
|
return n == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting a task, clearing completed ones and the automatic clean-up all go through the trash;
|
||||||
|
// restoring brings the task back with its fields and assignees.
|
||||||
|
func TestTaskTrashRoundTrip(t *testing.T) {
|
||||||
|
s, err := NewStore(filepath.Join(t.TempDir(), "trash.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
openSharing(t, s)
|
||||||
|
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||||
|
ben, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||||
|
carla, _ := s.CreateUser("carla@example.com", "hash", "Carla", "member")
|
||||||
|
list, _ := s.CreateTaskList(anna.ID, "", "", "Haushalt", "", "")
|
||||||
|
_, _ = s.AddTaskListShare(list.ID, "user", ben.ID, "editor", anna.ID)
|
||||||
|
_, _ = s.AddTaskListShare(list.ID, "user", carla.ID, "viewer", anna.ID)
|
||||||
|
|
||||||
|
task, _ := s.CreateThoughtWithDetails(anna.ID, "Fenster putzen #haus", "high", "manual", "2026-11-20", list.ID, "in_progress", "Innen und außen")
|
||||||
|
if _, err := s.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-20", list.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.SetTaskAssignees(anna.ID, task.ID, []string{ben.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, _ := s.GetThoughtByIDUnchecked(task.ID)
|
||||||
|
if ass := s.getTaskAssignees(task.ID); len(ass) != 1 {
|
||||||
|
t.Fatalf("setup: assignee missing: %v", ass)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteThought(ben.ID, task.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if exists(s, task.ID) || !inTaskTrash(s, task.ID) {
|
||||||
|
t.Fatal("a deleted task should leave the list and wait in the trash")
|
||||||
|
}
|
||||||
|
// Everyone who sees the list sees the entry, only editors may act on it
|
||||||
|
if items, _ := s.GetTaskTrash(carla.ID, list.ID); len(items) != 1 || items[0].CanEdit || items[0].TrashedBy != "Ben" {
|
||||||
|
t.Fatalf("viewer should see the entry read-only: %+v", items)
|
||||||
|
}
|
||||||
|
if _, err := s.RestoreTrashedTask(carla.ID, task.ID); err == nil {
|
||||||
|
t.Fatal("a viewer must not restore")
|
||||||
|
}
|
||||||
|
outsider, _ := s.CreateUser("dora@example.com", "hash", "Dora", "member")
|
||||||
|
if items, _ := s.GetTaskTrash(outsider.ID, ""); len(items) != 0 {
|
||||||
|
t.Fatalf("outsiders see nothing: %+v", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := s.RestoreTrashedTask(anna.ID, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if inTaskTrash(s, task.ID) || restored.Content != before.Content || restored.Notes != before.Notes || restored.Priority != before.Priority ||
|
||||||
|
restored.DueDate != before.DueDate || restored.TaskListID != list.ID || restored.KanbanStatus != before.KanbanStatus {
|
||||||
|
t.Fatalf("restored task differs:\nbefore %+v\nafter %+v", before, restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ass := s.getTaskAssignees(task.ID); len(ass) != 1 || ass[0] != ben.ID {
|
||||||
|
t.Fatalf("assignees should come back: %v", ass)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting for good
|
||||||
|
_ = s.DeleteThought(anna.ID, task.ID)
|
||||||
|
if err := s.PurgeTrashedTask(carla.ID, task.ID); err == nil {
|
||||||
|
t.Fatal("a viewer must not delete for good")
|
||||||
|
}
|
||||||
|
if err := s.PurgeTrashedTask(anna.ID, task.ID); err != nil || inTaskTrash(s, task.ID) {
|
||||||
|
t.Fatalf("purge failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A restored completed task starts its delay anew; the clean-up then waits again.
|
||||||
|
func TestRestoredDoneTaskIsNotTrashedAgainAtOnce(t *testing.T) {
|
||||||
|
s, err := NewStore(filepath.Join(t.TempDir(), "again.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||||
|
id := doneTask(t, s, anna.ID, "", "alt", "2020-01-01T08:00:00Z")
|
||||||
|
if moved, _ := s.PurgeExpiredDoneTasks(time.Now(), true); len(moved) != 1 || !inTaskTrash(s, id) {
|
||||||
|
t.Fatalf("old completed task should be in the trash: %+v", moved)
|
||||||
|
}
|
||||||
|
if _, err := s.RestoreTrashedTask(anna.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if moved, _ := s.PurgeExpiredDoneTasks(time.Now(), true); len(moved) != 0 || !exists(s, id) {
|
||||||
|
t.Fatal("restored task must not go straight back to the trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting a task list turns its trashed tasks into their creators' own, like its tasks.
|
||||||
|
func TestTaskTrashAfterListDeletion(t *testing.T) {
|
||||||
|
s, err := NewStore(filepath.Join(t.TempDir(), "listgone.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
openSharing(t, s)
|
||||||
|
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||||
|
ben, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||||
|
list, _ := s.CreateTaskList(anna.ID, "", "", "Projekt", "", "")
|
||||||
|
_, _ = s.AddTaskListShare(list.ID, "user", ben.ID, "editor", anna.ID)
|
||||||
|
task, _ := s.CreateThoughtWithDetails(ben.ID, "Bens Aufgabe", "normal", "manual", "", list.ID, "todo", "")
|
||||||
|
_, _ = s.UpdateThoughtTriage(ben.ID, task.ID, "keep", "task", "", "", list.ID)
|
||||||
|
_ = s.DeleteThought(ben.ID, task.ID)
|
||||||
|
if err := s.DeleteTaskList(anna.ID, list.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if items, _ := s.GetTaskTrash(ben.ID, ""); len(items) != 1 || items[0].TaskListID != "" {
|
||||||
|
t.Fatalf("Ben should find his task in his own trash: %+v", items)
|
||||||
|
}
|
||||||
|
restored, err := s.RestoreTrashedTask(ben.ID, task.ID)
|
||||||
|
if err != nil || restored.TaskListID != "" || restored.UserID != ben.ID {
|
||||||
|
t.Fatalf("should come back as Ben's own task: %+v %v", restored, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every area keeps its delay; older items count from the update on; "never" can be switched off.
|
||||||
|
func TestPurgeExpiredAreas(t *testing.T) {
|
||||||
|
s, err := NewStore(filepath.Join(t.TempDir(), "areas.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||||
|
ben, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||||
|
if r := s.RetentionOf(anna.ID, true); r != DefaultRetention {
|
||||||
|
t.Fatalf("defaults: %+v", r)
|
||||||
|
}
|
||||||
|
if err := s.SetRetention(ben.ID, Retention{}, false); err != ErrRetentionNever {
|
||||||
|
t.Fatalf("never must be refused when switched off: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetRetention(ben.ID, Retention{DoneTasks: 12}, true); err != ErrInvalidRetention {
|
||||||
|
t.Fatalf("only offered delays: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetRetention(ben.ID, Retention{}, true); err != nil { // Ben keeps everything
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r := s.RetentionOf(ben.ID, false); r != DefaultRetention {
|
||||||
|
t.Fatalf("with never switched off Ben gets the defaults: %+v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = s.db.Exec(`UPDATE system_settings SET value = '2026-01-01T00:00:00Z' WHERE key = 'retention_since'`)
|
||||||
|
now := time.Date(2026, 12, 15, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// Triage trash (14 days), timestamps before the update count from the update
|
||||||
|
discard := func(user string, at string) string {
|
||||||
|
th, _ := s.CreateThought(user, "weg", "normal", "manual", "")
|
||||||
|
_, _ = s.UpdateThoughtTriage(user, th.ID, "discard", "", "", "", "")
|
||||||
|
_, _ = s.db.Exec(`UPDATE thoughts SET triaged_at = ? WHERE id = ?`, at, th.ID)
|
||||||
|
return th.ID
|
||||||
|
}
|
||||||
|
oldDiscard := discard(anna.ID, "2026-11-01T00:00:00Z")
|
||||||
|
newDiscard := discard(anna.ID, "2026-12-10T00:00:00Z")
|
||||||
|
benDiscard := discard(ben.ID, "2026-01-02T00:00:00Z")
|
||||||
|
|
||||||
|
// Notes trash (30 days)
|
||||||
|
doc, _ := s.CreateDocument(anna.ID, "", "Alte Notiz", "", "", nil, nil, false)
|
||||||
|
_ = s.DeleteDocument(anna.ID, doc.ID)
|
||||||
|
_, _ = s.db.Exec(`UPDATE documents SET archived_at = '2026-10-01T00:00:00Z' WHERE id = ?`, doc.ID)
|
||||||
|
|
||||||
|
// Ticked-off list items (7 days, list owner)
|
||||||
|
lst, _ := s.CreateList(anna.ID, "Einkauf", "🛒", "shopping")
|
||||||
|
oldItem, _ := s.CreateListItem(lst.ID, "Milch", "", "", 0, anna.ID)
|
||||||
|
freshItem, _ := s.CreateListItem(lst.ID, "Brot", "", "", 0, anna.ID)
|
||||||
|
openItem, _ := s.CreateListItem(lst.ID, "Käse", "", "", 0, anna.ID)
|
||||||
|
_, _ = s.ToggleListItem(oldItem.ID, true, anna.ID, "Anna")
|
||||||
|
_, _ = s.ToggleListItem(freshItem.ID, true, anna.ID, "Anna")
|
||||||
|
_, _ = s.db.Exec(`UPDATE list_items SET completed_at = '2026-12-01T00:00:00Z' WHERE id = ?`, oldItem.ID)
|
||||||
|
_, _ = s.db.Exec(`UPDATE list_items SET completed_at = '2026-12-14T00:00:00Z' WHERE id = ?`, freshItem.ID)
|
||||||
|
|
||||||
|
res, err := s.PurgeExpired(now, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if exists(s, oldDiscard) || !exists(s, newDiscard) || !exists(s, benDiscard) || res.Discarded != 1 {
|
||||||
|
t.Fatalf("triage trash wrong: %+v", res)
|
||||||
|
}
|
||||||
|
var docs int
|
||||||
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, doc.ID).Scan(&docs)
|
||||||
|
if docs != 0 || len(res.Notes) != 1 {
|
||||||
|
t.Fatalf("old note should be gone: %+v", res)
|
||||||
|
}
|
||||||
|
itemExists := func(id string) bool {
|
||||||
|
var n int
|
||||||
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM list_items WHERE id = ?`, id).Scan(&n)
|
||||||
|
return n == 1
|
||||||
|
}
|
||||||
|
if itemExists(oldItem.ID) || !itemExists(freshItem.ID) || !itemExists(openItem.ID) || len(res.ListItems) != 1 {
|
||||||
|
t.Fatalf("list items wrong: %+v", res.ListItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With "never" switched off, Ben's discarded thought (older than 14 days after the update) goes too
|
||||||
|
if res, _ := s.PurgeExpired(now, false); exists(s, benDiscard) || res.Discarded != 1 {
|
||||||
|
t.Fatalf("Ben's never should count as 14 days: %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items from before the update count from the update: with a fresh update nothing old goes
|
||||||
|
_, _ = s.db.Exec(`UPDATE system_settings SET value = '2026-12-14T00:00:00Z' WHERE key = 'retention_since'`)
|
||||||
|
veryOld := discard(anna.ID, "2025-01-01T00:00:00Z")
|
||||||
|
if _, _ = s.PurgeExpired(now, true); !exists(s, veryOld) {
|
||||||
|
t.Fatal("things from before the update count from the update on")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -549,6 +549,8 @@ func (s *Store) DeleteTaskList(userID, listID string) error {
|
|||||||
// The tasks stay (as personal tasks of their owners), shares and the list go – all or nothing
|
// The tasks stay (as personal tasks of their owners), shares and the list go – all or nothing
|
||||||
if err := s.execInTx(
|
if err := s.execInTx(
|
||||||
stmt(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID),
|
stmt(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID),
|
||||||
|
// Its trashed tasks likewise land in their creators' own trash
|
||||||
|
stmt(`UPDATE trashed_tasks SET task_list_id = '', owner_id = user_id WHERE task_list_id = ?`, listID),
|
||||||
stmt(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID),
|
stmt(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID),
|
||||||
stmt(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID),
|
stmt(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -523,6 +523,8 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
|||||||
return thought, nil
|
return thought, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteThought: a task goes to the task trash (task_trash.go); anything else (a thought from the inbox
|
||||||
|
// or the triage trash) is deleted for good.
|
||||||
func (s *Store) DeleteThought(userID, thoughtID string) error {
|
func (s *Store) DeleteThought(userID, thoughtID string) error {
|
||||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -531,6 +533,10 @@ func (s *Store) DeleteThought(userID, thoughtID string) error {
|
|||||||
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Tasks as in completedTaskSQL: kept, or converted into a task
|
||||||
|
if thought.Status == domain.StatusKept || (thought.Status == domain.StatusConverted && thought.ConvertedType == "task") {
|
||||||
|
return s.trashTask(thought.ID, userID)
|
||||||
|
}
|
||||||
_, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID)
|
_, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user