62 Commits
Author SHA1 Message Date
marcandClaude Opus 5 f064eb62c7 fix(drawing): zwei stille Wege in den Datenverlust
Feinschliff vor dem Ausrollen, beim Durchlesen mit fremdem Blick gefunden und
beide nachgestellt.

Ein Fehler beim Laden ergab ein leeres Zeichenbrett. fetch wirft bei 404 oder
403 nicht. Der Rumpf {"error":"file not found"} ist gültiges JSON, JSON.parse
gelang, elements war undefined – und die Zeichnung ging leer auf. Sie sah aus,
als wäre sie verloren. Wer dann speichert, macht es wahr. Gegen die laufende
Instanz nachgestellt, bevor ich es behoben habe.

Weiterzeichnen während des Speicherns ging verloren. Der Upload lief mit dem
Stand von Sekunde 0; die Fassungsnummer wurde hinterher aus aktuell.current
gelesen – und das war inzwischen ein anderer Stand. Die neuen Striche galten
als gespeichert, ohne je hochgegangen zu sein, und verschwanden beim Schliessen
ohne Rückfrage. Jetzt wird der Stand einmal festgehalten: Er geht hoch, seine
Nummer wird vermerkt, das Vorschaubild zeigt denselben.

Das Laden steht dafür jetzt in ladeZeichnung() statt im Effekt – der Weg hat
drei Fallen, und zwei enden in verlorener Arbeit; das gehört prüfbar. Vier neue
Tests, unter Mutation nachgewiesen: Ohne die Statusprüfung werden zwei rot.

Bewusst so gelassen: Leserecht fällt erst beim Speichern auf. FileItem trägt
kein Rechtefeld, der Text-Editor verhält sich seit jeher genauso, und das
Zeichenbrett bleibt offen – die Arbeit ist also nicht weg.

Geprüft: 57 Vitest-Tests, alle acht Selbsttest-Suiten grün, alle Go-Pakete
grün, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 12:30:22 +02:00
marcandClaude Opus 5 cedcb1ba6f fix(drawing): Rückfrage nach dem Speichern, Vorschaubild in der Liste, eigener Dialog
Drei Punkte aus Marcs Test.

„Close without saving?" kam, obwohl gerade gespeichert war. Der Schalter
„geändert" wurde in onChange gesetzt – und das feuert bei jeder Kleinigkeit,
auch bei Mauszeiger, Auswahl und Bildlauf. Sekunden nach dem Speichern stand er
wieder auf offen. Jetzt zählt getSceneVersion den Inhalt statt der Bedienung.

Das Vorschaubild kam nicht in der Liste an. Der Editor lädt es nach dem
Speichern hoch, die Liste kannte aber nur die Antwort des Uploads – und die
entstand, bevor es das Bild gab. Ohne Neuladen blieb die Kachel leer. Sie wird
jetzt ein zweites Mal benachrichtigt.

Rückfragen laufen über den ConfirmDialog des Projekts statt über
window.confirm. Beim Schliessen über die Zurück-Geste gibt der Griff false
zurück, solange der Dialog steht: Sonst wäre der Verlaufseintrag schon
verbraucht, und ein „Cancel" liesse die nächste Zurück-Geste aus der Anwendung
führen.

Nebenbei: Die Beschriftung des Schliessen-Knopfes versprach „Esc". Den Griff
gab es nie – und er wäre falsch, weil das Zeichenbrett die Taste selbst
braucht (Abwählen, Werkzeug verlassen). Wer sie abfängt, schliesst die
Zeichnung, während jemand nur ein Rechteck abwählen wollte. Versprechen
entfernt statt Griff gebaut.

Geprüft: tsc sauber, 53 Vitest-Tests, Zeichnungs-Selbsttest 18/18.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 21:23:07 +02:00
marcandClaude Opus 5 dd68bc594d feat(drawing): Vorschaubilder und ein Zeichenbrett, das zur Oberfläche passt
Vorschaubilder entstehen sonst serverseitig aus den hochgeladenen Bytes – bei
einer Zeichnung steht dort JSON, daraus wird ohne Browser kein Bild. Wer sie
gerade bearbeitet, hat aber genau den Browser, der sie ohnehin zeichnet.

Neu: POST /api/v1/files/{id}/thumbnail. Der Editor schickt nach dem Speichern
ein PNG hinterher.

Den Bytes wird dabei nicht geglaubt: Sie werden nicht abgelegt, sondern durch
denselben Weg geschickt wie jedes andere Vorschaubild – dekodieren,
verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
übliche Grösse; nichts anderes kann im thumbs-Bereich landen. Dazu Schreibrecht
auf die Datei: Wer sie nicht ändern darf, bestimmt auch nicht, wie sie in der
Liste aussieht.

Das Hochladen läuft nach dem Speichern und ohne darauf zu warten. Ein
fehlgeschlagenes Vorschaubild ist ein Schönheitsfehler, kein Grund, das
Speichern scheitern zu lassen – und bleibt deshalb stumm.

Politur am Zeichenbrett: dunkles Thema (ein weisses Brett in dunkler Oberfläche
war der auffälligste Bruch), „Unsaved" als Marke statt eines Punktes hinter dem
Namen, Strg+S, Dateisymbol und Name ohne Endung.

„Open" und „Save to…" sind aus. Beide gehen am Browser vorbei: „Open" ersetzte
den Inhalt dieser Zeichnung durch eine fremde Datei, „Save to…" schriebe eine
zweite Fassung in den Download-Ordner, die niemand wiederfindet.

Selbsttest 12 → 18 Prüfungen, darunter die beiden Ablehnungen (kein Bild → 400,
fremder Typ → 415).

Geprüft: go build/vet sauber, alle Go-Pakete grün, 261 Prüfungen der acht
Selbsttest-Suiten grün, 53 Vitest-Tests, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 20:13:04 +02:00
marcandClaude Opus 5 2e1eaf9cbf fix(drawing): Zeichnung liess sich nicht wieder oeffnen
Anlegen und bearbeiten ging, beim zweiten Öffnen blieb das Zeichenbrett leer.

Die erste Fassung schrieb den appState roh mit JSON.stringify heraus. Darin
steckt `collaborators`, und das ist eine Map. JSON kennt keine Maps; es wurde
`{}` daraus. Beim Laden bekam Excalidraw ein leeres Objekt, wo es eine Map
erwartet, und brach ab. Beim ersten Öffnen fiel das nicht auf – da war noch
nichts gespeichert.

Jetzt über die Werkzeuge des Pakets: serializeAsJSON schreibt nur, was eine
Zeichnung ausmacht, restore setzt beim Laden zusammen, was fehlt. Selbst zu
prüfen hiesse, Excalidraws Feldliste nachzubauen und bei jeder Fassung
nachzuziehen.

Eine Sache erledigt restore allerdings nicht – nachgemessen, nicht angenommen:
Ein bereits verunglücktes collaborators: {} reicht es unverändert durch; die
Map entsteht nur, wenn das Feld ganz fehlt. Neue Dateien haben es nicht mehr,
aber die aus jener Stunde wären dauerhaft kaputt geblieben. Deshalb wird es
beim Laden zusätzlich entfernt – Marcs bereits angelegte Zeichnungen öffnen
damit wieder.

Fünf Tests in drawing.test.ts, darunter einer, der festhält, dass restore
allein nicht reicht. Zwei Stolpersteine dabei, in vite.config.ts vermerkt:
Excalidraw importiert roughjs/bin/rough ohne Endung (Nodes Auflösung verlangt
sie, der Bündler nicht) und fasst beim Laden ein Canvas an, das jsdom nicht
hat.

Geprüft: tsc sauber, 53 Vitest-Tests, Zeichnungs-Selbsttest 12/12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 19:55:46 +02:00
marcandClaude Opus 5 6fc157bc07 feat(files): Zeichnen und Mindmaps mit Excalidraw
Eine Zeichnung ist eine Datei, kein neues Ding – das ist die ganze Entscheidung
dahinter. .excalidraw ist JSON; damit erbt sie Ordner, Gruppenordner,
Papierkorb, Versionen, Freigabelinks, interne Freigaben und die Suche. Keine
neue Tabelle, kein zweites Rechtemodell. Gespeichert wird über denselben Upload
wie alles andere, wie beim Text-Editor in der Vorschau.

Drei Sorgen, alle nachgemessen statt gehofft:

Das Hauptbündel wächst von 484,11 auf 485,22 kB – das Zeichenbrett ist ein
eigenes Stück (584 kB + 142 kB CSS) und wird nachgeladen wie der PDF-Betrachter.
Wer nie zeichnet, holt es nie.

Die Schriften: Die Richtlinie ist default-src 'self' ohne font-src, Excalidraw
hätte seine handgezeichneten Schriften von aussen geladen und still eine
Ersatzschrift gezeigt. Sie werden jetzt beim Bauen kopiert und über
window.EXCALIDRAW_ASSET_PATH gefunden – gesetzt im Modul, nicht per
Inline-Skript, das wäre von derselben Richtlinie blockiert. Xiaolai bleibt
draussen: 13 der 14 MB für die chinesische Schrift.

Die Reihenfolge der Dateiarten: .excalidraw wird vor code und text geprüft,
sonst öffnete eine Zeichnung im Texteditor als Zahlenkolonne (json steht in
codeExt).

Kein gleichzeitiges Arbeiten. Die Komponente könnte es, aber den Transport
bringt sie nicht mit – das wäre ein zweiter Dienst neben Aether. Solange es ihn
nicht gibt, überschreibt der Zweite den Ersten. Zwei Dinge fangen das ab: Vor
dem Speichern wird geprüft, ob sich die Fassung geändert hat, und dann gefragt
statt überschrieben. Und Zeichnungen behalten ihre Fassungen von der ersten
Minute an.

Der zweite Punkt ist erst durch den Selbsttest aufgefallen: Versionierung ist
sonst bewusst aus (Migration 000006), damit der Speicher nicht vollläuft. Für
Zeichnungen stimmt die Rechnung nicht – sie werden in sich immer wieder
bearbeitet und sind ein paar Kilobyte gross. Ohne sie wäre die Warnung im
Editor ein Versprechen ohne Deckung gewesen.

Neuer Selbsttest mit 12 Prüfungen: Er prüft die These nach, auf der alles
beruht – wenn eine Zeichnung wirklich eine Datei ist, muss sie können, was
Dateien können.

Nebenbei: db-tests.sh liess je Lauf ein anonymes Postgres-Volume liegen (rund
49 MB). `docker rm -v` nimmt es jetzt mit; zehn verwaiste entfernt.

