diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml index 28ead19..600a9a4 100644 --- a/.gitea/workflows/build-and-publish.yaml +++ b/.gitea/workflows/build-and-publish.yaml @@ -37,6 +37,12 @@ jobs: echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" echo "tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT" echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT" + # Releases (tags) show the tag as version in the app header + APP_VERSION="" + case "${GITHUB_REF:-}" in + refs/tags/*) APP_VERSION="${REF_NAME}" ;; + esac + echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT" echo "Target Docker image: ${IMAGE_NAME}:${DOCKER_TAG} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})" - name: Login to Gitea Container Registry @@ -50,6 +56,6 @@ jobs: run: | FULL_IMAGE="${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.tag }}" echo "🚀 Building container & executing Frontend/Backend checks inside image: ${FULL_IMAGE}" - DOCKER_BUILDKIT=1 docker build --progress=plain -t "${FULL_IMAGE}" . + DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg APP_VERSION="${{ steps.prep.outputs.app_version }}" -t "${FULL_IMAGE}" . echo "✅ All checks & tests passed inside container! Pushing ${FULL_IMAGE}..." docker push "${FULL_IMAGE}" diff --git a/Dockerfile b/Dockerfile index 0bfc856..1bca035 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,9 @@ WORKDIR /app/frontend COPY frontend/package*.json ./ RUN npm install --no-audit --no-fund COPY frontend/ ./ -RUN npx tsc --noEmit && npm run test:run && npm run build +# Version shown in the header: the git tag for releases (set by CI), otherwise "-dev" +ARG APP_VERSION="" +RUN npx tsc --noEmit && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build # ============================================================================== # Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer) diff --git a/Doku/README.md b/Doku/README.md index d599a4a..6a2c4c6 100644 --- a/Doku/README.md +++ b/Doku/README.md @@ -13,20 +13,21 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code | [modules/triage.md](modules/triage.md) | Schnellerfassung und Triage | | [modules/tasks.md](modules/tasks.md) | Aufgaben, Kanban, Alarme | | [modules/workspace.md](modules/workspace.md) | Outliner-Notizen, Ordner, Tags | +| [modules/collaboration.md](modules/collaboration.md) | Gemeinsames Bearbeiten von Notizen: Log, Sperren, Konflikte, Offline, Transport-Stufen | | [modules/lists.md](modules/lists.md) | Geteilte Listen | | [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben | | [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare | | [theming.md](theming.md) | Themes und Design-Variablen | | [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden | -| [plans/kollaboration-notizen.md](plans/kollaboration-notizen.md) | Plan: gemeinsames Bearbeiten von Notizen | ## Überblick in 60 Sekunden - **Ein Go-Binary** liefert REST-API, WebSocket/SSE und das eingebettete React-Frontend (`go:embed`). Es gibt genau einen Container und eine SQLite-Datei (`/data/mindsift.db`). - **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`). -- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen. Verteilt werden Ereignisse nach REST-Änderungen. Es gibt keine CRDTs, Sperren oder Cursor-Presence. +- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen (WebSocket, SSE als Ersatz). Verteilt werden Ereignisse nach Änderungen. +- **Notizen gemeinsam bearbeiten:** server-autoritatives Operations-Log mit Versionen und Sperren pro Bullet, Konfliktkopien statt Überschreiben, Offline-Warteschlange in IndexedDB, Abgleich auch hinter restriktiven Proxys ([modules/collaboration.md](modules/collaboration.md)). - **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks. -- **Freigaben:** Aufgabenlisten und geteilte Listen mit Rolle Bearbeiter oder Leser, Notizen und Ordner nur lesend. +- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser. ## Entwickeln diff --git a/Doku/api/rest.md b/Doku/api/rest.md index f6ad229..6dc26a8 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -80,7 +80,7 @@ Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/syn ## Gemeinsames Bearbeiten von Notizen -Grundlage: [plans/kollaboration-notizen.md](../plans/kollaboration-notizen.md). Zugriff über die Rolle `owner`/`editor`/`viewer` (`Store.DocRole`); ohne Rolle `404`. +Konzept und Regeln: [modules/collaboration.md](../modules/collaboration.md). Zugriff über die Rolle `owner`/`editor`/`viewer` (`Store.DocRole`); ohne Rolle `404`. | Methode | Pfad | Zweck | |---|---|---| diff --git a/Doku/api/websocket.md b/Doku/api/websocket.md index 3a16545..a1f1312 100644 --- a/Doku/api/websocket.md +++ b/Doku/api/websocket.md @@ -61,4 +61,4 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":""}` - Die globale Verbindung (ein Singleton) leitet jede Nachricht als Browser-Ereignis `mindsift:ws` weiter; Ansichten (`TasksView`, `WorkspaceView`, `SharedListsView`) hören darauf und laden bzw. patchen ihren Zustand. - Nach einem Abbruch verbindet sich der Client mit Verzögerung neu. -- Notiz-Inhalte kommen nie per Push, sondern immer über `POST /docs/{id}/sync`; `DOC_SEQ` ist nur der Weckruf. Konflikte entscheidet der Server (Versionen und Sperren, siehe [plans/kollaboration-notizen.md](../plans/kollaboration-notizen.md)). +- Notiz-Inhalte kommen nie per Push, sondern immer über `POST /docs/{id}/sync`; `DOC_SEQ` ist nur der Weckruf. Konflikte entscheidet der Server (Versionen und Sperren, siehe [modules/collaboration.md](../modules/collaboration.md)). diff --git a/Doku/architecture.md b/Doku/architecture.md index 78069c3..4d677ce 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -91,4 +91,6 @@ Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift ( Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata ``` +Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.2`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.2-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`. + Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`). diff --git a/Doku/modules/collaboration.md b/Doku/modules/collaboration.md new file mode 100644 index 0000000..1e3d640 --- /dev/null +++ b/Doku/modules/collaboration.md @@ -0,0 +1,162 @@ +# Modul: Gemeinsames Bearbeiten von Notizen + +Code: [internal/collab/](../../internal/collab/) (Regeln, ohne Datenbank), [internal/storage/docsync.go](../../internal/storage/docsync.go) (Log, Snapshot, Rollen, Verdichtung), [internal/api/docsync.go](../../internal/api/docsync.go) (Endpunkte, Hintergrundjob), [frontend/src/lib/docSync/](../../frontend/src/lib/docSync/) (Engine im Browser), Anzeige in [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx) und [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx). + +Mehrere Personen und Geräte bearbeiten dieselbe Notiz gleichzeitig. Jede Notiz, auch eine private, speichert über diesen Weg; es gibt keinen zweiten Speicherweg. + +## Anforderungen + +1. **Kein Datenverlust.** Was jemand getippt hat, steht danach in der Notiz, auch nach Verbindungsabbruch, Neuladen oder gleichzeitigem Bearbeiten desselben Bullets. +2. **Funktioniert ohne WebSocket.** Hinter Proxys, die WebSockets blocken oder Streams puffern, funktioniert die Bearbeitung voll, nur mit etwas mehr Verzögerung. +3. **Funktioniert bei schlechtem Netz.** Eingaben werden lokal gesichert und später zuverlässig übertragen, auch nach Tagen offline. +4. **Klare Regel bei Konflikten.** Bearbeiten zwei Personen denselben Bullet, bekommt eine das Recht. Der Text der anderen bleibt als Konfliktkopie erhalten. +5. **Berechtigungen.** Notizen und Ordner lassen sich als *Bearbeiter* oder *Leser* teilen; der Server prüft das bei jeder Änderung. + +## Ansatz + +**Server-autoritatives Operations-Log mit Versionen pro Block und Block-Sperren (Leases).** Daten fließen immer über gewöhnliche HTTP-Anfragen; WebSocket, SSE und Long-Polling sind nur der Weckruf. + +| Baustein | Aufgabe | +|---|---| +| **Operations-Log** | Jede Änderung ist eine kleine Operation (Text ändern, Block einfügen, verschieben, löschen …). Der Server prüft und wendet sie an und schreibt die Auswirkungen (Effekte) mit fortlaufender Nummer `seq` pro Notiz ins Log. Wer seine `seq` kennt, holt alles danach. | +| **Version pro Block** | Jede Textänderung nennt die Blockversion, die der Nutzer beim Tippen vor sich hatte. Passt sie nicht mehr, liegt ein Konflikt vor. | +| **Lease pro Block** | Wer in einen Bullet klickt, hält ihn für 20 s und verlängert das, solange er dort arbeitet. Andere sehen „Anna bearbeitet …“ und können genau diesen Bullet so lange nicht ändern, alle anderen schon, auch im selben Ast. | +| **Konfliktkopie** | Kommt es trotzdem zum Konflikt (Lease abgelaufen, jemand war offline), behält der Block die zuerst gespeicherte Fassung; der andere Text wird als markierter Block direkt darunter eingefügt. | +| **Lokale Warteschlange** | Jede Änderung landet zuerst in IndexedDB und wird von dort gesendet. Jede Operation hat eine eindeutige ID; doppeltes Senden ist harmlos. | + +### Warum nicht CRDT (Yjs/Automerge) oder OT + +- **CRDT** führt Zeichen für Zeichen zusammen. Dafür bräuchte das Go-Backend eine vollständige Yjs-Implementierung, um Rechte, Hashtags und Markdown serverseitig auszuwerten; die gibt es in Go nicht ausgereift. Ohne sie wäre der Server nur ein Speicher für Binärdaten, den er nicht prüfen kann. Ein früherer Versuch ist daran gescheitert. +- **OT** braucht einen fehlerfreien Transformations-Kern für jede Operationsart; schwer korrekt zu bekommen und schwer zu testen. +- Gewollt ist, dass bei einem Bullet **einer das Recht bekommt**. Sperren plus Versionsprüfung sind einfach zu verstehen, vollständig serverseitig prüfbar und gegen Netzfehler testbar. +- Bewusster Verzicht: Zwei Personen können nicht gleichzeitig im **selben** Bullet tippen. In **verschiedenen** Bullets, auch im selben Ast, geht es. + +## Datenmodell + +Details in [database.md](../database.md). + +- **Baum statt flacher Liste.** `blocks.parent_id` (leer = oberste Ebene) und `blocks.order_key` sortieren Geschwister. Einrücken oder Verschieben eines Astes ist *eine* Operation auf *einem* Block. Ebene und Position für die Oberfläche berechnet der Server beim Lesen. +- **Sortierschlüssel (Fractional Index).** Ein neuer Block zwischen `a0` und `a1` bekommt einen Schlüssel dazwischen; umnummeriert wird nie. Gleiche Schlüssel (zwei gleichzeitige Einfügungen) sortiert die Block-ID. Go (`orderkey.go`) und TypeScript (`orderKey.ts`) erzeugen identische Schlüssel, geprüft mit gemeinsamen Testvektoren. +- **Grabsteine.** Gelöschte Blöcke bleiben mit `deleted = 1` und `deleted_at` erhalten. Eine verspätete Textänderung stellt den Block wieder her, statt verloren zu gehen. Nach 30 Tagen werden sie endgültig entfernt (siehe [Verdichtung](#verdichtung)). +- **Log** `doc_ops` (Effekte je `seq`) und **Idempotenz** `doc_op_results` (Ergebnis je Operations-ID). Das Abspielen des Logs ab einem Snapshot ergibt exakt den Serverstand. +- **Leases und Sitzungen liegen im Arbeitsspeicher** (`collab.LeaseManager`). Sie sind kurzlebig, nach einem Neustart fordern Clients sie automatisch neu an. MindSift läuft als eine Instanz, das passt. +- **Abgeleitete Daten** (`markdown_cache`, Suchtext, `tags`, `block_tags`) entstehen in derselben Transaktion wie die Änderung. + +## Protokoll + +Endpunkte und Felder: [api/rest.md](../api/rest.md#gemeinsames-bearbeiten-von-notizen). + +| Methode | Pfad | Zweck | +|---|---|---| +| GET | `/api/v1/docs/{id}/snapshot` | Alle Blöcke inkl. Grabsteine, Titel, `headSeq`, eigene Rolle, `shared` | +| POST | `/api/v1/docs/{id}/sync` | **Ein Aufruf für alles:** eigene Operationen senden, Leases halten, neue Log-Einträge abholen | +| GET | `/api/v1/docs/{id}/changes?since=N&wait=25` | Long-Polling: antwortet, sobald sich die Notiz (oder Sperren/Präsenz) ändert, spätestens nach `wait` Sekunden | +| POST | `/api/v1/docs/{id}/leave` | Sitzung beenden, Leases freigeben | + +Operationen: `insert`, `set_content`, `set_props` (Typ, abgehakt, eingeklappt, Sprache, Konflikt erledigt), `move`, `delete`, `restore`, `set_title`. Im Log steht zusätzlich `purge` (Verdichtung). + +### Regeln auf dem Server + +Alle Operationen einer Notiz laufen nacheinander: ein Mutex pro Notiz und eine SQLite-Transaktion pro `/sync`. „Wer zuerst kommt“ ist dadurch immer eindeutig. + +1. **Idempotenz:** Ist die Operations-ID bekannt, kommt das gespeicherte Ergebnis zurück, ohne erneut anzuwenden. Verlorene Antworten sind harmlos. +2. **Berechtigung:** Operationen nur mit Rolle `owner` oder `editor`, sonst `403`. Block-IDs anderer Notizen sind tabu. +3. **`set_content`:** Hält ein *anderer* Client eine Lease oder passt die Basisversion nicht → **Konflikt**. Gleicher Text → angewendet ohne neue Version. Gelöschter Block → wiederherstellen (bei gelöschtem Elternblock unter dem nächsten lebenden Vorfahren) und anwenden. Endgültig entfernter Block → Text wird als neuer Block am Ende angelegt. +4. **Konflikt:** Der Block behält seinen Inhalt. Direkt danach entsteht ein Block mit dem abgelehnten Text, `conflictOf` und `conflictAuthor` gesetzt, mit deterministischer ID (`blk_cf_` + Hash der Operations-ID), damit Wiederholungen keine zweite Kopie erzeugen. Für den Titel gilt dasselbe mit eigener Titelversion. +5. **`move`:** abgelehnt bei Zyklus, bei gelöschtem Ziel-Elternblock oder wenn jemand anders den Block selbst hält. Unterpunkte wandern mit; wer darin tippt, tippt einfach an der neuen Stelle weiter. +6. **`delete`:** abgelehnt, wenn jemand anders einen Block im Ast hält; sonst Grabstein für den ganzen Ast. +7. **`insert`:** Ist der Elternblock gelöscht, wird unter dem nächsten lebenden Vorfahren eingefügt, ist er endgültig entfernt, auf oberster Ebene. +8. **Leases:** Vergabe nur, wenn frei, abgelaufen oder schon im Besitz desselben Clients; gültig 20 s ab letzter Bestätigung. `leases.hold` ist immer die vollständige Liste. Neu eingefügte Blöcke und Konfliktkopien bekommt ihr Autor automatisch. +9. **Keine Lease auf Elternblöcke:** Einfügen, Ein- und Ausrücken unter einem Elternblock brauchen keine Sperre auf diesen. + +### Ablauf beim Tippen + +```mermaid +sequenceDiagram + participant A as Client Anna + participant S as Server + participant B as Client Ben + A->>S: /sync leases.hold=[b9] + S-->>A: granted b9 + S--)B: DOC_SEQ (Weckruf) + B->>S: /sync lastSeq=412 + S-->>B: leases.others: b9 = Anna (Bullet gesperrt) + A->>S: /sync op set_content b9 base=7 + S-->>A: applied, seq 414 + S--)B: DOC_SEQ headSeq=414 + B->>S: /sync lastSeq=413 + S-->>B: ops [set_content b9 v8] +``` + +## Client (`DocSyncEngine`) + +- **Zustände:** *bestätigter Stand* (vom Server, mit `lastSeq`) und *Warteschlange* (eigene, unbestätigte Operationen). Angezeigt wird der bestätigte Stand mit der Warteschlange darübergelegt; das macht `model.ts` mit denselben Regeln wie der Server. +- **Editor-Anbindung:** Die Editor-Komponenten arbeiten mit einer flachen Liste (Ebene + Reihenfolge). `diff.ts` übersetzt jede Änderung per Vergleich in Operationen. +- **Persistenz:** Warteschlange und bestätigter Stand liegen in IndexedDB. Beim Öffnen erscheint sofort der gespeicherte Stand, auch offline. +- **Senden:** gesammelt nach 300 ms, immer nur ein `/sync` gleichzeitig; Wiederholung mit Backoff 1 s, 2 s, 4 s … höchstens 30 s. Mehrere Textänderungen eines Blocks werden zusammengefasst; die Basisversion bleibt die der ersten (die der Nutzer beim Tippen sah), damit nie eine fremde Änderung überschrieben wird. +- **Leases:** + - Tippen wartet nie auf eine Sperre. Beim Fokussieren wird sie sofort angemeldet. + - Neue Bullets (Enter, Einfügen) gehören sofort dem Ersteller. + - Verlassene Bullets bleiben 20 s gehalten; nach 60 s ohne Eingabe wird auch der fokussierte Bullet freigegeben. + - Fremd gesperrte Bullets zeigen ein Schloss mit Namen und sind schreibgeschützt; Enter darin legt einen neuen Bullet darunter an. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen, die einen gesperrten Bullet berühren, werden gar nicht erst ausgeführt (Hinweis unten im Editor). +- **Konflikte:** Konfliktkopien sind farbig markiert, mit „Übernehmen“ (ersetzt den Originaltext) und „Verwerfen“. Beide prüfen vorher die Sperren von Original und Kopie. +- **Hintergrund und Netz:** Beim Verbergen der Seite sendet die Engine Ausstehendes mit `keepalive` und gibt ihre Sperren frei; bei `online` und Rückkehr in den Tab gleicht sie sofort ab. +- **Rechteentzug:** Der nächste `/sync` liefert `403` bzw. `404`. Die Engine verwirft die Warteschlange nicht, hört aber auf zu senden und zeigt den Fehler. +- **Neu laden:** Meldet der Server `resync` (Client voraus oder hinter der verdichteten Log-Grenze) oder fehlt eine `seq`, lädt die Engine den Snapshot; die Warteschlange bleibt erhalten und wird danach angewendet. + +## Transport: robust auch hinter Proxys + +Daten fließen nur über `/sync` und `/snapshot`. Push-Kanäle melden nur „Notiz X hat jetzt `headSeq` N“ (`DOC_SEQ` im Raum `doc:`, siehe [api/websocket.md](../api/websocket.md)). + +| Stufe | Ausfall erkannt durch | Wechsel zu | +|---|---|---| +| 1. WebSocket | Verbindungsfehler oder 35 s ohne `PONG` auf das `PING` alle 15 s (WebSocket angenommen, aber nichts kommt durch) | SSE | +| 2. SSE | Erstes Ereignis kommt nicht innerhalb von 5 s (Proxy puffert); gilt dann für den ganzen Tab | Long-Polling | +| 3. Long-Polling (`/changes`, 25 s) | 3 Fehlschläge hintereinander | Intervall | +| 4. Intervall | – | Abfrage alle 3 s; bei `online` wird Long-Polling erneut versucht | + +Zusätzlich, solange Stufe 1–3 läuft: Abgleich alle 7 s, wenn die Engine Sperren hält (Verlängerung), sonst alle 30 s zur Sicherheit. Der Editor zeigt den Weg an („Live“, „Live (Long-Polling)“, „Abgleich alle 3 s“). Gemessen im Browser-Test: 0,2–0,3 s über WebSocket, SSE und Long-Polling, rund 3 s im Intervall. + +Long-Polling bleibt mit 25 s unter üblichen Proxy-Grenzen (Nginx 60 s, Cloudflare 100 s). Den Schreib-Timeout hebt der Server für Streams und Long-Polling auf. Räume betritt nur, wer die Notiz lesen darf. Proxy-Beispiele: [reverse-proxy.md](../reverse-proxy.md). + +## Berechtigungen + +- Rollen je Notiz: `owner`, `editor`, `viewer`. Die effektive Rolle ist die höchste aus Eigentum, direkter Freigabe, Gruppenfreigabe und Freigabe des geteilten Ordners. Eine zentrale Funktion `Store.DocRole` entscheidet für Snapshot, Sync, Long-Polling, Raumbeitritt und Aufgaben-Verknüpfungen. +- Nur der Eigentümer verwaltet Freigaben und löscht die Notiz. Bearbeiter dürfen sonst alles: Inhalt, Struktur, Titel und Symbol. +- Freigaben liegen in `doc_shares`, `doc_team_shares` und `workspace_folder_shares` (`read`/`write`). Eine Zusammenlegung zu einer Tabelle wurde bewusst nicht umgesetzt: `DocRole` entscheidet bereits zentral, das Umschreiben der rund 20 Sichtbarkeitsabfragen brächte nur Risiko. Mehr: [sharing-and-permissions.md](../sharing-and-permissions.md). + +## Private Notizen + +Gleiches Modell, aber sparsam: Solange eine Notiz nicht geteilt ist (`shared = false`) und nur in einem Tab offen ist (`sessions = 1`), gibt es keine Leases, keinen Herzschlag und nur die 30-s-Abfrage. Öffnet ein zweites Gerät die Notiz oder wird sie geteilt, schalten alle offenen Clients mit der nächsten Antwort auf den vollen Ablauf um. Gegenüber dem früheren Speichern des ganzen Dokuments alle 800 ms überträgt und schreibt das Modell nur geänderte Blöcke. + +## Rückgängig und Wiederholen + +- `Strg+Z`/`Cmd+Z` nimmt die letzte **eigene** Änderung zurück, `Strg+Umschalt+Z` bzw. `Strg+Y` wiederholt. Fremde Änderungen bleiben unberührt. +- Zu jeder eigenen Änderung merkt sich die Engine den Gegenschritt (`undo.ts`) und sendet ihn als normale Operation; Server und Log brauchen dafür nichts Besonderes. +- Tippen im selben Bullet ist ein Schritt bis zu einer Pause von 1 s; Einfügen, Löschen, Einrücken, Verschieben, Abhaken und mehrzeiliges Einfügen sind jeweils ein Schritt. Bis zu 200 Schritte pro Tab und Notiz. +- Hat inzwischen jemand anders den Bullet geändert oder hält ihn gerade, wird der Schritt mit Hinweis übersprungen, nie blind überschrieben. +- Das Undo des Textfelds selbst wird abgefangen (`keydown` und `beforeinput` mit `historyUndo`), damit Browser-Undo und eigenes Undo nicht gegeneinander arbeiten. + +## Verdichtung + +Ein Hintergrundjob (`startCompaction`) läuft beim Start und danach alle 6 Stunden: + +| Was | Aufbewahrung | +|---|---| +| Log geteilter Notizen | 30 Tage; die Grenze steht in `documents.log_floor` | +| Log privater Notizen | 7 Tage | +| Grabsteine | 30 Tage nach `deleted_at`, dann endgültig entfernt per Log-Eintrag `purge` (offene Editoren übernehmen das ohne Neuladen) | +| Idempotenz-Ergebnisse | 90 Tage | +| Leases und Sitzungen im Speicher | abgelaufene werden entfernt | + +Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: [FLAW-27](../flaws.md). + +## Tests + +| Ebene | Was | Wo | +|---|---|---| +| Regeln | Konfliktmatrix aus Lease, Version, Grabstein, Verschieben, Löschen, Purge | `internal/collab/ops_test.go` | +| Simulation | 4 Clients, 25 Seeds × 600 Schritte über ein Netz, das Anfragen und Antworten verliert und verdoppelt; Clients gehen offline; zwischendurch Verdichtung. Geprüft: Konvergenz, gültiger Baum, jede Operation genau einmal angewendet, kein Text verloren. Per Gegenprobe (absichtlich eingebaute Fehler) abgesichert. | `internal/storage/docsync_sim_test.go` | +| Speicher und API | Log ↔ Snapshot, Rollen, Long-Polling, Verdichtung | `internal/storage/docsync_test.go`, `internal/api/docsync_test.go` | +| Browser-Engine | Vitest mit gefälschtem Server und Netz, `fake-indexeddb`: offline, Neuladen, verlorene Antworten, Push-Wechsel, Verdichtung | `frontend/src/lib/docSync/__tests__/` | +| Ende-zu-Ende | Playwright mit zwei Benutzern: gemeinsames Tippen, Freigabe, Sperren, Konflikte, Undo, alle Transport-Stufen mit blockiertem WebSocket/SSE | `e2e/` (`e2e/run.sh`) | diff --git a/Doku/plans/kollaboration-notizen.md b/Doku/plans/kollaboration-notizen.md deleted file mode 100644 index 49da789..0000000 --- a/Doku/plans/kollaboration-notizen.md +++ /dev/null @@ -1,299 +0,0 @@ -# Plan: Gemeinsames Bearbeiten von Notizen - -Status: Entschieden, bereit zur Umsetzung · Stand 28.10.2026 - -## 1. Ziel und Anforderungen - -Mehrere Personen bearbeiten dieselbe Notiz gleichzeitig. Verbindlich: - -1. **Kein Datenverlust.** Was jemand getippt hat, steht danach in der Notiz, auch nach Verbindungsabbruch, Neuladen oder gleichzeitigem Bearbeiten desselben Blocks. -2. **Funktioniert ohne WebSocket.** Hinter Firmen-Proxys, die WebSockets blocken oder Streams puffern, muss die Bearbeitung voll funktionieren, nur mit etwas mehr Verzögerung. -3. **Funktioniert bei schlechtem Netz.** Mobil mit Abbrüchen, Wechsel zwischen WLAN und Mobilfunk, App im Hintergrund: Eingaben werden lokal gesichert und später zuverlässig übertragen. -4. **Klare Regel bei Konflikten.** Bearbeiten zwei Personen denselben Bullet, bekommt eine das Recht. Der Text der anderen geht nicht verloren. -5. **Berechtigungen.** Notizen und Ordner lassen sich als *Bearbeiter* oder *Leser* teilen. Der Server setzt das bei jeder Änderung durch. - -Ausgangslage: Der Editor sendet alle 800 ms das ganze Dokument (`PUT /documents/{id}`), der Server ersetzt alle Blöcke. Zwei gleichzeitige Bearbeiter überschreiben sich dabei vollständig. Die vorhandenen Ansätze (CRDT-Reste, `PATCH /blocks`, erzwungenes Nur-Lesen) werden **nicht** weiterverwendet. Es gibt keine Produktivdaten, das Datenmodell wird neu aufgesetzt. - -## 2. Entscheidung - -**Server-autoritatives Operations-Log mit Versionen pro Block und Block-Sperren (Leases).** Die Übertragung läuft immer über gewöhnliche HTTP-Anfragen; WebSocket, SSE und Long-Polling dienen nur als „Weckruf“. - -| Baustein | Aufgabe | -|---|---| -| **Operations-Log** | Jede Änderung ist eine kleine Operation (Text eines Blocks ändern, Block einfügen, verschieben, löschen). Der Server prüft, wendet an und vergibt eine fortlaufende Nummer pro Dokument (`seq`). Wer `seq` kennt, holt sich einfach alles danach. | -| **Version pro Block** | Jede Operation nennt die Blockversion, auf der sie basiert. Passt sie nicht mehr, liegt ein Konflikt vor, und der Server erkennt ihn sicher. | -| **Lease (Sperre) pro Block** | Wer in einen Bullet klickt, bekommt ihn für 20 Sekunden reserviert und verlängert das, solange er tippt. Neu erzeugte Bullets gehören automatisch ihrem Ersteller. Andere sehen „Anna bearbeitet …“ und können diesen Bullet so lange nicht ändern, alle anderen aber schon, auch im selben Ast. Das verhindert fast alle Konflikte. | -| **Konfliktkopie** | Kommt es trotzdem zum Konflikt (z. B. Lease abgelaufen, weil jemand offline war), behält der Block die zuerst gespeicherte Fassung. Der andere Text wird als eigener Block direkt darunter eingefügt und als Konflikt markiert. Nichts geht verloren. | -| **Lokale Warteschlange** | Jede Änderung landet sofort in IndexedDB und wird von dort gesendet. Jede Operation hat eine eindeutige ID; doppeltes Senden ist harmlos (idempotent). | -| **Transport-Stufen** | Daten fließen immer über `POST /sync`. Benachrichtigung über WebSocket → SSE → Long-Polling → Intervall-Abfrage, je nachdem, was durchkommt. | - -### Warum nicht CRDT (Yjs/Automerge) oder OT - -- **CRDT** führt Zeichen für Zeichen zusammen, auch gleichzeitiges Tippen im selben Satz. Dafür braucht das Go-Backend eine vollständige Yjs-Implementierung, um Rechte pro Block, Hashtags und Markdown serverseitig auszuwerten; die gibt es in Go nicht ausgereift. Ohne sie wäre der Server nur ein Speicher für Binärdaten, den er nicht prüfen kann. Der frühere Versuch ist genau daran gescheitert. -- **OT** braucht einen fehlerfreien Transformations-Kern für jede Operationsart; das ist schwer korrekt zu bekommen und schwer zu testen. -- Du willst ausdrücklich, dass bei einem Bullet **einer das Recht bekommt**. Das passt zu Sperren plus Versionsprüfung: einfach zu verstehen, vollständig serverseitig prüfbar und mit Tests gegen Netzfehler absicherbar. -- Bewusster Verzicht: Zwei Personen können nicht gleichzeitig im **selben** Bullet tippen. Gleichzeitig in **verschiedenen** Bullets, auch im selben Ast, funktioniert. - -## 3. Datenmodell (neu) - -Die bisherigen Tabellen `documents`, `blocks`, `block_tags`, `doc_shares`, `doc_team_shares`, `workspace_folder_shares` und die Spalten `blocks_json`, `content` werden ersetzt. - -```sql -CREATE TABLE documents ( - id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title TEXT NOT NULL DEFAULT '', - title_version INTEGER NOT NULL DEFAULT 0, - icon TEXT NOT NULL DEFAULT '📄', - shared_folder_id TEXT, -- falls in einem geteilten Ordner - head_seq INTEGER NOT NULL DEFAULT 0, -- letzte vergebene Operationsnummer - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE blocks ( - id TEXT PRIMARY KEY, -- vom Client erzeugt (UUID), damit offline einfügbar - doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, - parent_id TEXT, -- NULL = oberste Ebene; Baum statt "level" - order_key TEXT NOT NULL, -- Fractional Index, sortiert Geschwister - type TEXT NOT NULL DEFAULT 'bullet', - content TEXT NOT NULL DEFAULT '', - props TEXT NOT NULL DEFAULT '{}', -- checked, collapsed, lang, conflictOf … - version INTEGER NOT NULL DEFAULT 1, - updated_by TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT -- Grabstein statt hartem Löschen -); -CREATE INDEX idx_blocks_doc ON blocks(doc_id, parent_id, order_key); - -CREATE TABLE doc_ops ( -- das Log - doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, - seq INTEGER NOT NULL, - op_id TEXT NOT NULL, -- vom Client, eindeutig → Idempotenz - user_id TEXT NOT NULL, - client_id TEXT NOT NULL, - kind TEXT NOT NULL, -- set_content | set_props | insert | move | delete | set_title - block_id TEXT, - base_version INTEGER, - payload TEXT NOT NULL, -- JSON inkl. alter und neuer Werte - status TEXT NOT NULL, -- applied | conflict | rejected - result TEXT NOT NULL DEFAULT '{}', -- z. B. ID der Konfliktkopie - created_at TEXT NOT NULL, - PRIMARY KEY (doc_id, seq) -); -CREATE UNIQUE INDEX idx_doc_ops_op ON doc_ops(op_id); - -CREATE TABLE block_tags (tag TEXT, doc_id TEXT, block_id TEXT, PRIMARY KEY(tag, block_id)); - -CREATE TABLE workspace_access ( -- ein Freigabemodell für Notizen und Ordner - id TEXT PRIMARY KEY, - resource_type TEXT NOT NULL CHECK(resource_type IN ('document','folder')), - resource_id TEXT NOT NULL, - principal_type TEXT NOT NULL CHECK(principal_type IN ('user','team')), - principal_id TEXT NOT NULL, - role TEXT NOT NULL CHECK(role IN ('viewer','editor')), - created_by TEXT NOT NULL, - created_at TEXT NOT NULL, - UNIQUE(resource_type, resource_id, principal_type, principal_id) -); -``` - -Entscheidungen dazu: - -- **Baum mit `parent_id` statt flacher Liste mit `level`.** Einrücken oder Verschieben eines Astes ist dann *eine* Operation auf *einem* Block; die Unterpunkte wandern automatisch mit. Mit der flachen Liste ergeben gleichzeitige Einrückungen leicht kaputte Strukturen. -- **Fractional Index (`order_key`).** Ein neuer Block zwischen `a0` und `a1` bekommt `a0V`; es muss nie umnummeriert werden. Gleiche Schlüssel (zwei gleichzeitige Einfügungen) werden über die Block-ID eindeutig sortiert. -- **Grabsteine.** Gelöschte Blöcke bleiben mit `deleted_at` erhalten. So kann eine verspätete Textänderung an einem gelöschten Block den Block wiederherstellen statt verloren zu gehen. Aufräumen nach 30 Tagen. -- **Leases liegen im Arbeitsspeicher**, nicht in der Datenbank. Sie sind kurzlebig (20 s), und nach einem Neustart fordern Clients sie automatisch neu an. MindSift läuft als eine Instanz, das passt. -- **Abgeleitete Daten** (`block_tags`, Markdown für Export und Suche) werden aus den geänderten Blöcken in derselben Transaktion bzw. bei Bedarf erzeugt. - -## 4. Protokoll - -### 4.1 Endpunkte - -| Methode | Pfad | Zweck | -|---|---|---| -| GET | `/api/v1/docs/{id}/snapshot` | Alle lebenden Blöcke, Titel, `headSeq`, eigene Rolle, aktuelle Leases | -| POST | `/api/v1/docs/{id}/sync` | **Ein Aufruf für alles**: eigene Operationen senden, Leases halten/abgeben, neue Operationen abholen | -| GET | `/api/v1/docs/{id}/changes?since=N&wait=25` | Long-Polling: antwortet sofort, wenn `headSeq > N`, sonst nach höchstens 25 s mit leerem Ergebnis | - -Anfrage an `/sync`: - -```json -{ - "clientId": "tab-7f3…", - "lastSeq": 412, - "ops": [ - { "opId": "c1a…", "kind": "set_content", "blockId": "b9…", "baseVersion": 7, "content": "Milch und Brot" }, - { "opId": "c1b…", "kind": "insert", "blockId": "bA…", "parentId": "b2…", "orderKey": "a0V", "type": "bullet", "content": "" } - ], - "leases": { "hold": ["b9…"], "release": ["b4…"] } -} -``` - -Antwort: - -```json -{ - "results": [ - { "opId": "c1a…", "status": "applied", "seq": 413, "version": 8 }, - { "opId": "c1b…", "status": "applied", "seq": 414 } - ], - "ops": [ /* alle Operationen mit seq > 412, auch fremde */ ], - "headSeq": 415, - "leases": { "granted": ["b9…"], "denied": [], "others": [{ "blockId": "b3…", "user": "Anna" }] }, - "presence": [{ "userId": "…", "name": "Anna", "blockId": "b3…" }] -} -``` - -### 4.2 Regeln auf dem Server - -Alle Operationen eines Dokuments laufen nacheinander: ein Mutex pro Dokument im Prozess und eine SQLite-Transaktion pro `/sync`-Aufruf. Dadurch gibt es eine eindeutige Reihenfolge, und „wer zuerst kommt“ ist immer eindeutig. - -1. **Idempotenz:** Ist `opId` schon im Log, wird das gespeicherte Ergebnis zurückgegeben, ohne erneut anzuwenden. Verlorene Antworten sind dadurch harmlos. -2. **Berechtigung:** Ohne Rolle `editor` oder `owner` wird jede Operation abgelehnt (`403`). -3. **Zugehörigkeit:** Block und Elternblock müssen zu diesem Dokument gehören (behebt FLAW-11). -4. **`set_content`:** - - Hält ein *anderer* Client eine gültige Lease → **Konflikt**. - - `baseVersion == version` → anwenden, `version + 1`. - - `baseVersion < version` → **Konflikt**. - - Block gelöscht → wiederherstellen (am alten Platz; ist der Elternblock auch gelöscht, unter dem nächsten lebenden Vorfahren) und anwenden. -5. **Konflikt:** Der Block behält seinen Inhalt. Der Server fügt direkt danach einen neuen Block mit dem abgelehnten Text ein, `props.conflictOf = `, `props.conflictAuthor = `. Ergebnis `status: "conflict"` mit der neuen Block-ID. -6. **`move`:** Wird abgelehnt, wenn ein Zyklus entstünde (Block unter seinen eigenen Nachfahren) oder ein anderer Client eine Lease auf dem Block hält. Der Client lädt dann den Stand neu. -7. **`delete`:** Wird abgelehnt, wenn ein anderer Client gerade einen Block in diesem Ast bearbeitet (Lease). Sonst Grabstein für den ganzen Ast. -8. **`insert`:** Ist der Elternblock gelöscht, wird unter dem nächsten lebenden Vorfahren eingefügt. -9. **Leases:** Vergabe nur, wenn frei, abgelaufen oder schon im Besitz desselben Clients; Gültigkeit 20 s ab letzter Bestätigung. Ein Client darf beliebig viele Blöcke gleichzeitig halten. Der Client sendet alle 7 s einen `/sync` mit allen gehaltenen Blöcken (ein Herzschlag für alle). -10. **Automatische Lease beim Einfügen:** Ein `insert` vergibt dem einfügenden Client die Lease auf den neuen Block, ohne eigene Anfrage. Der Block ist vorher niemandem bekannt, es kann also keinen Wettlauf geben. -11. **Keine Lease auf Elternblöcke:** Einfügen, Einrücken und Ausrücken unter einem Elternblock brauchen keine Lease auf diesen. Gleichzeitiges Einfügen zweier Personen in denselben Ast ergibt zwei Blöcke (Sortierung über `order_key`). Gesperrt wird nur der Inhalt einzelner Blöcke sowie Löschen/Verschieben von Ästen, in denen jemand anders einen Block hält (Regeln 6 und 7). - -### 4.3 Ablauf beim Tippen - -```mermaid -sequenceDiagram - participant A as Client Anna - participant S as Server - participant B as Client Ben - A->>S: /sync leases.hold=[b9] - S-->>A: granted b9 - S--)B: Weckruf headSeq=413 - B->>S: /sync lastSeq=412 - S-->>B: leases.others: b9 = Anna (Bullet gesperrt) - A->>S: /sync op set_content b9 base=7 - S-->>A: applied v8, seq 414 - S--)B: Weckruf headSeq=414 - B->>S: /sync lastSeq=413 - S-->>B: ops [set_content b9 v8] -``` - -## 5. Client - -Neues Modul `frontend/src/lib/docSync/` (ersetzt das Autosave in `DocumentEditor.tsx`): - -- **`SyncEngine` pro geöffnetem Dokument:** - - *Bestätigter Stand*: Blöcke wie vom Server zuletzt gemeldet, plus `lastSeq`. - - *Warteschlange*: eigene, noch nicht bestätigte Operationen. - - *Angezeigter Stand* = bestätigter Stand + Warteschlange darüber angewendet. Kommen fremde Operationen herein, wird der bestätigte Stand aktualisiert und die Warteschlange erneut darübergelegt. -- **Persistenz:** Warteschlange und bestätigter Stand liegen in IndexedDB. Jede Eingabe wird dort gesichert, bevor sie gesendet wird. Nach Neuladen, Absturz oder vom Handy beendetem Tab wird die Warteschlange erneut gesendet; die Operations-IDs verhindern doppelte Anwendung. -- **Tippen:** Textänderungen eines Blocks werden 300 ms gesammelt. Pro Block ist höchstens eine Textoperation unterwegs; weitere Änderungen werden in die nächste zusammengefasst. So bleibt `baseVersion` immer eindeutig. -- **Senden:** immer nur ein `/sync` gleichzeitig. Das hält die Reihenfolge der eigenen Operationen stabil. Wiederholung mit Backoff (1 s, 2 s, 4 s … max. 30 s). -- **Leases:** - - **Tippen wartet nie auf eine Lease.** Beim Fokussieren eines bestehenden Bullets wird die Lease mit dem nächsten `/sync` angefordert; getippt werden darf sofort. - - **Neue Bullets (Enter, Einfügen) sind sofort eigene**, ohne Anfrage (Regel 10). Bullet für Bullet zu schreiben erzeugt keinen einzigen Sperrwechsel. - - **Nachlauf:** Verlassene Bullets bleiben noch 20 s gesperrt und werden erst dann freigegeben, wenn man nicht zurückgekehrt ist. Springen mit Pfeiltasten oder Ergänzen im Bullet davor braucht keine neue Anfrage. - - **Verweigert** (nur bei bestehenden Bullets möglich, die jemand anders schon hält): Der Bullet wird schreibgeschützt („Ben bearbeitet gerade“), schon Getipptes wird als neuer, eigener Block darunter eingefügt. - - Nach 60 s ohne Eingabe gibt der Client alle Leases frei, auch beim fokussierten Bullet. - - **Bewusst nicht:** Sperre auf Elternblock oder ganzen Ast. Bei Bullets auf oberster Ebene wäre das die ganze Notiz, und Arbeit tief im Ast würde Kollegen im selben Ast ausschließen. -- **Mobil und Hintergrund:** Bei `visibilitychange` (Seite verborgen) und `pagehide` sendet der Client die Warteschlange mit `fetch(…, { keepalive: true })` und gibt Leases frei. Bei `online` und beim Zurückkehren in die App synchronisiert er sofort. -- **Anzeige:** - - Gesperrte Bullets zeigen dezent Avatar und Namen. - - Konfliktblöcke sind farbig markiert, mit den Aktionen „Übernehmen“ (ersetzt den Originaltext) und „Verwerfen“. - - Ein Statushinweis zeigt „Offline – 3 Änderungen warten“ bzw. „Gespeichert“. - - Leser sehen alles schreibgeschützt und live. - -## 6. Transport: robust auch hinter Proxys - -Grundsatz: **Daten fließen nur über normale HTTP-Anfragen** (`/sync`, `/snapshot`). Push-Kanäle melden nur „Dokument X hat jetzt `headSeq` N“. Fällt jeder Push-Kanal aus, funktioniert die Bearbeitung trotzdem, nur mit Verzögerung. - -| Stufe | Erkennung eines Ausfalls | Wechsel zu | -|---|---|---| -| 1. WebSocket (`/api/v1/ws`) | Kein Aufbau in 3 s oder 2 verpasste Pongs | SSE | -| 2. SSE (`/api/v1/events`) | Erstes Ereignis kommt nicht innerhalb von 5 s an (Proxy puffert) | Long-Polling | -| 3. Long-Polling (`/changes?wait=25`) | Wiederholte Fehler oder Zeitüberschreitungen | Intervall-Abfrage | -| 4. Intervall-Abfrage | – | alle 3 s bei offener Notiz, alle 30 s sonst | - -Unabhängig von der Stufe: -- Herzschlag-`/sync` alle 7 s, solange eine Lease gehalten wird. Das ist zugleich eine Abfrage. -- Sicherheitsabfrage alle 30 s, auch wenn der Push-Kanal angeblich steht. -- Der Client merkt sich die zuletzt funktionierende Stufe und versucht WebSocket alle 5 Minuten erneut. - -Serverseitig nötig: -- Räume nur nach Berechtigungsprüfung betreten (behebt FLAW-01 und FLAW-02). -- Schreib-Timeout für Streams aufheben (behebt FLAW-15). Long-Polling bleibt mit 25 s unter üblichen Proxy-Grenzen (Nginx 60 s, Cloudflare 100 s). - -## 7. Berechtigungen - -- Rollen je Notiz: `owner` (Eigentümer), `editor`, `viewer`. Freigaben über `workspace_access` an Benutzer oder Gruppen, für eine Notiz oder einen geteilten Ordner. Eine Ordnerfreigabe gilt für alle Notizen darin. -- **Effektive Rolle** = höchste aus Eigentümer, direkter Freigabe, Gruppenfreigaben und Ordnerfreigaben. Eine zentrale Funktion `docRole(userID, docID)` im Backend, benutzt von Snapshot, Sync, Long-Polling, Raumbeitritt und Export. -- Nur der Eigentümer verwaltet Freigaben und löscht die Notiz. Bearbeiter haben sonst volle Rechte: Inhalt, Struktur, Titel und Symbol. Der Titel wird wie ein Block behandelt (eigene Version, Lease, Konfliktkopie als Block am Anfang der Notiz). -- Im Freigabe-Dialog wird „Bearbeiten“ für Notizen und Ordner wieder auswählbar (`allowRoleSelection` statt `fixedPermission="viewer"` in `WorkspaceView.tsx`). -- Rechteentzug wirkt sofort: Der nächste `/sync` liefert `403`. Der Client verwirft die Warteschlange nicht, sondern bietet an, die Änderungen als neue eigene Notiz zu sichern. - -## 8. Nicht geteilte Notizen - -Es gibt nur ein Modell; das alte `PUT` mit Komplettersatz entfällt auch für private Notizen. Gründe: kein zweiter Speicherweg, Schutz vor gegenseitigem Überschreiben zwischen eigenen Geräten und Tabs, Offline-Sicherung über die lokale Warteschlange. - -Damit private Notizen keinen unnötigen Aufwand erzeugen, liefert der Server im Snapshot und in jeder `/sync`-Antwort `shared` (gibt es Freigaben?) und `sessions` (wie viele Clients haben die Notiz gerade offen). Solange `shared = false` und `sessions = 1`: - -- keine Leases, also auch kein Herzschlag, -- kein Live-Raum, keine regelmäßige Abfrage außer der 30-s-Sicherheitsabfrage, -- kürzere Aufbewahrung im Log (7 Tage statt 30), weil niemand alte Einträge nachholen muss. - -Öffnet ein zweites Gerät die Notiz oder wird sie geteilt, bekommen alle offenen Clients das mit der nächsten Antwort mit und schalten auf den vollen Ablauf um (gleicher Code, nur Leases und Push aktiv). Im Vergleich zum heutigen `PUT` (ganzes Dokument alle 800 ms) überträgt und schreibt das neue Modell nur geänderte Blöcke und ist damit bei längeren Notizen sparsamer. - -## 9. Rückgängig und Wiederholen (Strg+Z) - -- **Jeder macht nur seine eigenen Änderungen rückgängig.** `Strg+Z` / `Cmd+Z` macht die letzte eigene Änderung rückgängig, `Strg+Umschalt+Z` bzw. `Strg+Y` wiederholt. Fremde Änderungen bleiben unberührt. -- **Umsetzung im Client:** Zu jeder eigenen Operation merkt sich der Client die Gegenoperation (alter Text, alter Platz im Baum, Wiederherstellen statt Löschen). Rückgängig sendet die Gegenoperation als ganz normale Operation über `/sync`. Server, Log und Konfliktregeln brauchen dafür nichts Neues. -- **Schritte:** Tippen wird zu Schritten gebündelt (neuer Schritt nach 1 s Pause, beim Wechsel des Bullets oder nach Satzzeichen). Einfügen, Löschen, Einrücken, Verschieben und Abhaken sind jeweils ein Schritt. Ein Einfügen mehrerer Zeilen ist ein Schritt. -- **Wenn inzwischen jemand anders den Block geändert hat:** Passt die Version nicht mehr, wird dieser Schritt nicht blind überschrieben. Der Client zeigt „Bullet wurde inzwischen von Anna geändert“ und überspringt ihn. Kein fremder Text geht durch Rückgängig verloren. -- **Gesperrte Bullets:** Hält gerade jemand anders die Lease, wartet der Schritt nicht, sondern wird mit demselben Hinweis übersprungen. -- **Reichweite:** Der Stapel gilt pro Tab und Notiz, bis zu 200 Schritte, und bleibt beim Wechsel zwischen Notizen erhalten. Nach Neuladen beginnt er leer. -- **Native Textfeld-Undo** wird abgefangen (`keydown` und `beforeinput` mit `historyUndo`), sonst würden Browser-Undo und eigenes Undo gegeneinander arbeiten. -- **Später möglich:** Eine Verlaufsansicht („Wer hat wann was geändert“, ältere Fassung eines Bullets wiederherstellen) lässt sich direkt aus dem Log bauen. Sie ist nicht Teil dieses Features. - -## 10. Tests (Pflicht für „super robust“) - -1. **Simulation im Go-Backend** (Kern der Absicherung): N simulierte Clients erzeugen zufällige Operationen. Ein simuliertes Netz verliert, verdoppelt, verzögert und vertauscht Anfragen und Antworten; Clients gehen offline und kommen zurück. Nach jedem Lauf wird geprüft: - - Alle Clients haben denselben Stand wie der Server (Konvergenz). - - Jeder je getippte Endtext eines Clients steht im Dokument, im Block selbst oder in einer Konfliktkopie (kein Verlust). - - Der Baum ist gültig: keine Zyklen, keine verwaisten Blöcke. - - Tausende Läufe mit festem Seed in CI. -2. **Konfliktmatrix als Unit-Tests:** jede Kombination aus Lease, Version, Grabstein, Verschieben und Löschen. -3. **Frontend-Engine mit Vitest:** gefälschter Transport, `fake-indexeddb`, Neuladen mitten im Senden, verlorene Antworten. -4. **Rückgängig:** eigene Schritte zurücknehmen und wiederholen, Überspringen bei fremder Änderung, gebündeltes Tippen, Einfügen mehrerer Zeilen als ein Schritt. -5. **Ende-zu-Ende mit Playwright:** zwei Browser-Kontexte im selben Dokument. WebSocket per Routing blockiert → Fallback auf SSE und Long-Polling; Offline-Schalter mitten im Tippen; gedrosseltes Netz. - -## 11. Umsetzung in Phasen - -| Phase | Inhalt | Ergebnis | -|---|---|---| -| 0 | Sicherheitsfixes vorziehen: Raumberechtigung (FLAW-01/02), Umsortieren (FLAW-03), Stream-Timeout (FLAW-15). **Erledigt.** Die zentrale `docRole` wandert in Phase 1, weil sie auf dem neuen Freigabemodell (`workspace_access`) aufbaut. | Grundlage dicht | -| 1 | Operations-Log, `/snapshot`, `/sync`, `/changes`, `/leave`, Konfliktregeln, Leases, zentrale `DocRole`, Simulationstests. **Erledigt.** Abweichungen: Die bestehenden Tabellen `blocks`/`documents` wurden erweitert statt ersetzt (Baum per `parent_id`/`order_key`, alte Daten werden umgewandelt); der bisherige Speicherweg (`PUT`/`PATCH`) läuft als Adapter durch dieselbe Engine. `DocRole` arbeitet auf den vorhandenen Freigabetabellen; deren Zusammenlegung zu `workspace_access` erfolgt mit dem Freigabe-Dialog in Phase 5. `/changes` liefert nur `{headSeq, changed}`, die Daten kommen immer über `/sync`. | Server fertig und abgesichert | -| 2 | `SyncEngine` mit IndexedDB-Warteschlange, nur Intervall-Abfrage als Transport, Editor umgestellt. **Erledigt.** Abweichung: Die Editor-Komponenten arbeiten weiter mit der flachen Liste (Ebene + Reihenfolge); die Engine übersetzt jede Änderung per Vergleich in Operationen (`lib/docSync/diff.ts`). Sperren werden bereits gehalten (Fokus, 20 s Nachlauf, neue Bullets), aber noch nicht angezeigt. Ende-zu-Ende-Test mit zwei Browsern: `e2e/`. | Bearbeiten funktioniert überall, auch offline | -| 3 | Leases und Präsenz in UI, Konfliktblöcke mit Übernehmen/Verwerfen, Rückgängig/Wiederholen, FLAW-25/26. **Erledigt.** Ergänzungen: Enter in einem fremd gesperrten Bullet legt einen neuen Bullet darunter an (der gesperrte bleibt unberührt); „Übernehmen“/„Verwerfen“ prüfen auch die Sperre der Kopie selbst. | Gemeinsames Bearbeiten ohne Kollisionen, Strg+Z | -| 4 | Push-Stufen WebSocket → SSE → Long-Polling → Intervall mit automatischer Erkennung. **Erledigt.** Ergänzungen: Herzschlag `PING`/`PONG` erkennt WebSockets, die angenommen werden, aber nichts weiterleiten (Wechsel auf SSE nach 35 s ohne Antwort); Sperren werden beim Fokuswechsel sofort angemeldet. Abweichung: Kein automatischer erneuter WebSocket-Versuch alle 5 Minuten – die Erkennung „WebSocket bzw. SSE blockiert“ gilt für die Sitzung des Tabs, ein neuer Tab versucht es erneut. Browser-Test: `e2e/channels.e2e.mjs`. | Live-Gefühl, wo das Netz es zulässt | -| 5 | Freigabe-Dialog mit Bearbeiter-Rolle (vorgezogen), Altlasten entfernen. **Erledigt:** `PATCH /documents/{id}/blocks`, `PatchDocumentBlocks`, `DOC_BLOCKS_PATCHED`, Blöcke in `PUT /documents/{id}`, `FlushDocumentState`/`LoadDocumentState` und die Zwangsmigration auf „read“ sind entfernt; das To-Do-Rollup und die Aufgaben-Verknüpfungen schreiben gezielte Operationen. **Bewusst nicht umgesetzt:** Zusammenlegung von `doc_shares`, `doc_team_shares` und `workspace_folder_shares` zu `workspace_access`. Rechte entscheidet bereits zentral `DocRole`; das Umschreiben der rund 20 Sichtbarkeitsabfragen brächte keinen Nutzen, aber das Risiko, dass geteilte Notizen falsch angezeigt werden. | Feature komplett | -| 6 | Härtung: Playwright-Tests, Log-Kompaktierung (Snapshot statt Log für Clients mit sehr altem `lastSeq`), Aufräumen der Grabsteine. **Erledigt.** Playwright-Tests entstanden bereits in Phasen 2–5 (`e2e/`). Verdichtung: Log 30 Tage (private Notizen 7), Grenze in `documents.log_floor`, ältere Clients bekommen `resync` und behalten ihre Warteschlange; Grabsteine 30 Tage nach `deleted_at`, das Aufräumen ist selbst ein Log-Eintrag `purge` (kein Neuladen nötig); Idempotenz-Ergebnisse 90 Tage; Job beim Start und alle 6 h, räumt auch die Lease-Tabellen im Speicher auf. Text für endgültig gelöschte Blöcke wird als neuer Block angelegt. Die Simulation verdichtet zwischendurch. | Langzeitbetrieb | - -Phase 2 ist bewusst vor den Push-Kanälen: Steht die Bearbeitung über reine HTTP-Abfragen, ist der schwierigste Fall (Proxy blockt alles außer HTTP) von Anfang an abgedeckt. WebSocket und SSE machen es danach nur schneller. - -## 12. Getroffene Entscheidungen - -| Frage | Entscheidung | -|---|---| -| Lease-Dauer | 20 s, Verlängerung alle 7 s beim Tippen | -| Lease-Umfang | Pro Block, nicht pro Ast. Neue Bullets automatisch gesperrt für den Ersteller, 20 s Nachlauf nach Verlassen, Tippen wartet nie auf die Sperre. | -| Konflikte | Konfliktkopie als eigener Block darunter, mit Übernehmen/Verwerfen. Kein automatisches Zusammenführen. | -| Titel | Jeder Bearbeiter darf ihn ändern; Bearbeiter haben außer Freigaben und Löschen volle Rechte. | -| Verlauf/Undo | Strg+Z/Wiederholen für eigene Änderungen ist Teil des Features (Phase 3). Eine Verlaufsansicht kommt später. | -| Private Notizen | Gleiches Modell, ohne Leases und Live-Kanal, solange nur eine Sitzung offen ist | diff --git a/README.md b/README.md index 7a22a50..dabf877 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) +[![Version](https://img.shields.io/badge/Version-v0.2-16a34a?style=flat-square)](#-schnellstart) -**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. +**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.

