- TypeScript 55%
- Kotlin 34.3%
- JavaScript 5.2%
- QML 3.6%
- Shell 0.8%
- Other 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| android | ||
| docker | ||
| e2e | ||
| plasma | ||
| scripts | ||
| server | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| compose.test.yaml | ||
| compose.yaml | ||
| README.md | ||
| SPEC-NOTES.md | ||
| SPEC.md | ||
Merkzettel
Selbst gehostete Notiz-App, Nachfolger von GlassKeep: Web-App (PWA, offline-fähig), native Android-App mit lokaler Datenhaltung und Sync, Medien (Bilder, Audio/Sprachmemos, Video, Dateien, Zeichnungen), Erinnerungen, Versionsverlauf, Papierkorb, Freigabelinks, Volltext- und KI-Suche.
Der verbindliche Vertrag zwischen Server, Web und Android steht in SPEC.md,
Abweichungen und Auslegungen in SPEC-NOTES.md.
| Stack-Verzeichnis | /opt/stacks/merkzettel (in Dockge sichtbar) |
| Container | merkzettel (Image merkzettel-app:latest) |
| URL (LAN) | http://192.168.56.136:8094 |
| Daten | ./data → /app/data (SQLite + Medien) |
| Android-App | http://192.168.56.136:8094/download/merkzettel.apk |
| KDE-Plasma-Widget | http://192.168.56.136:8094/download/merkzettel.plasmoid (Quellen in plasma/) |
1. Betrieb
cd /opt/stacks/merkzettel
docker compose up -d --build # bauen (inkl. Android-APK) und starten
docker compose ps # Status – "healthy" nach ca. 10–40 s
docker compose logs -f app # Logs (JSON, pino)
docker compose down # stoppen (Daten bleiben in ./data)
Der erste Build dauert lange (Android-SDK + Gradle-Abhängigkeiten, ~10–20 min). Danach greifen die
BuildKit-Caches (merkzettel-gradle, merkzettel-npm); nur geänderte Teile werden neu gebaut.
Ein Update ist einfach erneut docker compose up -d --build – die App-Version (versionCode) steigt
dabei automatisch, installierte Android-Apps melden das Update selbst.
Konfiguration (.env)
.env liegt neben compose.yaml (Rechte 600, nicht weitergeben). Alle Variablen sind in
.env.example dokumentiert. Die wichtigsten:
| Variable | Wert hier | Bedeutung |
|---|---|---|
JWT_SECRET |
zufällig (64 Zeichen) | Signatur der Zugriffstoken. Ändern = alle müssen sich neu anmelden. |
PUBLIC_URL |
http://192.168.56.136:8094 |
Basis für Freigabelinks, apk.json und die in der APK voreingestellte Server-URL. Nach Einrichtung des Reverse Proxys auf https://… umstellen und neu bauen. |
ADMIN_EMAILS |
jonas |
Diese Benutzernamen werden beim Start Admin (wie in GlassKeep; ADMIN_USERNAMES ist gleichwertig). |
TRUST_PROXY |
192.168.56.37 |
Nur diesem Proxy (NPM) wird X-Forwarded-For/-Proto geglaubt (Rate-Limits, Logs). Liste von IPs/CIDRs möglich; true (= jedem) nur hinter einem Proxy, der den Header immer überschreibt. Ohne Proxy weglassen. |
ALLOW_REGISTRATION |
false |
Nur Startwert – danach gilt der Schalter in der Admin-Oberfläche. |
GLASSKEEP_IMPORT_DB |
/import/glasskeep-notes.db |
Auto-Import beim allerersten Start (siehe 2.1). |
LLM_BASE_URL |
http://host.docker.internal:11434/v1 |
llama-swap auf dem Host. Leer = KI aus. Nicht erreichbar = KI-Funktionen blenden sich aus, alles andere läuft weiter. |
LLM_CHAT_MODEL / LLM_EMBED_MODEL |
qwen2.5:7b / embeddinggemma |
Modelle für „Frag deine Notizen“ und die semantische Suche. |
TRANSCRIBE_BASE_URL / TRANSCRIBE_MODEL |
leer / – | OpenAI-kompatibler Whisper-Endpunkt (…/v1, POST /audio/transcriptions) für Sprachmemo-Transkription. Leer = Transkription aus und in allen Oberflächen ausgeblendet. Einrichtung siehe 1.1. |
Nach einer Änderung an .env: docker compose up -d (bei PUBLIC_URL zusätzlich --build, weil die URL in die APK eingebaut wird).
1.1 Sprachmemo-Transkription (optional, standardmäßig aus)
Merkzettel braucht dafür einen Endpunkt mit der OpenAI-API POST /v1/audio/transcriptions.
Der vorhandene Home-Assistant-Whisper (ha-whisper-gpu, Port 10300) spricht das Wyoming-Protokoll und
passt nicht. Der Stack bringt deshalb einen optionalen Dienst whisper mit
(speaches, faster-whisper auf der CPU, int8, nur im Stack-Netz erreichbar,
kein Host-Port). Einschalten – drei Zeilen in .env:
COMPOSE_PROFILES=transkription
TRANSCRIBE_BASE_URL=http://whisper:8000/v1
TRANSCRIBE_MODEL=Systran/faster-whisper-small
danach docker compose up -d. Beim ersten Start lädt merkzettel-whisper das Modell (~480 MB) in das
Volume merkzettel-whisper-models; spätere Starts laden nichts mehr. TRANSCRIBE_MODEL muss gesetzt
sein und gilt für App und Whisper-Dienst (der App-Standard whisper-1 wäre bei speaches ein Alias auf
das große large-v3, das nicht vorab geladen wird). Genauer, aber langsamer: Systran/faster-whisper-medium.
Ressourcen: bis 3 GB RAM während einer Transkription, 4 CPU-Threads (WHISPER_CPU_THREADS), das Modell
wird nach 5 min Leerlauf entladen. Ein 10-Sekunden-Memo dauert mit small wenige Sekunden.
Danach zeigen Web- und Android-App bei Audio-Anhängen „Transkribieren“, in den Einstellungen gibt es
„Sprachmemos automatisch transkribieren“; Transkripte sind durchsuchbar. Beides nur für Nutzer, die unter
Einstellungen → KI „KI-Assistent aktivieren“ eingeschaltet haben (sonst 403 ai_disabled). Prüfen:
curl -s http://192.168.56.136:8094/api/config → "transcription":true, und e2e/run-sync.sh führt dann
zusätzlich den Transkriptionstest aus. Ausschalten: die drei Zeilen entfernen, docker compose up -d --remove-orphans.
1.2 Sicherheit des Containers
- Der App-Container läuft als uid 1000 (
orion) ohne Capabilities (cap_drop: ALL), mitno-new-privileges, schreibgeschütztem Root-Dateisystem (read_only) und Limits (3 GB RAM, 512 Prozesse). Beschreibbar sind nur./dataund ein 256-MB-/tmp(tmpfs) – wichtig, weil der Server fremde Medien mit ffmpeg, pdftoppm, sharp und ZIP-Entpacker verarbeitet. ./dataenthält Passwort-Hashes, Token-Hashes und alle privaten Notizen. Der Container setzt beim Startdata/auf0700, Datenbank und Import-Backups auf0600und arbeitet mitumask 077– andere lokale Konten (z. B.kiosk) können nichts davon lesen. Root (Backups, Dockge) ist davon nicht betroffen.
Kommandozeile im Container
docker exec -it merkzettel node dist/cli.js list-users
printf '%s' 'neues-passwort' | docker exec -i merkzettel node dist/cli.js reset-password jonas
printf '%s' 'passwort' | docker exec -i merkzettel node dist/cli.js create-admin anna --name "Anna" --admin
docker exec -it merkzettel node dist/cli.js delete-user anna
docker exec -it merkzettel node dist/cli.js purge-trash | gc-media | reindex-fts
Wurde beim ersten Start kein Admin importiert, legt der Server admin an; das Passwort steht dann in
data/initial-admin-password.txt (wird nach der ersten Passwortänderung gelöscht).
2. Import aus GlassKeep
Importiert werden Benutzer (mit ihren alten Passwörtern und geheimen Schlüsseln), alle Notizen
(Text, Checklisten, Zeichnungen → Bild-Anhang), Tags, Farben, Anheften/Archiv, eingebettete Bilder
(als Mediendateien) und Mitbearbeitungen. Notiz-IDs bleiben erhalten. Vor jedem Import wird
data/backups/pre-import-<zeit>.db angelegt (die neuesten 5 bleiben). Der Import arbeitet immer auf
einer Kopie – die GlassKeep-Datenbank selbst wird nie geöffnet oder verändert.
2.1 Automatisch beim ersten Start (eingerichtet)
compose.yaml mountet ./reference nur lesend nach /import; .env setzt
GLASSKEEP_IMPORT_DB=/import/glasskeep-notes.db. Beim allerersten Start (leere Benutzertabelle) wird
diese Datei im Modus merge importiert. Danach passiert beim Neustart nichts mehr
(glasskeepAutoImportDone ist gesetzt).
2.2 Final-Import mit frischem Snapshot (Umstieg)
Solange GlassKeep parallel weiterläuft, können dort noch Änderungen entstehen. Zum endgültigen Umstieg:
# 1. Konsistenten Snapshot der laufenden GlassKeep-DB ziehen (liest nur, GlassKeep darf weiterlaufen)
# (die GlassKeep-Dateien gehören root, daher sudo; der Snapshot gehört danach orion)
sudo sqlite3 /opt/stacks/glasskeep/glass-keep/notes.db \
".backup /opt/stacks/merkzettel/reference/glasskeep-notes.db"
sudo chown orion:orion /opt/stacks/merkzettel/reference/glasskeep-notes.db
chmod 600 /opt/stacks/merkzettel/reference/glasskeep-notes.db
# 2. Probelauf: zeigt, was neu/aktualisiert würde, ändert nichts
docker exec merkzettel node dist/cli.js import-glasskeep /import/glasskeep-notes.db --mode update --dry-run
# 3. Echt importieren
docker exec merkzettel node dist/cli.js import-glasskeep /import/glasskeep-notes.db --mode update
--mode updateübernimmt in GlassKeep geänderte Notizen, fügt neue Notizen/Benutzer/ Mitbearbeitungen hinzu und übernimmt in GlassKeep geänderte Passwörter/Schlüssel/Admin-Rechte sowie Anheften/Archiv/Reihenfolge. Vor dem Überschreiben wird ein Versionsstand („Import“) gespeichert – im Versionsverlauf der Notiz wiederherstellbar.- Wurde etwas in beiden Apps geändert, gewinnt Merkzettel: bei Notizen bleibt die Merkzettel-Fassung, die GlassKeep-Fassung kommt als Kopie „… (Import-Konflikt)“ dazu; bei Konten bleibt der Merkzettel-Stand. Beides steht als Warnung im Bericht.
--mode merge(Standard) fügt nur Neues hinzu und lässt Bestehendes unverändert.- In Merkzettel gelöschte Notizen und Benutzer werden nie wiederbelebt.
- Ein zweiter Lauf mit derselben Datei ändert nichts (idempotent).
- Danach GlassKeep stoppen (in Dockge) – am besten den Snapshot erst ziehen, wenn niemand mehr in GlassKeep schreibt.
2.3 Weitere Wege
- Admin-Oberfläche → Admin → „GlassKeep-Datenbank importieren“ (Datei-Upload, Modus wählbar, Probelauf).
- Pro Benutzer in den Einstellungen → Daten: GlassKeep-JSON-Export, Google-Keep-Takeout (ZIP/JSON), Markdown-Dateien und das verlustfreie Merkzettel-ZIP. Diese importieren immer ins eigene Konto.
3. Backup
Zu sichern sind zwei Dinge:
data/– Datenbank (merkzettel.db+-wal/-shm), Mediendateien (media/,derived/),secrets.json(Schlüssel für signierte Medienlinks, Web-Push). Konsistent sichern:
(Alternativ Container kurz stoppen undsqlite3 /opt/stacks/merkzettel/data/merkzettel.db ".backup /pfad/zum/backup/merkzettel.db" rsync -a --exclude 'merkzettel.db*' --exclude tmp/ /opt/stacks/merkzettel/data/ /pfad/zum/backup/data/data/komplett kopieren.)derived/lässt sich zur Not neu erzeugen,media/nicht.android/keystore/–merkzettel-release.jksundkeystore.properties. Ohne Keystore keine Updates der App möglich: eine mit einem anderen Schlüssel gebaute APK lässt sich nicht über die installierte App installieren; dann bleibt nur Deinstallieren (nicht synchronisierte lokale Änderungen gehen verloren). Den Keystore einmalig an einem sicheren Ort ablegen (Passwortmanager/Offline-Backup).scripts/gen-keystore.shüberschreibt ihn nie.
Vor jeder Datenbank-Migration legt der Server selbst eine Sicherung data/backups/pre-migration-<NNN>-<Zeit>.db
an (die neuesten 5 bleiben); ohne erfolgreiche Sicherung startet er nicht.
.env (enthält JWT_SECRET) ebenfalls sichern; geht sie verloren, reicht ein neues Secret –
dann müssen sich nur alle neu anmelden.
4. Android-App
- Auf dem Handy http://192.168.56.136:8094/download/merkzettel.apk öffnen (oder in der Web-App: Einstellungen → App → QR-Code scannen).
- Installation aus dieser Quelle erlauben, APK installieren.
- Server-URL ist vorbelegt (
PUBLIC_URLzum Build-Zeitpunkt), mit dem bisherigen GlassKeep-Login anmelden.
Die App hält alle Notizen lokal (Room-Datenbank), funktioniert komplett offline und synchronisiert Änderungen, sobald wieder eine Verbindung besteht (WorkManager, alle 15 min und sofort nach Änderungen, live per Server-Sent Events, solange die App offen ist). Konflikte gleichzeitiger Bearbeitung werden feldweise zusammengeführt; bei echtem Textkonflikt entsteht eine Kopie „… (Konflikt )“.
Updates: Nach jedem docker compose up -d --build meldet die App „Update verfügbar“ (prüft
/download/apk.json, SHA-256 wird verifiziert).
Hinweis: Die Android-App funktioniert über reines http:// im LAN vollständig (auch Sprachmemos
aufnehmen – das macht die App nativ). Für die Web-App im Browser gelten über http:// die
Einschränkungen aus Abschnitt 5. Für den Zugriff von unterwegs den Reverse Proxy (Abschnitt 5)
einrichten und in der App die https://-Adresse eintragen.
4.1 Ordner (Web und App)
- Notizen lassen sich in Ordner einsortieren, bis zu 3 Ebenen tief (Ordner › Unterordner › Unterordner), höchstens 500 Ordner pro Benutzer. Jede Notiz liegt in höchstens einem Ordner; ohne Ordner erscheint sie unter „Ohne Ordner“. „Notizen“ zeigt weiterhin alle aktiven Notizen, egal in welchem Ordner.
- Ordner sind privat: Bei geteilten Notizen sortiert jeder Mitbearbeiter in seine eigenen Ordner ein und sieht die Ordner der anderen nicht.
- Seitenleiste (Web) bzw. Menü (App), Abschnitt „Ordner“: Baum zum Auf-/Zuklappen mit Anzahl, „Ohne Ordner“, „Neuer Ordner“. Über ⋮ / Rechtsklick (Web) bzw. langes Drücken (App): Unterordner erstellen, Umbenennen, Farbe, Symbol (Emoji), Verschieben…, Löschen.
- Löschen eines Ordners löscht nie Notizen: Notizen (auch archivierte und im Papierkorb) und Unterordner wandern in den übergeordneten Ordner bzw. nach „Ohne Ordner“.
- Notiz einsortieren: Ordner-Chip im Editor bzw. auf der Karte, Mehrfachauswahl → „Verschieben nach…“ (mit Rückgängig), im Web auch per Drag & Drop einer Karte auf einen Ordner in der Seitenleiste (nur wenn die Seitenleiste in den Einstellungen auf „immer anzeigen“ steht).
- In der Ordneransicht angelegte Notizen landen direkt in diesem Ordner; der Schalter „Unterordner einbeziehen“ zeigt auch die Notizen der Unterordner. Die Suche hat einen Filter „Ordner“ (inkl. Unterordner).
- ZIP-Export legt die Notizen in Ordnerpfaden ab (
markdown/<Ordner>/<Unterordner>/…); der Archiv-Import stellt die Ordner wieder her.
4.2 Teilen (z. B. per WhatsApp)
In der Notiz ⋮ → „Teilen…“ oder das Teilen-Symbol oben (Web und App); in der Mehrfachauswahl „Teilen“ (eine Notiz → Auswahl wie unten, mehrere → alle zusammen als Text). Die empfohlene Variante steht oben:
| Option | Ergebnis |
|---|---|
| Als Text | Titel + Inhalt als normale Nachricht; Markdown wird zu lesbarem Text, Checklisten werden zu ☐/☑-Zeilen, fertige Transkripte von Sprachmemos werden angehängt |
| Als Bild | die Notiz als PNG (helles Design in der Notizfarbe, bis zu 4 Bilder, Schlagwörter, optional Fußzeile „Merkzettel · Datum“) |
| Anhänge (n) | Sprachmemos, Bilder, Videos, Dateien im Original – empfohlen, wenn die Notiz nur aus Anhängen besteht (z. B. ein reines Sprachmemo) |
| In Zwischenablage kopieren | der Text aus „Als Text“ |
Alles wird lokal auf dem Gerät erzeugt, es wird kein externer Dienst angesprochen. In der App öffnet sich das normale Android-Teilen-Menü (WhatsApp, Signal, Mail …). Im Browser wird das System-Teilen genutzt, wo es das gibt (Handy, über HTTPS); sonst wird der Text kopiert bzw. das Bild/die Datei heruntergeladen. „Link teilen…“ (öffentlicher Freigabelink) gibt es weiterhin separat.
4.3 Startbildschirm-Widget (App)
Jedes Widget wird beim Hinzufügen einzeln eingerichtet (und lässt sich später über langes Drücken → Einstellungen ändern):
- Quelle: angeheftete Notizen, alle Notizen, ein Schlagwort oder ein Ordner (optional mit Unterordnern).
- Anzeige: nur Titel, Vorschau oder ganzer Inhalt; Sortierung; Anzahl der Notizen.
- Schnellaktionen (neue Text-/Checklisten-/Sprachnotiz – bei einem Ordner-Widget direkt in diesem Ordner), Abhaken von Checklisten-Punkten direkt im Widget, transparenter Hintergrund.
- Wurde der Ordner gelöscht, zeigt das Widget „Ordner nicht gefunden – Widget neu einrichten“.
4.4 KDE-Plasma-Widget (Desktop/Panel, Plasma 6)
Merkzettel als Plasmoid li.krg.merkzettel für den KDE-Plasma-6-Desktop oder das Panel: angeheftete Notizen,
alle Notizen, ein Tag oder ein Ordner (optional mit Unterordnern, oder „Ohne Ordner“) im Blick behalten,
Listen abhaken, schnell neue Notizen/Listen erfassen – auch offline. Einzelheiten in plasma/README.md.
- Herunterladen: in der Web-App Einstellungen → KDE-Plasma-Widget → „Plasma-Widget herunterladen“ (dort steht auch die Version) oder direkt http://192.168.56.136:8094/download/merkzettel.plasmoid.
- Installieren bzw. aktualisieren (im Ordner mit der Datei):
kpackagetool6 -t Plasma/Applet -i merkzettel.plasmoid # erstmalig kpackagetool6 -t Plasma/Applet -u merkzettel.plasmoid # Update systemctl --user restart plasma-plasmashell # nach einem Update, damit Plasma die neuen Dateien lädt - Rechtsklick auf Desktop oder Panel → Widgets hinzufügen … → „Merkzettel“ hinziehen, dann im Widget anmelden
(Server
http://192.168.56.136:8094bzw. späterhttps://…).
Das Widget erscheint unter Einstellungen → Sitzungen als „Plasma-Widget “ mit der Kennzeichnung
„KDE-Plasma-Widget“ (Sitzungsart plasma; an Servern vor dieser Version meldet es sich automatisch als
android-Sitzung an). Das Paket wird beim Image-Bau aus plasma/package gebaut (Stage plasmoid im
Dockerfile, plasma/build.sh) – es ist also immer auf dem Stand des Repos; GET /api/config meldet
features.plasmoid, die Versionsdaten liegen unter /download/merkzettel-plasmoid.json.
5. Reverse Proxy (Nginx Proxy Manager auf 192.168.56.37)
Der Proxy ist nicht Teil dieses Stacks. Vorschlag für einen Proxy Host, z. B. notes.krg.li:
- Forward:
http://192.168.56.136:8094, „Websockets Support“ an, „Block Common Exploits“ an, SSL: Let's-Encrypt-Zertifikat (Wildcard vorhanden), „Force SSL“ + HTTP/2 an. - Advanced → Custom Nginx Configuration:
# große Uploads (Videos, Audio): Merkzettel lädt in 4-MiB-Blöcken, einzelne Requests bis 32 MiB
client_max_body_size 64m;
# Server-Sent Events (/api/events): nicht puffern, lange offen halten
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
proxy_http_version 1.1;
proxy_set_header Connection "";
# Uploads direkt durchreichen statt zwischenzuspeichern
proxy_request_buffering off;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
(„Websockets Support“ setzt in NPM Upgrade/Connection-Header; Merkzettel nutzt SSE statt
WebSockets – die Zeile proxy_set_header Connection ""; sorgt für dauerhafte Upstream-Verbindungen.
Der Server sendet selbst X-Accel-Buffering: no auf dem Event-Stream.)
Danach in .env PUBLIC_URL=https://notes.krg.li setzen und docker compose up -d --build.
Browser erlauben einige Funktionen nur in einem „sicheren Kontext“ (HTTPS oder localhost). Über
http://192.168.56.136:8094 gilt für die Web-App deshalb:
| Funktion | über http:// im LAN |
|---|---|
| Anmelden, Notizen, Sync, Medien hochladen/ansehen, Zeichnen, Suche, KI | funktioniert |
| Sprachmemo aufnehmen (Mikrofon) | nicht möglich – der Knopf öffnet den Hinweis „Audioaufnahme ist nur über HTTPS möglich“; stattdessen eine Audiodatei anhängen oder in der Android-App aufnehmen |
| Offline-Start der Web-App (Service Worker), Installation als PWA, „Teilen-Ziel“ | ausgeblendet |
| Erinnerungen als Browser-Benachrichtigung / Web-Push | ausgeblendet; Erinnerungen erscheinen nur in der geöffneten App bzw. auf Android |
| In die Zwischenablage kopieren (Codeblöcke) | funktioniert über eine Ersatzmethode |
Mit HTTPS über den Reverse Proxy ist alles verfügbar. (Die Playwright-Suite startet Chromium mit
--unsafely-treat-insecure-origin-as-secure, damit sie diese Funktionen auch über http:// testen kann –
das ist kein Hinweis darauf, dass normale Browser das erlauben.)
6. Tests
Alle Tests laufen in Wegwerf-Containern (merkzettel-*, --rm); auf dem Host ist weder Node noch Java nötig.
cd /opt/stacks/merkzettel
scripts/test-all.sh # Server-, Web-, Android-Unit-Tests + Web-E2E (compose.test.yaml), nacheinander
# gegen den LAUFENDEN Stack: jeder Lauf legt einen temporären Admin + Benutzer mit eigener Lauf-Kennung an
# ("e2e-<lauf-id>-…") und löscht danach genau diese – mehrere Läufe gleichzeitig stören sich nicht:
e2e/run-e2e.sh # Playwright: alle Web-Szenarien + Medien/Video/PDF + GlassKeep-JSON-Roundtrip
e2e/run-sync.sh # Sync-Protokoll-Harness (Mehrgeräte-Szenarien, Konflikte, Dedup, Entzug)
# + Sprachmemo-Transkription, falls eingerichtet (sonst übersprungen)
e2e/run-android-live.sh # Android-SyncEngine (Robolectric) gegen den laufenden Server
Gegen einen anderen Container/Port: APP_CONTAINER=<name> BASE_URL=http://127.0.0.1:<port> e2e/run-sync.sh.
Die Hilfsskripte selbst testet e2e/tests/common.test.sh (ohne Stack).
Einzelbefehle (SPEC §12):
# Server
docker build -t merkzettel-server-test -f docker/server-test.Dockerfile docker/
docker run --rm --name merkzettel-server-test -v $PWD/server:/work/server -v $PWD/reference:/work/reference:ro \
-v merkzettel-npm-cache:/root/.npm -w /work/server merkzettel-server-test bash -lc "npm ci && npm run typecheck && npm test"
# Web
docker run --rm --name merkzettel-web-test -v $PWD/web:/work/web -v merkzettel-npm-cache:/root/.npm \
-w /work/web node:22-bookworm bash -lc "npm ci && npm run typecheck && npm test && npm run build"
# Android (immer nur ein Gradle-Lauf gleichzeitig). Der WYSIWYG-Editor der App (WebView) ist der
# TipTap-Editor aus web/src/android-editor; ohne vorher gebautes Bundle fällt die App aufs Textfeld zurück.
scripts/build-android-editor.sh # → android/app/src/main/assets/editor/ (im Image: Dockerfile-Stage "editor")
docker build -t merkzettel-android-builder -f docker/android-builder.Dockerfile docker/
docker run --rm --name merkzettel-android-test -v $PWD/android:/android -v merkzettel-gradle-cache:/root/.gradle \
-w /android merkzettel-android-builder gradle --no-daemon testDebugUnitTest assembleDebug
# Gerätetest im Emulator (KVM, headless; adb im Container, Screenshots per `adb exec-out screencap -p`)
docker build -t merkzettel-android-emulator -f docker/android-emulator.Dockerfile docker/
docker run -d --name merkzettel-emu --device /dev/kvm --network host merkzettel-android-emulator \
bash -c "emulator -avd mz -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -no-snapshot -port 5584 & sleep infinity"
# Web-E2E auf Wegwerf-Stack
docker compose -f compose.test.yaml up --build --abort-on-container-exit --exit-code-from e2e
docker compose -f compose.test.yaml down --rmi local
# Plasma-Widget: node-Tests + qmllint + QV4-Smoke (Image merkzettel-plasma-dev), danach Paket bauen
plasma/test/run-all.sh && plasma/build.sh
Die echte GlassKeep-Datenbank (reference/) wird nur im Server-Test import-reference (Zählwerte,
keine Inhalte) und für den Auto-Import verwendet – nie in E2E-Läufen mit Screenshots/Traces.
7. Stand (2026-10-12) und offene Punkte
Produktiv eingespielt sind Ordner/Teilen mit Migration 003 (docker compose up -d --build, 2026-10-12 ~19:36).
Die anschließenden Review-Fixes (Web: Outbox-Reihenfolge bei neuen Ordnern, Anhänge-Teilen mit Download-Fallback,
ZIP-Pfade; Android: „Als Bild“ als mehrere Seiten, Einrückung im Bild, Text-Kürzung, Widget-Sperre) sind getestet,
aber noch nicht eingespielt – dafür docker compose up -d --build ausführen und die neue APK installieren.
Testläufe nach den Review-Fixes (Wegwerf-Container):
| Suite | Ergebnis |
|---|---|
| Server (tsc + vitest) | 216 Tests bestanden, 1 übersprungen (opt-in Test gegen DB-Kopie) |
| Web (typecheck, eslint, vitest, Build) | 205 Tests bestanden |
Android-Unit-Tests (Robolectric) + assembleDebug |
169 Tests, 0 Fehler (3 Live-Tests ohne Server übersprungen) |
Web-E2E auf Wegwerf-Stack (compose.test.yaml) |
20 bestanden (inkl. Ordner und Teilen) |
Live gegen Produktiv-Stack (Server-Stand mit Migration 003, alte Web-Oberfläche): run-sync.sh / run-android-live.sh / run-e2e.sh |
14 bestanden + 1 übersprungen (Transkription aus) / 3 bestanden / 21 bestanden; e2e-Benutzer wieder gelöscht, echte Daten unverändert (2 Benutzer, 689 Notizen) |
Sicherungen: data/backups/pre-folders-20261012-192628.db (manuell), data/backups/pre-migration-003-20261012-193603.db
(automatisch) und data/backups/pre-folders-final-20261012.db (vor dem Einspielen der Review-Fixes).
Bekannte Einschränkungen beim Teilen/Ordnern:
- Web „Anhänge“ teilt höchstens 10 Dateien / 50 MB und nur vom Browser erlaubte Typen (z. B. kein HEIC/ZIP) – sonst wird heruntergeladen. „Als Bild“ ist im Web ein einzelnes langes PNG, in der App mehrere Seiten à 1920 px.
- Android-Outbox: Notiz in einen gerade offline neu angelegten Ordner verschieben, während schon eine ältere Änderung derselben Notiz wartet, kann die Ordnerzuordnung verlieren (im Web behoben, App folgt).
Offen bzw. bewusst so:
- Umstieg: frischen GlassKeep-Snapshot importieren (2.2), NPM-Proxy anlegen (5), danach
PUBLIC_URL=https://…+docker compose up -d --build, GlassKeep stoppen. - Web über
http://: kein Mikrofon, kein Offline-Start/PWA, keine Browser-Benachrichtigungen (Tabelle in 5) – mit HTTPS über den Proxy verfügbar. Web-Push ist erst mit HTTPS sinnvoll. - Transkription ist eingebaut, aber aus (1.1) – Einschalten kostet bis 3 GB RAM, der Host ist knapp.
- Android-Sprachmemos nehmen nur im Vordergrund auf (kein Foreground-Service/keine Zusatzrechte); beim Sperren des Bildschirms wird die Aufnahme beendet und das Bisherige gespeichert.