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:
1 parent
662c0ebdc1
commit
6fc157bc07
14 files changed
+3699
-18
No files matched your search
@@ -29,3 +29,6 @@ Thumbs.db
|
||||
|
||||
# Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften)
|
||||
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
@@ -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)
|
||||
|
||||
`internal/rescan` ist das einzige Stueck Aether, das **hart** loescht:
|
||||
|
||||
@@ -765,6 +765,9 @@ 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{
|
||||
// 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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+3049
-11
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.18.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"react": "^19.2.7",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -35,13 +35,14 @@ interface Props {
|
||||
viewMode: ViewMode
|
||||
onViewMode: (v: ViewMode) => void
|
||||
onNewFolder: () => void
|
||||
onNewDrawing: () => void
|
||||
onUpload: () => void
|
||||
}
|
||||
|
||||
export default function FileToolbar({
|
||||
selectedCount, onDownloadSelection, onTrashSelection, onClearSelection,
|
||||
onOpenFolder, onOpenFile, sort, onSort, viewMode, onViewMode,
|
||||
onNewFolder, onUpload,
|
||||
onNewFolder, onNewDrawing, onUpload,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
@@ -103,6 +104,16 @@ export default function FileToolbar({
|
||||
>
|
||||
+ Folder
|
||||
</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
|
||||
onClick={onUpload}
|
||||
title="Upload files (drag & drop for folders)"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
export type FileKind =
|
||||
| '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
|
||||
* 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. */
|
||||
const docExt = ['doc', 'docx', 'odt', 'rtf', 'pages']
|
||||
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 {
|
||||
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'
|
||||
if (slidesExt.includes(ext) ||
|
||||
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 (codeExt.includes(ext)) return 'code'
|
||||
if (docExt.includes(ext) ||
|
||||
@@ -72,6 +78,8 @@ export const kindColors: Record<FileKind, string> = {
|
||||
doc: '#3b82f6',
|
||||
code: '#14b8a6',
|
||||
text: '#94a3b8',
|
||||
// Türkis – deutlich weg vom Grün der Tabellen und vom Blau der Dokumente.
|
||||
drawing: '#14b8a6',
|
||||
other: '#6366f1',
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,14 @@ export function FileTypeIcon({ mime, name = '', size = 20 }: { mime: string; nam
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<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 'text':
|
||||
return svg(<>
|
||||
|
||||
@@ -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 {
|
||||
filesApi, foldersApi, versionsApi, trashApi, downloadZip, tagsChangedEvent,
|
||||
isOfficeFile,
|
||||
@@ -11,7 +11,11 @@ import FilePreview, { canPreview, kindOf } from '../components/FilePreview'
|
||||
import ContextMenu, { type MenuItem } from '../components/ContextMenu'
|
||||
import UploadQueue from '../components/UploadQueue'
|
||||
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 { fileKind } from '../components/filetypes'
|
||||
import { SkeletonGrid, SkeletonRows, useSkeleton } from '../components/Skeleton'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
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)
|
||||
/** Datei, die gerade im Office-Server bearbeitet wird. */
|
||||
const [editing, setEditing] = useState<FileItem | null>(null)
|
||||
/** Zeichnung, die gerade offen ist. */
|
||||
const [drawing, setDrawing] = useState<FileItem | null>(null)
|
||||
const { officeEnabled } = useAppSettings()
|
||||
|
||||
/** Etiketten je Datei-ID – kommen mit der Ordnerliste in einer Abfrage mit,
|
||||
@@ -360,6 +366,11 @@ export default function FileBrowser({
|
||||
setEditing(file)
|
||||
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)
|
||||
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) {
|
||||
setDialog(null)
|
||||
try {
|
||||
@@ -660,6 +690,7 @@ export default function FileBrowser({
|
||||
viewMode={viewMode}
|
||||
onViewMode={setViewMode}
|
||||
onNewFolder={() => setDialog({ type: 'new-folder' })}
|
||||
onNewDrawing={handleNewDrawing}
|
||||
onUpload={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
</PageHeader>
|
||||
@@ -683,6 +714,16 @@ export default function FileBrowser({
|
||||
)}
|
||||
</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 && (
|
||||
<OfficeEditor
|
||||
file={editing}
|
||||
|
||||
@@ -17,6 +17,39 @@ import { VitePWA } from 'vite-plugin-pwa'
|
||||
* Quelltext, und die Fassung soll der Abhaengigkeit folgen. public/pdfjs steht
|
||||
* 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 {
|
||||
return {
|
||||
name: 'aether-pdfjs-standard-fonts',
|
||||
@@ -35,6 +68,7 @@ function pdfjsStandardFonts(): Plugin {
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
pdfjsStandardFonts(),
|
||||
excalidrawAssets(),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
|
||||
+4
-1
@@ -19,7 +19,10 @@ DB=aether-dbtest
|
||||
NETZ=aether-dbtest-netz
|
||||
|
||||
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
|
||||
}
|
||||
trap aufraeumen EXIT
|
||||
|
||||
Executable
+184
@@ -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)
|
||||
Reference in new issue
Block a user