feat: task trash, triage trash and retention settings for every area; admin can forbid keeping things for good
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m39s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a927a94a95
commit
5a9c5bac37
36 files changed
+1335
-154
No files matched your search
@@ -32,6 +32,11 @@ TZ=Europe/Berlin
|
||||
# Alt: true entspricht REGISTRATION_MODE=open; false lässt die Wahl dem Admin.
|
||||
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 ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Wessen Angaben zur Besucher-Adresse (X-Forwarded-For / X-Real-IP) geglaubt werden. Wichtig für die
|
||||
|
||||
+14
-1
@@ -6,6 +6,18 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### 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.
|
||||
- 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.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
|
||||
|
||||
+9
-5
@@ -34,7 +34,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
|
||||
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
|
||||
| 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`) |
|
||||
| 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) |
|
||||
@@ -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` |
|
||||
| `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) |
|
||||
| `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 |
|
||||
| `create_list` | `lists:write` | Liste anlegen (`title`, `icon`, `listType` shopping/checklist/vacation) |
|
||||
| `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) |
|
||||
| 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` |
|
||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
||||
| 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 der Triage (verworfene Gedanken) leeren |
|
||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||
| 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)) |
|
||||
| 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/{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` |
|
||||
|
||||
@@ -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_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 |
|
||||
| `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_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 |
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
|
||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||
|
||||
@@ -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.
|
||||
* **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.
|
||||
* 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">
|
||||
<img src="screenshots/Mobile_Triage.webp" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
|
||||
|
||||
@@ -18,6 +18,7 @@ services:
|
||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-private}
|
||||
- RETENTION_ALLOW_NEVER=${RETENTION_ALLOW_NEVER:-}
|
||||
- LOGIN_PROTECTION=${LOGIN_PROTECTION:-true}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- DEBUG=${DEBUG:-false}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.42.0",
|
||||
"version": "0.43.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.42.0",
|
||||
"version": "0.43.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.42.0",
|
||||
"version": "0.43.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+14
-1
@@ -10,6 +10,7 @@ import {
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Palette,
|
||||
HelpCircle,
|
||||
Plus,
|
||||
@@ -73,6 +74,7 @@ import { CommandPalette } from './components/CommandPalette';
|
||||
import { AppFooter } from './components/AppFooter';
|
||||
import { ThemeQuickPicker } from './components/ThemeQuickPicker';
|
||||
import { AboutModal } from './components/AboutModal';
|
||||
import { TriageTrash } from './features/triage/TriageTrash';
|
||||
import { UpdateAvailableModal } from './components/UpdateAvailableModal';
|
||||
import { useUpdateCheck } from './lib/useUpdateCheck';
|
||||
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
||||
@@ -191,6 +193,7 @@ export const App: React.FC = () => {
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const serverOnline = useServerOnline();
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [showTriageTrash, setShowTriageTrash] = useState(false);
|
||||
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -314,6 +317,13 @@ export const App: React.FC = () => {
|
||||
badge: stats.inboxCount,
|
||||
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
|
||||
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
||||
@@ -1275,6 +1285,8 @@ export const App: React.FC = () => {
|
||||
onDeckChange={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onFocusCapture={handleTriggerAddButton}
|
||||
discardedCount={stats.discardedCount}
|
||||
onOpenTrash={() => setShowTriageTrash(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1886,6 +1898,7 @@ export const App: React.FC = () => {
|
||||
/>
|
||||
|
||||
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||
{showTriageTrash && <TriageTrash onClose={() => setShowTriageTrash(false)} onChanged={refreshAll} />}
|
||||
{availableUpdate && <UpdateAvailableModal update={availableUpdate} onLater={dismissUpdate} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -264,7 +264,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<strong className="text-main">Rückverweise:</strong> Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">[[Titel]]</code> verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.
|
||||
</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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -158,6 +158,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
backupEnabled: Boolean(sys.backupEnabled),
|
||||
backupTime: sys.backupTime || '03:00',
|
||||
backupRetentionDays: sys.backupRetentionDays || 3,
|
||||
retentionAllowNever: sys.retentionAllowNever ?? true,
|
||||
retentionNeverViaEnv: Boolean(sys.retentionNeverViaEnv),
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -1315,6 +1317,23 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
/>
|
||||
</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 */}
|
||||
{(() => {
|
||||
const mode = settings.registrationMode || 'closed';
|
||||
|
||||
@@ -19,8 +19,9 @@ import {
|
||||
Copy,
|
||||
Archive,
|
||||
Mic,
|
||||
Clock,
|
||||
} 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 { ServerBackupsCard } from './ServerBackupsCard';
|
||||
import { saveBlob } from '../../lib/download';
|
||||
@@ -68,7 +69,25 @@ interface UserSettingsViewProps {
|
||||
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 {
|
||||
const hasLegacy = Array.isArray(user.alarmChannels);
|
||||
@@ -140,6 +159,22 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
useEffect(() => {
|
||||
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') => {
|
||||
setVoiceSaving(true);
|
||||
try {
|
||||
@@ -675,6 +710,21 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</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
|
||||
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 ${
|
||||
@@ -1964,6 +2014,55 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</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 */}
|
||||
{/* ================================================================= */}
|
||||
|
||||
@@ -28,8 +28,8 @@ import {
|
||||
Filter,
|
||||
Repeat,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS, TrashedTask } from '../../lib/types';
|
||||
import { formatDeletedAt, formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
||||
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
@@ -129,7 +129,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}, [selectedListId, viewMode]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
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);
|
||||
|
||||
// Drag and Drop state
|
||||
@@ -215,12 +216,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const listId = selectedListId;
|
||||
if (loadedListRef.current !== listId) setLoading(true);
|
||||
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.getTaskLists().catch(() => []),
|
||||
api.getUserTeams().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
|
||||
loadedListRef.current = listId;
|
||||
@@ -232,7 +233,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}
|
||||
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
||||
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
|
||||
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
|
||||
setTaskTrash(Array.isArray(trash) ? trash : []);
|
||||
} catch {
|
||||
// Prevent unhandled promise rejection from crashing view
|
||||
} finally {
|
||||
@@ -447,7 +448,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
const safeTeams = Array.isArray(teams) ? teams : [];
|
||||
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
|
||||
const safeDiscarded = Array.isArray(discarded) ? discarded : [];
|
||||
|
||||
// Available unique hashtags from current tasks
|
||||
const allAvailableTags = useMemo(() => {
|
||||
@@ -751,13 +751,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (item: Thought) => {
|
||||
triggerHaptic(15);
|
||||
await api.triageAction(item.id, 'restore');
|
||||
await loadData();
|
||||
onRefreshStats();
|
||||
};
|
||||
|
||||
// Deleting a task puts it into the task trash (task_trash.go)
|
||||
const handleDeletePermanent = async (id: string) => {
|
||||
triggerHaptic(20);
|
||||
await api.deleteThought(id);
|
||||
@@ -765,37 +759,32 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onRefreshStats();
|
||||
};
|
||||
|
||||
// Completed tasks: cleared by hand or deleted after the owner's delay (done_cleanup.go)
|
||||
const [doneRetentionDays, setDoneRetentionDays] = useState<number>(currentUser?.doneRetentionDays ?? 30);
|
||||
useEffect(() => {
|
||||
if (currentUser?.doneRetentionDays !== undefined) setDoneRetentionDays(currentUser.doneRetentionDays);
|
||||
}, [currentUser?.doneRetentionDays]);
|
||||
const handleRestoreTrashed = async (item: TrashedTask) => {
|
||||
triggerHaptic(15);
|
||||
await api.restoreTrashedTask(item.id);
|
||||
await loadData();
|
||||
onRefreshStats();
|
||||
};
|
||||
|
||||
const handleChangeDoneRetention = async (days: number) => {
|
||||
const previous = doneRetentionDays;
|
||||
setDoneRetentionDays(days);
|
||||
try {
|
||||
await api.updateSettings({ doneRetentionDays: days });
|
||||
} catch {
|
||||
setDoneRetentionDays(previous);
|
||||
}
|
||||
const handlePurgeTrashed = async (item: TrashedTask) => {
|
||||
triggerHaptic(20);
|
||||
await api.purgeTrashedTask(item.id);
|
||||
await loadData();
|
||||
};
|
||||
|
||||
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);
|
||||
await api.clearDoneTasks(selectedListId === 'all' ? '' : selectedListId);
|
||||
await loadData();
|
||||
onRefreshStats();
|
||||
};
|
||||
|
||||
const handlePurgeAllDiscarded = async () => {
|
||||
if (!window.confirm('Alle verworfenen Gedanken endgültig löschen?')) return;
|
||||
const handleEmptyTaskTrash = async () => {
|
||||
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);
|
||||
await api.purgeDiscarded();
|
||||
await api.emptyTaskTrash(selectedListId === 'all' ? '' : selectedListId);
|
||||
await loadData();
|
||||
onRefreshStats();
|
||||
};
|
||||
|
||||
const handleQuickAddTask = async (e: React.FormEvent) => {
|
||||
@@ -1278,7 +1267,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
id: 'filter-discarded',
|
||||
label: 'Papierkorb',
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
badge: safeDiscarded.length,
|
||||
badge: taskTrash.length,
|
||||
active: viewMode === 'list' && filter === 'discarded',
|
||||
onClick: () => {
|
||||
setViewMode('list');
|
||||
@@ -1357,7 +1346,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
sortBy,
|
||||
openTasks.length,
|
||||
doneTasks.length,
|
||||
safeDiscarded.length,
|
||||
taskTrash.length,
|
||||
kanbanTasks.length,
|
||||
safeTaskLists,
|
||||
openTasksCount,
|
||||
@@ -1837,7 +1826,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
? `Offen (${openTasks.length})`
|
||||
: filter === 'done'
|
||||
? `Erledigt (${doneTasks.length})`
|
||||
: `Papierkorb (${safeDiscarded.length})`}
|
||||
: `Papierkorb (${taskTrash.length})`}
|
||||
</span>
|
||||
</div>
|
||||
{currentSelectedList && (
|
||||
@@ -2755,12 +2744,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
filter === 'discarded' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
Papierkorb ({safeDiscarded.length})
|
||||
Papierkorb ({taskTrash.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filter === 'discarded' && safeDiscarded.length > 0 && (
|
||||
<button onClick={handlePurgeAllDiscarded} className="btn-action min-h-[38px] sm:min-h-0">
|
||||
{filter === 'discarded' && taskTrash.some((t) => t.canEdit) && (
|
||||
<button onClick={handleEmptyTaskTrash} className="btn-action min-h-[38px] sm:min-h-0 ml-auto">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Papierkorb leeren
|
||||
</button>
|
||||
@@ -2773,68 +2762,51 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
)}
|
||||
</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 ? (
|
||||
<div className="p-6 text-center text-faint text-sm">Lade Aufgaben…</div>
|
||||
) : filter === 'discarded' ? (
|
||||
<div className="panel-surface divide-y divide-border">
|
||||
{safeDiscarded.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-muted text-sm">
|
||||
Keine verworfenen Gedanken im Papierkorb.
|
||||
</div>
|
||||
{taskTrash.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-muted text-sm">Der Papierkorb ist leer.</div>
|
||||
) : (
|
||||
safeDiscarded.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="px-3 py-2 flex items-center justify-between gap-3 hover:bg-panel-hover"
|
||||
>
|
||||
taskTrash.map((item) => {
|
||||
const listOfItem = item.taskListId ? safeTaskLists.find((l) => l.id === item.taskListId) : undefined;
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
{item.canEdit && (
|
||||
<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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeletePermanent(item.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
onClick={() => handlePurgeTrashed(item)}
|
||||
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"
|
||||
aria-label="Endgültig löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { SharedList, Thought } from '../../lib/types';
|
||||
import { addDays, formatDue } from '../../lib/due';
|
||||
@@ -32,6 +33,9 @@ interface TriageDeckProps {
|
||||
onDeckChange: () => void;
|
||||
onOpenDocument: (docId: string) => void;
|
||||
onFocusCapture: () => void;
|
||||
// The triage's own trash (TriageTrash): thoughts thrown away while sorting
|
||||
discardedCount: number;
|
||||
onOpenTrash: () => void;
|
||||
}
|
||||
|
||||
interface SwipeableQueueItemProps {
|
||||
@@ -219,6 +223,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
lists,
|
||||
onDeckChange,
|
||||
onFocusCapture,
|
||||
discardedCount,
|
||||
onOpenTrash,
|
||||
}) => {
|
||||
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
||||
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
|
||||
@@ -469,6 +475,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
Letzte Aktion rückgängig
|
||||
</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>
|
||||
);
|
||||
@@ -488,6 +501,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{lastTriaged && (
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
@@ -497,6 +511,14 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
Rückgängig
|
||||
</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>
|
||||
|
||||
{/* 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}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
discardedCount={0}
|
||||
onOpenTrash={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -100,6 +102,8 @@ describe('TriageDeck single-list shortcut', () => {
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
discardedCount={0}
|
||||
onOpenTrash={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -123,6 +127,8 @@ describe('TriageDeck single-list shortcut', () => {
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
discardedCount={0}
|
||||
onOpenTrash={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -149,6 +155,8 @@ describe('TriageDeck single-list shortcut', () => {
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
discardedCount={0}
|
||||
onOpenTrash={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -171,6 +179,8 @@ describe('TriageDeck single-list shortcut', () => {
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={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 { TrashedDocument } from '../../lib/types';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { formatDeletedAt } from '../../lib/due';
|
||||
|
||||
interface TrashViewProps {
|
||||
items: TrashedDocument[];
|
||||
@@ -12,12 +13,6 @@ interface TrashViewProps {
|
||||
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
|
||||
// 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 }) => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
DashboardStats,
|
||||
Document,
|
||||
TrashedDocument,
|
||||
TrashedTask,
|
||||
Retention,
|
||||
JournalDay,
|
||||
PushDevice,
|
||||
BackupFile,
|
||||
@@ -302,6 +304,7 @@ export const api = {
|
||||
notificationSettings?: NotificationSettings;
|
||||
doneRetentionDays?: number;
|
||||
voiceInput?: '' | 'browser';
|
||||
retention?: Retention;
|
||||
}): Promise<User> {
|
||||
return request<User>('/api/v1/auth/settings', {
|
||||
method: 'PATCH',
|
||||
@@ -479,6 +482,24 @@ export const api = {
|
||||
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 }> {
|
||||
return request<{ purged: number }>('/api/v1/thoughts/discarded/purge', { method: 'DELETE' });
|
||||
},
|
||||
|
||||
@@ -48,3 +48,10 @@ export const formatDue = (due: string | undefined, now: Date = new Date()): stri
|
||||
else label = `${pad(d)}.${pad(m)}.${y}`;
|
||||
return time ? `${label}, ${time}` : label;
|
||||
};
|
||||
|
||||
/** When something went to a trash, e.g. "01.11.2026, 14:05". */
|
||||
export const formatDeletedAt = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
@@ -115,6 +115,9 @@ export interface User {
|
||||
doneRetentionDays?: number;
|
||||
// Voice input: '' = the server's Whisper (default), 'browser' = the browser's recognition
|
||||
voiceInput?: '' | 'browser';
|
||||
// How long finished and deleted things are kept, in days (0 = for good; only when allowed)
|
||||
retention?: Retention;
|
||||
retentionNeverAllowed?: boolean;
|
||||
organizationId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -293,6 +296,9 @@ export interface SystemSettings {
|
||||
backupEnabled?: boolean;
|
||||
backupTime?: string;
|
||||
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. */
|
||||
@@ -382,6 +388,26 @@ export interface JournalDay {
|
||||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -2,16 +2,19 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// startDoneCleanupTicker deletes completed tasks whose delay ran out (storage/done_cleanup.go), once
|
||||
// shortly after the start and then every hour.
|
||||
// startDoneCleanupTicker runs the retention delays (storage/retention.go): completed tasks into the
|
||||
// task trash, the trashes and ticked-off list items emptied; once shortly after the start and then
|
||||
// every hour.
|
||||
func (s *Server) startDoneCleanupTicker() {
|
||||
time.Sleep(2 * time.Minute)
|
||||
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) {
|
||||
deleted, err := s.store.PurgeExpiredDoneTasks(now)
|
||||
res, err := s.store.PurgeExpired(now, s.allowNeverRetention())
|
||||
if err != nil {
|
||||
slog.Error("Aufräumen erledigter Aufgaben fehlgeschlagen", "error", err)
|
||||
return
|
||||
slog.Error("Aufräumen nach Aufbewahrungsfristen fehlgeschlagen", "error", err)
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
slog.Info("Erledigte Aufgaben aufgeräumt", "count", len(deleted))
|
||||
if n := len(res.TasksToTrash) + len(res.TrashedTasks) + res.Discarded + len(res.Notes) + len(res.ListItems); n > 0 {
|
||||
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.
|
||||
@@ -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) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
@@ -57,3 +72,59 @@ func (s *Server) handleClearDoneTasks(w http.ResponseWriter, r *http.Request) {
|
||||
s.broadcastDeletedTasks(deleted)
|
||||
writeJSON(w, http.StatusOK, map[string]int{"deleted": len(deleted)})
|
||||
}
|
||||
|
||||
// --- Task trash (storage/task_trash.go) ---
|
||||
|
||||
// handleGetTaskTrash: GET /tasks/trash?taskListId= – the trashed tasks the user can see.
|
||||
func (s *Server) handleGetTaskTrash(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.GetTaskTrash(auth.GetUserID(r.Context()), r.URL.Query().Get("taskListId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// handleRestoreTrashedTask puts a task back and tells everyone who sees it.
|
||||
func (s *Server) handleRestoreTrashedTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, err := s.store.RestoreTrashedTask(auth.GetUserID(r.Context()), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeTaskTrashError(w, err)
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
|
||||
writeJSON(w, http.StatusOK, task)
|
||||
}
|
||||
|
||||
func (s *Server) handlePurgeTrashedTask(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.PurgeTrashedTask(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil {
|
||||
writeTaskTrashError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// handleEmptyTaskTrash: POST /tasks/trash/empty {taskListId} – one list or all the user may edit.
|
||||
func (s *Server) handleEmptyTaskTrash(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TaskListID string `json:"taskListId"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
ids, err := s.store.EmptyTaskTrash(auth.GetUserID(r.Context()), req.TaskListID)
|
||||
if err != nil {
|
||||
writeTaskTrashError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"deleted": len(ids)})
|
||||
}
|
||||
|
||||
func writeTaskTrashError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNotInTaskTrash):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, storage.ErrTaskListReadOnly):
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -340,6 +340,11 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys
|
||||
out.RegistrationVerifyEmail = s.cfg.RegistrationVerifyEmail == "true"
|
||||
out.RegistrationVerifyViaEnv = true
|
||||
}
|
||||
if s.cfg.RetentionAllowNever != nil {
|
||||
allow := *s.cfg.RetentionAllowNever
|
||||
out.RetentionAllowNever = &allow
|
||||
out.RetentionNeverViaEnv = true
|
||||
}
|
||||
out.EmailLinksAvailable = s.emailLinksAvailable()
|
||||
return &out
|
||||
}
|
||||
@@ -372,6 +377,10 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
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()
|
||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -384,7 +393,11 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
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) {
|
||||
|
||||
@@ -404,8 +404,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
stats, _ := s.store.GetDashboardStats(userID)
|
||||
ns := s.store.NotificationSettingsOf(userID)
|
||||
user.NotificationSettings = &ns
|
||||
user.DoneRetentionDays = s.store.DoneRetentionDays(userID)
|
||||
user.VoiceInput = s.store.VoiceInput(userID)
|
||||
s.fillUserPreferences(user)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"user": user,
|
||||
"stats": stats,
|
||||
@@ -519,6 +518,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
DoneRetentionDays *int `json:"doneRetentionDays"`
|
||||
// Voice input: "" (Whisper server, the default) or "browser"
|
||||
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 {
|
||||
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 == 0 && !s.allowNeverRetention() {
|
||||
writeError(w, http.StatusBadRequest, storage.ErrRetentionNever.Error())
|
||||
return
|
||||
}
|
||||
if err := s.store.SetDoneRetentionDays(userID, *req.DoneRetentionDays); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -617,10 +622,15 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
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)
|
||||
u.NotificationSettings = &ns
|
||||
u.DoneRetentionDays = s.store.DoneRetentionDays(userID)
|
||||
u.VoiceInput = s.store.VoiceInput(userID)
|
||||
s.fillUserPreferences(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)
|
||||
}
|
||||
|
||||
// fillUserPreferences adds the settings kept outside the user row's own fields.
|
||||
func (s *Server) fillUserPreferences(u *domain.User) {
|
||||
allowNever := s.allowNeverRetention()
|
||||
r := s.store.RetentionOf(u.ID, allowNever)
|
||||
u.Retention = &r
|
||||
u.RetentionNeverAllowed = allowNever
|
||||
u.DoneRetentionDays = r.DoneTasks
|
||||
u.VoiceInput = s.store.VoiceInput(u.ID)
|
||||
}
|
||||
+1
-1
@@ -550,7 +550,7 @@ var mcpTools = []mcpTool{
|
||||
},
|
||||
{
|
||||
Name: "delete_task",
|
||||
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,
|
||||
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) {
|
||||
|
||||
@@ -207,6 +207,10 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask)
|
||||
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
|
||||
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)
|
||||
|
||||
// Task Lists & Shares
|
||||
|
||||
@@ -54,6 +54,9 @@ type Config struct {
|
||||
// Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login
|
||||
Legal 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 SpeechConfig
|
||||
|
||||
@@ -254,6 +257,7 @@ func Load() *Config {
|
||||
DemoMode: demoMode,
|
||||
DemoResetMinutes: demoResetMinutes(),
|
||||
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
|
||||
RetentionAllowNever: envBoolPtr("RETENTION_ALLOW_NEVER"),
|
||||
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
@@ -369,3 +373,16 @@ func boolEnvOrEmpty(key string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// envBoolPtr reads true/false; nil when unset or anything else.
|
||||
func envBoolPtr(key string) *bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||
case "true", "1", "yes":
|
||||
v := true
|
||||
return &v
|
||||
case "false", "0", "no":
|
||||
v := false
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -145,6 +145,10 @@ type User struct {
|
||||
DoneRetentionDays int `json:"doneRetentionDays"`
|
||||
// VoiceInput: "" uses the server's Whisper (when set up), "browser" the browser's recognition
|
||||
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.
|
||||
@@ -410,6 +414,10 @@ type SystemSettings struct {
|
||||
BackupEnabled bool `json:"backupEnabled"`
|
||||
BackupTime string `json:"backupTime"`
|
||||
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
|
||||
@@ -682,3 +690,13 @@ type FolderShare struct {
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Retention: delays in days for completed tasks, the task, triage and notes trash and ticked-off list
|
||||
// items; 0 = keep for good.
|
||||
type Retention struct {
|
||||
DoneTasks int `json:"doneTasks"`
|
||||
TaskTrash int `json:"taskTrash"`
|
||||
TriageTrash int `json:"triageTrash"`
|
||||
NoteTrash int `json:"noteTrash"`
|
||||
ListDone int `json:"listDone"`
|
||||
}
|
||||
@@ -1,23 +1,18 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Completed tasks are deleted a while after they were ticked off, so the "Erledigt" tab does not fill
|
||||
// up for good. Every user chooses the delay (done_retention_days, 0 = never); for tasks in a task list
|
||||
// the owner's choice counts, for personal tasks the user's own. 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}
|
||||
// Completed tasks move to the task trash (task_trash.go) a while after they were ticked off, so the
|
||||
// "Erledigt" tab does not fill up for good. Every user chooses the delay (done_retention_days, see
|
||||
// retention.go); for tasks in a task list the owner's choice counts, for personal tasks the user's own.
|
||||
// thoughts.completed_at is kept by two triggers, so every way of completing a task (app, Kanban, note
|
||||
// checkbox, API) sets it.
|
||||
|
||||
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 {
|
||||
if err := s.addColumnIfMissing("users", "done_retention_days", "INTEGER NOT NULL DEFAULT 30"); err != nil {
|
||||
return err
|
||||
@@ -47,20 +42,16 @@ func (s *Store) DoneRetentionDays(userID string) int {
|
||||
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 {
|
||||
ok := false
|
||||
for _, d := range DoneRetentionChoices {
|
||||
ok = ok || d == days
|
||||
}
|
||||
if !ok {
|
||||
if !validRetention(days) {
|
||||
return ErrInvalidRetention
|
||||
}
|
||||
_, err := s.db.Exec(`UPDATE users SET done_retention_days = ? WHERE id = ?`, days, userID)
|
||||
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 {
|
||||
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
|
||||
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
|
||||
// delay, or the user's own for personal tasks. A note checkbox linked to such a task stays as a plain
|
||||
// checkbox (the link lives on the task).
|
||||
func (s *Store) PurgeExpiredDoneTasks(now time.Time) ([]DeletedTask, error) {
|
||||
// PurgeExpiredDoneTasks moves completed tasks whose delay has run out at now into the task trash: the
|
||||
// task list owner's delay, or the user's own for personal tasks. A note checkbox linked to such a task
|
||||
// stays as a plain checkbox (the link lives on the task). Without allowNever, "never" counts as the
|
||||
// default delay.
|
||||
func (s *Store) PurgeExpiredDoneTasks(now time.Time, allowNever bool) ([]DeletedTask, error) {
|
||||
def := neverAs(allowNever, DefaultDoneRetentionDays)
|
||||
rows, err := s.db.Query(`
|
||||
SELECT t.id, t.user_id, t.task_list_id
|
||||
FROM thoughts t
|
||||
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)
|
||||
WHERE `+completedTaskSQL+`
|
||||
AND t.completed_at != '' AND u.done_retention_days > 0
|
||||
AND t.completed_at < strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || u.done_retention_days || ' days')`,
|
||||
now.UTC().Format("2006-01-02 15:04:05"))
|
||||
AND t.completed_at != '' AND `+daysSQL("u.done_retention_days")+` > 0
|
||||
AND t.completed_at < strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || `+daysSQL("u.done_retention_days")+` || ' days')`,
|
||||
def, sqlNow(now), def)
|
||||
if err != nil {
|
||||
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
|
||||
// the user may delete: own personal tasks and tasks in lists they own or edit.
|
||||
// ClearDoneTasks moves the completed tasks of one task list ("" = all lists the user can see) into the
|
||||
// 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) {
|
||||
if taskListID != "" {
|
||||
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
|
||||
@@ -99,7 +92,7 @@ func (s *Store) ClearDoneTasks(userID, taskListID string) ([]DeletedTask, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.deleteTasks(rows)
|
||||
return s.trashTaskRows(rows, userID)
|
||||
}
|
||||
lists, err := s.GetTaskLists(userID)
|
||||
if err != nil {
|
||||
@@ -122,15 +115,15 @@ func (s *Store) ClearDoneTasks(userID, taskListID string) ([]DeletedTask, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.deleteTasks(rows)
|
||||
return s.trashTaskRows(rows, userID)
|
||||
}
|
||||
|
||||
// deleteTasks deletes the tasks the query rows name and returns them.
|
||||
func (s *Store) deleteTasks(rows interface {
|
||||
// trashTaskRows moves the tasks the query rows name into the task trash and returns them.
|
||||
func (s *Store) trashTaskRows(rows interface {
|
||||
Next() bool
|
||||
Scan(dest ...any) error
|
||||
Close() error
|
||||
}) ([]DeletedTask, error) {
|
||||
}, byUserID string) ([]DeletedTask, error) {
|
||||
var out []DeletedTask
|
||||
for rows.Next() {
|
||||
var d DeletedTask
|
||||
@@ -140,7 +133,7 @@ func (s *Store) deleteTasks(rows interface {
|
||||
}
|
||||
_ = rows.Close()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
openTask, _ := s.CreateThoughtWithDetails(anna.ID, "offen", "normal", "manual", "", "", "todo", "")
|
||||
|
||||
deleted, err := s.PurgeExpiredDoneTasks(now)
|
||||
deleted, err := s.PurgeExpiredDoneTasks(now, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestPurgeExpiredDoneTasks(t *testing.T) {
|
||||
}
|
||||
// Edge: exactly at the limit nothing happens yet
|
||||
edge := doneTask(t, s, anna.ID, "", "Grenze", "2026-11-15T00:30:00Z")
|
||||
_, _ = s.PurgeExpiredDoneTasks(now)
|
||||
_, _ = s.PurgeExpiredDoneTasks(now, true)
|
||||
if !exists(s, edge) {
|
||||
t.Fatal("a task completed exactly 30 days ago stays until the limit has passed")
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ var migrations = []migration{
|
||||
{13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin},
|
||||
{14, "Erledigte Aufgaben aufräumen", (*Store).migrateDoneCleanup},
|
||||
{15, "Spracheingabe pro Person", (*Store).migrateVoiceInput},
|
||||
{16, "Aufbewahrung und Papierkorb für Aufgaben", (*Store).migrateRetention},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// How long finished and deleted things are kept, chosen by everyone in the account settings
|
||||
// ("Aufbewahrung"): completed tasks (then into the task trash), the task trash, the triage trash
|
||||
// (discarded thoughts), the notes trash and ticked-off list items (deleted for good). In shared task
|
||||
// lists, lists and notes the owner's choice counts. 0 keeps things for good; the admin can switch that
|
||||
// off (RETENTION_ALLOW_NEVER or the system settings), then 0 counts as the area's default.
|
||||
//
|
||||
// Things that were already finished or deleted before this feature count from its update on
|
||||
// (retention_since), so nothing disappeared at once.
|
||||
|
||||
// RetentionChoices are the delays offered in the app, in days; 0 = never.
|
||||
var RetentionChoices = []int{0, 7, 14, 30, 90, 365, 730}
|
||||
|
||||
// Retention: the delays of one user, in days.
|
||||
type Retention = domain.Retention
|
||||
|
||||
// DefaultRetention: what everyone starts with, and what "never" becomes when it is not allowed.
|
||||
var DefaultRetention = Retention{DoneTasks: DefaultDoneRetentionDays, TaskTrash: 30, TriageTrash: 14, NoteTrash: 30, ListDone: 7}
|
||||
|
||||
var (
|
||||
ErrInvalidRetention = errors.New("Ungültige Frist (erlaubt: 7, 14, 30, 90, 365, 730 Tage oder nie)")
|
||||
ErrRetentionNever = errors.New("Dauerhaftes Aufbewahren ist auf diesem Server abgeschaltet")
|
||||
)
|
||||
|
||||
// retentionFields maps each area to its users column.
|
||||
var retentionFields = []struct {
|
||||
column string
|
||||
field func(r *Retention) *int
|
||||
}{
|
||||
{"done_retention_days", func(r *Retention) *int { return &r.DoneTasks }},
|
||||
{"task_trash_days", func(r *Retention) *int { return &r.TaskTrash }},
|
||||
{"triage_trash_days", func(r *Retention) *int { return &r.TriageTrash }},
|
||||
{"note_trash_days", func(r *Retention) *int { return &r.NoteTrash }},
|
||||
{"list_done_days", func(r *Retention) *int { return &r.ListDone }},
|
||||
}
|
||||
|
||||
func (s *Store) migrateRetention() error {
|
||||
d := DefaultRetention
|
||||
for _, c := range []struct {
|
||||
column string
|
||||
days int
|
||||
}{{"task_trash_days", d.TaskTrash}, {"triage_trash_days", d.TriageTrash}, {"note_trash_days", d.NoteTrash}, {"list_done_days", d.ListDone}} {
|
||||
if err := s.addColumnIfMissing("users", c.column, "INTEGER NOT NULL DEFAULT "+strconv.Itoa(c.days)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.execInTx(
|
||||
stmt(`CREATE TABLE IF NOT EXISTS trashed_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
task_list_id TEXT NOT NULL DEFAULT '',
|
||||
owner_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
is_completed INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT NOT NULL,
|
||||
assignees TEXT NOT NULL DEFAULT '[]',
|
||||
trashed_at TEXT NOT NULL,
|
||||
trashed_by TEXT NOT NULL DEFAULT ''
|
||||
)`),
|
||||
stmt(`CREATE INDEX IF NOT EXISTS idx_trashed_tasks_list ON trashed_tasks(task_list_id, trashed_at)`),
|
||||
stmt(`CREATE INDEX IF NOT EXISTS idx_trashed_tasks_owner ON trashed_tasks(owner_id, trashed_at)`),
|
||||
stmt(`INSERT OR IGNORE INTO system_settings (key, value) VALUES ('retention_since', ?)`, domain.NowISO()),
|
||||
)
|
||||
}
|
||||
|
||||
func validRetention(days int) bool {
|
||||
for _, d := range RetentionChoices {
|
||||
if d == days {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RetentionOf returns the user's delays; without allowNever a stored 0 counts as the default.
|
||||
func (s *Store) RetentionOf(userID string, allowNever bool) Retention {
|
||||
r := DefaultRetention
|
||||
_ = s.db.QueryRow(`SELECT done_retention_days, task_trash_days, triage_trash_days, note_trash_days, list_done_days FROM users WHERE id = ?`, userID).
|
||||
Scan(&r.DoneTasks, &r.TaskTrash, &r.TriageTrash, &r.NoteTrash, &r.ListDone)
|
||||
if !allowNever {
|
||||
def := DefaultRetention
|
||||
for _, f := range retentionFields {
|
||||
if v := f.field(&r); *v == 0 {
|
||||
*v = *f.field(&def)
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// SetRetention stores all delays; each must be one of RetentionChoices, 0 only when allowed.
|
||||
func (s *Store) SetRetention(userID string, r Retention, allowNever bool) error {
|
||||
stmts := []sqlStmt{}
|
||||
for _, f := range retentionFields {
|
||||
v := *f.field(&r)
|
||||
if !validRetention(v) {
|
||||
return ErrInvalidRetention
|
||||
}
|
||||
if v == 0 && !allowNever {
|
||||
return ErrRetentionNever
|
||||
}
|
||||
stmts = append(stmts, stmt(`UPDATE users SET `+f.column+` = ? WHERE id = ?`, v, userID))
|
||||
}
|
||||
return s.execInTx(stmts...)
|
||||
}
|
||||
|
||||
// retentionSince: when retention started; older items count from then on.
|
||||
func (s *Store) retentionSince() string {
|
||||
var v string
|
||||
_ = s.db.QueryRow(`SELECT value FROM system_settings WHERE key = 'retention_since'`).Scan(&v)
|
||||
return v
|
||||
}
|
||||
|
||||
// daysSQL: the delay in days of an owner column, with "never" (0) replaced by def when it is not allowed.
|
||||
func daysSQL(column string) string {
|
||||
return `COALESCE(NULLIF(` + column + `, 0), ?)`
|
||||
}
|
||||
|
||||
// neverAs: what a stored 0 means in daysSQL: 0 (keep) when allowed, else the area's default.
|
||||
func neverAs(allowNever bool, def int) int {
|
||||
if allowNever {
|
||||
return 0
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// cutoffSQL: true when the timestamp column (counted from retention_since at the earliest) lies more
|
||||
// than the delay before now. Arguments: since, since, now (in this order).
|
||||
func cutoffSQL(tsColumn, days string) string {
|
||||
return `(CASE WHEN ` + tsColumn + ` = '' OR ` + tsColumn + ` < ? THEN ? ELSE ` + tsColumn + ` END)
|
||||
< strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || ` + days + ` || ' days')`
|
||||
}
|
||||
|
||||
func sqlNow(now time.Time) string { return now.UTC().Format("2006-01-02 15:04:05") }
|
||||
|
||||
// PurgeResult: what PurgeExpired removed, for the realtime messages.
|
||||
type PurgeResult struct {
|
||||
TasksToTrash []DeletedTask
|
||||
TrashedTasks []string // purged from the task trash for good
|
||||
Discarded int
|
||||
Notes []string
|
||||
ListItems []PurgedListItem
|
||||
}
|
||||
|
||||
// PurgedListItem: a ticked-off list item deleted after its delay.
|
||||
type PurgedListItem struct {
|
||||
ID, ListID string
|
||||
}
|
||||
|
||||
// PurgeExpired runs every delay at now: completed tasks go to the task trash, the trashes and ticked-off
|
||||
// list items are emptied for good.
|
||||
func (s *Store) PurgeExpired(now time.Time, allowNever bool) (PurgeResult, error) {
|
||||
var res PurgeResult
|
||||
var err error
|
||||
if res.TasksToTrash, err = s.PurgeExpiredDoneTasks(now, allowNever); err != nil {
|
||||
return res, err
|
||||
}
|
||||
if res.TrashedTasks, err = s.purgeExpiredTaskTrash(now, allowNever); err != nil {
|
||||
return res, err
|
||||
}
|
||||
since := s.retentionSince()
|
||||
if since == "" {
|
||||
since = domain.NowISO()
|
||||
}
|
||||
d := DefaultRetention
|
||||
|
||||
// Triage trash: discarded thoughts, from the moment they were discarded (triaged_at)
|
||||
r, err := s.db.Exec(`DELETE FROM thoughts WHERE status = 'discarded' AND id IN (
|
||||
SELECT t.id FROM thoughts t JOIN users u ON u.id = t.user_id
|
||||
WHERE t.status = 'discarded' AND `+daysSQL("u.triage_trash_days")+` > 0
|
||||
AND `+cutoffSQL("t.triaged_at", daysSQL("u.triage_trash_days"))+`)`,
|
||||
neverAs(allowNever, d.TriageTrash), since, since, sqlNow(now), neverAs(allowNever, d.TriageTrash))
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
n, _ := r.RowsAffected()
|
||||
res.Discarded = int(n)
|
||||
|
||||
// Notes trash: the note owner's delay, from the deletion
|
||||
rows, err := s.db.Query(`SELECT d.id FROM documents d JOIN users u ON u.id = d.user_id
|
||||
WHERE d.is_archived = 1 AND `+daysSQL("u.note_trash_days")+` > 0
|
||||
AND `+cutoffSQL("d.archived_at", daysSQL("u.note_trash_days")),
|
||||
neverAs(allowNever, d.NoteTrash), since, since, sqlNow(now), neverAs(allowNever, d.NoteTrash))
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
var docIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
docIDs = append(docIDs, id)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
for _, id := range docIDs {
|
||||
if err := s.hardDeleteDocument(id); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Notes = append(res.Notes, id)
|
||||
}
|
||||
|
||||
// Ticked-off list items: the list owner's delay, from ticking them off
|
||||
rows, err = s.db.Query(`SELECT li.id, li.list_id FROM list_items li JOIN lists l ON l.id = li.list_id JOIN users u ON u.id = l.user_id
|
||||
WHERE li.is_completed = 1 AND `+daysSQL("u.list_done_days")+` > 0
|
||||
AND `+cutoffSQL("li.completed_at", daysSQL("u.list_done_days")),
|
||||
neverAs(allowNever, d.ListDone), since, since, sqlNow(now), neverAs(allowNever, d.ListDone))
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var it PurgedListItem
|
||||
if rows.Scan(&it.ID, &it.ListID) == nil {
|
||||
res.ListItems = append(res.ListItems, it)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
for _, it := range res.ListItems {
|
||||
if _, err := s.db.Exec(`DELETE FROM list_items WHERE id = ?`, it.ID); err != nil {
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// RetentionAllowNeverSetting: the admin's switch in the system settings (default: allowed).
|
||||
func (s *Store) RetentionAllowNeverSetting() bool {
|
||||
var v string
|
||||
_ = s.db.QueryRow(`SELECT value FROM system_settings WHERE key = 'retention_allow_never'`).Scan(&v)
|
||||
return v != "false"
|
||||
}
|
||||
@@ -176,6 +176,9 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
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
|
||||
if settings.RetentionAllowNever == nil {
|
||||
allow := true
|
||||
settings.RetentionAllowNever = &allow
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
@@ -269,5 +276,9 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
settings.RegistrationDomains,
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// Deleted tasks go to a trash first: deleting a task, "Erledigte leeren" and the automatic clean-up of
|
||||
// completed tasks. The task leaves the thoughts table with its whole row and its assignees kept as a
|
||||
// copy (trashed_tasks), so no query on tasks has to know about the trash; restoring puts the row back
|
||||
// as it was. Everyone who sees a task list sees its trashed tasks; restoring and deleting for good need
|
||||
// edit rights, like changing the list. The delay of the list owner (or of the user for personal tasks)
|
||||
// empties the trash.
|
||||
|
||||
var ErrNotInTaskTrash = errors.New("Aufgabe nicht im Papierkorb")
|
||||
|
||||
// TrashedTask is one entry of the task trash.
|
||||
type TrashedTask struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
TrashedAt string `json:"trashedAt"`
|
||||
TrashedBy string `json:"trashedBy"` // display name
|
||||
CanEdit bool `json:"canEdit"`
|
||||
}
|
||||
|
||||
// trashTasks moves tasks into the trash, each with its row and assignees, in one transaction per task.
|
||||
func (s *Store) trashTasks(ids []string, byUserID string) error {
|
||||
for _, id := range ids {
|
||||
if err := s.trashTask(id, byUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) trashTask(id, byUserID string) error {
|
||||
rows, err := s.db.Query(`SELECT * FROM thoughts WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cols, _ := rows.Columns()
|
||||
data := map[string]any{}
|
||||
found := false
|
||||
if rows.Next() {
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
for i, c := range cols {
|
||||
if b, ok := vals[i].([]byte); ok {
|
||||
data[c] = string(b)
|
||||
} else {
|
||||
data[c] = vals[i]
|
||||
}
|
||||
}
|
||||
found = true
|
||||
}
|
||||
_ = rows.Close()
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
str := func(k string) string { v, _ := data[k].(string); return v }
|
||||
userID, listID := str("user_id"), str("task_list_id")
|
||||
owner := userID
|
||||
if listID != "" {
|
||||
_ = s.db.QueryRow(`SELECT user_id FROM task_lists WHERE id = ?`, listID).Scan(&owner)
|
||||
}
|
||||
done := false
|
||||
switch v := data["is_completed"].(type) {
|
||||
case int64:
|
||||
done = v == 1
|
||||
}
|
||||
assignees := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ?`, id)
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
assJSON, _ := json.Marshal(assignees)
|
||||
return s.execInTx(
|
||||
stmt(`INSERT OR REPLACE INTO trashed_tasks (id, user_id, task_list_id, owner_id, content, is_completed, data, assignees, trashed_at, trashed_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, userID, listID, owner, str("content"), boolInt(done), string(dataJSON), string(assJSON), domain.NowISO(), byUserID),
|
||||
stmt(`DELETE FROM thoughts WHERE id = ?`, id),
|
||||
)
|
||||
}
|
||||
|
||||
// taskTrashScope: entries the user can see: own personal tasks and tasks of lists they can see.
|
||||
const taskTrashScope = `((tt.user_id = ? AND tt.task_list_id = '') OR tt.task_list_id IN (` + accessibleTaskListsSQL + `))`
|
||||
|
||||
func taskTrashArgs(userID string) []any {
|
||||
return append([]any{userID}, accessibleTaskListArgs(userID)...)
|
||||
}
|
||||
|
||||
// GetTaskTrash lists the trashed tasks of one task list ("" = all the user can see), newest first.
|
||||
func (s *Store) GetTaskTrash(userID, taskListID string) ([]TrashedTask, error) {
|
||||
q := `SELECT tt.id, tt.content, tt.task_list_id, tt.is_completed, tt.trashed_at, COALESCE(u.display_name, '')
|
||||
FROM trashed_tasks tt LEFT JOIN users u ON u.id = tt.trashed_by
|
||||
WHERE ` + taskTrashScope
|
||||
args := taskTrashArgs(userID)
|
||||
if taskListID != "" {
|
||||
q += ` AND tt.task_list_id = ?`
|
||||
args = append(args, taskListID)
|
||||
}
|
||||
rows, err := s.db.Query(q+` ORDER BY tt.trashed_at DESC`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []TrashedTask{}
|
||||
for rows.Next() {
|
||||
var t TrashedTask
|
||||
var done int
|
||||
if rows.Scan(&t.ID, &t.Content, &t.TaskListID, &done, &t.TrashedAt, &t.TrashedBy) == nil {
|
||||
t.IsCompleted = done == 1
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
for i := range out {
|
||||
out[i].CanEdit = out[i].TaskListID == "" || s.CanEditTaskList(userID, out[i].TaskListID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// trashedTaskFor loads an entry the user may restore or delete.
|
||||
func (s *Store) trashedTaskFor(userID, id string) (data map[string]any, assignees []string, listID string, err error) {
|
||||
var raw, rawAss, owner string
|
||||
err = s.db.QueryRow(`SELECT tt.data, tt.assignees, tt.task_list_id, tt.user_id FROM trashed_tasks tt WHERE tt.id = ? AND `+taskTrashScope,
|
||||
append([]any{id}, taskTrashArgs(userID)...)...).Scan(&raw, &rawAss, &listID, &owner)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil, "", ErrNotInTaskTrash
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if listID != "" && !s.CanEditTaskList(userID, listID) {
|
||||
return nil, nil, "", ErrTaskListReadOnly
|
||||
}
|
||||
_ = json.Unmarshal([]byte(raw), &data)
|
||||
_ = json.Unmarshal([]byte(rawAss), &assignees)
|
||||
for k, v := range data {
|
||||
if f, ok := v.(float64); ok && f == float64(int64(f)) {
|
||||
data[k] = int64(f) // JSON numbers come back as floats; the columns hold integers
|
||||
}
|
||||
}
|
||||
data["task_list_id"] = listID // a deleted list made it a personal task
|
||||
return data, assignees, listID, nil
|
||||
}
|
||||
|
||||
// RestoreTrashedTask puts a task back where it was (a list deleted meanwhile: into its creator's own
|
||||
// tasks, see DeleteTaskList). A
|
||||
// completed task starts its delay anew, so the automatic clean-up does not take it again at once.
|
||||
func (s *Store) RestoreTrashedTask(userID, id string) (*domain.Thought, error) {
|
||||
data, assignees, _, err := s.trashedTaskFor(userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols := map[string]bool{}
|
||||
rows, err := s.db.Query(`SELECT name FROM pragma_table_info('thoughts')`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if rows.Scan(&c) == nil {
|
||||
cols[c] = true
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
if done, ok := data["is_completed"].(int64); ok && done == 1 {
|
||||
data["completed_at"] = domain.NowISO()
|
||||
}
|
||||
names, marks, args := "", "", []any{}
|
||||
for k, v := range data {
|
||||
if !cols[k] || v == nil {
|
||||
continue
|
||||
}
|
||||
if names != "" {
|
||||
names += ", "
|
||||
marks += ", "
|
||||
}
|
||||
names += k
|
||||
marks += "?"
|
||||
args = append(args, v)
|
||||
}
|
||||
stmts := []sqlStmt{
|
||||
stmt(`INSERT INTO thoughts (`+names+`) VALUES (`+marks+`)`, args...),
|
||||
stmt(`DELETE FROM trashed_tasks WHERE id = ?`, id),
|
||||
}
|
||||
for _, uid := range assignees {
|
||||
stmts = append(stmts, stmt(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at)
|
||||
SELECT ?, id, ? FROM users WHERE id = ?`, id, domain.NowISO(), uid))
|
||||
}
|
||||
if err := s.execInTx(stmts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.PruneTaskAssignees()
|
||||
return s.GetThoughtByIDUnchecked(id)
|
||||
}
|
||||
|
||||
// PurgeTrashedTask deletes one trashed task for good.
|
||||
func (s *Store) PurgeTrashedTask(userID, id string) error {
|
||||
if _, _, _, err := s.trashedTaskFor(userID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.purgeTrashedTasks([]string{id})
|
||||
}
|
||||
|
||||
// EmptyTaskTrash deletes the trashed tasks of one list ("" = all the user may edit) for good.
|
||||
func (s *Store) EmptyTaskTrash(userID, taskListID string) ([]string, error) {
|
||||
if taskListID != "" {
|
||||
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
items, err := s.GetTaskTrash(userID, taskListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := []string{}
|
||||
for _, it := range items {
|
||||
if it.CanEdit {
|
||||
ids = append(ids, it.ID)
|
||||
}
|
||||
}
|
||||
return ids, s.purgeTrashedTasks(ids)
|
||||
}
|
||||
|
||||
func (s *Store) purgeTrashedTasks(ids []string) error {
|
||||
for _, id := range ids {
|
||||
if err := s.execInTx(
|
||||
stmt(`DELETE FROM trashed_tasks WHERE id = ?`, id),
|
||||
stmt(`DELETE FROM task_watchers WHERE task_id = ?`, id),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// purgeExpiredTaskTrash empties the task trash after the owner's delay (list owner, else the creator).
|
||||
func (s *Store) purgeExpiredTaskTrash(now time.Time, allowNever bool) ([]string, error) {
|
||||
def := neverAs(allowNever, DefaultRetention.TaskTrash)
|
||||
rows, err := s.db.Query(`SELECT tt.id FROM trashed_tasks tt JOIN users u ON u.id = tt.owner_id
|
||||
WHERE `+daysSQL("u.task_trash_days")+` > 0
|
||||
AND tt.trashed_at < strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || `+daysSQL("u.task_trash_days")+` || ' days')`,
|
||||
def, sqlNow(now), def)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
return ids, s.purgeTrashedTasks(ids)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func inTaskTrash(s *Store, id string) bool {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM trashed_tasks WHERE id = ?`, id).Scan(&n)
|
||||
return n == 1
|
||||
}
|
||||
|
||||
// Deleting a task, clearing completed ones and the automatic clean-up all go through the trash;
|
||||
// restoring brings the task back with its fields and assignees.
|
||||
func TestTaskTrashRoundTrip(t *testing.T) {
|
||||
s, err := NewStore(filepath.Join(t.TempDir(), "trash.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
openSharing(t, s)
|
||||
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
ben, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||
carla, _ := s.CreateUser("carla@example.com", "hash", "Carla", "member")
|
||||
list, _ := s.CreateTaskList(anna.ID, "", "", "Haushalt", "", "")
|
||||
_, _ = s.AddTaskListShare(list.ID, "user", ben.ID, "editor", anna.ID)
|
||||
_, _ = s.AddTaskListShare(list.ID, "user", carla.ID, "viewer", anna.ID)
|
||||
|
||||
task, _ := s.CreateThoughtWithDetails(anna.ID, "Fenster putzen #haus", "high", "manual", "2026-11-20", list.ID, "in_progress", "Innen und außen")
|
||||
if _, err := s.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-20", list.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SetTaskAssignees(anna.ID, task.ID, []string{ben.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := s.GetThoughtByIDUnchecked(task.ID)
|
||||
if ass := s.getTaskAssignees(task.ID); len(ass) != 1 {
|
||||
t.Fatalf("setup: assignee missing: %v", ass)
|
||||
}
|
||||
|
||||
if err := s.DeleteThought(ben.ID, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists(s, task.ID) || !inTaskTrash(s, task.ID) {
|
||||
t.Fatal("a deleted task should leave the list and wait in the trash")
|
||||
}
|
||||
// Everyone who sees the list sees the entry, only editors may act on it
|
||||
if items, _ := s.GetTaskTrash(carla.ID, list.ID); len(items) != 1 || items[0].CanEdit || items[0].TrashedBy != "Ben" {
|
||||
t.Fatalf("viewer should see the entry read-only: %+v", items)
|
||||
}
|
||||
if _, err := s.RestoreTrashedTask(carla.ID, task.ID); err == nil {
|
||||
t.Fatal("a viewer must not restore")
|
||||
}
|
||||
outsider, _ := s.CreateUser("dora@example.com", "hash", "Dora", "member")
|
||||
if items, _ := s.GetTaskTrash(outsider.ID, ""); len(items) != 0 {
|
||||
t.Fatalf("outsiders see nothing: %+v", items)
|
||||
}
|
||||
|
||||
restored, err := s.RestoreTrashedTask(anna.ID, task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inTaskTrash(s, task.ID) || restored.Content != before.Content || restored.Notes != before.Notes || restored.Priority != before.Priority ||
|
||||
restored.DueDate != before.DueDate || restored.TaskListID != list.ID || restored.KanbanStatus != before.KanbanStatus {
|
||||
t.Fatalf("restored task differs:\nbefore %+v\nafter %+v", before, restored)
|
||||
}
|
||||
|
||||
if ass := s.getTaskAssignees(task.ID); len(ass) != 1 || ass[0] != ben.ID {
|
||||
t.Fatalf("assignees should come back: %v", ass)
|
||||
}
|
||||
|
||||
// Deleting for good
|
||||
_ = s.DeleteThought(anna.ID, task.ID)
|
||||
if err := s.PurgeTrashedTask(carla.ID, task.ID); err == nil {
|
||||
t.Fatal("a viewer must not delete for good")
|
||||
}
|
||||
if err := s.PurgeTrashedTask(anna.ID, task.ID); err != nil || inTaskTrash(s, task.ID) {
|
||||
t.Fatalf("purge failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A restored completed task starts its delay anew; the clean-up then waits again.
|
||||
func TestRestoredDoneTaskIsNotTrashedAgainAtOnce(t *testing.T) {
|
||||
s, err := NewStore(filepath.Join(t.TempDir(), "again.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
id := doneTask(t, s, anna.ID, "", "alt", "2020-01-01T08:00:00Z")
|
||||
if moved, _ := s.PurgeExpiredDoneTasks(time.Now(), true); len(moved) != 1 || !inTaskTrash(s, id) {
|
||||
t.Fatalf("old completed task should be in the trash: %+v", moved)
|
||||
}
|
||||
if _, err := s.RestoreTrashedTask(anna.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved, _ := s.PurgeExpiredDoneTasks(time.Now(), true); len(moved) != 0 || !exists(s, id) {
|
||||
t.Fatal("restored task must not go straight back to the trash")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a task list turns its trashed tasks into their creators' own, like its tasks.
|
||||
func TestTaskTrashAfterListDeletion(t *testing.T) {
|
||||
s, err := NewStore(filepath.Join(t.TempDir(), "listgone.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
openSharing(t, s)
|
||||
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
ben, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||
list, _ := s.CreateTaskList(anna.ID, "", "", "Projekt", "", "")
|
||||
_, _ = s.AddTaskListShare(list.ID, "user", ben.ID, "editor", anna.ID)
|
||||
task, _ := s.CreateThoughtWithDetails(ben.ID, "Bens Aufgabe", "normal", "manual", "", list.ID, "todo", "")
|
||||
_, _ = s.UpdateThoughtTriage(ben.ID, task.ID, "keep", "task", "", "", list.ID)
|
||||
_ = s.DeleteThought(ben.ID, task.ID)
|
||||
if err := s.DeleteTaskList(anna.ID, list.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items, _ := s.GetTaskTrash(ben.ID, ""); len(items) != 1 || items[0].TaskListID != "" {
|
||||
t.Fatalf("Ben should find his task in his own trash: %+v", items)
|
||||
}
|
||||
restored, err := s.RestoreTrashedTask(ben.ID, task.ID)
|
||||
if err != nil || restored.TaskListID != "" || restored.UserID != ben.ID {
|
||||
t.Fatalf("should come back as Ben's own task: %+v %v", restored, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every area keeps its delay; older items count from the update on; "never" can be switched off.
|
||||
func TestPurgeExpiredAreas(t *testing.T) {
|
||||
s, err := NewStore(filepath.Join(t.TempDir(), "areas.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
ben, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||
if r := s.RetentionOf(anna.ID, true); r != DefaultRetention {
|
||||
t.Fatalf("defaults: %+v", r)
|
||||
}
|
||||
if err := s.SetRetention(ben.ID, Retention{}, false); err != ErrRetentionNever {
|
||||
t.Fatalf("never must be refused when switched off: %v", err)
|
||||
}
|
||||
if err := s.SetRetention(ben.ID, Retention{DoneTasks: 12}, true); err != ErrInvalidRetention {
|
||||
t.Fatalf("only offered delays: %v", err)
|
||||
}
|
||||
if err := s.SetRetention(ben.ID, Retention{}, true); err != nil { // Ben keeps everything
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := s.RetentionOf(ben.ID, false); r != DefaultRetention {
|
||||
t.Fatalf("with never switched off Ben gets the defaults: %+v", r)
|
||||
}
|
||||
|
||||
_, _ = s.db.Exec(`UPDATE system_settings SET value = '2026-01-01T00:00:00Z' WHERE key = 'retention_since'`)
|
||||
now := time.Date(2026, 12, 15, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Triage trash (14 days), timestamps before the update count from the update
|
||||
discard := func(user string, at string) string {
|
||||
th, _ := s.CreateThought(user, "weg", "normal", "manual", "")
|
||||
_, _ = s.UpdateThoughtTriage(user, th.ID, "discard", "", "", "", "")
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET triaged_at = ? WHERE id = ?`, at, th.ID)
|
||||
return th.ID
|
||||
}
|
||||
oldDiscard := discard(anna.ID, "2026-11-01T00:00:00Z")
|
||||
newDiscard := discard(anna.ID, "2026-12-10T00:00:00Z")
|
||||
benDiscard := discard(ben.ID, "2026-01-02T00:00:00Z")
|
||||
|
||||
// Notes trash (30 days)
|
||||
doc, _ := s.CreateDocument(anna.ID, "", "Alte Notiz", "", "", nil, nil, false)
|
||||
_ = s.DeleteDocument(anna.ID, doc.ID)
|
||||
_, _ = s.db.Exec(`UPDATE documents SET archived_at = '2026-10-01T00:00:00Z' WHERE id = ?`, doc.ID)
|
||||
|
||||
// Ticked-off list items (7 days, list owner)
|
||||
lst, _ := s.CreateList(anna.ID, "Einkauf", "🛒", "shopping")
|
||||
oldItem, _ := s.CreateListItem(lst.ID, "Milch", "", "", 0, anna.ID)
|
||||
freshItem, _ := s.CreateListItem(lst.ID, "Brot", "", "", 0, anna.ID)
|
||||
openItem, _ := s.CreateListItem(lst.ID, "Käse", "", "", 0, anna.ID)
|
||||
_, _ = s.ToggleListItem(oldItem.ID, true, anna.ID, "Anna")
|
||||
_, _ = s.ToggleListItem(freshItem.ID, true, anna.ID, "Anna")
|
||||
_, _ = s.db.Exec(`UPDATE list_items SET completed_at = '2026-12-01T00:00:00Z' WHERE id = ?`, oldItem.ID)
|
||||
_, _ = s.db.Exec(`UPDATE list_items SET completed_at = '2026-12-14T00:00:00Z' WHERE id = ?`, freshItem.ID)
|
||||
|
||||
res, err := s.PurgeExpired(now, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists(s, oldDiscard) || !exists(s, newDiscard) || !exists(s, benDiscard) || res.Discarded != 1 {
|
||||
t.Fatalf("triage trash wrong: %+v", res)
|
||||
}
|
||||
var docs int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, doc.ID).Scan(&docs)
|
||||
if docs != 0 || len(res.Notes) != 1 {
|
||||
t.Fatalf("old note should be gone: %+v", res)
|
||||
}
|
||||
itemExists := func(id string) bool {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM list_items WHERE id = ?`, id).Scan(&n)
|
||||
return n == 1
|
||||
}
|
||||
if itemExists(oldItem.ID) || !itemExists(freshItem.ID) || !itemExists(openItem.ID) || len(res.ListItems) != 1 {
|
||||
t.Fatalf("list items wrong: %+v", res.ListItems)
|
||||
}
|
||||
|
||||
// With "never" switched off, Ben's discarded thought (older than 14 days after the update) goes too
|
||||
if res, _ := s.PurgeExpired(now, false); exists(s, benDiscard) || res.Discarded != 1 {
|
||||
t.Fatalf("Ben's never should count as 14 days: %+v", res)
|
||||
}
|
||||
|
||||
// Items from before the update count from the update: with a fresh update nothing old goes
|
||||
_, _ = s.db.Exec(`UPDATE system_settings SET value = '2026-12-14T00:00:00Z' WHERE key = 'retention_since'`)
|
||||
veryOld := discard(anna.ID, "2025-01-01T00:00:00Z")
|
||||
if _, _ = s.PurgeExpired(now, true); !exists(s, veryOld) {
|
||||
t.Fatal("things from before the update count from the update on")
|
||||
}
|
||||
}
|
||||
@@ -549,6 +549,8 @@ func (s *Store) DeleteTaskList(userID, listID string) error {
|
||||
// The tasks stay (as personal tasks of their owners), shares and the list go – all or nothing
|
||||
if err := s.execInTx(
|
||||
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_lists WHERE id = ? AND user_id = ?`, listID, userID),
|
||||
); err != nil {
|
||||
|
||||
@@ -523,6 +523,8 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
||||
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 {
|
||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||
if err != nil {
|
||||
@@ -531,6 +533,10 @@ func (s *Store) DeleteThought(userID, thoughtID string) error {
|
||||
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user