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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-27 18:17:05 +02:00
1 parent 662c0ebdc1
commit 6fc157bc07
14 files changed
+3700 -19

No files matched your search

+4 -1
View File
@@ -765,7 +765,10 @@ func parseID(r *http.Request, param string) (uuid.UUID, error) {
// Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich,
// auch wenn das Grundbild wechselt, und lässt sich testen.
var extraMIMETypes = map[string]string{
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
// Zeichnungen und Mindmaps. Innen JSON – aber mit eigener Endung, damit
// die Oberflaeche sie vom Texteditor fernhaelt.
".excalidraw": "application/json",
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg",
".wav": "audio/wav", ".flac": "audio/flac",
+23 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -37,10 +38,12 @@ func scanFile(row interface{ Scan(...any) error }, f *File) error {
func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
var out File
err := scanFile(s.db.QueryRow(ctx,
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key,
versioning_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING `+fileCols,
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
versioniertVonAnfang(f.Name),
), &out)
if err != nil {
if isUniqueViolation(err) {
@@ -51,6 +54,24 @@ func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
return &out, nil
}
// versioniertVonAnfang: Welche Dateien ihre Fassungen von der ersten Minute an
// behalten.
//
// Versionierung ist sonst bewusst abgeschaltet (Migration 000006): Jede
// Ueberschreibung hiesse eine weitere Fassung, und der Speicher liefe
// unbemerkt voll.
//
// Fuer Zeichnungen stimmt diese Rechnung nicht. Sie werden **in sich** immer
// wieder bearbeitet – das ist ihr normales Leben, anders als bei einer
// hochgeladenen Rechnung – und sie sind ein paar Kilobyte gross. Vor allem
// aber: Ohne gleichzeitiges Arbeiten kann der Zweite den Ersten
// ueberschreiben, und die Versionshistorie ist dann das Einzige, was die
// Arbeit zurueckholt. Ohne sie waere die Warnung im Editor ein Versprechen
// ohne Deckung.
func versioniertVonAnfang(name string) bool {
return strings.HasSuffix(strings.ToLower(name), ".excalidraw")
}
// GetFileByID fetches a file without owner check (used by public share downloads).
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
var f File
@@ -538,7 +559,6 @@ func isUniqueViolation(err error) bool {
// entstanden ist – 0 heißt: es gibt keins.
//
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
// Vorschau-PDF für herrenlos und löschte es nach einem Tag weg.