Geprüft: go build/vet sauber, alle 15 Go-Pakete grün, 255 Prüfungen der acht
Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:17:05 +02:00
marcandClaude Opus 5 662c0ebdc1 security(rescan): Notbremse vor dem harten Löschen
internal/rescan ist das einzige Stück Aether, das hart löscht: DELETE FROM
files, kein Papierkorb. Entfernt wird, was beim Durchgang über die Platte nicht
gesehen wurde.

Der Ernstfall ist nicht ausgedacht. LocalFS.Walk gibt für ein nicht vorhandenes
Verzeichnis nil zurück – kein Fehler, keine Einträge. Genau der Fingerabdruck
eines Bind-Mounts, der nach einem Neustart nicht hochkam, oder eines
Tippfehlers in DATA_PATH. Dann gilt jede Datei als verwaist, und ein einziger
Aufruf mit ?anwenden=true löscht den gesamten Bestand aus der Datenbank. Die
Bytes lägen noch da, aber niemand wüsste mehr, dass es sie gibt – und der GC
räumte sie 24 Stunden später ab.

Der GC hat diese Bremse seit dem 12.09. Beide gleichen Platte und Datenbank ab,
nur aus entgegengesetzter Richtung, und beide haben denselben Fehlerfall: Eine
Seite antwortet fälschlich mit „leer". Der GC löscht dann Blobs – die Datenbank
weiss hinterher noch, was fehlt. Hier fällt die Zeile selbst. Die gefährlichere
Seite war die ungesicherte.

Zwei Bremsen: Die Notbremse (nichts gefunden, Datenbank kennt Dateien) lässt
sich nicht übersteuern. Die Mengenbremse (mehr als die Hälfte) verlangt
&bestaetigt=true. Die leere Installation ist ausgenommen.

Dazu umgebaut: erst sammeln, dann entscheiden, dann löschen. Vorher wurde
mitten in der Schleife gelöscht; die Gesamtzahl kennt man dort erst, wenn die
Hälfte schon weg ist.

Der Bericht nennt den Grund und ob er bestätigbar ist – die Oberfläche soll
keinen Knopf anbieten, der nichts bewirkt. Ein abgebrochener Lauf steht rot in
der Karte und im Audit-Log; gerade der, denn er ist der Hinweis darauf, dass
mit dem Datenordner etwas nicht stimmt.

Fünf Tests, unter Mutation nachgewiesen. Der Ernstfall läuft am echten Gerät:
fünf Zeilen in der Datenbank, ein Wurzelverzeichnis ohne users/ – nach Apply
stehen alle fünf noch, auch mit Bestätigung.

audit und thumbnails bekommen bewusst keine Go-Tests; die Begründung steht in
BEFUNDE.md statt als ewig offener Punkt.

Nebenbefund behoben: kalender-teilen-selbsttest.py leckte je Lauf ein
App-Passwort des Besitzers. Nach achtzehn Läufen war maxAppPasswords = 20
erreicht, und zwei Suiten scheiterten mit 409, ohne dass am Code etwas kaputt
war.

Geprüft: go build/vet sauber, alle Go-Pakete grün, 243 Prüfungen der sieben
Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 15:26:17 +02:00
marcandClaude Opus 5 a7528d7365 test(frontend): Wächter gegen deutschen Oberflächentext – und fünf Reste
Konsolidierung: Bestandsaufnahme auf den Stand vom 24.09. gebracht (Punkte 1,
2, 2b und die Tests für documents/tags sind erledigt; sharing, rescan, audit
und thumbnails bleiben offen; jsdom ist seit dem 18.09. drin).

Beim Nachsehen fielen fünf Übersetzungsreste auf: label="Ganztägig",
aria-label="Kalendermenü", label="Bearbeiten" und zweimal "Löschen". Alle in
**doppelt** gequoteten JSX-Attributen – die Ersetzungsliste hatte nur die
einfach gequoteten, und das damalige Prüfskript hielt Einzelwörter ohne
Leerzeichen für Bezeichner.

Die Prüfung steht deshalb jetzt als Test, nicht als Skript. Sie läuft bei
jedem npm test mit.

Der erste Wächter taugte nichts, und das kam nur durch die Mutation heraus: Er
prüfte allein auf Umlaute und liess "Papierkorb ist leer" durch. Wer einen
Wächter baut, muss ihn einmal angreifen. Jetzt Umlaute plus eine Wortliste mit
Wortgrenzen, nachgewiesen an vier Mutationen.

Kommentare werden vorher entfernt – mit einem kleinen Durchlauf statt
zeilenweise: Blockkommentare haben Fortsetzungszeilen ohne führenden Stern,
die beim ersten Versuch fünf Fehlalarme erzeugten.

Ehrlich dazu: Das ist ein Netz, kein Beweis. Ein deutscher Satz ohne Umlaut
und ohne gelistetes Wort käme durch. Steht so im Test.

@types/node kam dazu, weil der Test den Quelltext von der Platte liest.

Geprüft: tsc sauber, 48 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün. Gebautes Bündel gegengeprüft – deutsche Texte weg,
englische da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 18:31:18 +02:00
marcandClaude Opus 5 e1259dd55c refactor(calendar): Wochentage und Monate aus Intl statt aus sechs Arrays
Sechs handgeschriebene Arrays in drei Dateien, und Intl kam im ganzen Projekt
nicht vor – obwohl jeder Browser es seit Jahren mitbringt.

Die Doppelpflege war nicht nur lästig, sie war eine Falle: DayDialog begann
seine Woche am Sonntag, die anderen am Montag, jeweils passend zu ihrer
Indizierung. Beide waren für sich richtig. Wer aber eines der Arrays in eine
neue Ansicht kopiert hätte, hätte sich einen Off-by-one gebaut, der nur an
sieben Tagen im Jahr auffällt.

Jetzt eine Ordnung (Montag zuerst, wie weekdayIndex) plus eine ausdrücklich
benannte zweite Liste für alles, was direkt mit getDay() indiziert – statt
eines Umrechnens am Aufrufort, wo der Off-by-one sonst entsteht.

Verhaltensgleich belegt: Die erzeugten Namen stimmen zeichengenau mit den
gelöschten Arrays überein, vor dem Austausch verglichen.

Die Sprache ist fest auf 'en' gesetzt, nicht die des Browsers.
Intl.DateTimeFormat(undefined, …) nähme die Einstellung des Rechners und
setzte auf einem deutschen System „Montag" neben ein englisches „Repeat" –
genau der Mischmasch von vorgestern, nur durch die Hintertür. Kommt eine
Sprachwahl (IDEAS.md), ist uiLocale die eine Stelle, an der sie andockt.

Fünf neue Tests, unter Mutation nachgewiesen: Nimmt man dem Sonntag seinen
Platz vorn, wird der Test rot.

Geprüft: tsc sauber, 46 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:04:33 +02:00
marcandClaude Opus 5 8586664fae feat(ui): Rückmeldung zu Ende verdrahtet – und zwei Lügen entfernt
Die Meldungen sprachen bisher nur im Dateibrowser. Papierkorb, Freigabe-Links,
„Für mich freigegeben", Profil, Theming, Audit und die Nutzerliste schwiegen
weiter – und das war schlechter als vorher: Wer gelernt hat, dass Aether
Bescheid gibt, hält Schweigen für Erfolg.

Nicht blind verdrahtet. Sechs Stellen bleiben absichtlich still, samt Grund in
TESTING.md: die Suche (bricht bei jedem Anschlag die vorige ab), das Kopieren
der Prüfsumme (ohne HTTPS gibt es keine Zwischenablage, der Wert steht ja da),
die Theming-Vorgaben, die Absender-Vorschläge, die OCR-Abfrage im Hintergrund
und useAuth. Wo ein Dialog seinen Fehler bei sich anzeigt, bleibt es dabei –
dort gehört er hin, nicht an den Bildschirmrand.

Dabei zwei Fälle gefunden, die schlimmer sind als Schweigen:

**ProfilePage meldete „gespeichert", wenn das Speichern fehlgeschlagen war.**
`updateProfile(...).catch(() => {})`, danach ungeprüft das Häkchen.

**TagEditDialog riet die Ursache:** Jeder Fehler meldete „That name is already
taken" – auch ein Netzausfall, auch ein 500er. Jetzt steht dort der Satz des
Servers. Im TagDialog war genau das schon einmal aufgefallen und behoben
worden; hier stand es noch.

Dazu zwei Aufrufe ganz ohne Absicherung: das Wiederherstellen aus dem
Papierkorb und das Löschen eines Freigabe-Links. Und das Kopieren eines
Links scheiterte ohne HTTPS stumm – der Knopf tat schlicht nichts.

Eigener Fehler nebenbei behoben: Ich hatte deutsche Anführungszeichen in
englische Meldungen gesetzt.

Geprüft: tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:59:46 +02:00
marcandClaude Opus 5 4208676aa9 fix(ui): Platzhalter blitzt auch beim Tab-Wechsel nicht mehr auf
Die Frist von 220 ms galt nur beim Wechsel *innerhalb* der Dateiansicht. Beim
Wechsel zwischen den Menüpunkten entsteht die Seite jedes Mal neu – damit war
jeder Tab-Wechsel ein „erstes Laden", und genau dort war die Regel „beim ersten
Mal sofort zeigen" falsch herum. Die Frist gilt jetzt immer; 220 ms Leere ist
das ruhigere Bild, man kommt ohnehin von einer anderen Seite.

Als gemeinsamer Haken `useSkeleton`, statt die Logik in fünf Seiten zu
wiederholen.

Dabei wäre beinahe ein schlimmerer Geist entstanden: Im Wartefenster hat kurz
der **Leerzustand** aufgeblitzt – „Nothing shared with you yet", eine
Zehntelsekunde bevor die Liste kam. Der Platzhalter sieht nur nach Daten aus,
ein Satz behauptet etwas. Das Wartefenster bleibt jetzt überall leer.

