Merge dev into main (v0.43.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s

This commit is contained in:
sandrodach committed 2026-11-01 15:47:23 +01:00
commit f49d599d12
36 files changed
+1335 -154

No files matched your search

+5
View File
@@ -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
View File
@@ -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
View File
@@ -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` |
+1
View File
@@ -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 |
+2 -2
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.42.0-16a34a?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/Version-v0.43.0-16a34a?style=flat-square)](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" />
+1
View File
@@ -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}
+2 -2
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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>
); );
+1 -1
View File
@@ -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 */}
{/* ================================================================= */} {/* ================================================================= */}
+55 -83
View File
@@ -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 }) => {
+21
View File
@@ -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' });
}, },
+7
View File
@@ -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' });
};
+26
View File
@@ -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;
+80 -9
View File
@@ -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())
}
}
+14 -1
View File
@@ -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) {
+24 -4
View File
@@ -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
View File
@@ -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) {
+4
View File
@@ -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
+17
View File
@@ -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
}
+18
View File
@@ -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"`
}
+26 -33
View File
@@ -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
} }
} }
+2 -2
View File
@@ -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")
} }
+1
View File
@@ -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 {
+239
View File
@@ -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"
}
+11
View File
@@ -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
} }
+267
View File
@@ -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)
}
+217
View File
@@ -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")
}
}
+2
View File
@@ -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 {
+6
View File
@@ -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
} }