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

+3
View File
@@ -29,3 +29,6 @@ Thumbs.db
# Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften) # Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften)
frontend/public/pdfjs/ frontend/public/pdfjs/
# Excalidraws Schriften – aus dem Paket kopiert, nicht eingecheckt (siehe
# vite.config.ts). Fremde Binaerdateien gehoeren nicht in den Quelltext.
frontend/public/excalidraw/
+85
View File
@@ -1562,6 +1562,91 @@ bleibt `node` (schnell); wer ein DOM braucht, schreibt
--- ---
## Zeichnen und Mindmaps (27.09.2026)
Auf Excalidraw (MIT). **Eine Zeichnung ist eine Datei, kein neues Ding** – das
ist die ganze Entscheidung dahinter. `.excalidraw` ist JSON; damit erbt sie
alles, was Aether fuer Dateien schon kann: Ordner, Gruppenordner, Papierkorb,
Versionen, Freigabelinks, interne Freigaben, Suche. Keine neue Tabelle, kein
zweites Rechtemodell.
Gespeichert wird ueber denselben Upload wie alles andere – gleicher Name,
gleicher Ordner, wie beim Text-Editor in der Vorschau.
### Drei Sorgen, alle nachgemessen
**Das Hauptbuendel darf nicht wachsen.** Wer nie zeichnet, soll den Brocken nie
holen. Nachgeladen wie der PDF-Betrachter:
| | vorher | nachher |
|---|---:|---:|
| Hauptbuendel `index-*.js` | 484,11 kB | **485,22 kB** |
| Zeichenbrett (eigenes Stueck) | – | 584 kB + 142 kB CSS |
**Schriften.** Die Sicherheitsrichtlinie ist `default-src 'self'` ohne eigenes
`font-src` – Excalidraw haette seine handgezeichneten Schriften von aussen
geladen und still eine Ersatzschrift gezeigt. Sie werden jetzt beim Bauen ins
`public/`-Verzeichnis kopiert (`vite.config.ts`) und ueber
`window.EXCALIDRAW_ASSET_PATH` gefunden.
> Der Pfad wird **im Modul** gesetzt, nicht per Inline-Skript in `index.html`:
> Das waere von derselben Richtlinie blockiert (`script-src 'self'`).
> **Xiaolai bleibt draussen.** Die chinesische Schrift ist 13 der 14 MB; alles
> andere zusammen 512 KB. Wer Chinesisch in eine Zeichnung tippt, bekommt eine
> Ersatzschrift – der Preis dafuer, das Abbild nicht um 13 MB aufzublaehen.
**Reihenfolge der Dateiarten.** `.excalidraw` wird **vor** `code` und `text`
geprueft: `json` steht in `codeExt`, und stuende die Pruefung spaeter, oeffnete
eine Zeichnung im Texteditor als Zahlenkolonne.
### Kein gleichzeitiges Arbeiten – und was das kostet
Die Komponente koennte es (`isCollaborating`), aber den Transport bringt sie
nicht mit: Excalidraw betreibt dafuer einen eigenen WebSocket-Dienst, Nextcloud
hat fuer seine Whiteboard-App ein eigenes Node-Backend gebaut. Das waere ein
zweiter Container neben Aether, so wie Euro-Office einer ist – eine eigene
Entscheidung, kein Nebenprodukt.
Solange es den nicht gibt, gilt: **Zwei Leute speichern nacheinander, der
Zweite ueberschreibt den Ersten.** Zwei Dinge fangen das ab:
1. Vor dem Speichern wird geprueft, ob sich die Fassung geaendert hat. Wenn ja,
wird **gefragt** statt ueberschrieben.
2. Zeichnungen behalten ihre Fassungen **von der ersten Minute an**.
Punkt 2 ist erst durch den Selbsttest aufgefallen: Versionierung ist sonst
bewusst abgeschaltet (Migration 000006), damit der Speicher nicht volllaeuft.
Fuer Zeichnungen stimmt die Rechnung nicht – sie werden in sich immer wieder
bearbeitet und sind ein paar Kilobyte gross. **Ohne sie waere die Warnung im
Editor ein Versprechen ohne Deckung** gewesen.
### Selbsttest
`scripts/zeichnung-selbsttest.py`, 12 Pruefungen. Er prueft genau die These
nach, auf der die Umsetzung beruht: Wenn eine Zeichnung wirklich eine Datei
ist, muss sie koennen, was Dateien koennen.
- [x] Anlegen, Pruefsumme, Inhalt kommt unveraendert zurueck
- [x] Aendern erzeugt eine Fassung in der Historie
- [x] Jede Speicherung hebt die Fassungsnummer – darauf beruht der Abgleich
- [x] Freigabelink darauf, oeffentlich abrufbar
- [x] Zeichnung im Gruppenordner
- [x] Excalifont wird vom eigenen Server ausgeliefert
### Von Hand zu pruefen
- [ ] `+ Drawing` legt eine Zeichnung an und oeffnet sie 🔎
- [ ] Zeichnen, Rechteck, Text darin, Pfeil andocken – Block verschieben, der
Pfeil folgt (das ist der Mindmap-Fall)
- [ ] Die Schrift sieht handgezeichnet aus, nicht nach Arial
- [ ] Speichern -> Punkt neben dem Namen verschwindet, Meldung erscheint
- [ ] Schliessen mit ungespeicherten Aenderungen -> Rueckfrage
- [ ] Zurueck-Geste im Zeichenbrett -> schliesst es, nicht die App
- [ ] Am Telefon: Geht es mit dem Finger?
- [ ] In zwei Browsern oeffnen, in beiden etwas zeichnen, nacheinander
speichern -> der Zweite bekommt die Rueckfrage
## Der Abgleich bekommt eine Notbremse (26.09.2026) ## Der Abgleich bekommt eine Notbremse (26.09.2026)
`internal/rescan` ist das einzige Stueck Aether, das **hart** loescht: `internal/rescan` ist das einzige Stueck Aether, das **hart** loescht:
+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, // Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich,
// auch wenn das Grundbild wechselt, und lässt sich testen. // auch wenn das Grundbild wechselt, und lässt sich testen.
var extraMIMETypes = map[string]string{ 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", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg",
".wav": "audio/wav", ".flac": "audio/flac", ".wav": "audio/wav", ".flac": "audio/flac",
+23 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "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) { func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
var out File var out File
err := scanFile(s.db.QueryRow(ctx, err := scanFile(s.db.QueryRow(ctx,
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key) `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) versioning_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING `+fileCols, RETURNING `+fileCols,
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey, f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
versioniertVonAnfang(f.Name),
), &out) ), &out)
if err != nil { if err != nil {
if isUniqueViolation(err) { if isUniqueViolation(err) {
@@ -51,6 +54,24 @@ func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
return &out, nil 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). // GetFileByID fetches a file without owner check (used by public share downloads).
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) { func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
var f File var f File
@@ -538,7 +559,6 @@ func isUniqueViolation(err error) bool {
// entstanden ist – 0 heißt: es gibt keins. // entstanden ist – 0 heißt: es gibt keins.
// //
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF // FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes // 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. // Vorschau-PDF für herrenlos und löschte es nach einem Tag weg.
+3049 -11
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -11,6 +11,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@excalidraw/excalidraw": "^0.18.1",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"pdfjs-dist": "^6.2.108", "pdfjs-dist": "^6.2.108",
"react": "^19.2.7", "react": "^19.2.7",
+242
View File
@@ -0,0 +1,242 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Excalidraw } from '@excalidraw/excalidraw'
import '@excalidraw/excalidraw/index.css'
import { filesApi, type FileItem } from '../api/client'
import { useBackClose } from '../hooks/useBackClose'
import { toast, fehlertext } from './Toasts'
/**
* Zeichnen und Mindmaps – auf Excalidraw.
*
* **Eine Zeichnung ist eine Datei, kein neues Ding.** Das ist die ganze
* Entscheidung dahinter. `.excalidraw` ist JSON; damit erbt sie alles, was
* Aether für Dateien schon kann: Ordner, Gruppenordner, Papierkorb,
* Versionen, Freigabelinks, interne Freigaben, Suche nach Namen und Etikett.
* Keine neue Tabelle, kein zweites Rechtemodell.
*
* Gespeichert wird über denselben Weg wie ein Upload – gleicher Name,
* gleicher Ordner. Genau wie der Text-Editor in der Vorschau. Ein eigener
* Schreib-Endpunkt bräuchte dieselben Prüfungen noch einmal.
*
* **Kein gleichzeitiges Arbeiten.** Die Komponente könnte es (siehe
* `isCollaborating`), aber der Transport gehört nicht dazu – dafür bräuchte
* es einen zweiten Dienst neben Aether, so wie Euro-Office einer ist. Der
* Preis dafür steht unten bei `pruefeFremdeAenderung`: Ohne Abgleich würden
* zwei Leute sich lautlos gegenseitig die Arbeit löschen.
*/
/**
* Wo die Schriften liegen.
*
* Ohne das laedt Excalidraw sie von einem fremden Ursprung – und die
* Sicherheitsrichtlinie (`default-src 'self'`, kein eigenes `font-src`)
* verbietet genau das. Die Zeichnung erschiene in einer Ersatzschrift, und man
* suchte den Grund im Zeichenbrett statt in der Richtlinie.
*
* **Hier und nicht in index.html:** Ein Inline-Skript dort waere von derselben
* Richtlinie blockiert (`script-src 'self'`, kein `unsafe-inline`). Kopiert
* werden die Dateien beim Bauen, siehe vite.config.ts.
*/
declare global {
interface Window { EXCALIDRAW_ASSET_PATH?: string }
}
if (typeof window !== 'undefined') {
window.EXCALIDRAW_ASSET_PATH = '/excalidraw/'
}
/** Was in einer leeren Zeichnung steht. */
export function leereZeichnung(): string {
return JSON.stringify({
type: 'excalidraw',
version: 2,
source: 'aether',
elements: [],
appState: {},
files: {},
})
}
export const drawingExtension = '.excalidraw'
interface Props {
file: FileItem
onClose: () => void
/** Nach dem Speichern: Liste auffrischen, damit Größe und Fassung stimmen. */
onSaved?: (aktualisiert: FileItem) => void
}
export default function DrawingEditor({ file, onClose, onSaved }: Props) {
const [szene, setSzene] = useState<{ elements: unknown[]; appState: Record<string, unknown>; files: unknown } | null>(null)
const [fehler, setFehler] = useState('')
const [speichert, setSpeichert] = useState(false)
const [geaendert, setGeaendert] = useState(false)
/** Die Fassung, auf der wir arbeiten. Grundlage für den Abgleich unten. */
const basis = useRef(file.version)
/** Der jeweils letzte Stand aus dem Zeichenbrett, ohne bei jedem Strich zu rendern. */
const aktuell = useRef<{ elements: readonly unknown[]; appState: Record<string, unknown>; files: unknown } | null>(null)
useEffect(() => {
let lebt = true
fetch(filesApi.inline(file.id), { credentials: 'include' })
.then(r => r.text())
.then(text => {
if (!lebt) return
// Eine frisch angelegte Zeichnung ist leer – das ist kein Fehler.
const d = text.trim() ? JSON.parse(text) : JSON.parse(leereZeichnung())
setSzene({ elements: d.elements ?? [], appState: d.appState ?? {}, files: d.files ?? {} })
})
.catch(err => { if (lebt) setFehler(fehlertext(err, 'Could not open this drawing')) })
return () => { lebt = false }
}, [file.id])
/**
* Hat jemand anders die Datei inzwischen geändert?
*
* Ohne diese Frage überschreibt der Zweite den Ersten – **lautlos**, und das
* ist die unangenehmste Sorte Datenverlust: Niemand merkt sie, bis jemand
* seine Arbeit sucht. Es gibt keinen Endpunkt für eine einzelne Datei,
* deshalb über die Ordnerliste; das ist eine Anfrage und keine neue
* Schnittstelle.
*/
const pruefeFremdeAenderung = useCallback(async (): Promise<boolean> => {
try {
const d = await filesApi.list(file.folder_id ?? undefined)
const jetzt = (d.files ?? []).find(f => f.id === file.id)
return !!jetzt && jetzt.version !== basis.current
} catch {
// Lässt sich die Frage nicht beantworten, wird nicht geraten: Wir
// speichern und verlassen uns auf die Versionshistorie.
return false
}
}, [file.id, file.folder_id])
async function speichern() {
if (!aktuell.current) return
setSpeichert(true)
try {
if (await pruefeFremdeAenderung()) {
const weiter = window.confirm(
'Someone else saved this drawing while you were working on it.\n\n' +
'Saving now replaces their version. The previous one stays in the ' +
'version history either way.\n\nSave anyway?')
if (!weiter) { setSpeichert(false); return }
}
const inhalt = JSON.stringify({
type: 'excalidraw',
version: 2,
source: 'aether',
elements: aktuell.current.elements,
appState: aktuell.current.appState,
files: aktuell.current.files,
})
const blob = new File([inhalt], file.name, { type: 'application/json' })
const neu = await filesApi.upload(blob, file.folder_id ?? undefined)
basis.current = neu.version
setGeaendert(false)
onSaved?.(neu)
toast.info('Drawing saved')
} catch (err) {
toast.error(fehlertext(err, 'Could not save the drawing'))
} finally {
setSpeichert(false)
}
}
// Die Zurück-Geste schließt das Zeichenbrett. Bei ungespeicherten Änderungen
// erst fragen – `false` hält die Ansicht offen (siehe useBackClose).
useBackClose({
active: true,
onClose: () => {
if (geaendert && !window.confirm('Close without saving?')) return false
onClose()
},
})
return (
<div data-fullscreen="" style={{
position: 'fixed', inset: 'var(--honeybar-h) 0 0 0', zIndex: 120,
background: 'var(--color-background)', display: 'flex', flexDirection: 'column',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px', flexShrink: 0,
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
}}>
<span style={{
flex: 1, minWidth: 0, fontSize: 14, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{file.name}{geaendert ? ' •' : ''}
</span>
<button
onClick={speichern}
disabled={speichert || !geaendert}
style={{
padding: '6px 14px', borderRadius: 8, border: 'none',
background: geaendert ? 'var(--color-primary)' : 'var(--color-surface-2)',
color: geaendert ? 'var(--color-primary-fg)' : 'var(--color-text-muted)',
fontSize: 12, fontWeight: 600,
cursor: speichert || !geaendert ? 'default' : 'pointer',
}}
>
{speichert ? 'Saving…' : 'Save'}
</button>
<button
onClick={() => {
if (geaendert && !window.confirm('Close without saving?')) return
onClose()
}}
title="Close (Esc)"
style={{
padding: 6, borderRadius: 8, border: 'none', background: 'none',
color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex',
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{fehler && (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center', padding: 24,
color: 'var(--color-danger)', fontSize: 13, textAlign: 'center',
}}>
{fehler}
</div>
)}
{!fehler && !szene && (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
color: 'var(--color-text-muted)', fontSize: 13,
}}>
Opening…
</div>
)}
{szene && (
<Excalidraw
initialData={{ elements: szene.elements as never, appState: szene.appState as never, files: szene.files as never }}
onChange={(elements, appState, files) => {
// **Nicht in den Zustand schreiben.** onChange feuert bei jeder
// Mausbewegung; ein setState hier würde das Zeichenbrett bei
// jedem Strich neu rendern.
aktuell.current = { elements, appState: appState as unknown as Record<string, unknown>, files }
if (!geaendert) setGeaendert(true)
}}
/>
)}
</div>
</div>
)
}
+12 -1
View File
@@ -35,13 +35,14 @@ interface Props {
viewMode: ViewMode viewMode: ViewMode
onViewMode: (v: ViewMode) => void onViewMode: (v: ViewMode) => void
onNewFolder: () => void onNewFolder: () => void
onNewDrawing: () => void
onUpload: () => void onUpload: () => void
} }
export default function FileToolbar({ export default function FileToolbar({
selectedCount, onDownloadSelection, onTrashSelection, onClearSelection, selectedCount, onDownloadSelection, onTrashSelection, onClearSelection,
onOpenFolder, onOpenFile, sort, onSort, viewMode, onViewMode, onOpenFolder, onOpenFile, sort, onSort, viewMode, onViewMode,
onNewFolder, onUpload, onNewFolder, onNewDrawing, onUpload,
}: Props) { }: Props) {
return ( return (
<> <>
@@ -103,6 +104,16 @@ export default function FileToolbar({
> >
+ Folder + Folder
</button> </button>
<button
onClick={onNewDrawing}
title="New drawing or mind map"
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}
>
+ Drawing
</button>
<button <button
onClick={onUpload} onClick={onUpload}
title="Upload files (drag & drop for folders)" title="Upload files (drag & drop for folders)"
+9 -1
View File
@@ -6,7 +6,7 @@
*/ */
export type FileKind = export type FileKind =
| 'image' | 'video' | 'audio' | 'pdf' | 'archive' | 'image' | 'video' | 'audio' | 'pdf' | 'archive'
| 'sheet' | 'slides' | 'doc' | 'code' | 'text' | 'other' | 'sheet' | 'slides' | 'doc' | 'code' | 'text' | 'drawing' | 'other'
/** Endungen, die der MIME-Typ oft nicht verrät. Ein `.tar.gz` kommt je nach /** Endungen, die der MIME-Typ oft nicht verrät. Ein `.tar.gz` kommt je nach
* Betreuer als application/gzip, application/x-tar oder octet-stream an. */ * Betreuer als application/gzip, application/x-tar oder octet-stream an. */
@@ -21,6 +21,9 @@ const slidesExt = ['ppt', 'pptx', 'odp', 'key']
* lesbarer Text, kein Office-Format, und lassen sich hier sogar bearbeiten. */ * lesbarer Text, kein Office-Format, und lassen sich hier sogar bearbeiten. */
const docExt = ['doc', 'docx', 'odt', 'rtf', 'pages'] const docExt = ['doc', 'docx', 'odt', 'rtf', 'pages']
const textExt = ['md', 'txt', 'log', 'rst'] const textExt = ['md', 'txt', 'log', 'rst']
/** Zeichnungen und Mindmaps. JSON im Inneren – aber kein Code und kein Text:
* Wer eine `.excalidraw` in einem Textfeld öffnet, sieht Zahlenkolonnen. */
const drawingExt = ['excalidraw']
export function extensionOf(name: string): string { export function extensionOf(name: string): string {
const dot = name.lastIndexOf('.') const dot = name.lastIndexOf('.')
@@ -48,6 +51,9 @@ export function fileKind(mime: string, name = ''): FileKind {
m.includes('spreadsheet') || m.includes('excel') || m.includes('csv')) return 'sheet' m.includes('spreadsheet') || m.includes('excel') || m.includes('csv')) return 'sheet'
if (slidesExt.includes(ext) || if (slidesExt.includes(ext) ||
m.includes('presentation') || m.includes('powerpoint')) return 'slides' m.includes('presentation') || m.includes('powerpoint')) return 'slides'
// **Vor** code und text: `.excalidraw` ist JSON, und `json` steht in
// codeExt. Stünde die Prüfung später, öffnete eine Zeichnung im Texteditor.
if (drawingExt.includes(ext)) return 'drawing'
if (textExt.includes(ext)) return 'text' if (textExt.includes(ext)) return 'text'
if (codeExt.includes(ext)) return 'code' if (codeExt.includes(ext)) return 'code'
if (docExt.includes(ext) || if (docExt.includes(ext) ||
@@ -72,6 +78,8 @@ export const kindColors: Record<FileKind, string> = {
doc: '#3b82f6', doc: '#3b82f6',
code: '#14b8a6', code: '#14b8a6',
text: '#94a3b8', text: '#94a3b8',
// Türkis – deutlich weg vom Grün der Tabellen und vom Blau der Dokumente.
drawing: '#14b8a6',
other: '#6366f1', other: '#6366f1',
} }
+8
View File
@@ -190,6 +190,14 @@ export function FileTypeIcon({ mime, name = '', size = 20 }: { mime: string; nam
<polyline points="16 18 22 12 16 6" /> <polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" /> <polyline points="8 6 2 12 8 18" />
</>) </>)
// Stift auf Fläche – eine Zeichnung ist kein Blatt Text.
case 'drawing':
return svg(<>
<path d="M12 19l7-7 3 3-7 7-3-3z" />
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z" />
<path d="M2 2l7.586 7.586" />
<circle cx="11" cy="11" r="2" />
</>)
case 'doc': case 'doc':
case 'text': case 'text':
return svg(<> return svg(<>
+42 -1
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import React, { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { import {
filesApi, foldersApi, versionsApi, trashApi, downloadZip, tagsChangedEvent, filesApi, foldersApi, versionsApi, trashApi, downloadZip, tagsChangedEvent,
isOfficeFile, isOfficeFile,
@@ -11,7 +11,11 @@ import FilePreview, { canPreview, kindOf } from '../components/FilePreview'
import ContextMenu, { type MenuItem } from '../components/ContextMenu' import ContextMenu, { type MenuItem } from '../components/ContextMenu'
import UploadQueue from '../components/UploadQueue' import UploadQueue from '../components/UploadQueue'
import OfficeEditor from '../components/OfficeEditor' import OfficeEditor from '../components/OfficeEditor'
// Nachgeladen wie der PDF-Betrachter: Excalidraw ist ein dicker Brocken, und
// wer nie zeichnet, soll ihn nie holen.
const DrawingEditor = lazy(() => import('../components/DrawingEditor'))
import { toast, fehlertext } from '../components/Toasts' import { toast, fehlertext } from '../components/Toasts'
import { fileKind } from '../components/filetypes'
import { SkeletonGrid, SkeletonRows, useSkeleton } from '../components/Skeleton' import { SkeletonGrid, SkeletonRows, useSkeleton } from '../components/Skeleton'
import EmptyState from '../components/EmptyState' import EmptyState from '../components/EmptyState'
import FileToolbar, { type Sort, type ViewMode } from '../components/files/FileToolbar' import FileToolbar, { type Sort, type ViewMode } from '../components/files/FileToolbar'
@@ -103,6 +107,8 @@ export default function FileBrowser({
const [preview, setPreview] = useState<FileItem | null>(null) const [preview, setPreview] = useState<FileItem | null>(null)
/** Datei, die gerade im Office-Server bearbeitet wird. */ /** Datei, die gerade im Office-Server bearbeitet wird. */
const [editing, setEditing] = useState<FileItem | null>(null) const [editing, setEditing] = useState<FileItem | null>(null)
/** Zeichnung, die gerade offen ist. */
const [drawing, setDrawing] = useState<FileItem | null>(null)
const { officeEnabled } = useAppSettings() const { officeEnabled } = useAppSettings()
/** Etiketten je Datei-ID – kommen mit der Ordnerliste in einer Abfrage mit, /** Etiketten je Datei-ID – kommen mit der Ordnerliste in einer Abfrage mit,
@@ -360,6 +366,11 @@ export default function FileBrowser({
setEditing(file) setEditing(file)
return return
} }
// Eine Zeichnung geht in ihr Zeichenbrett – die Vorschau zeigte JSON.
if (fileKind(file.mime_type, file.name) === 'drawing') {
setDrawing(file)
return
}
if (canPreview(file)) setPreview(file) if (canPreview(file)) setPreview(file)
else downloadFile(file) else downloadFile(file)
} }
@@ -468,6 +479,25 @@ export default function FileBrowser({
} }
} }
/**
* Eine neue Zeichnung ist eine neue *Datei* – angelegt über denselben
* Upload wie alles andere. Deshalb gelten Kontingent, Rechte und
* Namensvergabe ohne eine Zeile Sonderbehandlung.
*/
async function handleNewDrawing() {
const { leereZeichnung } = await import('../components/DrawingEditor')
const name = `Drawing ${new Date().toISOString().slice(0, 10)}.excalidraw`
try {
const blob = new File([leereZeichnung()], name, { type: 'application/json' })
const neu = await filesApi.upload(blob, currentFolderId)
setFiles(f => [...f, neu])
setDrawing(neu)
await onUserChanged?.()
} catch (err) {
toast.error(fehlertext(err, 'Could not create the drawing'))
}
}
async function handleCreateFolder(name: string) { async function handleCreateFolder(name: string) {
setDialog(null) setDialog(null)
try { try {
@@ -660,6 +690,7 @@ export default function FileBrowser({
viewMode={viewMode} viewMode={viewMode}
onViewMode={setViewMode} onViewMode={setViewMode}
onNewFolder={() => setDialog({ type: 'new-folder' })} onNewFolder={() => setDialog({ type: 'new-folder' })}
onNewDrawing={handleNewDrawing}
onUpload={() => fileInputRef.current?.click()} onUpload={() => fileInputRef.current?.click()}
/> />
</PageHeader> </PageHeader>
@@ -683,6 +714,16 @@ export default function FileBrowser({
)} )}
</div> </div>
{drawing && (
<Suspense fallback={null}>
<DrawingEditor
file={drawing}
onClose={() => setDrawing(null)}
onSaved={neu => setFiles(f => f.map(x => x.id === neu.id ? neu : x))}
/>
</Suspense>
)}
{editing && ( {editing && (
<OfficeEditor <OfficeEditor
file={editing} file={editing}
+34
View File
@@ -17,6 +17,39 @@ import { VitePWA } from 'vite-plugin-pwa'
* Quelltext, und die Fassung soll der Abhaengigkeit folgen. public/pdfjs steht * Quelltext, und die Fassung soll der Abhaengigkeit folgen. public/pdfjs steht
* deshalb in .gitignore. * deshalb in .gitignore.
*/ */
/**
* Excalidraws Schriften selbst ausliefern.
*
* Ohne das laedt die Komponente sie zur Laufzeit von einem fremden Ursprung –
* und die Sicherheitsrichtlinie (`default-src 'self'`, kein `font-src`)
* verbietet genau das. Die Zeichnung erschiene dann in einer Ersatzschrift,
* und man sucht den Grund im Zeichenbrett statt in der Richtlinie.
*
* Gesteuert wird es ueber `window.EXCALIDRAW_ASSET_PATH` (siehe index.html).
*
* **Xiaolai bleibt draussen.** Die chinesische Schrift ist 13 der 14 MB; alles
* andere zusammen sind 512 KB. Wer Chinesisch in eine Zeichnung tippt, bekommt
* eine Ersatzschrift – das ist der Preis dafuer, das Abbild nicht um 13 MB
* aufzublaehen. Wird sie gebraucht, hier die Zeile streichen.
*/
function excalidrawAssets(): Plugin {
return {
name: 'aether-excalidraw-assets',
config() {
// Direkter Pfad statt require.resolve: Das Paket gibt seine
// package.json in `exports` nicht frei, die Aufloesung scheitert daran.
const wurzel = join(import.meta.dirname, 'node_modules',
'@excalidraw', 'excalidraw', 'dist', 'prod')
const ziel = join(import.meta.dirname, 'public', 'excalidraw')
mkdirSync(ziel, { recursive: true })
cpSync(join(wurzel, 'fonts'), join(ziel, 'fonts'), {
recursive: true,
filter: (quelle) => !quelle.includes('Xiaolai'),
})
},
}
}
function pdfjsStandardFonts(): Plugin { function pdfjsStandardFonts(): Plugin {
return { return {
name: 'aether-pdfjs-standard-fonts', name: 'aether-pdfjs-standard-fonts',
@@ -35,6 +68,7 @@ function pdfjsStandardFonts(): Plugin {
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
pdfjsStandardFonts(), pdfjsStandardFonts(),
excalidrawAssets(),
react(), react(),
tailwindcss(), tailwindcss(),
VitePWA({ VitePWA({
+4 -1
View File
@@ -19,7 +19,10 @@ DB=aether-dbtest
NETZ=aether-dbtest-netz NETZ=aether-dbtest-netz
aufraeumen() { aufraeumen() {
docker rm -f "$DB" >/dev/null 2>&1 || true # -v nimmt das anonyme Volume mit. Ohne das blieb je Lauf eines liegen
# (rund 49 MB); nach zehn Laeufen waren es ein halbes Gigabyte, das
# niemandem gehoerte und in keiner Uebersicht auftauchte.
docker rm -f -v "$DB" >/dev/null 2>&1 || true
docker network rm "$NETZ" >/dev/null 2>&1 || true docker network rm "$NETZ" >/dev/null 2>&1 || true
} }
trap aufraeumen EXIT trap aufraeumen EXIT
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Selbsttest der Zeichnungen (Excalidraw).
python3 scripts/zeichnung-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
Eine Zeichnung ist bei uns **eine Datei** – das ist die ganze Entscheidung
hinter der Umsetzung. Dieser Test prueft genau das nach: Wenn sie wirklich
eine Datei ist, muss sie alles koennen, was Dateien koennen – Versionen,
Freigabelinks, Gruppenordner, Papierkorb. Und das ist nicht selbstverstaendlich,
sondern der Gewinn, den man sonst nur behauptet.
Dazu die eine Stelle, die ohne gleichzeitiges Arbeiten wehtut: Zwei Leute
speichern nacheinander, und der Zweite darf den Ersten nicht spurlos
ueberschreiben.
"""
import hashlib
import json
import sys
import urllib.error
import urllib.request
import uuid
import http.cookiejar
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090").rstrip("/")
USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
ok_count, fail = 0, []
def check(name, cond, detail=""):
global ok_count
if cond:
ok_count += 1
print(f" \033[32mok\033[0m {name}")
else:
fail.append(name)
print(f" \033[31mFEHL\033[0m {name} {detail}")
def sitzung():
return urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
def ruf(opener, method, path, body=None, headers=None, roh=False):
hdrs = dict(headers or {})
data = None
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method)
try:
with (opener or urllib.request.build_opener()).open(req) as r:
payload = r.read()
if roh:
return r.status, payload
text = payload.decode()
return r.status, (json.loads(text).get("data") if text else None)
except urllib.error.HTTPError as e:
return e.code, None
def anmelden(user, passwort):
o = sitzung()
st, d = ruf(o, "POST", "/api/v1/auth/login", {"username": user, "password": passwort})
assert st == 200, f"Anmeldung {user} fehlgeschlagen: {st}"
return o, {"X-CSRF-Token": d["csrf_token"]}
def hochladen(opener, csrf, name, inhalt: bytes, folder_id=None):
grenze = "----aether" + uuid.uuid4().hex
teile = []
if folder_id:
teile.append(f'--{grenze}\r\nContent-Disposition: form-data; name="folder_id"\r\n\r\n{folder_id}\r\n')
kopf = (f'--{grenze}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
f'Content-Type: application/json\r\n\r\n')
leib = b"".join(t.encode() for t in teile) + kopf.encode() + inhalt + f"\r\n--{grenze}--\r\n".encode()
req = urllib.request.Request(BASE + "/api/v1/files", data=leib, method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={grenze}", **csrf})
with opener.open(req) as r:
return json.loads(r.read().decode())["data"]
def zeichnung(elemente=0):
return json.dumps({
"type": "excalidraw", "version": 2, "source": "aether",
"elements": [{"id": f"e{i}", "type": "rectangle", "x": i * 10, "y": 0,
"width": 80, "height": 40} for i in range(elemente)],
"appState": {}, "files": {},
}).encode()
print(f"Zeichnungs-Selbsttest gegen {BASE}\n")
admin, CSRF = anmelden(USER, PASSWORT)
_, ich = ruf(admin, "GET", "/api/v1/auth/me")
CSRF = {"X-CSRF-Token": ich["csrf_token"]}
name = f"selbsttest-{uuid.uuid4().hex[:6]}.excalidraw"
angelegt, links, gruppen = [], [], []
def aufraeumen():
for lid in links:
ruf(admin, "DELETE", f"/api/v1/shares/{lid}", None, CSRF)
for fid in angelegt:
ruf(admin, "DELETE", f"/api/v1/files/{fid}", None, CSRF)
_, muell = ruf(admin, "GET", "/api/v1/trash")
for f in muell or []:
if f.get("name", "").startswith("selbsttest-"):
ruf(admin, "DELETE", f"/api/v1/trash/{f['id']}", None, CSRF)
for gid in gruppen:
ruf(admin, "DELETE", f"/api/v1/admin/groups/{gid}", None, CSRF)
try:
print("── Als Datei anlegen ──")
inhalt = zeichnung(3)
datei = hochladen(admin, CSRF, name, inhalt)
angelegt.append(datei["id"])
check("angelegt", datei["size"] == len(inhalt), datei["size"])
check("Pruefsumme stimmt", datei["checksum"] == hashlib.sha256(inhalt).hexdigest())
check("als JSON erkannt", datei["mime_type"] == "application/json", datei["mime_type"])
st, roh = ruf(admin, "GET", f"/api/v1/files/{datei['id']}/download?inline=1", None, None, True)
check("Inhalt kommt unveraendert zurueck", st == 200 and roh == inhalt, st)
print("\n── Aendern erzeugt eine Version ──")
# Speichern heisst: gleicher Name, gleicher Ordner, neuer Upload.
zweite = hochladen(admin, CSRF, name, zeichnung(7))
check("gleiche Datei, neue Fassung", zweite["id"] == datei["id"] and zweite["version"] > datei["version"],
f"{datei['version']} -> {zweite['version']}")
st, versionen = ruf(admin, "GET", f"/api/v1/files/{datei['id']}/versions")
check("die alte Fassung liegt in der Historie", st == 200 and len(versionen or []) >= 1,
len(versionen or []))
print("\n── Ueberschreiben faellt auf ──")
# Ohne gleichzeitiges Arbeiten ist die Fassungsnummer der einzige Hinweis
# darauf, dass jemand anders gespeichert hat. Sie MUSS sich aendern.
dritte = hochladen(admin, CSRF, name, zeichnung(9))
check("jede Speicherung hebt die Fassung", dritte["version"] > zweite["version"],
f"{zweite['version']} -> {dritte['version']}")
_, liste = ruf(admin, "GET", "/api/v1/files")
inliste = next((f for f in (liste or {}).get("files", []) if f["id"] == datei["id"]), None)
check("und die Liste zeigt sie – darauf beruht der Abgleich im Editor",
inliste is not None and inliste["version"] == dritte["version"],
inliste and inliste["version"])
print("\n── Erbt alles, was Dateien koennen ──")
st, link = ruf(admin, "POST", f"/api/v1/files/{datei['id']}/share", {}, CSRF)
check("Freigabelink darauf (201)", st == 201, st)
if st == 201:
links.append(link["id"])
st, geholt = ruf(None, "POST", f"/share/{link['token']}/download", None, None, True)
check("oeffentlich abrufbar, Inhalt stimmt", st == 200 and geholt == zeichnung(9), st)
gname = "selbsttest-zeichnung-" + uuid.uuid4().hex[:6]
st, gruppe = ruf(admin, "POST", "/api/v1/admin/groups", {"name": gname, "description": ""}, CSRF)
if st in (200, 201):
gruppen.append(gruppe["id"])
ruf(admin, "POST", f"/api/v1/admin/groups/{gruppe['id']}/members",
{"user_id": ich["user"]["id"]}, CSRF)
_, wurzeln = ruf(admin, "GET", "/api/v1/files")
gordner = next((f for f in (wurzeln or {}).get("folders", []) if f.get("group_id")), None)
if gordner:
gz = hochladen(admin, CSRF, "selbsttest-gruppe.excalidraw", zeichnung(2), gordner["id"])
angelegt.append(gz["id"])
check("Zeichnung im Gruppenordner", gz["folder_id"] == gordner["id"])
print("\n── Schriften werden selbst ausgeliefert ──")
# Ohne das laedt Excalidraw sie von aussen – und die Sicherheitsrichtlinie
# verbietet genau das. Man saehe eine Ersatzschrift und suchte den Grund
# im Zeichenbrett statt in der Richtlinie.
st, _ = ruf(None, "GET", "/excalidraw/fonts/Excalifont/"
"Excalifont-Regular-349fac6ca4700ffec595a7150a0d1e1d.woff2", None, None, True)
check("Excalifont liegt auf dem eigenen Server", st == 200, st)
finally:
aufraeumen()
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
if fail:
print("Fehlgeschlagen: " + ", ".join(fail))
sys.exit(1 if fail else 0)