Dazu drei Reste der Kalender-Übersetzung, die mein Suchmuster übersehen hatte,
weil sie in Template-Strings standen: der Löschen-Dialog des Kalenders und der
Wochentag-Hinweis im Terminfenster. Und das Datum in der Listenansicht stand
noch im deutschen Format („18. September 2026") in einer englischen
Oberfläche – jetzt „September 18, 2026".

Geprüft: tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:15:45 +02:00
marcandClaude Opus 5 0591a40d12 feat(calendar): Oberfläche auf Englisch, Standardkalender heißt „Personal"
Die Kalenderseite war durchgehend deutsch, die übrige Oberfläche englisch –
80 Stellen in sechs Dateien. Kommentare bleiben deutsch; das Ersetzen hat
Kommentarzeilen übersprungen.

Shell, TagChip und UserMenu standen nur wegen ihrer Kommentare auf der
Verdachtsliste, deren Oberfläche war längst englisch.

Der automatisch angelegte Kalender hieß „Persönlich" und heißt jetzt
„Personal". Migration 000027 zieht die bestehenden nach, bewusst eng gefasst:
nur Zeilen mit Name *und* Farbe des Automaten. Wer sich selbst einen Kalender
„Persönlich" angelegt hat, behält seinen Namen – das ist seine Beschriftung,
nicht unsere. Nachgeprüft: Die API meldet danach 'Personal', nicht nur
„Migration gelaufen".

Dazu zwei Punkte aus Marcs Rückmeldung:

fix(ui): Der Leerzustand der Freigabe-Links stand auf anderer Höhe als die
übrigen – die Seite hatte ich beim Umbau übersehen. Nutzt jetzt EmptyState
wie alle anderen.

fix(ui): Beim Durchklicken von Ordner zu Ordner blitzte für einen Moment ein
Feld grauer Kästen auf. Sie sahen aus wie Dateien, die gleich wieder
verschwinden – „Geister". Der Platzhalter kommt jetzt erst nach 220 ms; ist
die Liste vorher da, wird er nie sichtbar, und solange bleibt die vorherige
stehen (load() löscht sie nicht). Nur beim allerersten Laden kommt er sofort:
Da gibt es nichts, was stehenbleiben könnte.

Nebenbei: Ein fehlgeschlagenes Laden eines Ordners ist nicht mehr stumm.
Vorher sah es aus wie ein Ordner, der zufällig denselben Inhalt hat wie der
vorige.

Geprüft: go build/vet sauber, tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen
der sieben Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:38:30 +02:00
marcandClaude Opus 5 1d10c6bd0e feat(ui): Platzhalter beim Laden und ordentliche Leerzustände
Beim Laden stand ein Kreisel in der Mitte. Der sagt „warte", aber nicht,
worauf – und wenn die Daten kommen, klappt das Layout um. Jetzt steht dort ein
Platzhalter in der Form des Kommenden: Kacheln in der Kachelansicht, Zeilen in
der Liste. Die Maße sind aus FileViews.tsx abgeschrieben, nicht geschätzt;
weichen sie ab, springt es beim Wechsel wieder und der Zweck wäre dahin.

Leerzustände sagen jetzt, warum nichts da ist, und bieten den Knopf an, der an
dieser Stelle weiterhilft. Der leere Papierkorb bekommt bewusst keinen: Er ist
ein guter Zustand, kein Mangel, und ein Knopf, der nur da ist damit einer da
ist, führt nirgendwohin.

Beides respektiert prefers-reduced-motion.

Nebenbei gefunden: `@keyframes spin` lag als Inline-<style> in **sechs**
Komponenten, und zwei Nutzer (AuditTab, SearchBox) hatten gar keine eigene
Kopie. Dass es lief, war Zufall – @keyframes gilt fürs ganze Dokument, es
genügte also, dass irgendeine der sechs gerade im Bild war. Kaputt wäre es an
dem Tag gewesen, an dem jemand die letzte Kopie entfernt. Steht jetzt einmal
in index.css.

Nicht angefasst: der Sprachmix. CalendarPage ist durchgehend deutsch, die
übrige Oberfläche englisch, betroffen sind sechs Dateien. Eine halbe
Übersetzung wäre schlimmer als keine – das gehört in einem Zug gemacht, samt
der Entscheidung, welche Sprache die Oberfläche spricht. In TESTING.md notiert.

Geprüft: tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:33:25 +02:00
marcandClaude Opus 5 94a803bf7a feat(ui): Meldungen mit Rückgängig, dazu jsdom und ein Fehler in der letzten Behebung
Vorher bestätigte nichts irgendetwas, und Fehler verschwanden still: Die
Handler fingen sie mit `catch { /* bleibt stehen */ }` ab. Wer eine Datei
löschen wollte und nicht durfte, sah genau dasselbe wie jemand, bei dem es
klappte – nichts.

Neu: components/Toasts.tsx. Kein Context, sondern Modulzustand, damit auch
api/client.ts melden kann, ohne Komponente zu sein. Löschen, Umbenennen und
Verschieben bekommen ein „Undo" – beim Löschen ist das der Papierkorb plus
einen Aufruf, kostet also fast nichts. Ordner löschen bekommt bewusst keines:
Der Ordner ist wirklich weg, nur seine Dateien liegen im Papierkorb, und ein
Knopf, der etwas anderes tut als er verspricht, wäre schlimmer als keiner.

Dazu die erste Animation im Projekt – und gleich die Regel dazu:
prefers-reduced-motion wird respektiert.

**jsdom ist jetzt in den devDependencies**, und der erste DOM-Test hat sofort
einen Fehler in der Behebung von vorgestern gefunden:

Der Zähler für selbst ausgelöste Verlaufsschritte setzt voraus, dass jedes
`back()` auch ein `popstate` nach sich zieht. Das gilt nicht – folgt im selben
Durchlauf ein `pushState`, verwirft der Browser den Sprung. Der Zähler blieb
dann stehen, und die nächste echte Zurück-Geste wurde verschluckt: Statt die
Ansicht zu schließen, hätte man die Anwendung verlassen.

Jetzt wird erst im nächsten Durchlauf entschieden und geprüft, ob der eigene
Eintrag noch obenauf liegt. Nur dann kommt es zum Sprung – und dann folgt das
`popstate` garantiert. Der Zuhörer hängt am Modul statt an der Ansicht; vorher
entfernte ihn deren Aufräumer, bevor der eigene Sprung ausgeführt war.

Ehrlich dokumentiert: Den ursprünglichen Fehler bilden diese Tests **nicht**
ab. jsdom verwirft den Sprung, das verhängnisvolle popstate entsteht dort nie
– mit der alten, kaputten Fassung blieben alle Tests grün. Dafür bräuchte es
einen echten Browser. Steht so in TESTING.md und im Test selbst.

Geprüft: tsc sauber, 41 Vitest-Tests (4 neue unter Mutation nachgewiesen),
alle 234 Prüfungen der sieben Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:05:12 +02:00
marcandClaude Opus 5 944b2c63e2 feat(office): mobileEdit am Telefon, PDF-Umweg entfällt
Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, weil OnlyOffices
Oberfläche auf 400 Pixeln nichts vom Dokument übrig lässt. Die Begründung war
falsch: Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und
OnlyOffice/Euro-Office führen neben `edit` auch `mobileEdit` – dieselbe
Adresse mit `?mobile=1`, dahinter eine für kleine Schirme gebaute Oberfläche.
`EditURL` kannte nur `edit` und `view`; die Handy-Fassung lag die ganze Zeit
in der Tabelle und wurde nie abgerufen.

Entschieden wird am Zeigegerät, nicht an der Fensterbreite – ein schmales
Fenster auf dem Schreibtisch ist immer noch eine Maus.

Mit dem Anlass fällt die Notlösung: preview.go (229 Zeilen), convert.go (271)
samt Test, files.preview_version (Migration 000026), die Sonderabfrage im GC,
die Route und dreizehn Fundstellen im Frontend. convert.go hatte keinen
anderen Aufrufer – die gesamte Umwandlung nach PDF existierte allein für
diese Ansicht. Wer ein PDF braucht, exportiert es im Editor. Die Dateien
unter previews/ sammelt der GC ein.

Zwei Fehler, die dabei sichtbar wurden:

fix(history): „Edit" schloss die Datei, statt den Editor zu öffnen. Beim
Wechsel räumt React erst die alte Ansicht ab; der Aufräumer von useBackClose
ruft history.back(), das verzögert wirkt und deshalb den Eintrag verbrauchte,
den der Editor gerade angelegt hatte. Ein Zähler für selbst ausgelöste
Schritte verschluckt genau diese popstate.

fix(filetypes): `.txt` ging gar nicht auf. Es stand in officeExtensions und
lief damit in den Office-Zweig statt in den eingebauten Text-Editor – am
Telefon in die PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst`
standen nie drin; das war die Asymmetrie.

Dazu Euro-Office als Compose-Profil (Abbild 6,99 GB, nachgemessen – die
Ankündigung nennt 5) und die Android-App als Idee.

Geprüft: go build/vet sauber, tsc sauber, 37 Vitest-Tests, alle 234
Prüfungen der sieben Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:36:31 +02:00
marcandClaude Opus 5 5d90c0a2d2 feat(calendar): Kalender an Nutzer und Gruppen freigeben
Nicht als eigene Tabelle, wie im Plan vorgesehen: acl_entries beantwortet
fuer Ordner und Dateien laengst dieselbe Frage -- wer darf was, direkt oder
ueber eine Gruppe. Ein zweites Rechtesystem daneben waere eine zweite
Antwort auf dieselbe Frage, mit eigener Gruppenaufloesung, eigenem
Widerruf, eigenem Pruefpfad; die Luecke dazwischen faellt erst auf, wenn
jemand sieht, was er nicht sehen soll. Migration 25 haengt deshalb nur eine
dritte Ressourcenspalte an.

Der Gewinn: Freigeben an eine **Gruppe** kostete keine Zeile extra.

Drei Entscheidungen, die nicht offensichtlich waren:

* Ein fremder Kalender steht immer unter seiner UUID, nie unter dem
  Pfadsegment des Eigentuemers -- das hat der sich ausgesucht, und zwei
  Leute duerfen denselben Namen waehlen. Sonst laegen im Kalenderbestand
  des Empfaengers zwei Kalender auf derselben Adresse.
* Alle Wege laufen durch RequireCalendar. CalDAV und Weboberflaeche sind
  zwei getrennte Zugaenge zu denselben Daten; ein Recht, das nur einer von
  beiden prueft, ist kein Recht. Im CalDAV-Teil genuegte, resolveCalendar
  umzustellen -- dort kommt jede Operation vorbei.
* Wer ein Recht geliehen hat, darf es nicht weiterverleihen: Umbenennen,
  Loeschen und Freigeben bleiben auch mit Schreibrecht beim Eigentuemer.

Oberflaeche: fremde Kalender tragen Eigentuemer und "(nur lesen)", das
Menue zeigt nur, was auch geht, und ein Termin laesst sich gar nicht erst
in einen Kalender ziehen, den der Server danach ablehnen wuerde.

scripts/kalender-teilen-selbsttest.py, 41 Pruefungen, jede ueber beide
Zugaenge. Er beginnt damit, dass der Empfaenger vorher nichts sieht --
ohne diesen Anfang beweist der Rest nichts.

Dabei hatte der Test selbst eine Falle: Mit festem Testdatum lief die
Terminliste aus ihrem Standardzeitraum, und die Pruefung "beim Eigentuemer
ist nichts angekommen" bestand **leer**.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:46:18 +02:00
marcandClaude Opus 5 e9f69f7d7f feat(uploads): Fortschritt in MB, Tempo und Restzeit; Abbruchkreuz rot
Das Kreuz faerbt sich beim Darueberfahren rot -- aber nur, solange der
Upload laeuft. Dasselbe Zeichen tut zwei Dinge: eine Meldung wegraeumen
ist folgenlos, einen laufenden Vorgang fallen zu lassen nicht.

Unter dem Balken steht jetzt "4.2 MB / 18.0 MB · 1.8 MB/s · 8 s left".
Die Zahlen kamen schon immer vom XMLHttpRequest, sie wurden nur
weggeworfen. Neu gerechnet wird nur das Tempo -- der Browser meldet
Bytes, nicht deren Ableitung.

Zwei Daempfungen, aus zwei Gruenden:

* Das Tempo aus einem einzelnen Abstand springt zwischen dem Doppelten
  und der Haelfte. Gleitender Mittelwert (0,7/0,3) ueber Messpunkte von
  mindestens 0,2 s.
* Die Anzeige wird hoechstens alle 150 ms nachgezogen. Der Browser meldet
  im 50-ms-Takt; bei drei Dateien waeren das sechzig Neuaufbauten der
  Liste je Sekunde fuer eine Zahl, die niemand so schnell liest.

Jeder Teil erscheint erst, wenn er stimmt: Groesse sofort, Tempo nach dem
zweiten Messpunkt, Restzeit erst mit beidem. Eine Schaetzung aus dem
Nichts waere schlimmer als keine -- man richtet sich danach.

formatDuration in src/format.ts, 5 Tests (37 gesamt).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:46:28 +02:00
marcandClaude Opus 5 511213b40d feat(uploads): laufenden Upload abbrechen; fix(pdf): Zoomgeste entlasten
Zoom
----
Jede Zoomstufe warf die Leinwaende weg und liess PDF.js neu malen -- eine
Zusammenziehgeste hat leicht zwanzig davon, bei A4 und doppelter
Pixeldichte je rund acht Megabyte. Noetig war das nie: Die Leinwand liegt
mit width:100% im Seitenkasten, der Browser streckt sie also von selbst.
Gezeichnet wird jetzt 120 ms nach der letzten Stufe. Die Textebene wandert
korrekt mit (PDF.js setzt sie in Prozent), nur die Trefferkaesten sitzen in
Pixeln -- die werden waehrend der Geste geraeumt und danach neu gemalt.

Dabei ein aelterer Verlust: Jedes Oeffnen zeichnete einmal umsonst, weil
der Zeichner lief, bevor die Einpassung gemessen war. Jetzt wartet er.

Upload abbrechen
----------------
Das Kreuz an der Fortschrittsmeldung laesst einen laufenden Upload fallen;
eine noch wartende Datei faengt gar nicht erst an. Ein Abbruch ist kein
Fehler und verschwindet wie eine fertige Meldung.

Auf dem Server bleibt nichts liegen -- das ist die Bauweise (Temp-Datei,
Umbenennen erst am Ende), aber eine Bauweise ist keine Pruefung. Deshalb
scripts/upload-abbruch-selbsttest.py: kappt eine echte Verbindung auf einem
rohen Socket, 13 Pruefungen. Der teuerste Fall ist abgedeckt -- beim
Ueberschreiben mit Versionierung schiebt der Server die alte Fassung
kurzzeitig zur Seite; sie bleibt in Groesse, Pruefsumme und Inhalt heil.

Downloads haben bewusst kein Kreuz: Sie gehen als Anker bzw. Formular an
den Browser, dessen Download-Liste sie besitzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:38:26 +02:00
marcandClaude Opus 5 ea90f43e77 refactor(frontend): PdfViewer und FileBrowser aufteilen, Tests dazu
FileBrowser 1177 -> 712 Zeilen, PdfViewer 1193 -> 848. Herausgeloest
wurde, was fuer sich zusammenhaengt:

  hooks/useUploads            Warteschlange, Ordnerbaeume, Ablage
  components/files/           Werkzeugleiste, Dialoge, Menues, Reihenfolge
  components/pdf/             Werkzeugleiste, Zoomstufen, Seitenmasse, Suche
  src/format.ts               formatBytes -- lag in einer *Seite*

Dabei gefundene und behobene Fehler:

* Ordner-Upload haengte Fortschritt und Fehler an die falsche Zeile,
  sobald in einem Ordner Dateien *nach* einem Unterordner lagen: Die
  Zeilen standen in Fundreihenfolge, hochgeladen wurde nach Ordnern
  gruppiert, und ein mitlaufender Zaehler verband beides falsch.
* formatBytes gab es zweimal, zeichengleich (Dateibrowser + Admin).
* Beide Sortierungen liefen bei *jedem* Bild -- der Rahmen beim Ziehen
  ist eines je Mausbewegung. Jetzt gemerkt.
* Die Suche im PDF las bei jedem Tastendruck den Text aller Seiten neu.
  Jetzt 160 ms Wartezeit; "Searching..." bleibt auch bei einem Fehler
  nicht mehr stehen.
* Seitenmasse werden in Buendeln statt streng nacheinander geholt.

Pruefinfrastruktur: vitest stand auf 'jsdom', ohne dass jsdom je
installiert war -- "npm test" brach ab, bevor es etwas ausfuehrte. Jetzt
'node' und 32 Tests fuer die herausgeloesten reinen Funktionen. Das
Abbild baut nur noch mit gruenen Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:23:21 +02:00
marcandClaude Opus 5 8e27c703d2 fix(ui): eine Breite fuer Seitenleiste und Kopfleiste
Kopfleiste blendete den Menue-Knopf ab 720px aus, die Seitenleiste stand
aber erst ab 768px fest im Bild. Dazwischen lag ein Band von 48 Pixeln
ohne Menue und ohne Knopf dorthin -- ein aufgeklapptes Fold landet mit
rund 757px genau darin.

Die Grenze steht jetzt einmal in src/breakpoints.ts.

Dazu zwei Folgefehler derselben Sache:

* Der abdunkelnde Grund lag mit z-index 40 ueber der fest stehenden
  Leiste (die hat dort keinen). Per Randwisch aufgeschoben, war die
  Leiste danach sichtbar, aber taub. Er blendet sich ab 720px aus.
* Der Randwisch loest breit gar nicht mehr aus, und Aufklappen bei
  aufgeschobener Leiste setzt sie zurueck -- sonst schluckte die
  Zurueck-Geste einmal ins Leere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:33:08 +02:00
marcandClaude Opus 5 fbaf24bdbd fix(api): leere Listen als [] statt null -- Kalender blieb weiss
Ein Konto ohne Kalender bekam auf GET /api/v1/calendars die Antwort
`null`, und die Ansicht brach ab:

    for (const cal of calendars)   ->  "e is not iterable"

Nicht der Kalender ist schuld, sondern eine Eigenheit, die jeden
Listenendpunkt betrifft: In Go ist eine Liste ohne Eintraege oft nil,
und encoding/json schreibt dafuer null statt []. Jede Store-Funktion,
die mit `var out []T` anfaengt, liefert das bei leerem Ergebnis --
Favoriten, Papierkorb, Versionen, Freigaben, Etiketten. Der Kalender war
nur der, der daran zerbricht.

Deshalb die Regel dort, wo alle Antworten durchlaufen: response.JSON
macht aus einer nil-Liste eine leere. Einzelne Objekte und nil bleiben
unberuehrt. In den Stores waere es die falsche Stelle -- es gibt
Dutzende Listenendpunkte, und der naechste faengt wieder mit
`var out []T` an. Eine Regel, an die man sich erinnern muss, ist keine.

Dazu zwei Dinge, die derselbe Fall ans Licht brachte:

  - Der Web-Weg legte, anders als CalDAV, keinen Standardkalender an.
    Ein neues Konto stand vor einer leeren Ansicht und musste erraten,
    dass es selbst einen anlegen soll -- waehrend ein CalDAV-Client, der
    sich vorher verbunden hatte, bereits einen sah, den die Oberflaeche
    nicht zeigte.
  - Die Ansicht faengt eine unerwartete Antwort jetzt selbst ab (?? []).
    Der Server schickt kein null mehr; eine Seite sollte daran trotzdem
    nicht zerbrechen.

Drei Tests in pkg/response: leere Liste wird [], befuellte bleibt
unveraendert, Objekte und nil bleiben, was sie sind.

Zur Korrektur meiner frueheren Erklaerung: Die fehlenden Cache-Header
waren ein echtes Problem, aber nicht die Ursache dieses Fehlers. Der
gemeldete Bundle-Name stimmte diesmal mit dem ausgelieferten ueberein --
daran war zu sehen, dass es kein alter Stand sein konnte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:56:54 +02:00
marcandClaude Opus 5 55dcf517f2 feat(admin): Abgleich zwischen Platte und Datenbank
Aether fuehrt zwei Buecher: die Bytes auf der Platte und die Eintraege in
der Datenbank. Wer Dateien von aussen hineinlegt -- per SFTP oder aus
einer Sicherung zurueckgeholt --, erzeugt nur das eine. Die Datei liegt
da, Aether weiss nichts von ihr, und der GC haelt sie nach 24 Stunden
fuer einen Rest und loescht sie.

Neu deshalb internal/rescan, die Gegenrichtung zum GC, und ein Knopf
unter Admin -> Audit. Drei Faelle:

  Datei da, kein Eintrag    Eintrag anlegen, samt Ordnerkette,
                            Pruefsumme und Dateityp
  Eintrag da, Datei fort    Eintrag entfernen, dazu Vorschaubild und
                            fruehere Fassungen
  von aussen ersetzt        Groesse und Pruefsumme uebernehmen

Zwei Schritte, nie einer: Check liest nur, Apply aendert. Ein Knopf, der
beides in einem taete, gaebe niemandem die Gelegenheit, vorher
hinzusehen -- und was hier geloescht wird, ist nicht der Papierkorb.

Nicht angefasst werden .trash/ und .versions/. Beide liegen im selben
Baum, gehoeren aber dem System: Wuerden sie uebernommen, tauchten
geloeschte Dateien wieder auf und jede fruehere Fassung wuerde zu einer
eigenen Datei.

Zwei Fallen, die erst der Praxistest zeigte:

  - Der Dateityp muss aus Endung *oder Inhalt* kommen. Beim Hochladen
    schickt der Browser ihn mit; eine per SFTP abgelegte Datei bringt
    niemanden mit, der ihn kennt. Dazu hat das Alpine-Abbild keine
    /etc/mime.types, und Gos eingebaute Liste kennt nicht einmal .txt --
    jede Textdatei waere als application/octet-stream gelandet.
  - COALESCE($1, $2) ohne Cast: Postgres haelt die Parameter dann fuer
    Text und bricht mit "operator does not exist: uuid = text" ab. Die
    Ordnerkette wurde nie angelegt.

Die Pruefsumme wird trotz des vollstaendigen Lesevorgangs berechnet. Ein
Eintrag ohne sie waere ein Eintrag, dessen Beschaedigung beim
Herunterladen niemand bemerkt -- genau die Zusage, die Aether sonst
ueberall einhaelt.

scripts/rescan-selbsttest.py mit 20 Pruefungen in fuenf Gruppen, darunter
"Pruefen aendert nichts" und "Papierkorb bleibt unangetastet". Nebenbei
raeumt der Freigabe-Selbsttest jetzt auch seinen Papierkorb und nimmt
einen eindeutigen Kontonamen je Lauf -- ein abgebrochener Lauf blockierte
sonst jeden weiteren mit 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 01:10:25 +02:00
marcandClaude Opus 5 fce8053d09 fix(web): Updates kommen an, ohne den Cache von Hand zu leeren
Nach einem Update sah man die alte Anwendung -- und wer dann einen
Bereich anklickte, der erst beim Klick nachgeladen wird (Kalender,
PDF-Betrachter), bekam entweder gar nichts oder einen Fehler, der nach
einem Programmfehler aussieht ("e is not iterable"). Nachgemessen: Der
Browser lud index-BlqR-CVf.js, ausgeliefert wurde index-arO1lebV.js.

Ursache: Der Server schickte fuer index.html **gar kein**
Cache-Control. Fehlt es, schaetzt der Browser anhand von Last-Modified
selbst, wie lange die Antwort wohl gilt -- bei einer Datei, die seit
Tagen unveraendert war, sind das schnell Stunden. Er benutzte also
weiter die alte Seite, die auf Bausteine zeigt, die es nach dem Update
nicht mehr gibt. Auch die Fetches des Service Workers laufen durch
diesen Cache, weshalb selbst dessen "Netz zuerst" nicht half.

Jetzt die uebliche Zweiteilung, die sich aus den Dateinamen ergibt:

  /assets/…   public, max-age=31536000, immutable
              Der Inhalts-Hash steht im Namen. Anderer Inhalt = anderer
              Name, also darf der Browser sie fuer immer behalten.

  alles sonst no-cache
              index.html, sw.js, das Manifest. "no-cache" heisst nicht
              "nicht speichern", sondern "vor dem Benutzen rueckfragen" --
              mit ETag antwortet der Server meist mit 304 und ein paar
              Byte.

Dazu ein Sicherheitsnetz fuer den Fall, dass doch einmal ein alter
Baustein angefordert wird: vite:preloadError laedt die Seite einmal neu.
Einmal, per sessionStorage-Merkmal -- laege der Fehler woanders, drehte
sich die Seite sonst endlos im Kreis.

Das ist der Unterschied zwischen "ich leere eben den Cache" und einem
Menschen, der am Handy vor einer Seite sitzt, auf der nichts passiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:31:59 +02:00
marcandClaude Opus 5 ec8f162fd5 security(sharing): Selbsttest fuer Freigabelinks, drei Befunde behoben
Freigabelinks sind der einzige Teil von Aether, den man ohne Anmeldung
erreicht -- und hatten als einziger Bereich keinen einzigen Test. Der
neue scripts/sharing-selbsttest.py geht sie mit 24 Pruefungen ab, mit
einem eigens angelegten Zweitnutzer, ohne den sich "fremde Dateien
bleiben fremd" gar nicht pruefen laesst.

Beim ersten Lauf sofort drei Befunde:

1. Der Download-Zaehler blieb auf 0. Die Zaehlung lief in einer Goroutine
   mit dem Kontext der Anfrage -- der wird abgebrochen, sobald die
   Antwort draussen ist, und die Zaehlung verlor das Rennen fast immer.
   Jetzt context.WithoutCancel mit eigener Frist.

2. owner_id, file_id und die Link-Kennung standen in der oeffentlichen
   Antwort. Das ganze Datenbankobjekt durchzureichen war bequem, gab aber
   die Kennung des Besitzers an jeden heraus, der den Link hat: Zwei
   Links desselben Besitzers liessen sich einander zuordnen, ohne dass
   irgendwer angemeldet ist. Jetzt nur noch, was die oeffentliche Seite
   braucht.

3. Passwoerter liessen sich unbegrenzt durchprobieren. Der oeffentliche
   Freigabepfad lag ausserhalb jeder Ratenbegrenzung, und weil jeder
   Versuch Argon2 rechnet, war das zugleich ein billiger Weg, den Server
   zu beschaeftigen. Jetzt dieselbe Fehlversuchssperre wie bei /dav und
   /caldav: zehn Fehlschlaege in 15 Minuten, dann 15 Minuten Pause.

Die Sperre hat eine Folge fuers Testen selbst: Nach einem Lauf ist die
eigene IP 15 Minuten fuer passwortgeschuetzte Links gesperrt. Deshalb
steht die Probe am Ende, und der Test erkennt zu Beginn, ob eine Sperre
aus einem frueheren Lauf noch steht, statt reihenweise Fehlschlaege zu
melden, die keine sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:31:39 +02:00
marcandClaude Opus 5 1e1f35955d fix(preview): Blaettern bleibt bei einer Art Datei
Die Pfeile (und die Pfeiltasten) in der Vorschau liefen bisher ueber
alles Anzeigbare. Wer Bilder durchsah, landete mitten in der Reihe auf
einem PDF -- das ganz anders bedient wird: Seiten statt Dateien, eigene
Zoomleiste, auf schmalen Fenstern gar keine Pfeile.

Jetzt blaettert die Vorschau nur noch durch Dateien derselben Art wie
die gerade offene. Bewusst nicht nur fuer Bilder gesondert geloest,
obwohl es dort auffiel: Dieselbe Ueberraschung gaebe es beim Sprung von
einem PDF in ein Video oder in eine Textdatei.

Solange nichts offen ist, zaehlt weiterhin alles Anzeigbare -- sonst
waere die Liste leer, bevor sie ueberhaupt gebraucht wird.

Nebenwirkung mit Absicht: Gibt es nur ein Bild im Ordner, verschwinden
die Pfeile ganz, statt im Kreis durch fremde Dateitypen zu fuehren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:16:25 +02:00
marcandClaude Opus 5 cc35161e74 feat(calendar): Kalenderwahl wieder als Ausklappliste, weiterhin mit Farbe
Die Plaketten aus dem vorigen Stand nahmen bei mehreren Kalendern viel
Platz im Formular und liefen ueber mehrere Zeilen um. Also zurueck zur
Ausklappliste -- aber nicht zurueck zum reinen Text.

Ein natives <select> kam dafuer nicht in Frage: Dessen Eintraege lassen
sich in keinem Browser verlaesslich einfaerben, und im zugeklappten
Zustand schon gar nicht. Deshalb eine eigene Liste, die im Ruhezustand
aussieht wie die anderen Felder daneben, aber den Farbpunkt des
Kalenders traegt -- gefuellt fuer den gewaehlten, leerer Ring fuer die
anderen, dieselbe Sprache wie in der Kalenderliste.

Eine Falle dabei: Escape muss beim Schliessen der Liste angehalten
werden. Sonst laeuft es weiter bis zum Fenster, wo der Termindialog
lauscht, und macht gleich das ganze Formular zu -- mitsamt allem, was
schon eingetippt war.

Selbsttest weiter 36 gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 07:50:19 +02:00
marcandClaude Opus 5 7a1f0837f5 feat(calendar): Kalenderwahl im Termin mit Farbe statt nur Text
Die Farbe ist das, woran man einen Kalender im Raster wiedererkennt --
im Termindialog stand aber nur der Name in einem Auswahlfeld. Wer
"Arbeit" waehlt, soll dabei schon das Blau sehen, in dem der Termin
gleich erscheint.

Deshalb Plaketten statt <select>: Farbpunkt plus Name, alle nebeneinander
sichtbar, kein Aufklappen noetig. Der Punkt spricht dieselbe Sprache wie
ueberall sonst im Kalender -- gefuellt heisst gewaehlt, ein leerer Ring
heisst nicht gewaehlt, genau wie in der Kalenderliste und bei der
Terminfarbe.

Die gewaehlte Plakette bekommt nur einen Hauch der Kalenderfarbe als
Grund. Acht kraeftig ausgefuellte Toene nebeneinander erschluegen sonst
das ganze Formular, in dem die Farbe eigentlich der Nebendarsteller ist.

Als CalendarPicker in controls.tsx, wo schon Toggle und ColorSwatches
liegen -- damit die naechste Stelle, die eine Kalenderwahl braucht,
nicht wieder bei null anfaengt.

Selbsttest weiter 36 gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:04:30 +02:00
marcandClaude Opus 5 77bf16b27f feat(calendar): Termin einem Kalender zuordnen und umhaengen
Beim Anlegen war die Kalenderauswahl schon da (der oberste ist die
Vorgabe), beim Bearbeiten war sie festgestellt -- mit der Begruendung,
ein Umhaengen sei Loeschen und Neuanlegen unter neuer Kennung. Das
stimmt so nicht: In CalDAV wechselt dabei nur die Sammlung, UID und
Inhalt bleiben. Genau das macht der neue Endpunkt

    POST /api/v1/calendars/{id}/events/{uid}/move  {"calendar_id": …}

Er aendert nur die Zugehoerigkeit der Zeile und erhoeht das ctag auf
**beiden** Seiten. Mit nur einem holte ein Client die andere Haelfte
erst beim naechsten Vollabgleich, und der Termin stuende
zwischenzeitlich doppelt oder gar nicht im Telefon.

Bewusst ein eigener Endpunkt und kein weiteres Feld im PATCH: Ein Umzug
laesst sich auch ohne jede Aenderung am Termin ausloesen, und dafuer den
ganzen Termin mitschicken zu muessen waere eine Einladung, nebenbei
etwas zu ueberschreiben. Im Formular laeuft deshalb erst das PATCH und
dann der Umzug -- andersherum stuende der Termin nach einem Fehler
verschoben, aber unveraendert da, die verwirrendere Haelfte.

Dazu die Zieh-Geste: einen Termin auf einen Kalender in der
Seitenleiste ziehen, am Handy nach kurzem Gedrueckthalten. Ueber
Pointer-Events statt ueber die Drag-and-Drop-Schnittstelle des
Browsers -- die gibt es auf Android nicht, und genau dort soll die
Geste ja auch funktionieren. Ist die Kalenderliste zugeklappt (am Handy
der Regelfall), erscheint waehrend des Ziehens eine Leiste unten in
Daumennaehe, sonst liefe die Geste ins Leere.

Zwei Faelle, die sonst aergern: Wer in einen ausgeblendeten Kalender
zieht, bekommt ihn eingeblendet -- der Termin waere sonst scheinbar
spurlos verschwunden. Und der Klick, der einem Ziehen hinterherkommt,
wird verschluckt, statt den Termindialog zu oeffnen.

Eine Reihe zieht vollstaendig um; sie ist eine einzige Datei, kein
Buendel von Einzelterminen. Das sagt das Formular auch an.

Selbsttest um elf Pruefungen erweitert (36 gruen): Umzug angenommen,
Kennung bleibt, aus der Quelle weg und im Ziel da, ctag auf beiden
Seiten gestiegen, Umzug auf sich selbst folgenlos, fremder Kalender
404, Reihe vollstaendig umgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:07:36 +02:00
marcandClaude Opus 5 688bb13bde feat(calendar): einzelnen Termin aus einer Reihe streichen
Beim Löschen eines Serientermins gibt es jetzt zwei Wege statt einem:
"Nur diesen Termin" und "Ganze Reihe". Das sind zwei verschiedene
Absichten, und sie sind nicht ineinander umkehrbar -- eine einzelne
Ja/Nein-Rückfrage musste sich bisher für eine davon entscheiden, und die
andere war gar nicht erreichbar.

"Nur diesen" wird als EXDATE in der Reihe vermerkt, so wie das
iCalendar-Format eine Ausnahme ausdrückt: Die Reihe behält ihre Regel
und ihre UID, jeder CalDAV-Client versteht es ohne Zutun. Bewusst nicht
über ein Aufspalten in zwei Reihen gelöst -- das änderte die UID, und
alle Geräte hielten den Rest für einen neuen Termin.

Ein zweites Streichen desselben Termins bleibt folgenlos (sonst liefe
die Datei mit der Zeit mit doppelten EXDATE-Zeilen voll), und bei einem
Einzeltermin ohne Wiederholung sind beide Wege dasselbe -- der Server
löscht dann einfach, statt den Aufrufer mit einer Unterscheidung zu
behelligen, die es dort nicht gibt.

Selbsttest um sechs Prüfungen erweitert (25 grün): Reihe steht,
Streichen liefert 204, der gestrichene Termin fehlt und der Rest bleibt,
EXDATE steht im .ics, zweimal Streichen ist harmlos, ganze Reihe löschen
räumt alles weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:06:53 +02:00
marcandClaude Opus 5 0c9cc69493 feat(calendar): Wiederholungsmuster, Terminfarbe synchronisierbar, Selbsttest
Terminfarbe: Sie kam auf dem Telefon nie an. RFC 7986 schreibt für COLOR
einen CSS3-*Namen* vor, wir schrieben Hex – DAVx5 liest die Eigenschaft
über eine Namenstabelle und verwirft alles andere, also blieb der Termin
im Samsung-Kalender farblos. Jetzt beides nebeneinander:

    COLOR:tomato            für fremde Clients (damit die Farbe ankommt)
    X-AETHER-COLOR:#ea4335  für die eigene Palette (damit sie genau bleibt)

Beim Lesen gewinnt der genaue Wert; ein von außen eingefärbter Termin
wird über die Namenstabelle übersetzt. Nächstgelegener Ton per Abstand im
RGB-Würfel (color.go).

Wiederholungen können jetzt mehr als "alle N":

- wöchentlich an ausgewählten Wochentagen (Mo–So, Mehrfachauswahl). Der
  Wochentag des Starts bleibt fest angehakt – eine Reihe, die ihren
  eigenen ersten Termin auslässt, ist keine.
- monatlich und jährlich wahlweise am festen Kalendertag (am 4.) oder am
  n-ten Wochentag (am 1. Freitag). Beide Angaben leitet der Server aus
  dem Startdatum ab, statt sie getrennt entgegenzunehmen – so kann die
  Regel gar nicht erst auf einen anderen Tag zeigen als der Termin.
- Der fünfte Wochentag eines Monats wird bewusst zu "letzter": Einen
  fünften Freitag hat nicht jeder Monat, BYDAY=5FR ließe die Reihe sonst
  in etwa jedem zweiten Monat stillschweigend ausfallen.

Regeln, die das Formular weiterhin nicht abbildet, bleiben lesbar, aber
nicht bearbeitbar (409) – unverändert.

Dazu scripts/kalender-selbsttest.py, 19 Prüfungen gegen die laufende
Instanz, in TESTING.md verlinkt. Alle grün, unter anderem:

    am 1. Freitag monatlich   -> 04.09. 02.10. 06.11. 04.12.
    am 1. Freitag im September -> 2026-09-04, 2027-09-03
    Mo+Fr wöchentlich          -> 07./11./14./18./21./25.09.
    ganztägig am 3.            -> bleibt der 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:16:06 +02:00
marcandClaude Opus 5 79a7cc6a3d feat(calendar): Wiederholungen, Tagesansicht, Terminfarben, Farbpunkt als Schalter
Fünf Wünsche aus dem Durchtesten:

1. Wiederholungen einstellbar (täglich/wöchentlich/monatlich/jährlich,
   alle N, Ende nie/nach Anzahl/an einem Datum). Der eigentliche Aufwand
   lag nicht im Formular, sondern im Aufklappen: eine wöchentliche Reihe
   erschien sonst als *ein* Eintrag. Der Server klappt sie jetzt im
   angefragten Zeitfenster auf (?from=&to=, rrule-go über go-icals
   RecurrenceSet), die Ansichten fragen ihren sichtbaren Ausschnitt an.
   Geprüft: 5 Termine wöchentlich, nach Umstellung auf 14-tägig korrekt
   neu gerechnet, Fenster einer Woche liefert genau einen.

   Regeln, die das Formular nicht abbildet (etwa jeder zweite Dienstag
   im Monat), werden weiterhin korrekt *angezeigt* -- geprüft, die vier
   Termine stimmen -- aber nicht zum Bearbeiten angeboten (409). Ein
   halb verstandenes Speichern würde die Regel sonst still beschneiden.

2. Tag wird erst geöffnet, statt sofort einen Termin anzulegen: Klick
   auf einen Tag zeigt dessen Termine als Liste, dort wählt man einen
   aus oder legt bewusst einen neuen an.

3. Eigene Farbe je Termin (COLOR nach RFC 7986), leer = Kalenderfarbe.

4. Kalenderliste ohne Kästchen: der Farbpunkt selbst ist der Schalter --
   gefüllt heißt sichtbar, leerer Ring ausgeblendet.

5. Ganztägig als Schiebeschalter statt Kästchen -- es stellt das
   Formular um, das ist keine Nebenangabe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:56:28 +02:00
marcandClaude Opus 5 105ce8de4a fix(calendar): Termine anklickbar, Ganztags-Datum korrekt, Wochenraster mit Stunden
Drei gemeldete Fehler, alle nachgestellt:

1. Ein vorhandener Termin ließ sich nicht öffnen. Der Klick auf den
   Termin reichte an die Tageszelle darunter durch, die sofort 'Neuer
   Termin' öffnete und den Bearbeiten-Dialog überschrieb. Fehlendes
   stopPropagation.

2. Klick auf den 3. legte den Termin am 2. an. Ein ganztägiger Termin
   ist ein *Datum*, kein Zeitpunkt - die Oberfläche rechnete ihn über
   new Date(...).toISOString() trotzdem in UTC um, was östlich von UTC
   den Vortag ergibt. Nachgestellt und bestätigt: DTSTART landete als
   20260902 statt 20260903. Ganztägige Termine laufen jetzt über
   dates.ts (allDayISO/eventDay), damit Dialog und Raster dieselbe
   Rechnung benutzen. Nebenbefund mitgefixt: DTEND war gleich DTSTART,
   was RFC 5545 verbietet (DTEND ist exklusiv) - ein eintägiger Termin
   am 3. schreibt jetzt DTEND 20260904, nach außen bleibt die
   Schnittstelle beim letzten belegten Tag.

3. 'Fühlt sich starr an': Wochenansicht ist jetzt ein echtes
   Stundenraster (Klick trifft die Stunde statt Mitternacht, ganztägige
   in eigener Zeile, überlappende Termine nebeneinander, Jetzt-Linie),
   mehrtägige Termine stehen in jeder Zelle statt nur am Starttag, neue
   Termine schlagen die nächste volle Stunde bzw. 9 Uhr vor,
   Kalenderauswahl im Termindialog, Escape schließt, Hover-Rückmeldung,
   Kalenderliste auf schmalen Schirmen einklappbar.

Außerdem: ein neu angelegter Kalender blieb unsichtbar, weil er in
keiner Sichtbar-Liste stand - die Liste merkt sich jetzt die
ausgeblendeten statt der sichtbaren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:47:08 +02:00
marcandClaude Sonnet 5 ba33706d2e fix(calendar): Symbol in der Kopfleiste ergänzt
Jede andere Seite zeigt neben dem Titel ein Strichsymbol (Papierkorb,
Geteiltes, Etiketten, ...) - beim Kalender fehlte es, PageHeader bekam
keinen icon-Prop. Dasselbe Kalendersymbol wie in der Seitenleiste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 23:36:21 +02:00
marcandClaude Sonnet 5 27a026421f feat(calendar): Phase 3 – Weboberfläche mit Monats-, Wochen- und Listenansicht
Neue Seite CalendarPage (Menüpunkt 'Calendar' in der Seitenleiste,
zwischen Home und Papierkorb) mit Kalenderliste zum Anlegen/Umbenennen/
Löschen/Ein- und Ausblenden, Export/Import als .ics über die
Phase-2-API, und Termine anlegen/ändern/löschen über einen neuen
JSON-Endpunkt.

Backend: neuer events_api.go-Handler unter /api/v1/calendars/{id}/events
(Session-Auth) neben dem bestehenden CalDAV-Protokoll – ein Termin, den
die Web-Oberfläche anlegt, ist ein ganz normales CalDAV-Objekt und
synchronisiert genauso zu DAVx5 wie umgekehrt. Ein per CalDAV mit RRULE
angelegter, wiederkehrender Termin zeigt sich hier nur (recurring:true),
lässt sich aber bewusst nicht über dieses vereinfachte Formular
bearbeiten (409) – das würde die Wiederholungsregel eher beschädigen
als korrekt anpassen.

Gegen den laufenden Container getestet: Kalender/Termin anlegen, per
Web-API ändern und löschen, ein per CalDAV mit RRULE angelegter
Wiederkehrer taucht als recurring:true auf und blockt PATCH mit 409.
TypeScript-Build (tsc + vite build) sauber.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 23:14:28 +02:00
marcandClaude Opus 5 9d152e3677 refactor: App-Umschalter und HONEYBAR_KEY entfernt
Beide waren Ballast, aber aus verschiedenen Gründen.

Der App-Umschalter (EXTERNAL_APPS, AppSwitcher.tsx) hing seit dem
30.07.2026 nirgends mehr in der Oberfläche – auf Marcs Wunsch damals aus
der Seitenleiste genommen, "weil eine gemeinsame Leiste für alle Apps
noch überlegt wird". Diese Leiste gibt es inzwischen: die HoneyBar. Das
Backend las die Variable trotzdem weiter und lieferte external_apps in
der öffentlichen Konfiguration aus. Weg damit, samt Typ und Parser.

HONEYBAR_KEY war der Ausweis für die App-Liste, wenn HoneyOS auf einer
fremden Site liegt. Bei Marc liegen honeyos.duckdns.org und
cloud.honeyos.duckdns.org unter derselben Site, das Sitzungscookie fließt
also mit. Nachgesehen, wie HoneyOS entscheidet: Es prüft zuerst die
Sitzung (SubFromRequest) und greift erst danach zum Schlüssel – die
Leiste kommt hier ohne ihn an ihre Liste. Gedacht war er für Widgets;
die sind vertagt.

Aether schickt damit kein data-key mehr; HoneyOS behält seine Seite der
Prüfung, für den Fall, dass später doch eine App auf fremder Domain dazu
kommt.

Nachgeprüft: go build, go vet und die Tests laufen; das Abbild baut ohne
TypeScript-Fehler; die öffentliche Konfiguration liefert weder
honeybar_key noch external_apps; im gebauten Bundle kommt data-key nicht
mehr vor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:15:28 +02:00
marcandClaude Opus 5 31d51f9bc3 docs(config): Office-Abschnitt der .env.example auf die Schlüssel gekürzt
Die Adressen und Fallstricke stehen im README; hier standen sie ein
zweites Mal. Jetzt sechs Zeilen mit knapper Randnotiz, welche Richtung
welche Adresse meint.

Der Hinweis zu Collabora bleibt: OFFICE_WOPI_URL muss dort die
öffentliche Adresse sein, sonst verweigert der Browser den Rahmen. Das
hat einen Abend gekostet und ist beim Überfliegen nicht zu erraten.

Beim Kürzen selbst wieder in die dokumentierte Falle getappt: zwei leere
Werte mit Kommentar dahinter. Diesmal nachgemessen statt geglaubt – ein
Container mit so einer Zeile bekommt LEER=[# Browser → Office], während
ein gefüllter Wert den Kommentar verliert. Beide Kommentare stehen jetzt
in der Zeile darüber, die ganze Datei ist gegengeprüft.

Nebenbei: veralteter deploy/-Verweis im Kommentar von AppSwitcher.tsx
auf build/ gezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:10:08 +02:00
marcandClaude Opus 5 6c53651568 fix(office): PDF am Telefon nur bei OnlyOffice, Collabora behält den Editor
Die Regel galt für beide Anbieter – das war falsch. Marcs Kritik betraf
nur OnlyOffice: Es baut auf dem Telefon dieselbe Oberfläche auf wie am
PC. Collabora bringt für kleine Schirme eine eigene Ansicht mit, die
taugt; dort wäre der Umweg über ein PDF ein Rückschritt.

Der Anbieter steht jetzt in der öffentlichen Konfiguration
(office_provider), und die Oberfläche entscheidet daran. Der Endpunkt
selbst kann weiterhin beides.

Nachgeholt: Der ganze Weg ist jetzt auch mit Collabora gemessen, nicht
nur mit OnlyOffice – gegen die Dev-Instanz, die damit läuft.
  erster Abruf 0,347 s, zweiter 0,009 s, eine Datei auf der Platte
  (OnlyOffice zum Vergleich: 1,05 s – es muss die Datei erst abholen)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:07:08 +02:00
marcandClaude Opus 5 065372359b feat(pdf): Zoom in festen Fünferschritten statt in fünf Prozent
Gemeint waren runde Zahlen: 65, 70, 75, 80 … Ein fester Faktor ist
rechnerisch die sauberere Größe – er fühlt sich unten wie oben gleich an
–, aber abzulesen war er nicht: Die Anzeige sprang um drei Punkte, dann
um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein Raster.

Weil fünf Punkte bei 25 % ein Fünftel und bei 400 % ein Hundertstel
sind, kann die Schwelle der Geste nicht mehr fest sein. Verlangt wird
jetzt genau die Fingerbewegung, die dem nächsten Schritt entspricht,
mit zwei Prozent als Untergrenze gegen Zittern.

Ein Fehler, den erst die Messung zeigte: 1,1 × 100 ergibt in Fließkomma
110.00000000000001, und die "nächste kleinere Fünferzahl" davon ist
wieder 110. Der Zoom blieb deshalb nach genau einer Stufe stehen –
sichtbar nur beim Zuziehen, weil es aufwärts eine Stufe später gegriffen
hätte. Vor der Rechnung wird jetzt gerundet.

Gemessen, eine lange Geste in jede Richtung:
  auf:  65 → 70 → 75 → 80 → 85 → 90 → 95 → 100 → 105 → 110 → 115 → 120 → 125 → 130
  zu:   125 → 120 → 115 → 110 → 105 → 100 → 95 → 90 → 85 → 80 → 75

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 00:57:31 +02:00
marcandClaude Opus 5 ded1134f6a feat(pdf): Zoomstufen von 10 % auf 5 % verfeinert
Marc las am Telefon Sprünge von acht Prozentpunkten – die Stufe ist
relativ, nicht in Punkten: Bei 65 % sind zehn Prozent sieben Punkte, bei
200 % zwanzig. Fünf Prozent treffen das Gemeinte besser.

Die Schwelle der Geste folgt der Stufenweite (vorher 8 % / 7 %), sonst
ließe sich die feinere Weite mit den Fingern gar nicht treffen.

Kein Ruckeln dadurch: Die Zeichenschleife bricht eine laufende
Darstellung ab, sobald sich der Maßstab ändert – von zwanzig Stufen
einer Geste wird nur die letzte fertig gemalt.

Gemessen, dieselbe Geste:
  65 → 68 → 71 → 75 → 79 → 83 → 87 → 91 → 96 → 101 → 106 → 111 → 117 → 123 → 129 %

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 00:52:37 +02:00
marcandClaude Opus 5 9c5cda75d7 feat(office): Dokumente am Telefon als PDF ansehen statt bearbeiten
Am Finger öffnet ein Office-Dokument jetzt das gewandelte PDF im eigenen
Betrachter; "Edit" daneben führt trotzdem in den Editor. Mit Maus ändert
sich nichts.

Der Web-Editor ist eine vollständige Textverarbeitung – zwischen seinen
Leisten bleibt auf 400 Pixeln vom Dokument ein Rechteck. Collabora und
OnlyOffice verhalten sich gleich; deshalb kein besserer Bedienmodus,
sondern ein anderes Werkzeug. Gewandelt wird von dem Server, der auch
bearbeitet: Sonst sähe man unterwegs ein LibreOffice-Layout eines
Dokuments, für das man OnlyOffice gewählt hat.

Je Dokument gibt es GENAU EIN PDF, und das ist keine Frage des
Aufräumens, sondern des Schlüssels: previews/<id>.pdf trägt die Fassung
nicht im Namen, eine neue Fassung überschreibt. files.preview_version
sagt, ob das Abgelegte noch passt.

Sicherheit: Die Adresse, die OnlyOffice zurückgibt, wird von uns
abgerufen – der Wähler des Transports lässt nur OFFICE_ALLOWED_HOSTS zu,
zusätzlich eine ausdrückliche Prüfung mit Test. JWT-Signatur (HS256) für
die eigene API des Dokumentenservers. Fremde Kennung 404, ohne Anmeldung
401. Das Ergebnis muss mit %PDF- beginnen, sonst landete eine
Fehlerseite im Zwischenspeicher.

Gemessen gegen einen echten OnlyOffice-Server im Docker-Netz:
  erster Abruf 1,05 s -> zweiter 0,009 s
  erneut hochgeladen -> Fassung 2, preview_version 2, weiterhin 1 Datei
  GC: herrenloses PDF von vorgestern weg, echtes bleibt
  endgültig gelöscht -> PDF weg; Papierkorb -> PDF bleibt
  ohne Anmeldung 401, fremde Kennung 404, Nicht-Office 415

Dazu im Admin-Bereich neu: Platzbedarf der abgeleiteten Daten
(Vorschaubilder, PDFs, Profilbilder). Sie zählen gegen kein Kontingent –
ein Ordner, der wächst, ohne dass ihn jemand sieht, ist eine stille
Überraschung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:30:49 +02:00
marcandClaude Opus 5 0723d853f0 feat(pdf): Finger und Mausrad zoomen in Zehnteln statt in Vierteln
Die Leiter der Knöpfe (0,5 / 0,75 / 1 / 1,25 / …) ist für eine Geste zu
grob – man schießt über die Größe hinaus, die man gerade sucht. Finger
und Mausrad gehen jetzt zehn Prozent pro Schritt, die Knöpfe behalten
ihre Leiter: Ein Klick soll etwas bewegen, eine Geste ist ein Regler.

Multiplikativ, nicht in festen Prozentpunkten: Von 50 auf 60 % ist ein
Fünftel mehr, von 300 auf 310 % ein Dreißigstel. Zehn Prozent vom
aktuellen Wert fühlen sich überall gleich an. Auf ganze Prozent
gerundet, sonst stünde 71,50000000000001 % in der Leiste.

Gemessen, dieselbe Geste wie vorher: 65 % -> 116 % statt 65 % -> 125 %,
zurück auf 71 % statt 50 %.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:17:57 +02:00
marcandClaude Opus 5 574ba3e6c5 feat(mobile): Zurück-Geste schließt die Ansicht, zwei Finger zoomen das PDF
Zwei Dinge, die am Telefon fehlten – beide in der installierten App
besonders schmerzhaft, weil es dort keine Adresszeile als Rückweg gibt.

1. Zurück schloss bisher die Anwendung, nicht die Vorschau. Vorschau,
   Office-Editor und die aufgeschobene Seitenleiste kamen im Verlauf des
   Browsers gar nicht vor: Der einzige Eintrag war die Dateiliste. Jetzt
   legt jede bildschirmfüllende Ansicht beim Öffnen einen leeren Eintrag
   an (die Adresse bleibt gleich – eine Vorschau ist kein Ort für ein
   Lesezeichen) und verbraucht ihn beim Schließen wieder, egal ob über
   die Geste, Escape oder den Knopf. Ein Stapel sorgt dafür, dass bei
   übereinanderliegenden Ansichten nur die oberste reagiert.

   Collabora blendet auf kleinen Schirmen seinen eigenen Schließen-Knopf
   aus (closeButtonEnabled && !isSmallScreenDevice) – ohne diesen Weg
   käme man aus einem Dokument am Telefon nur über die Kopfzeile heraus.

2. Die Zusammenziehgeste vergrößerte die ganze Seite statt des PDFs:
   Leiste und Knöpfe wuchsen mit, das Dokument blieb grob. touch-action
   nimmt dem Browser die Geste, ein eigener Zuhörer setzt unsere
   Zoomstufen an ihre Stelle, verankert an der Mitte zwischen den
   Fingern – dieselbe Regel wie bei Strg+Mausrad.

Gemessen in Chromium mit Touch-Emulation, 412x900:
  Vorschau offen -> Verlauf 2->3, Zurück -> Vorschau zu, Anwendung bleibt
  Esc -> Eintrag wieder weg (ein weiteres Zurück verlässt die App)
  Aufziehen 65% -> 125%, Zuziehen -> 50%, visualViewport.scale bleibt 1

Ein Befund aus dieser Messung steckt im Code: Zuerst kam pro Geste nur
eine Stufe, weil sich der Zuhörer bei jeder Zoomänderung neu anmeldete
und der gemerkte Fingerabstand dabei verlorenging. Abstand und Maßstab
liegen deshalb in Referenzen, der Zuhörer meldet sich einmal an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:25:59 +02:00
marcandClaude Opus 5 451f359734 feat(uploads): fertige Meldungen verschwinden von selbst
Nach einem Stapel-Upload blieb unten rechts für jede Datei eine Karte
stehen, bis man sie einzeln wegklickte oder die Seite neu lud. Bei
vielen Dateien war der Bildschirm zu.

Fertige Meldungen räumen sich jetzt nach vier Sekunden selbst ab.
Fehler bleiben: Sie tragen die einzige Auskunft, die es sonst nirgends
gibt. Zusätzlich stehen höchstens vier Karten gleichzeitig, der Rest
wird gezählt – der Bildschirm läuft ja schon während des Hochladens
voll, nicht erst danach. Gescheiterte Dateien stehen dabei oben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:14:13 +02:00
marcandClaude Opus 5 8d95088271 feat(mobile): Wischen vom linken Rand holt die Seitenleiste
Bisher gab es dafuer nur den Knopf oben links - am Telefon die Ecke,
die man einhaendig am schlechtesten erreicht.

Vier Bedingungen halten die Geste aus dem Weg: nur im 44-px-Streifen am
Rand (sonst waeren waagerecht scrollbare Bereiche nicht mehr zu
bedienen), mindestens 60 px Weg, doppelt so weit waagerecht wie
senkrecht (sonst oeffnet sie beim Blaettern), hoechstens 800 ms.

44 px und nicht 20: Androids Zurueck-Geste belegt die aeussersten ~20
px, was dort beginnt erreicht die Seite nie.

Aus einer bildschirmfuellenden Ansicht heraus tut sie nichts - dort
wischt man durch Seiten, und die Leiste laege ohnehin dahinter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:24:23 +02:00
marcandClaude Opus 5 5ea66ea884 fix(thumbnails): Fassung in der Adresse, Sofortanzeige, kein Flackern
Gleicher Dateiname erzeugt keine neue Datei, sondern eine neue Fassung -
gleiche ID, gleiche Vorschaubild-Adresse. Die darf einen Tag im Browser
liegen, also zeigte die Kachel nach dem erneuten Hochladen bis zu 24
Stunden das alte Bild. Das erklaert, warum eine Drehung erst nach
mehreren Versuchen "ankam". Die Adresse traegt jetzt ?v=<Fassung>.

Vorschaubilder entstehen im Hintergrund; die Kachel fragt jetzt bis zu
fuenfmal nach, statt bis zum naechsten Neuladen ihr Ersatzsymbol zu
zeigen. Nur bei Bildern juenger als zwei Minuten - sonst fragte jede
dauerhaft fehlgeschlagene Datei bei jedem Oeffnen erneut. Gemessen: 608
ms bis zur Anzeige, ohne Neuladen.

Das Anheben der Kachel beim Ueberfahren ist raus: Steht der Zeiger auf
der Unterkante, rutscht sie unter ihm weg, mouseleave, sie faellt
zurueck, mouseenter - im Kreis, sichtbar als Flackern.

Dazu liest die EXIF-Auswertung jetzt 256 statt 64 KB und gibt bei einem
abgeschnittenen Block nicht mehr auf: Die Drehung steht vorn, das
eingebettete Vorschaubild dahinter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:29:09 +02:00
marcandClaude Opus 5 5b3658640e revert(admin): Knopf zum Neuerzeugen der Vorschaubilder wieder raus
130 Zeilen fuer einen einmaligen Bedarf - die Haelfte davon eine Karte
im Admin-Bereich, die sonst dauerhaft dort steht. Die EXIF-Behebung
selbst bleibt: Neu hochgeladene Bilder stehen richtig herum.

Bestehende Vorschaubilder werden damit nicht mehr nachgezogen. Wer sie
gerade ruecken will, laedt die Datei erneut hoch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:15:00 +02:00
marcandClaude Opus 5 4bd9a6ca56 fix(thumbnails): EXIF-Drehung auswerten, hochkant blieb quer
Kameras speichern die Bildpunkte fast immer quer und legen die Drehung
als EXIF-Notiz daneben. Browser befolgen sie von selbst - Go nicht.
Deshalb stand dasselbe Foto in der grossen Ansicht aufrecht und lag im
Vorschaubild auf der Seite.

Neues Paket internal/imageutil liest genau ein Feld (0x0112) aus dem
Anfang der Datei und dreht danach. Von Hand statt mit einer Bibliothek:
Der Weg dorthin ist kurz und liegt fest, und gebraucht wird ein Feld.
Gelesen werden nur die ersten 64 KB, damit ein 40-MB-Foto nicht zweimal
im Speicher liegt.

Gedreht wird vor dem Verkleinern - andersherum bekaeme ein hochkantes
Foto den Zuschnitt eines Querformats.

Bestehende Vorschaubilder aendern sich davon nicht. Dafuer neu:
POST /admin/thumbnails/rebuild samt Knopf im Admin-Bereich.

Auch Profilbilder aus OIDC laufen jetzt durch dieselbe Auswertung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:08:10 +02:00
marcandClaude Opus 5 edefce3572 feat(pdf): Strg + Mausrad zoomt, Zoomgruppe rechts aussen
Die Zoomknoepfe sitzen jetzt rechts in der Leiste statt links neben der
Seitenzahl - dieselbe Anordnung wie in InkNote. Die dritte Rasterspalte
war ohnehin da, damit die Suche wirklich mittig sitzt; jetzt traegt sie
auch etwas. Schmal bleibt der Zoom in der ersten Zeile, sonst waere die
Leiste am Telefon dreizeilig geworden.

Strg (oder Cmd) + Mausrad zoomt das Dokument. Der Zuhoerer wird von Hand
angemeldet: React meldet Rad-Ereignisse passiv an, und passiv darf man
preventDefault nicht rufen - ohne das zoomt der Browser die ganze Seite
statt des Dokuments.

Beim Rad wird der Punkt unter dem Zeiger festgehalten statt der Seite.
Gemessen: Seite 2 bleibt ueber zwei Zoomstufen unter dem Zeiger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:02:06 +02:00
marcandClaude Opus 5 3d9a4832f9 fix(pwa): geaenderte Kopfzeilen erreichen den Browser wieder
Ein Eintrag im Cache ist eine ganze Antwort, mitsamt ihren Kopfzeilen. Weil
index.html vorab im Cache lag und von dort auch „/" beantwortete, bekam der
Browser dauerhaft die CSP von damals: eine Aenderung an HONEYBAR_ORIGIN kam
nie an, denn der Inhalt der Datei aendert sich dabei nicht, und der
Vorab-Cache holt nur Geaendertes nach. Sichtbar als Widerspruch, an dem
nichts falsch aussah — das Skript laedt den neuen Ursprung, die CSP erlaubt
den alten.

Navigationen laufen jetzt Netz zuerst, Cache als Rueckfall; index.html bleibt
vorab abgelegt, aber nur noch als letzte Reihe (precacheFallback). Dass sie
nicht mehr als Verzeichnisseite fuer „/" einspringt, ist der eigentliche
Punkt: sonst kaeme die neue Regel nie zum Zug.

Nachgemessen: Wechsel des Ursprungs kommt beim zweiten Aufruf an, und
offline laden „/", „/admin" und „/s/<token>" weiterhin die Anwendung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:54:25 +02:00
marcandClaude Opus 5 d6b9d6a1ce fix(ui): die Seite rechnet die Höhe der HoneyOS-Leiste ab
Die Leiste liegt fest am oberen Rand und schiebt den Inhalt herunter. Wer
danach noch mit 100vh rechnete, war genau um ihre Höhe zu hoch — am Rechner
eine Bildlaufleiste, die es vorher nicht gab. Und wer selbst fest am oberen
Rand klebte (Seitenleiste am Handy, Vorschau, Office), lag darunter und war
verdeckt.

--honeybar-h setzt die Leiste selbst auf <html>; ohne sie ist der Wert 0 und
alles bleibt wie zuvor. Drei Hilfsklassen in index.css halten das an einer
Stelle zusammen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:40:34 +02:00
marcandClaude Opus 5 57156a6394 fix(honeybar): Ursprung zur Laufzeit statt beim Bauen
Die Adresse im <script>-Tag stand fest in der index.html, waehrend die CSP
aus HONEYBAR_ORIGIN kam. Wer die Leiste woanders betreibt, bekam genau den
Widerspruch: die CSP erlaubte den eigenen Ursprung, geladen wurde der alte.

Der erste Versuch, das ueber ein Bau-Argument zu loesen, half beim fertigen
Abbild nicht — dort ist der Bau vorbei, und ein Umzug haette einen Neubau
verlangt. Ursprung und Schluessel stehen jetzt in /api/v1/public/config, die
Oberflaeche haengt das Skript selbst ein. Beides speist sich aus derselben
Variablen wie die CSP, sie koennen also nicht auseinanderlaufen, und eine
Aenderung wirkt beim Neustart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:07:35 +02:00