MindSift Triage Desktop @@ -61,8 +62,17 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn * Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`. * Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen. * Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`. -* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt. -* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen **zum Lesen** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein. +* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her. +* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein. + +#### 🤝 Gemeinsam bearbeiten + +* **Live:** Mehrere Personen schreiben gleichzeitig in derselben Notiz; Änderungen erscheinen bei den anderen in Bruchteilen einer Sekunde. Der Kopf der Notiz zeigt, wer gerade mitliest. +* **Kein Überschreiben:** Wer in einem Bullet schreibt, hält ihn kurz für sich; andere sehen ein Schloss mit Namen und arbeiten in den übrigen Bullets weiter. Kommt es doch zu einem Konflikt (z. B. nach Offline-Arbeit), bleibt der zweite Text als markierte Kopie mit „Übernehmen“/„Verwerfen“ erhalten. +* **Offline-fest:** Jede Eingabe wird zuerst im Browser gesichert und später übertragen, auch nach Neuladen oder Tagen ohne Netz. +* **Hinter strengen Proxys:** WebSocket → SSE → Long-Polling → Abfrage alle 3 s; MindSift wählt automatisch, was durchkommt. Der aktive Weg steht im Editor („Live“, „Abgleich alle 3 s“). + +Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).

MindSift Workspace Outliner @@ -118,7 +128,7 @@ Minimal: ```yaml services: mindsift: - image: git.dsit.me/sandrodach/mindsift:dev + image: git.dsit.me/sandrodach/mindsift:v0.2 # feste Version; :dev = neuester Entwicklungsstand restart: unless-stopped ports: ["8080:8080"] volumes: ["./data:/data"] @@ -146,11 +156,12 @@ Rufe `http://localhost:8080` auf. Beim ersten Start fĂĽhrt ein Assistent durch d | `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) | -| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin`, `user/user` an. **Nie auf öffentlichen Servern!** | +| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** | -* **Reverse-Proxy:** WebSocket-Upgrade und ungepufferte SSE-Streams mĂĽssen durchgereicht werden. Beispiele fĂĽr Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). +* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem ĂĽber Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele fĂĽr Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). * **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer. -* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. +* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen. +* **Image-Tags:** Jeder Git-Tag erzeugt ein gleichnamiges Image (`:v0.2`), jeder Branch eines mit dem Branch-Namen (`:dev`). --- @@ -171,6 +182,7 @@ npm run test:run # Komponententests (Vitest, jsdom) npm run build # Produktions-Build cd .. e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image +APP_VERSION=v0.2 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten) ``` FĂĽr das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und fĂĽhrt TypprĂĽfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus. @@ -181,7 +193,7 @@ FĂĽr das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:em ## 📚 Dokumentation -Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank, REST- und Echtzeit-API, Module, Freigaben und Berechtigungen, Themes. Bekannte Schwachstellen und offene Punkte stehen in [Doku/flaws.md](Doku/flaws.md). +Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank, REST- und Echtzeit-API, Module (inkl. gemeinsamem Bearbeiten), Freigaben und Berechtigungen, Reverse-Proxy, Themes. Bekannte Schwachstellen und offene Punkte stehen in [Doku/flaws.md](Doku/flaws.md). --- diff --git a/e2e/README.md b/e2e/README.md index ec35639..28a892f 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -37,3 +37,7 @@ Sperren, Präsenz, RĂĽckgängig und Konfliktkopien mit zwei Browsern: Schloss un Der EigentĂĽmer tippt mit normaler Verbindung, der Bearbeiter sitzt hinter einem simulierten Proxy. FĂĽr jede Situation wird der gewählte Weg und die Zeit bis zur Anzeige gemessen: nichts blockiert (WebSocket), WebSocket abgelehnt (SSE), WebSocket angenommen aber stumm (SSE nach Herzschlag-Ausfall), WebSocket und SSE blockiert sowie SSE hängend (Long-Polling), nur normale Anfragen (Abfrage alle 3 s). `collab.e2e.mjs`, `lock.e2e.mjs` und `channels.e2e.mjs` dauern je etwa eine Minute. Die Tests laufen nicht in der CI, weil sie Browser-Images brauchen. + +## Screenshots + +`e2e/screenshots.sh` baut das Image, startet es im Demo-Modus mit frischer Datenbank und erzeugt die Bilder in `screenshots/` neu (`screenshots.mjs`). FĂĽr die Versionsangabe im Kopf: `APP_VERSION=v0.2 e2e/screenshots.sh`. In der Workspace-Aufnahme bearbeitet der Demo-Benutzer `user` gleichzeitig einen Bullet, damit Sperre und Präsenz zu sehen sind. diff --git a/e2e/screenshots.mjs b/e2e/screenshots.mjs new file mode 100644 index 0000000..d8da61d --- /dev/null +++ b/e2e/screenshots.mjs @@ -0,0 +1,84 @@ +// Takes the README screenshots from a fresh server in demo mode (sample data of admin/admin). +// Usage: e2e/screenshots.sh (builds the image) or MINDSIFT_URL=… SHOTS_DIR=… node screenshots.mjs +import { chromium } from 'playwright'; + +const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080'; +const OUT = process.env.SHOTS_DIR || '../screenshots'; +const DESKTOP = { viewport: { width: 1518, height: 886 }, deviceScaleFactor: 2 }; +const MOBILE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function login(user) { + const res = await fetch(`${BASE}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ login: user, password: user }), + }); + if (!res.ok) throw new Error(`login ${user}: ${res.status}`); + return (await res.json()).token; +} + +async function open(browser, token, options) { + const ctx = await browser.newContext({ ...options, colorScheme: 'dark', locale: 'de-DE' }); + await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token); + const page = await ctx.newPage(); + page.on('pageerror', (e) => console.log('PAGEERROR', e.message)); + await page.goto(BASE); + await page.waitForLoadState('networkidle'); + return page; +} + +const tab = (page, name) => page.getByRole('button', { name: new RegExp(name) }).first().click(); + +async function shot(page, file) { + await sleep(800); // animations + await page.mouse.move(0, 0); + await page.screenshot({ path: `${OUT}/${file}` }); + console.log(`OK ${file}`); +} + +const main = async () => { + const admin = await login('admin'); + const user = await login('user'); + const browser = await chromium.launch({ args: ['--lang=de-DE'] }); + + const page = await open(browser, admin, DESKTOP); + await tab(page, 'Triage'); + await shot(page, 'Triage_desktop.png'); + + await tab(page, 'Aufgaben'); + await page.getByText('Homelab', { exact: true }).first().click().catch(() => {}); + await page.getByRole('button', { name: /Kanban|Board/ }).first().click().catch(() => {}); + await shot(page, 'aufgaben_desktop.png'); + + // The demo user edits a bullet of the shared note, so the screenshot shows lock and presence + const other = await open(browser, user, DESKTOP); + await tab(other, 'Workspace'); + await other.getByText(/^Homelab 2\.0/).first().click(); + await other.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 }); + await other.getByText('Endlich Platz fĂĽr die Dienste', { exact: false }).first().click(); + await other.keyboard.press('End'); + await other.keyboard.type(': Paperless und Immich', { delay: 20 }); + + await tab(page, 'Workspace'); + await page.getByText(/^Homelab 2\.0/).first().click(); + await page.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 }); + await page.getByText('User', { exact: false }).first().waitFor({ timeout: 15000 }).catch(() => {}); + await sleep(2000); + await shot(page, 'workspace_desktop.png'); + + await tab(page, 'Listen'); + await page.getByText('Wocheneinkauf', { exact: true }).first().click().catch(() => {}); + await shot(page, 'Listen_desktop.png'); + + const mobile = await open(browser, admin, MOBILE); + await mobile.getByText('Später im Browser weiter nutzen').click({ timeout: 5000 }).catch(() => {}); // install prompt + await shot(mobile, 'Mobile_Triage.png'); + + await browser.close(); +}; + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/e2e/screenshots.sh b/e2e/screenshots.sh new file mode 100755 index 0000000..040553e --- /dev/null +++ b/e2e/screenshots.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots +# in screenshots/. Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh +set -euo pipefail +ENGINE="${ENGINE:-podman}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$(mktemp -d)" +trap '"$ENGINE" rm -f mindsift-shots >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT + +echo "Baue Image …" +# Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh +"$ENGINE" build -q --build-arg APP_VERSION="${APP_VERSION:-}" -t mindsift-e2e-image "$ROOT" >/dev/null +"$ENGINE" run -d --name mindsift-shots --network host -e PORT=18081 -e MINDA_DEMO=true \ + -e JWT_SECRET=screenshots-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null +for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18081/api/v1/health >/dev/null && break; sleep 0.5; done + +echo "Erstelle Screenshots …" +"$ENGINE" run --rm --network host -e MINDSIFT_URL=http://localhost:18081 -e SHOTS_DIR=/shots -e LANG=de_DE.UTF-8 \ + -v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \ + sh -c 'apt-get update -qq >/dev/null && apt-get install -y -qq fonts-inter >/dev/null 2>&1; npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs' diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5fc62ea..4154551 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "1.0.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "1.0.0", + "version": "0.2.0", "dependencies": { "@types/prismjs": "^1.26.6", "framer-motion": "^11.15.0", diff --git a/frontend/package.json b/frontend/package.json index a502f5d..935837f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "1.0.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a2d73f..e503801 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,6 +52,7 @@ import { GroupsView } from './features/teams/GroupsView'; import { useMobileActions } from './context/MobileActionContext'; import { MobileHeaderMenu } from './components/MobileHeaderMenu'; import { UserAvatar } from './components/UserAvatar'; +import { APP_VERSION } from './version'; type ActiveTab = | 'triage' @@ -648,7 +649,7 @@ export const App: React.FC = () => { {authStatus.instanceName || 'MindSift'} - v0.1 + {APP_VERSION} {activeTabLabelMap[activeTab]} @@ -713,7 +714,7 @@ export const App: React.FC = () => { {authStatus.instanceName || 'MindSift'} - v0.1 + {APP_VERSION} diff --git a/frontend/src/version.ts b/frontend/src/version.ts new file mode 100644 index 0000000..510a313 --- /dev/null +++ b/frontend/src/version.ts @@ -0,0 +1,4 @@ +declare const __APP_VERSION__: string; + +/** Version shown in the header; set at build time in vite.config.ts. */ +export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d0f9eb8..294a2a2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,8 +1,17 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import { readFileSync } from 'node:fs'; + +// Shown in the header. Release builds get the git tag (APP_VERSION build argument, e.g. "v0.2"); +// other builds show the package version with a "-dev" suffix. +const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); +const appVersion = process.env.APP_VERSION || `v${pkg.version.replace(/\.0$/, '')}-dev`; export default defineConfig({ plugins: [react()], + define: { + __APP_VERSION__: JSON.stringify(appVersion), + }, server: { port: 5173, proxy: { diff --git a/internal/storage/docsync_test.go b/internal/storage/docsync_test.go index 36faf5f..26cf12e 100644 --- a/internal/storage/docsync_test.go +++ b/internal/storage/docsync_test.go @@ -362,3 +362,47 @@ func TestDeletedAtFollowsDeleteAndRestore(t *testing.T) { t.Fatal("restoring must clear deleted_at") } } + +func TestDemoAccountsGetSampleDataAndShares(t *testing.T) { + s, _ := newSyncTestStore(t) + if err := s.EnsureDemoAccounts(); err != nil { + t.Fatal(err) + } + admin, _ := s.GetUserByLogin("admin") + member, _ := s.GetUserByLogin("user") + var docID string + if err := s.db.QueryRow(`SELECT id FROM documents WHERE user_id = ? AND title LIKE 'Homelab 2.0%'`, admin.ID).Scan(&docID); err != nil { + t.Fatalf("sample note missing: %v", err) + } + doc, err := s.GetDocumentByID(admin.ID, docID) + if err != nil || len(doc.Blocks) < 20 || doc.FolderPath != "Projekte" { + t.Fatalf("sample note too small or misplaced: %d blocks, folder %q, %v", len(doc.Blocks), doc.FolderPath, err) + } + maxLevel := 0 + for _, b := range doc.Blocks { + if b.Level > maxLevel { + maxLevel = b.Level + } + } + if maxLevel < 2 { + t.Fatalf("sample note must have nested bullets, max level %d", maxLevel) + } + if role := s.DocRole(member.ID, docID); role != DocRoleEditor { + t.Fatalf("demo user must be editor of the sample note, got %q", role) + } + var tasks, inbox int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND converted_type = 'task'`, admin.ID).Scan(&tasks) + _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'inbox'`, admin.ID).Scan(&inbox) + if tasks < 8 || inbox < 5 { + t.Fatalf("expected tasks and inbox thoughts, got %d tasks, %d inbox", tasks, inbox) + } + // Running it again (container restart) must not seed twice + if err := s.EnsureDemoAccounts(); err != nil { + t.Fatal(err) + } + var docs int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ?`, admin.ID).Scan(&docs) + if docs != 3 { + t.Fatalf("restart seeded again: %d notes", docs) + } +} diff --git a/internal/storage/seed.go b/internal/storage/seed.go new file mode 100644 index 0000000..33bd256 --- /dev/null +++ b/internal/storage/seed.go @@ -0,0 +1,210 @@ +package storage + +import ( + "time" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// seedBlock describes one outliner bullet of the sample notes: nesting level, block type and text. +type seedBlock struct { + level int + typ string + text string + checked bool + lang string +} + +func seedBlocks(spec []seedBlock) []domain.DocumentBlock { + out := make([]domain.DocumentBlock, len(spec)) + for i, b := range spec { + typ := b.typ + if typ == "" { + typ = "bullet" + } + out[i] = domain.DocumentBlock{ID: domain.NewID("blk"), Type: typ, Content: b.text, Checked: b.checked, Lang: b.lang, Level: b.level} + } + return out +} + +type seedTask struct { + content string + priority string + due int // days from today; 0 = no due date unless dueToday + dueToday bool + status string // kanban column + notes string + subtasks []string + doneSubs int +} + +// SeedStarterContent fills a new account with realistic sample data (optional at setup, always in demo mode): +// inbox thoughts, two task lists with Kanban states, shopping and packing lists and a few linked notes. +func (s *Store) SeedStarterContent(user *domain.User) error { + day := func(offset int) string { return time.Now().AddDate(0, 0, offset).Format("2006-01-02") } + + inbox := []struct{ content, priority, source string }{ + {"ZĂĽndkerzen und Luftfilter fĂĽr den Rasenmäher bestellen #garage #wartung", "normal", "manual"}, + {"Urlaubsidee: WanderhĂĽtte in SĂĽdtirol fĂĽr Oktober buchen #urlaub #familie", "high", "voice"}, + {"Kellerfeuchtigkeit ĂĽber 68 % gestiegen – Entfeuchter prĂĽfen #smarthome #alarm", "urgent", "webhook"}, + {"Buchtipp von Jonas: „Die Kunst des klaren Denkens“ #lesen", "low", "manual"}, + {"Geburtstagsgeschenk fĂĽr Oma – vielleicht ein Fotobuch vom Sommer? #familie", "normal", "manual"}, + {"Idee: Backup-Report jeden Montag per Discord schicken #homelab", "normal", "voice"}, + } + for _, t := range inbox { + _, _ = s.CreateThought(user.ID, t.content, t.priority, t.source, "") + } + + addTasks := func(listID string, tasks []seedTask) { + for _, t := range tasks { + due := "" + if t.due != 0 || t.dueToday { + due = day(t.due) + } + th, err := s.CreateThoughtWithDetails(user.ID, t.content, t.priority, "manual", due, listID, t.status, "") + if err != nil { + continue + } + if _, err := s.UpdateThoughtTriage(user.ID, th.ID, "keep", "task", "", due, listID); err != nil { + continue + } + subs := make([]domain.SubTask, len(t.subtasks)) + for i, title := range t.subtasks { + subs[i] = domain.SubTask{ID: domain.NewID("sub"), Title: title, IsCompleted: i < t.doneSubs, CreatedAt: domain.NowISO()} + } + done := t.status == "done" + status, notes := t.status, t.notes + _, _ = s.UpdateThoughtDetails(user.ID, th.ID, nil, &done, nil, nil, nil, &status, ¬es, &subs, nil) + } + } + + homelab, err := s.CreateTaskList(user.ID, "", "", "Homelab", "🖥️", "#2563eb") + if err == nil { + addTasks(homelab.ID, []seedTask{ + {content: "MindSift-Container auf den neuen Server umziehen #docker", priority: "high", due: 2, status: "in_progress", + notes: "Volume `./data` vorher mit `sqlite3 .backup` sichern.", + subtasks: []string{"Backup der Datenbank ziehen", "docker-compose.yml anpassen", "DNS auf neue IP umstellen", "Alten Container stoppen"}, doneSubs: 2}, + {content: "Reverse-Proxy: Nginx-Timeouts fĂĽr Long-Polling erhöhen #nginx", priority: "normal", due: 5, status: "todo"}, + {content: "USV-Batterie tauschen lassen #hardware", priority: "urgent", due: -1, status: "todo", + notes: "Händler hat das Ersatzteil seit Montag da."}, + {content: "Wartet auf Lieferung: zweite SSD fĂĽr das RAID #hardware", priority: "normal", due: 7, status: "waiting"}, + {content: "Gitea-Runner auf Version 0.2 aktualisieren #devops", priority: "low", status: "done"}, + {content: "Uptime-Monitoring fĂĽr alle Dienste einrichten #monitoring", priority: "normal", due: 10, status: "todo", + subtasks: []string{"Uptime Kuma installieren", "Benachrichtigung per Discord", "Statusseite veröffentlichen"}, doneSubs: 1}, + }) + } + house, err := s.CreateTaskList(user.ID, "", "", "Haus & Garten", "🏡", "#16a34a") + if err == nil { + addTasks(house.ID, []seedTask{ + {content: "Hecke schneiden, bevor die Vögel brĂĽten #garten", priority: "normal", due: 3, status: "todo"}, + {content: "Heizungswartung beim Installateur anfragen #wartung", priority: "high", dueToday: true, status: "in_progress"}, + {content: "Steuerunterlagen fĂĽr 2026 sortieren #finanzen", priority: "normal", due: 14, status: "todo", + subtasks: []string{"Handwerkerrechnungen", "Spendenquittungen", "Fahrtkosten"}}, + {content: "Fahrradschloss nachkaufen", priority: "low", status: "done"}, + }) + } + + shop, err := s.CreateList(user.ID, "Wocheneinkauf", "đź›’", "shopping") + if err == nil { + items := []struct{ content, category, qty string }{ + {"Hafermilch (Barista Edition)", "KĂĽhlregal", "3x"}, + {"Griechischer Joghurt", "KĂĽhlregal", "500 g"}, + {"Bergkäse", "KĂĽhlregal", "200 g"}, + {"Ă„pfel (Elstar)", "Obst & GemĂĽse", "1 kg"}, + {"Cherrytomaten", "Obst & GemĂĽse", "2 Schalen"}, + {"Avocados", "Obst & GemĂĽse", "3x"}, + {"Bauernbrot", "Backwaren", "1x"}, + {"Espressobohnen (100 % Arabica)", "Kaffee & Tee", "1 kg"}, + {"Pfefferminztee", "Kaffee & Tee", "1x"}, + {"SpĂĽlmaschinentabs", "Drogerie", "1 Pack"}, + } + for i, it := range items { + item, err := s.CreateListItem(shop.ID, it.content, it.category, it.qty, i) + if err == nil && (i == 1 || i == 6 || i == 8) { + _, _ = s.ToggleListItem(item.ID, true, user.ID, user.DisplayName) + } + } + } + packing, err := s.CreateList(user.ID, "Packliste SĂĽdtirol", "🎒", "shopping") + if err == nil { + for i, it := range []struct{ content, category string }{ + {"Wanderschuhe", "Kleidung"}, {"Regenjacke", "Kleidung"}, {"Fleecepullover", "Kleidung"}, + {"Erste-Hilfe-Set", "AusrĂĽstung"}, {"Stirnlampe", "AusrĂĽstung"}, {"Trinkflaschen", "AusrĂĽstung"}, + {"Personalausweise", "Dokumente"}, {"HĂĽttenschlafsack", "AusrĂĽstung"}, + } { + _, _ = s.CreateListItem(packing.ID, it.content, it.category, "", i) + } + } + + _, _ = s.CreateFolder(user.ID, "Projekte") + _, _ = s.CreateFolder(user.ID, "Privat") + _, _ = s.CreateDocument(user.ID, "", "Homelab 2.0 – Umzug auf den neuen Server", "🚀", "Projekte", seedBlocks([]seedBlock{ + {typ: "callout", text: "Ziel: Bis Ende des Monats laufen alle Dienste auf dem neuen Mini-PC – leiser, sparsamer und mit echtem Backup. Aufgaben dazu stehen in der Liste #homelab."}, + {typ: "h2", text: "Warum ĂĽberhaupt?"}, + {level: 1, text: "Der alte Server zieht 65 W im Leerlauf, der neue nur 9 W"}, + {level: 2, text: "Ersparnis ca. 120 € im Jahr bei aktuellem Strompreis"}, + {level: 1, text: "Backups liefen bisher nur manuell – das soll sich ändern #backup"}, + {level: 1, text: "Endlich Platz fĂĽr die Dienste, die seit Monaten auf der Wunschliste stehen"}, + {typ: "h2", text: "Checkliste Umzug"}, + {level: 1, typ: "todo", text: "Vorbereitung"}, + {level: 2, typ: "todo", text: "Proxmox installieren und Updates einspielen", checked: true}, + {level: 2, typ: "todo", text: "Netzwerk: feste IP und VLAN fĂĽr Dienste", checked: true}, + {level: 2, typ: "todo", text: "ZFS-Pool mit zwei SSDs anlegen"}, + {level: 1, typ: "todo", text: "Dienste umziehen"}, + {level: 2, typ: "todo", text: "MindSift (Daten liegen komplett in einer SQLite-Datei)", checked: true}, + {level: 2, typ: "todo", text: "Gitea mit Runner und Container-Registry"}, + {level: 2, typ: "todo", text: "Home Assistant inklusive Zigbee-Stick"}, + {level: 1, typ: "todo", text: "Alten Server sicher löschen und verkaufen"}, + {typ: "h2", text: "Dienste & Ports"}, + {level: 1, text: "**MindSift** – Notizen, Aufgaben und Listen fĂĽr die ganze Familie #mindsift"}, + {level: 2, text: "läuft hinter Nginx, WebSocket-Upgrade und Long-Polling sind freigegeben"}, + {level: 2, text: "Demo-Konten nur im Testsystem, nie öffentlich"}, + {level: 1, text: "**Gitea** – Code, CI und Images, z. B. `git.example.com` #devops"}, + {level: 1, text: "**Home Assistant** – Sensoren melden Alarme per Webhook direkt in die MindSift-Inbox #smarthome"}, + {level: 2, text: "Beispiel: Kellerfeuchtigkeit ĂĽber 65 % → Gedanke mit Priorität „dringend“"}, + {typ: "h3", text: "Compose-Ausschnitt"}, + {level: 1, typ: "code", lang: "yaml", text: "services:\n mindsift:\n image: git.example.com/sandrodach/mindsift:v0.2\n restart: unless-stopped\n volumes: [\"./data:/data\"]\n environment:\n - JWT_SECRET=${JWT_SECRET}"}, + {typ: "h2", text: "Offene Fragen"}, + {level: 1, text: "Reicht eine USV fĂĽr Router und Server zusammen?"}, + {level: 1, text: "Offsite-Backup: Hetzner Storage Box oder Festplatte bei den Eltern? Siehe [[Ideen-Sammlung]]"}, + }), []string{"homelab", "projekt"}, true) + + _, _ = s.CreateDocument(user.ID, "", "Urlaub SĂĽdtirol", "🏔️", "Privat", seedBlocks([]seedBlock{ + {typ: "h2", text: "Reiseplan Oktober"}, + {level: 1, text: "Anreise Samstag, frĂĽh los wegen Brenner-Stau"}, + {level: 1, text: "HĂĽtte bei Villnöß – Bestätigung kommt per Mail"}, + {level: 2, text: "Halbpension, Hunde erlaubt"}, + {typ: "h2", text: "Touren-Ideen"}, + {level: 1, typ: "todo", text: "Adolf-Munkel-Weg unter den Geislerspitzen", checked: true}, + {level: 1, typ: "todo", text: "Seceda mit der Seilbahn, oben Höhenweg"}, + {level: 1, typ: "todo", text: "Regentag: Ă–tzi-Museum in Bozen #familie"}, + {typ: "callout", text: "Packliste liegt unter Listen → Packliste SĂĽdtirol."}, + }), []string{"urlaub"}, false) + + _, _ = s.CreateDocument(user.ID, "", "Ideen-Sammlung", "đź’ˇ", "", seedBlocks([]seedBlock{ + {text: "Offsite-Backup verschlĂĽsselt mit restic #backup"}, + {level: 1, text: "Vorteil: Snapshots, Deduplizierung, läuft auf jedem Ziel"}, + {text: "Familien-Wiki statt WhatsApp-Gruppe fĂĽr Rezepte und Termine #familie"}, + {text: "WochenrĂĽckblick jeden Sonntag: Inbox leeren, Aufgaben fĂĽr die Woche planen #routine"}, + }), []string{"ideen"}, false) + + return nil +} + +// seedDemoShares shares part of the admin's sample data with the demo user, so both demo accounts can try +// out editing a note, a task list and a shopping list together. +func (s *Store) seedDemoShares(admin, member *domain.User) { + var docID, taskListID, listID string + _ = s.db.QueryRow(`SELECT id FROM documents WHERE user_id = ? AND title LIKE 'Homelab 2.0%'`, admin.ID).Scan(&docID) + _ = s.db.QueryRow(`SELECT id FROM task_lists WHERE user_id = ? AND title = 'Homelab'`, admin.ID).Scan(&taskListID) + _ = s.db.QueryRow(`SELECT id FROM lists WHERE user_id = ? AND title = 'Wocheneinkauf'`, admin.ID).Scan(&listID) + if docID != "" { + _, _ = s.CreateDocShare(admin.ID, docID, member.ID, "write") + } + if taskListID != "" { + _, _ = s.AddTaskListShare(taskListID, "user", member.ID, "editor", admin.ID) + } + if listID != "" { + _, _ = s.AddListShare(admin.ID, listID, "user", member.ID, "editor") + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index f251553..b50e6ec 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -474,58 +474,6 @@ func (s *Store) migrate() error { return nil } -// SeedStarterContent populates realistic initial content for a newly created user if requested -func (s *Store) SeedStarterContent(user *domain.User) error { - now := domain.NowISO() - - starterThoughts := []struct { - content string - priority string - source string - dueDate string - }{ - { - content: "ZĂĽndkerzen und Luftfilter fĂĽr den Rasenmäher bestellen #garage #wartung", - priority: "normal", - source: "manual", - }, - { - content: "Urlaubsidee: WanderhĂĽtte in SĂĽdtirol fĂĽr Oktober buchen #urlaub #familie", - priority: "high", - source: "voice", - }, - { - content: "Kellerfeuchtigkeit ĂĽber 68% gestiegen – Entfeuchter prĂĽfen #smarthome #alarm", - priority: "urgent", - source: "webhook", - }, - } - - for _, st := range starterThoughts { - _, _ = s.CreateThought(user.ID, st.content, st.priority, st.source, st.dueDate) - } - - keptTask, err := s.CreateThought(user.ID, "Gitea CI/CD Pipeline fĂĽr MindSift Container testen #devops #docker", "high", "manual", now[:10]) - if err == nil && keptTask != nil { - _, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "") - } - - shopList, err := s.CreateList(user.ID, "Wocheneinkauf Supermarkt", "đź›’", "shopping") - if err == nil && shopList != nil { - _, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "KĂĽhlregal", "3x", 0) - _, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1) - } - - starterBlocks := []domain.DocumentBlock{ - {ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flĂĽchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran."}, - {ID: "blk_2", Type: "h2", Content: "Projekt-Checkliste"}, - {ID: "blk_3", Type: "todo", Content: "MindSift als Docker-Container auf eigenem Server starten", Checked: true}, - } - - _, _ = s.CreateDocument(user.ID, "", "MindSift Architektur & Notizen", "🚀", "", starterBlocks, []string{"mindsift", "workflow"}, true) - return nil -} - // EnsureDefaultUser is retained strictly for unit test convenience func (s *Store) EnsureDefaultUser() (*domain.User, error) { s.initMu.Lock() @@ -679,13 +627,14 @@ func (s *Store) EnsureDemoAccounts() error { } // 1. Admin user (admin / admin) + adminCreated := false adminUser, err := s.GetUserByLogin("admin") if err != nil || adminUser == nil { adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin") if err != nil { return fmt.Errorf("failed to create demo admin: %w", err) } - _ = s.SeedStarterContent(adminUser) + adminCreated = true } else { _ = s.UpdateUserPassword(adminUser.ID, adminHash) _, _ = s.db.Exec(`UPDATE users SET is_active = 1, role = 'admin' WHERE id = ?`, adminUser.ID) @@ -694,7 +643,7 @@ func (s *Store) EnsureDemoAccounts() error { // 2. Regular user (user / user) normalUser, err := s.GetUserByLogin("user") if err != nil || normalUser == nil { - _, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member") + normalUser, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member") if err != nil { return fmt.Errorf("failed to create demo user: %w", err) } @@ -703,6 +652,12 @@ func (s *Store) EnsureDemoAccounts() error { _, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID) } + // 3. Sample data for the admin, partly shared with the demo user (only on first start) + if adminCreated { + _ = s.SeedStarterContent(adminUser) + s.seedDemoShares(adminUser, normalUser) + } + return nil } diff --git a/screenshots/Listen_desktop.png b/screenshots/Listen_desktop.png index f0bae8b..42e2749 100644 Binary files a/screenshots/Listen_desktop.png and b/screenshots/Listen_desktop.png differ diff --git a/screenshots/Mobile_Triage.png b/screenshots/Mobile_Triage.png index a8ab876..21d99e8 100644 Binary files a/screenshots/Mobile_Triage.png and b/screenshots/Mobile_Triage.png differ diff --git a/screenshots/Triage_desktop.png b/screenshots/Triage_desktop.png index 8d18562..e2bf035 100644 Binary files a/screenshots/Triage_desktop.png and b/screenshots/Triage_desktop.png differ diff --git a/screenshots/aufgaben_desktop.png b/screenshots/aufgaben_desktop.png index bf48e27..19c888f 100644 Binary files a/screenshots/aufgaben_desktop.png and b/screenshots/aufgaben_desktop.png differ diff --git a/screenshots/workspace_desktop.png b/screenshots/workspace_desktop.png index 5c8c0ea..446d387 100644 Binary files a/screenshots/workspace_desktop.png and b/screenshots/workspace_desktop.png differ