Rotatory anlegen: Morning-Meeting-Moderation auslosen und nach Nextcloud schreiben
Express-App mit JSON-Speicher unter VCH_DATA_DIR, Monatskalender mit Fotos, Personen mit Rollen und Teilzeit-Freitagen, Abwesenheiten, Feiertage je Bundesland, faire Zufallsauslosung mit Fixieren und Neu-Auslosen. Nextcloud: CalDAV-Discovery, Zielkalender wählbar, Termine je Werktag schreiben und aktualisieren, Abwesenheiten aus Kalender importieren, Personen samt Fotos aus dem Systemadressbuch übernehmen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
95756778d8
26 files changed
+4697
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
# Nextcloud-Anbindung (CalDAV/CardDAV). Ohne diese Werte läuft die App, nur Export und Import sind deaktiviert.
|
||||||
|
NEXTCLOUD_URL= # optional z. B. https://cloud.example.de
|
||||||
|
NEXTCLOUD_USER= # optional Anmeldename (E-Mail-Adresse geht auch); die Benutzer-ID ermittelt die App selbst
|
||||||
|
NEXTCLOUD_APP_PASSWORD= # optional App-Passwort aus Nextcloud → Einstellungen → Sicherheit → Geräte & Sitzungen
|
||||||
|
# ID des eigenen Kalenders, falls kein vorhandener Zielkalender gewählt wird
|
||||||
|
NEXTCLOUD_CALENDAR=morning-meeting
|
||||||
|
# Nur setzen, wenn die Nextcloud ein selbstsigniertes Zertifikat hat
|
||||||
|
NEXTCLOUD_INSECURE_TLS=false
|
||||||
|
# Optionaler Zugriffsschutz der App (HTTP Basic Auth). Leer = kein Passwort.
|
||||||
|
APP_PASSWORD=""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Rotatory lost fair und zufällig aus, wer das tägliche Morning Meeting moderiert: pro Wochentag ein Hauptmoderator und ein Nebenmoderator aus der Belegschaft. Urlaub, Krankheit, Feiertage, Teilzeit-Freitage und Personen, die nicht moderieren, werden automatisch berücksichtigt. Die Monatsübersicht zeigt auf jedem Tag die Fotos der beiden Moderatoren, sodass jeder auf einen Blick sieht, wer dran ist. Einzelne Tage lassen sich manuell festlegen oder neu auslosen. Die Belegschaft samt Fotos und die Abwesenheiten kommen auf Wunsch direkt aus der Nextcloud, und auf Knopfdruck landet der Plan als Termine im geteilten Nextcloud-Kalender des Teams.
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
# VCH-Plattform — die Regeln dieser Umgebung
|
||||||
|
|
||||||
|
Diese Datei gehört VCH und wird bei jedem Abgleich **überschrieben**. Projektwissen gehört
|
||||||
|
in die `CLAUDE.md` daneben, nicht hierher.
|
||||||
|
|
||||||
|
Sie beschreibt, was VCH mit diesem Projekt macht — vor allem die Stellen, an denen ein
|
||||||
|
Verstoß **nicht als Fehler auffällt, sondern als falsches Ergebnis**: ein Deploy, der grün
|
||||||
|
meldet und nichts ausliefert, eine Datenbank, die niemand verbindet, ein Audit, das etwas
|
||||||
|
prüft, das du gar nicht gepusht hast.
|
||||||
|
|
||||||
|
## 1. Zwei Maschinen, nicht eine
|
||||||
|
|
||||||
|
- **Hier (DEV):** ein Debian-LXC. Nutzer `vchuser`, Heimat `/home/vchuser`, Projekte unter
|
||||||
|
`/home/vchuser/projects/<name>`. code-server auf Port 8080, Claude Code als `claude`,
|
||||||
|
Git gegen Gitea vorkonfiguriert.
|
||||||
|
- **Dort (PROD):** eine **andere** Maschine. VCH klont dein Repository dorthin und startet es.
|
||||||
|
|
||||||
|
Daraus folgt die wichtigste Regel überhaupt: **VCH deployt und auditiert deinen gepushten
|
||||||
|
Commit, nie dein Arbeitsverzeichnis.** Nicht committete Änderungen existieren für Deploy,
|
||||||
|
Audit und Veröffentlichung nicht — auch dann nicht, wenn hier alles läuft.
|
||||||
|
|
||||||
|
## 2. Vorschau im Browser läuft über einen Proxy
|
||||||
|
|
||||||
|
Deine Entwicklungs-Server sind nicht direkt erreichbar. VCH erkennt lauschende Ports und
|
||||||
|
stellt zwei Wege bereit:
|
||||||
|
|
||||||
|
1. **Subdomain** (bevorzugt): `https://lxc<VMID>-<PORT>.<domain>/` — deine App liegt auf der
|
||||||
|
Wurzel, alles verhält sich normal.
|
||||||
|
2. **Pfad-Proxy** (Rückfallweg): `https://<host>/proxy/<instanz>/p/<port>/` — deine App liegt
|
||||||
|
hinter einem Pfadpräfix.
|
||||||
|
|
||||||
|
Damit beides funktioniert: **relative Pfade verwenden.**
|
||||||
|
|
||||||
|
- `css/style.css` ✅ — nicht `/css/style.css` ❌
|
||||||
|
- `fetch('api/daten')` ✅ — nicht `fetch('/api/daten')` ❌
|
||||||
|
- Wenn absolute Pfade unvermeidbar sind: `<base href="./">` in den `<head>`.
|
||||||
|
|
||||||
|
Rahmenwerke: **Vite** `base: './'` · **Create React App** `"homepage": "."` in der
|
||||||
|
`package.json` · **Next.js** Vorgabe belassen (Subdomain) oder `basePath` setzen (Pfad-Proxy)
|
||||||
|
· **Express** `express.static` genügt, solange das HTML relativ verweist.
|
||||||
|
|
||||||
|
## 3. Dateien, die VCH im Projektstamm liest
|
||||||
|
|
||||||
|
| Datei | Pflicht | Wozu |
|
||||||
|
|---|---|---|
|
||||||
|
| `README.md` | ja | Audit-Prüfung `readme` — blockiert die Veröffentlichung |
|
||||||
|
| `.vch-description` | ja | Audit-Prüfung `description`; der Text steht später im Showcase |
|
||||||
|
| `.env.example` | wenn die App Umgebungsvariablen liest | die **Deklaration** aller Variablen (Abschnitt 6) |
|
||||||
|
| `.vch/deploy.yaml` | wenn die Autoerkennung nicht reicht | wie gebaut und gestartet wird (Abschnitt 5) |
|
||||||
|
| `CLAUDE.md` | — | dein Projektwissen; VCH fasst sie nicht mehr an |
|
||||||
|
|
||||||
|
`README.md` muss beschreiben, **was** das Projekt tut, wie man es installiert, wie man es
|
||||||
|
startet und worauf es aufbaut. Die unveränderte Vorlage besteht die Prüfung nicht.
|
||||||
|
|
||||||
|
`.vch-description` ist **nicht technisch**: was das Projekt tut, welches Problem es löst,
|
||||||
|
für wen. Kein Technologie-Stapel, keine Installationsanleitung — die stehen im README.
|
||||||
|
Halte beide aktuell, wenn sich Zweck oder Aufbau ändern.
|
||||||
|
|
||||||
|
## 4. Der Port gehört VCH
|
||||||
|
|
||||||
|
VCH weist jedem Projekt auf der PROD-Maschine einen festen Port zu (Bereich `13000–13999`)
|
||||||
|
und hält ihn über alle Deploys hinweg stabil. Du wählst ihn nicht und stimmst dich mit
|
||||||
|
niemandem ab.
|
||||||
|
|
||||||
|
**Deine App MUSS auf `PORT` aus der Umgebung lauschen:**
|
||||||
|
|
||||||
|
- Node/Express: `app.listen(process.env.PORT || 3000)`
|
||||||
|
- Next.js: `next start` beachtet `PORT` von selbst
|
||||||
|
- Python: `gunicorn app:app -b 0.0.0.0:$PORT` bzw. `uvicorn main:app --port $PORT`
|
||||||
|
- Go/andere: den Wert aus der Umgebung lesen, nicht hartkodieren
|
||||||
|
|
||||||
|
Ein fest verdrahteter Port läuft hier und scheitert dort am Healthcheck. `PORT` und
|
||||||
|
`VCH_DATA_DIR` sind **VCH-eigene** Schlüssel: setzt du sie selbst — in `.env.example`, im
|
||||||
|
Env-Editor oder im Compose — wird dein Wert verworfen.
|
||||||
|
|
||||||
|
## 5. `.vch/deploy.yaml` — die vollständige Referenz
|
||||||
|
|
||||||
|
Ohne diese Datei versucht VCH, das Projekt selbst zu erkennen (Abschnitt 5.3). Reicht das
|
||||||
|
nicht, bricht der Deploy ab **und legt dir eine fertige Vorlage vor**, die du nur noch
|
||||||
|
committen musst.
|
||||||
|
|
||||||
|
### 5.1 Alle Felder
|
||||||
|
|
||||||
|
| Feld | Typ | Vorgabe | Bedeutung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `runtime` | `auto` \| `systemd` \| `docker` | `auto` | Wie gestartet wird. `auto`: Dockerfile oder Compose vorhanden → `docker`, sonst `systemd`. |
|
||||||
|
| `build` | Zeichenkette | — | Shell-Befehl im Release-Verzeichnis. **Nur `systemd`** — bei `docker` baut das Dockerfile. |
|
||||||
|
| `start` | Zeichenkette | — | Startbefehl des Dienstes. **Nur `systemd`.** Muss `$PORT` binden. |
|
||||||
|
| `port` | Ganzzahl 1–65535 | `3000` | **Nur `docker`:** der Port, auf dem deine App **im Container** lauscht. VCH bildet den zugewiesenen Host-Port darauf ab. |
|
||||||
|
| `health` | Pfad | `/` | Pfad, der 2xx oder 3xx liefert, sobald die App bereit ist. |
|
||||||
|
| `env` | Liste von Zeichenketten | `[]` | Zusätzliche Variablennamen, die VCH ausliefern soll. Wird mit den Schlüsseln aus `.env.example` vereinigt. |
|
||||||
|
| `compose` | Pfad | — | Welche Compose-Datei gilt, wenn mehrere gefunden werden. |
|
||||||
|
|
||||||
|
Unbekannte Felder und Tippfehler in `runtime` brechen den Deploy mit einer benannten
|
||||||
|
Fehlermeldung ab — sie werden nicht still ignoriert.
|
||||||
|
|
||||||
|
### 5.2 Beispiele
|
||||||
|
|
||||||
|
Node mit eigenem Build (systemd):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runtime: systemd
|
||||||
|
build: "npm ci && npm run build"
|
||||||
|
start: "npm start"
|
||||||
|
health: /
|
||||||
|
```
|
||||||
|
|
||||||
|
Python (systemd) — beachte `$PORT` im Startbefehl:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runtime: systemd
|
||||||
|
build: "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt"
|
||||||
|
start: ".venv/bin/gunicorn app:app -b 0.0.0.0:$PORT"
|
||||||
|
health: /
|
||||||
|
```
|
||||||
|
|
||||||
|
Statische Seite (systemd) — irgendetwas muss lauschen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runtime: systemd
|
||||||
|
build: "npm ci && npm run build"
|
||||||
|
start: "python3 -m http.server $PORT --directory dist"
|
||||||
|
```
|
||||||
|
|
||||||
|
Monorepo, Code in einem Unterordner:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runtime: systemd
|
||||||
|
build: "cd backend && npm ci && npm run build"
|
||||||
|
start: "cd backend && npm start"
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockerfile:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runtime: docker
|
||||||
|
port: 3000 # der Port INNERHALB des Containers
|
||||||
|
health: /
|
||||||
|
```
|
||||||
|
|
||||||
|
Mehrere Compose-Dateien im Repository:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runtime: docker
|
||||||
|
compose: docker/compose.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Was ohne die Datei passiert
|
||||||
|
|
||||||
|
In dieser Reihenfolge:
|
||||||
|
|
||||||
|
1. Ein echtes `Dockerfile` gewinnt → `runtime: docker` mit dem Image.
|
||||||
|
2. Eine Compose-Datei, die `${VCH_PORT}` veröffentlicht → Compose-Deploy.
|
||||||
|
3. Eine Compose-Datei, die **nur Infrastruktur** startet (Postgres, Redis, … und keinen
|
||||||
|
App-Dienst), wird als Entwicklungs-Beiwerk erkannt und **nicht** deployt — VCH sucht
|
||||||
|
weiter nach der Sprache.
|
||||||
|
4. Eine Compose-Datei mit App-Dienst, die `${VCH_PORT}` **nicht** veröffentlicht → Abbruch
|
||||||
|
mit Vorlage (Abschnitt 10).
|
||||||
|
5. `package.json` im Stamm: `build`-Skript → Build, `start`-Skript bzw. `main` → Start.
|
||||||
|
Paketmanager wird am Lockfile erkannt (`pnpm`/`yarn`/`npm`).
|
||||||
|
6. Alles andere — Python, PHP/Laravel, Go, reines Frontend ohne `start`, Code nur in
|
||||||
|
Unterordnern — endet mit einer passenden Vorlage zum Committen.
|
||||||
|
|
||||||
|
## 6. Umgebungsvariablen: `.env.example` ist die Deklaration
|
||||||
|
|
||||||
|
VCH liest keine Umgebungsvariablen aus deinem Code. **Was in einer Beispieldatei steht,
|
||||||
|
existiert; alles andere nicht.**
|
||||||
|
|
||||||
|
- Erkannte Dateinamen: `.env.example`, `.env.sample`, `.env.template` sowie dieselben ohne
|
||||||
|
führenden Punkt (`env.example`, …). Der erste Fund je Schlüssel gewinnt.
|
||||||
|
- Gesucht wird **im ganzen Repository** (ohne `node_modules`, `.git`, `dist`, `build`,
|
||||||
|
`.next`, `out`, `vendor`, `coverage`, `.turbo`), Stammdateien zuerst, höchstens 25 Dateien.
|
||||||
|
- Jede gefundene Beispieldatei bestimmt ein Ziel: `backend/.env.example` → VCH schreibt die
|
||||||
|
Produktionswerte nach `backend/.env`. Für Monorepos ist das der Weg, mehrere `.env` zu
|
||||||
|
bekommen.
|
||||||
|
|
||||||
|
**Was den Deploy blockiert:** ein Schlüssel, der **keinen Wert nennt** (`KEY=`) und für den
|
||||||
|
noch kein Produktionswert hinterlegt ist. Nicht blockierend sind:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
API_URL=https://beispiel.test # Vorgabewert vorhanden
|
||||||
|
OPTIONAL_TOKEN="" # ausdrücklich leer erlaubt
|
||||||
|
FEATURE_FLAG= # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Marker muss direkt hinter dem `#` stehen (`# optional`); Fließtext, in dem das Wort
|
||||||
|
vorkommt, zählt nicht. Die Vorgabewerte sind **Hinweise für den Menschen** — in Produktion
|
||||||
|
wird nichts davon verwendet.
|
||||||
|
|
||||||
|
Im Zweifel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -RhoE "process\.env\.[A-Z_]+" src/ | sort -u
|
||||||
|
```
|
||||||
|
|
||||||
|
und jeden Treffer in die `.env.example` schreiben.
|
||||||
|
|
||||||
|
**Bei Docker:** nur Variablen mit dem Präfix `NEXT_PUBLIC_`, `VITE_` oder `PUBLIC_` gehen als
|
||||||
|
`--build-arg` in den Build (sie sind ohnehin im Auslieferungsstand sichtbar) — dafür brauchst
|
||||||
|
du passende `ARG`-Zeilen im Dockerfile. Alle übrigen Werte kommen erst zur Laufzeit über
|
||||||
|
`--env-file` und landen nie im Image.
|
||||||
|
|
||||||
|
## 7. Datenbank
|
||||||
|
|
||||||
|
Deklariert deine App Verbindungsvariablen, bietet VCH eine verwaltete Datenbank an und trägt
|
||||||
|
die Werte **automatisch** in genau die Schlüssel ein, die du deklariert hast. Du musst deine
|
||||||
|
Variablennamen also nicht ändern — VCH erkennt sie.
|
||||||
|
|
||||||
|
**Erkannt wird** (Groß-/Kleinschreibung egal):
|
||||||
|
|
||||||
|
- Verbindungszeichenketten: `DATABASE_URL`, `DB_URL`, jeder Name auf `_DSN`
|
||||||
|
- Einzelteile mit Familienwort (`DB`, `DATABASE`, `POSTGRES`, `MYSQL`) plus Rolle:
|
||||||
|
`..._HOST`, `..._PORT`, `..._USER`/`..._USERNAME`, `..._PASSWORD`/`..._PASS`,
|
||||||
|
`..._NAME`/`..._DATABASE`/`..._DB`
|
||||||
|
- Die üblichen nackten Namen: `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`,
|
||||||
|
`MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`, `POSTGRES_DB`
|
||||||
|
|
||||||
|
**Bewusst nicht erkannt** (und deshalb nie überschrieben):
|
||||||
|
|
||||||
|
- alles mit `ROOT` im Namen (`POSTGRES_ROOT_PASSWORD`, `MYSQL_ROOT_PASSWORD`) — das sind die
|
||||||
|
Zugangsdaten des Datenbankservers, nicht die deiner App
|
||||||
|
- `SENTRY_DSN` und verwandte Fehlermelder (`GLITCHTIP`, `BUGSNAG`, `ROLLBAR`, `AIRBRAKE`,
|
||||||
|
`RAYGUN`, `APPSIGNAL`) — ein DSN ist dort keine Datenbank
|
||||||
|
- `HISTORY_DB=/data/verlauf.sqlite` und Ähnliches: ein `..._DB` ohne Datenbank-Familienwort
|
||||||
|
im übrigen Namen gilt nicht als Verbindung
|
||||||
|
|
||||||
|
Die **Engine** (Postgres oder MySQL) leitet VCH aus deinen Schlüsselnamen und dem Schema in
|
||||||
|
`DATABASE_URL` ab; ist das mehrdeutig, entscheidest du im Projektarbeitsplatz.
|
||||||
|
|
||||||
|
**Bringt dein Compose seine eigene Datenbank mit**, startet VCH sie mit und bietet **keine**
|
||||||
|
zweite an. Achte dann selbst auf ein dauerhaftes Volume — ein Datenbankdienst ohne Volume
|
||||||
|
verliert seine Daten beim nächsten Neubau, und VCH warnt nur.
|
||||||
|
|
||||||
|
Eine Compose-Datei, die **nur** eine Datenbank startet, ist Entwicklungs-Beiwerk: sie läuft in
|
||||||
|
Produktion nicht mit. Nimm dort die verwaltete Datenbank.
|
||||||
|
|
||||||
|
## 8. Dauerhafte Daten: `VCH_DATA_DIR`
|
||||||
|
|
||||||
|
Jedes Release liegt in einem eigenen Verzeichnis und verschwindet wieder. Was überleben soll —
|
||||||
|
SQLite-Datei, Uploads, erzeugte Dateien — gehört in das Verzeichnis aus **`VCH_DATA_DIR`**.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const datenVerzeichnis = process.env.VCH_DATA_DIR || './data';
|
||||||
|
```
|
||||||
|
|
||||||
|
VCH setzt die Variable bei allen Betriebsarten (bei Docker als Einhängepunkt `/data`). **Lies
|
||||||
|
sie zur Laufzeit**, schreibe den Pfad nirgends fest und trage sie nicht in `.env`-Dateien ein.
|
||||||
|
Ein Verzeichnis relativ zum Release (`./data`) ist beim nächsten Deploy weg.
|
||||||
|
|
||||||
|
Der Prüfling während des Audits (Abschnitt 9) bekommt diesen Speicher **nicht** — er läuft
|
||||||
|
gegen einen leeren. Das ist Absicht: zwei Prozesse dürfen nicht gleichzeitig auf dieselbe
|
||||||
|
SQLite-Datei schreiben.
|
||||||
|
|
||||||
|
## 9. Wie ein Deploy abläuft
|
||||||
|
|
||||||
|
```
|
||||||
|
Klonen → Bauen → Healthcheck (Prüfling) → Audit → Umschalten → Live
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Klonen.** VCH klont den Deploy-Branch auf die PROD-Maschine — genau den gepushten Stand.
|
||||||
|
2. **Torprüfung.** Fehlt ein blockierender Wert aus `.env.example` (Abschnitt 6), endet es hier,
|
||||||
|
bevor irgendetwas läuft.
|
||||||
|
3. **Bauen** nach `.vch/deploy.yaml` bzw. Autoerkennung.
|
||||||
|
4. **Healthcheck.** Der neue Stand startet als **Prüfling** auf einem freien Port aus dem
|
||||||
|
Bereich `14000–14999`, neben der weiterhin laufenden alten Fassung. VCH ruft dort deinen
|
||||||
|
`health`-Pfad auf: rund 30 Sekunden lang, alle 1,5 s ein Versuch, Erfolg beim ersten
|
||||||
|
2xx/3xx. Bis dahin muss die App antworten.
|
||||||
|
5. **Audit** (Abschnitt 10) gegen den deployten Commit.
|
||||||
|
6. **Umschalten.** Erst wenn das Audit besteht, wird der Prüfling zur laufenden Fassung auf
|
||||||
|
dem stabilen Port. **Ein durchgefallenes Audit heißt: der alte Stand bleibt live.**
|
||||||
|
|
||||||
|
Vor dem Umschalten legt VCH einen Datenschnappschuss an. Geht das Umschalten schief, werden
|
||||||
|
erst die Daten und dann der Code zurückgerollt. Frühere Releases lassen sich im
|
||||||
|
Projektarbeitsplatz per Rollback zurückholen.
|
||||||
|
|
||||||
|
**Auto-Deploy:** ist er eingeschaltet, löst jeder Push auf den konfigurierten Deploy-Branch
|
||||||
|
einen Deploy aus. Pushes auf andere Branches werden ignoriert.
|
||||||
|
|
||||||
|
## 10. Audit — was geprüft wird
|
||||||
|
|
||||||
|
Das Audit läuft nicht hier, sondern in einem eigenen Runner gegen einen **sauberen Checkout
|
||||||
|
des deployten Commits**. Nicht Gepushtes wird nicht geprüft — und ein `.gitignore`-Eintrag
|
||||||
|
macht eine Datei für das Audit unsichtbar.
|
||||||
|
|
||||||
|
| Prüfung | Schwere | Darf entfallen |
|
||||||
|
|---|---|---|
|
||||||
|
| `readme` | Fehler | nein |
|
||||||
|
| `secrets` | Fehler | nein |
|
||||||
|
| `description` | Warnung | nein |
|
||||||
|
| `lint` | Fehler | ja |
|
||||||
|
| `typescript` | Fehler | ja |
|
||||||
|
| `npm-audit` | Fehler | ja |
|
||||||
|
|
||||||
|
- Eine **Warnung** blockiert nie. `description` hält dich also nicht auf, kostet dich aber den
|
||||||
|
Platz im Showcase.
|
||||||
|
- „Darf entfallen" heißt: die Prüfung meldet `skipped`, wenn sie auf dein Projekt nicht passt
|
||||||
|
(kein TypeScript, kein npm). Eine Prüfung, die **nicht** entfallen darf, aber trotzdem
|
||||||
|
`skipped` meldet, gilt als **durchgefallen** — sie ist nie gelaufen.
|
||||||
|
- `secrets` sucht eingecheckte Zugangsdaten. Echte Werte gehören nie ins Repository: in die
|
||||||
|
`.env.example` nur Beispiele, die echten Werte in den Env-Editor von VCH.
|
||||||
|
|
||||||
|
## 11. Veröffentlichen
|
||||||
|
|
||||||
|
- **Intern erreichbar** ist dein Projekt, sobald ein Deploy läuft — ohne Audit, ohne weiteren
|
||||||
|
Schritt, unter `http://<prod-ip>:<port>`.
|
||||||
|
- **Öffentlich** unter einer Subdomain mit SSL wird es erst mit laufendem Deploy **und**
|
||||||
|
bestandenem Audit für genau diesen Deploy. Die Subdomain kommt oben drauf; entfernst du sie
|
||||||
|
wieder, bleibt das Projekt intern erreichbar.
|
||||||
|
- Im **Showcase** erscheinen veröffentlichte Projekte mit bestandenem Audit und einer
|
||||||
|
eingecheckten `.vch-description`.
|
||||||
|
|
||||||
|
## 12. Compose — die vier Regeln, an denen es sonst scheitert
|
||||||
|
|
||||||
|
1. **Der App-Dienst muss seinen Port als `ports: ["${VCH_PORT}:<intern>"]` veröffentlichen.**
|
||||||
|
VCH setzt `VCH_PORT` und kann so einen Prüfling neben deiner laufenden Fassung starten.
|
||||||
|
Ohne diese Zeile bricht der Deploy ab.
|
||||||
|
2. **VCH führt genau EINE Compose-Datei aus — keine `-f`-Ketten.** Gesucht wird
|
||||||
|
`compose.yaml`/`compose.yml`/`docker-compose.yaml`/`docker-compose.yml` im Stamm **oder in
|
||||||
|
einem Unterordner**. Eine Override-Datei (`docker-compose.ports.yml`, `*.override.yml`)
|
||||||
|
wird **ignoriert** — die `${VCH_PORT}`-Zeile gehört in die Hauptdatei. Bei mehreren
|
||||||
|
Fundstellen: `compose: <pfad>` in die `.vch/deploy.yaml`.
|
||||||
|
3. **Jeder Dienst, der Produktionswerte braucht, bekommt `env_file: [.env]`.** VCH schreibt
|
||||||
|
deine Werte in eine `.env` neben der Compose-Datei.
|
||||||
|
4. **Der ganze Stapel startet.** Datenbanken, Caches und Ähnliches aus deinem Compose laufen
|
||||||
|
automatisch mit — eine zusätzliche verwaltete Datenbank brauchst du dafür nicht. Dienste
|
||||||
|
hinter einem `profiles:`-Schlüssel bleiben aus.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
runtime: systemd
|
||||||
|
build: "npm ci --omit=dev"
|
||||||
|
start: "npm start"
|
||||||
|
health: /health
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@.vch/PLATFORM.md
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Die Regeln dieser Umgebung — Deployment, Datenbank, Audit, Veröffentlichen — stehen in
|
||||||
|
.vch/PLATFORM.md. Diese Datei wird von VCH gepflegt und überschrieben; lies sie, bevor du
|
||||||
|
deployst. Alles ab hier gehört dem Projekt: VCH fasst diese CLAUDE.md nie wieder an.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Rotatory
|
||||||
|
|
||||||
|
Wofür dieses Projekt gut ist, und was jemand wissen muss, der hier weiterarbeitet.
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
- **Abhängigkeiten installieren:** `npm install`
|
||||||
|
- **Entwicklungsserver:** `npm run dev`
|
||||||
|
- **Tests:** `npm test`
|
||||||
|
- **Bauen:** `npm run build`
|
||||||
|
- **Linten:** `npm run lint`
|
||||||
|
|
||||||
|
## Konventionen
|
||||||
|
|
||||||
|
- Aussagekräftige Commit-Nachrichten im Imperativ („Suche ergänzen", nicht „Suche ergänzt")
|
||||||
|
- Lieber kleine, abgeschlossene Commits als große
|
||||||
|
- Vor dem Commit linten und formatieren
|
||||||
|
- Tests für die tragende Fachlogik
|
||||||
|
- Wenig Abhängigkeiten — was Browser und Node schon können, reicht meistens
|
||||||
|
- Semantisches HTML, mobil zuerst, bedienbar per Tastatur, ausreichender Kontrast
|
||||||
|
- Konfiguration über Umgebungsvariablen; keine Zugangsdaten im Quelltext
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Rotatory
|
||||||
|
|
||||||
|
Rotatory plant die Moderation des werktäglichen Morning Meetings: Pro Tag werden ein
|
||||||
|
**Hauptmoderator** und ein **Nebenmoderator** aus der Belegschaft fair und zufällig
|
||||||
|
ausgelost. Die Monatsansicht zeigt auf jedem Tag die Fotos der beiden Personen. Der fertige
|
||||||
|
Plan lässt sich per Knopfdruck als Termine in einen Nextcloud-Kalender schreiben.
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
|
||||||
|
- Personen mit Vor- und Nachname, optionaler E-Mail, Foto (Upload, wird im Browser auf
|
||||||
|
320 px zugeschnitten) und Rolle: Hauptmoderation / Nebenmoderation / beides / keins
|
||||||
|
(Nicht-Moderator). Feste freie Wochentage für Teilzeitkräfte. Inaktive Personen bleiben
|
||||||
|
angelegt, werden aber nicht ausgelost.
|
||||||
|
- Personenimport aus Nextcloud: alle Konten aus dem Systemadressbuch zur Auswahl, Profilbilder
|
||||||
|
werden übernommen.
|
||||||
|
- Abwesenheiten (Urlaub, geplante Krankheit, Frei, Sonstiges) je Person mit Zeitraum – manuell
|
||||||
|
oder importiert aus einem Nextcloud-Kalender mit Terminen wie „Vorname Nachname (Urlaub)",
|
||||||
|
„… (krank)", „… (frei)". Wiederholungen werden aufgelöst, Namen den Personen zugeordnet,
|
||||||
|
nicht zuordenbare Namen aufgelistet.
|
||||||
|
- Feiertage: gesetzliche Feiertage je Bundesland auf Knopfdruck erzeugen, dazu eigene
|
||||||
|
betriebsfreie Tage. An diesen Tagen wird nicht geplant.
|
||||||
|
- Auslosung für beliebige Monatsbereiche. Regeln: nur Mo–Fr, keine Feiertage, keine
|
||||||
|
Abwesenden, niemand an zwei Werktagen hintereinander (wenn vermeidbar), Einsätze
|
||||||
|
gleichmäßig verteilt (Fairness-Fenster berücksichtigt auch vergangene Einsätze), Rollen
|
||||||
|
werden abgewechselt, dieselbe Paarung wiederholt sich ungern.
|
||||||
|
- Einzelne Tage manuell setzen, fixieren (überlebt jede Neuauslosung), neu auslosen oder
|
||||||
|
als „Kein Meeting" markieren.
|
||||||
|
- Einsatzstatistik je Monat.
|
||||||
|
- Export als ICS-Datei oder direkt per CalDAV in einen Nextcloud-Kalender: wahlweise ein
|
||||||
|
vorhandener (auch geteilter) Kalender oder ein neu angelegter. Wiederholtes Schreiben
|
||||||
|
aktualisiert bestehende Termine und löscht entfallene; fremde Termine im Zeitraum können auf
|
||||||
|
Wunsch mit entfernt werden.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Voraussetzung: Node.js 20 oder neuer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Entwicklung, startet bei Änderungen neu
|
||||||
|
npm start # Produktion
|
||||||
|
```
|
||||||
|
|
||||||
|
Die App lauscht auf `PORT` (Vorgabe 3000) und speichert alle Daten unter `VCH_DATA_DIR`
|
||||||
|
(Vorgabe `./data`): eine JSON-Datei plus die Fotos. Ein Datenbankserver ist nicht nötig.
|
||||||
|
Eine `.env` im Projektstamm wird beim Start gelesen (bereits gesetzte Variablen gewinnen).
|
||||||
|
Beim ersten Start werden die gesetzlichen Feiertage für dieses und nächstes Jahr angelegt;
|
||||||
|
Vorgaben: Bayern, Meeting 09:30 Uhr, 15 Minuten.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Alle Variablen sind optional, siehe `.env.example`:
|
||||||
|
|
||||||
|
| Variable | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `NEXTCLOUD_URL` | Basis-URL der Nextcloud, z. B. `https://cloud.example.de` |
|
||||||
|
| `NEXTCLOUD_USER` | Anmeldename, E-Mail-Adresse geht auch; die Benutzer-ID wird per CalDAV-Discovery ermittelt |
|
||||||
|
| `NEXTCLOUD_APP_PASSWORD` | App-Passwort (Nextcloud → Einstellungen → Sicherheit → Geräte & Sitzungen) |
|
||||||
|
| `NEXTCLOUD_CALENDAR` | ID des eigenen Kalenders, falls kein vorhandener Zielkalender gewählt wird (Vorgabe `morning-meeting`) |
|
||||||
|
| `NEXTCLOUD_INSECURE_TLS` | `true`, wenn die Nextcloud ein selbstsigniertes Zertifikat nutzt |
|
||||||
|
| `APP_PASSWORD` | Wenn gesetzt, schützt HTTP Basic Auth die ganze App (Benutzername beliebig) |
|
||||||
|
|
||||||
|
Ohne Nextcloud-Variablen läuft die App vollständig, nur der Button „Go – schreiben" ist
|
||||||
|
deaktiviert; der ICS-Download bleibt verfügbar.
|
||||||
|
|
||||||
|
## Nextcloud
|
||||||
|
|
||||||
|
1. Umgebungsvariablen setzen und App neu starten.
|
||||||
|
2. **Personen** → „Aus Nextcloud importieren …": Konten auswählen, Fotos kommen mit.
|
||||||
|
3. **Abwesenheiten** → Quellkalender (z. B. „Urlaub/Krankheit") und Zeitraum wählen,
|
||||||
|
importieren. Vor jeder Auslosung wiederholen, damit neue Urlaube berücksichtigt sind.
|
||||||
|
4. **Kalender** → „Auslosen …" für die kommenden Monate.
|
||||||
|
5. **Kalender** → „Nextcloud …": Zielkalender wählen (ein vorhandener geteilter Kalender wird
|
||||||
|
vorgeschlagen), **Verbindung testen**, Zeitraum wählen und **Go – schreiben**. Je Werktag
|
||||||
|
entsteht ein Termin „Morning Meeting: Haupt / Neben" zur eingestellten Uhrzeit.
|
||||||
|
|
||||||
|
Termine tragen eine stabile UID je Tag (`rotatory-<datum>@<host>`), daher aktualisiert ein
|
||||||
|
erneutes Schreiben statt zu duplizieren. Termine anderer Herkunft im Zeitraum werden gezählt
|
||||||
|
und nur auf ausdrücklichen Wunsch (Häkchen) entfernt.
|
||||||
|
|
||||||
|
Mit der Einstellung „Moderatoren als Teilnehmer eintragen" werden die beiden Personen (bei
|
||||||
|
hinterlegter E-Mail) als Teilnehmer eingetragen; Nextcloud verschickt dann Einladungen.
|
||||||
|
|
||||||
|
## Tests und Prüfung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # Auslosung, Feiertage, ICS, iCal/vCard-Parser, CalDAV-Client (mit simulierter Nextcloud)
|
||||||
|
npm run lint # Syntaxprüfung aller Quelldateien
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
index.js Express-App, REST-API, statische Dateien
|
||||||
|
store.js JSON-Speicher (atomar) und Fotoablage
|
||||||
|
scheduler.js Auslosungslogik (rein, testbar)
|
||||||
|
holidays.js Gesetzliche Feiertage je Bundesland
|
||||||
|
ics.js iCalendar-Erzeugung
|
||||||
|
nextcloud.js CalDAV/CardDAV-Client (Discovery, PROPFIND, MKCALENDAR, REPORT, PUT, DELETE)
|
||||||
|
ical.js iCalendar- und vCard-Leser, Namenszuordnung
|
||||||
|
env.js .env-Leser
|
||||||
|
public/
|
||||||
|
index.html, app.js, style.css Oberfläche ohne Framework, relative Pfade
|
||||||
|
test/ node:test
|
||||||
|
```
|
||||||
|
|
||||||
|
Abhängigkeiten: nur `express`. Fotos werden im Browser per Canvas verkleinert, `fetch` und
|
||||||
|
`crypto` kommen aus Node.
|
||||||
Generated
+835
@@ -0,0 +1,835 @@
|
|||||||
|
{
|
||||||
|
"name": "rotatory",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "rotatory",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/array-flatten": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/body-parser": {
|
||||||
|
"version": "1.20.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
|
||||||
|
"integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"content-type": "~1.0.5",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "~1.2.0",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"qs": "~6.16.0",
|
||||||
|
"raw-body": "~2.5.3",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bytes": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/destroy": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ee-first": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express": {
|
||||||
|
"version": "4.22.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
|
||||||
|
"integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.8",
|
||||||
|
"array-flatten": "1.1.1",
|
||||||
|
"body-parser": "~1.20.5",
|
||||||
|
"content-disposition": "~0.5.4",
|
||||||
|
"content-type": "~1.0.4",
|
||||||
|
"cookie": "~0.7.1",
|
||||||
|
"cookie-signature": "~1.0.6",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"finalhandler": "~1.3.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.0",
|
||||||
|
"merge-descriptors": "1.0.3",
|
||||||
|
"methods": "~1.1.2",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"path-to-regexp": "~0.1.13",
|
||||||
|
"proxy-addr": "~2.0.7",
|
||||||
|
"qs": "~6.16.0",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"send": "~0.19.0",
|
||||||
|
"serve-static": "~1.16.2",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "~2.0.1",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"utils-merge": "1.0.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/finalhandler": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fresh": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/media-typer": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/merge-descriptors": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/methods": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/on-finished": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "0.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||||
|
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||||
|
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"side-channel": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/raw-body": {
|
||||||
|
"version": "2.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||||
|
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "0.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
|
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "1.2.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"mime": "1.6.0",
|
||||||
|
"ms": "2.1.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"statuses": "~2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "1.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||||
|
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"send": "~0.19.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4",
|
||||||
|
"side-channel-list": "^1.0.1",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is": {
|
||||||
|
"version": "1.6.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"media-typer": "0.3.0",
|
||||||
|
"mime-types": "~2.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unpipe": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/utils-merge": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "rotatory",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Zufällige, faire Moderationsplanung für das Morning Meeting mit Kalenderansicht und Nextcloud-Export",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"main": "server/index.js",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server/index.js",
|
||||||
|
"dev": "node --watch server/index.js",
|
||||||
|
"test": "node --test test/",
|
||||||
|
"lint": "for f in server/*.js public/*.js; do node --check \"$f\" || exit 1; done && echo \"Syntax ok\"",
|
||||||
|
"build": "echo 'Kein Build nötig'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
+856
@@ -0,0 +1,856 @@
|
|||||||
|
// Rotatory – Frontend ohne Abhängigkeiten. Alle Pfade relativ (Proxy-tauglich).
|
||||||
|
|
||||||
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
|
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||||
|
|
||||||
|
const state = { data: null, month: startOfMonth(new Date()) };
|
||||||
|
|
||||||
|
// ---------- Hilfen ----------
|
||||||
|
function pad(n) { return String(n).padStart(2, '0'); }
|
||||||
|
function iso(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
|
||||||
|
function startOfMonth(d) { return new Date(d.getFullYear(), d.getMonth(), 1); }
|
||||||
|
function monthKey(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; }
|
||||||
|
function monthRange(fromKey, toKey) {
|
||||||
|
const [fy, fm] = fromKey.split('-').map(Number);
|
||||||
|
const [ty, tm] = toKey.split('-').map(Number);
|
||||||
|
return { from: `${fy}-${pad(fm)}-01`, to: iso(new Date(ty, tm, 0)) };
|
||||||
|
}
|
||||||
|
function todayIso() { return iso(new Date()); }
|
||||||
|
function fmtDate(isoStr) {
|
||||||
|
const [y, m, d] = isoStr.split('-');
|
||||||
|
return `${d}.${m}.${y}`;
|
||||||
|
}
|
||||||
|
function fmtLong(isoStr) {
|
||||||
|
return new Date(isoStr + 'T12:00:00').toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||||
|
|
||||||
|
let toastTimer;
|
||||||
|
function toast(msg, isError = false) {
|
||||||
|
const el = $('#toast');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.toggle('error', isError);
|
||||||
|
el.hidden = false;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => { el.hidden = true; }, isError ? 6000 : 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, { method = 'GET', body, raw } = {}) {
|
||||||
|
const opts = { method, headers: {} };
|
||||||
|
if (raw) { opts.body = raw.body; opts.headers['Content-Type'] = raw.type; }
|
||||||
|
else if (body !== undefined) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
state.data = await api('api/state');
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktion ausführen, Zustand neu laden, Fehler als Hinweis zeigen. */
|
||||||
|
async function run(action, okMessage) {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await load();
|
||||||
|
if (okMessage) toast(okMessage);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function personById(id) { return state.data.persons.find((p) => p.id === id); }
|
||||||
|
function fullName(p) { return p ? `${p.firstName} ${p.lastName}` : 'unbesetzt'; }
|
||||||
|
function shortName(p) { return p ? `${p.firstName} ${p.lastName.charAt(0)}.` : '?'; }
|
||||||
|
function initials(p) { return `${p.firstName.charAt(0)}${p.lastName.charAt(0)}`.toUpperCase(); }
|
||||||
|
function colorFor(id) {
|
||||||
|
let h = 0;
|
||||||
|
for (const c of id) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||||
|
return `hsl(${h % 360} 55% 45%)`;
|
||||||
|
}
|
||||||
|
function isAbsent(personId, date) {
|
||||||
|
return state.data.absences.some((a) => a.personId === personId && a.from <= date && a.to >= date);
|
||||||
|
}
|
||||||
|
function holidayFor(date) { return state.data.holidays.find((h) => h.date === date); }
|
||||||
|
|
||||||
|
function avatarEl(p, cls = '') {
|
||||||
|
if (!p) {
|
||||||
|
const e = document.createElement('span');
|
||||||
|
e.className = `avatar empty ${cls}`;
|
||||||
|
e.textContent = '?';
|
||||||
|
e.setAttribute('aria-label', 'unbesetzt');
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
if (p.hasPhoto) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = `avatar ${cls}`;
|
||||||
|
img.src = `photos/${p.id}?v=${p.photoVersion || 0}`;
|
||||||
|
img.alt = fullName(p);
|
||||||
|
img.loading = 'lazy';
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
const e = document.createElement('span');
|
||||||
|
e.className = `avatar ${cls}`;
|
||||||
|
e.style.background = colorFor(p.id);
|
||||||
|
e.textContent = initials(p);
|
||||||
|
e.setAttribute('role', 'img');
|
||||||
|
e.setAttribute('aria-label', fullName(p));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Navigation ----------
|
||||||
|
function showView(name) {
|
||||||
|
const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen'];
|
||||||
|
if (!known.includes(name)) name = 'kalender';
|
||||||
|
if (name === 'abwesenheiten' && state.data) prepareAbsenceImport();
|
||||||
|
for (const v of $$('.view')) v.hidden = v.id !== `view-${name}`;
|
||||||
|
for (const a of $$('.topbar nav a')) {
|
||||||
|
if (a.dataset.view === name) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', () => showView(location.hash.slice(1)));
|
||||||
|
|
||||||
|
// ---------- Kalender ----------
|
||||||
|
function renderCalendar() {
|
||||||
|
const d = state.month;
|
||||||
|
$('#cal-title').textContent = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
||||||
|
const grid = $('#cal-grid');
|
||||||
|
grid.textContent = '';
|
||||||
|
const { assignments, persons } = state.data;
|
||||||
|
const today = todayIso();
|
||||||
|
const first = new Date(d.getFullYear(), d.getMonth(), 1);
|
||||||
|
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||||||
|
// Auf Montag der ersten Woche zurückgehen
|
||||||
|
const start = new Date(first);
|
||||||
|
const offset = (first.getDay() + 6) % 7;
|
||||||
|
start.setDate(first.getDate() - offset);
|
||||||
|
let planned = 0;
|
||||||
|
let unfilled = 0;
|
||||||
|
for (let cur = new Date(start); cur <= last; cur.setDate(cur.getDate() + 1)) {
|
||||||
|
if (cur.getDay() === 0 || cur.getDay() === 6) continue;
|
||||||
|
const date = iso(cur);
|
||||||
|
const outside = cur.getMonth() !== d.getMonth();
|
||||||
|
const cell = document.createElement(outside ? 'div' : 'button');
|
||||||
|
cell.className = 'day';
|
||||||
|
if (!outside) { cell.type = 'button'; cell.dataset.date = date; }
|
||||||
|
if (outside) cell.classList.add('outside');
|
||||||
|
if (date === today) cell.classList.add('today');
|
||||||
|
if (date < today) cell.classList.add('past');
|
||||||
|
const num = document.createElement('div');
|
||||||
|
num.className = 'num';
|
||||||
|
num.textContent = cur.getDate();
|
||||||
|
cell.appendChild(num);
|
||||||
|
if (outside) { grid.appendChild(cell); continue; }
|
||||||
|
|
||||||
|
const hol = holidayFor(date);
|
||||||
|
const a = assignments[date];
|
||||||
|
if (hol) {
|
||||||
|
cell.classList.add('holiday');
|
||||||
|
const l = document.createElement('div');
|
||||||
|
l.className = 'label';
|
||||||
|
l.textContent = hol.name;
|
||||||
|
cell.appendChild(l);
|
||||||
|
cell.setAttribute('aria-label', `${fmtLong(date)}, ${hol.name}`);
|
||||||
|
} else if (a && a.off) {
|
||||||
|
const l = document.createElement('div');
|
||||||
|
l.className = 'label off';
|
||||||
|
l.textContent = 'Kein Meeting';
|
||||||
|
cell.appendChild(l);
|
||||||
|
cell.setAttribute('aria-label', `${fmtLong(date)}, kein Meeting`);
|
||||||
|
} else if (a) {
|
||||||
|
planned += 1;
|
||||||
|
if (a.locked) { const lock = document.createElement('span'); lock.className = 'lock'; lock.textContent = '🔒'; lock.title = 'fixiert'; num.appendChild(lock); }
|
||||||
|
const pair = document.createElement('div');
|
||||||
|
pair.className = 'pair';
|
||||||
|
const main = personById(a.main);
|
||||||
|
const co = personById(a.co);
|
||||||
|
if (!main || !co) unfilled += 1;
|
||||||
|
pair.appendChild(slotEl(main, 'main', date));
|
||||||
|
pair.appendChild(slotEl(co, 'co', date));
|
||||||
|
cell.appendChild(pair);
|
||||||
|
const absentNote = (p) => (p && isAbsent(p.id, date) ? ' (abwesend!)' : '');
|
||||||
|
cell.setAttribute('aria-label', `${fmtLong(date)}: Haupt ${fullName(main)}${absentNote(main)}, Neben ${fullName(co)}${absentNote(co)}${a.locked ? ', fixiert' : ''}`);
|
||||||
|
} else {
|
||||||
|
cell.setAttribute('aria-label', `${fmtLong(date)}, noch nicht ausgelost`);
|
||||||
|
}
|
||||||
|
grid.appendChild(cell);
|
||||||
|
}
|
||||||
|
const hint = $('#cal-hint');
|
||||||
|
if (persons.length === 0) { hint.textContent = 'Noch keine Personen angelegt – zuerst unter „Personen" die Belegschaft anlegen.'; hint.hidden = false; }
|
||||||
|
else if (planned === 0) { hint.textContent = 'Für diesen Monat ist noch nichts ausgelost. Mit „Auslosen …" starten.'; hint.hidden = false; }
|
||||||
|
else if (unfilled > 0) { hint.textContent = `${unfilled} Tag(e) sind nicht vollständig besetzt – im Kalender anklicken und manuell zuweisen oder neu auslosen.`; hint.hidden = false; }
|
||||||
|
else hint.hidden = true;
|
||||||
|
|
||||||
|
const range = monthRange(monthKey(d), monthKey(d));
|
||||||
|
$('#download-ics').href = `api/export.ics?from=${range.from}&to=${range.to}`;
|
||||||
|
renderStats(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotEl(p, role, date) {
|
||||||
|
const slot = document.createElement('div');
|
||||||
|
slot.className = 'slot';
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.className = 'avatar-wrap';
|
||||||
|
const absent = Boolean(p && isAbsent(p.id, date));
|
||||||
|
wrap.appendChild(avatarEl(p, `${role === 'co' ? 'co' : ''}${absent ? ' absent' : ''}`));
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = `badge ${role}`;
|
||||||
|
badge.textContent = role === 'main' ? 'H' : 'N';
|
||||||
|
badge.setAttribute('aria-hidden', 'true');
|
||||||
|
wrap.appendChild(badge);
|
||||||
|
slot.appendChild(wrap);
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = shortName(p);
|
||||||
|
name.title = fullName(p);
|
||||||
|
if (absent) { name.classList.add('absent'); name.title += ' – abwesend!'; }
|
||||||
|
slot.appendChild(name);
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats(range) {
|
||||||
|
const counts = {};
|
||||||
|
for (const [date, a] of Object.entries(state.data.assignments)) {
|
||||||
|
if (date < range.from || date > range.to || a.off) continue;
|
||||||
|
for (const [role, id] of [['main', a.main], ['co', a.co]]) {
|
||||||
|
if (!id) continue;
|
||||||
|
counts[id] = counts[id] || { main: 0, co: 0 };
|
||||||
|
counts[id][role] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const box = $('#stats');
|
||||||
|
box.textContent = '';
|
||||||
|
const rows = state.data.persons
|
||||||
|
.filter((p) => p.active !== false && (p.canMain || p.canCo || counts[p.id]))
|
||||||
|
.map((p) => ({ p, c: counts[p.id] || { main: 0, co: 0 } }))
|
||||||
|
.sort((a, b) => (b.c.main + b.c.co) - (a.c.main + a.c.co) || fullName(a.p).localeCompare(fullName(b.p)));
|
||||||
|
if (rows.length === 0) { box.innerHTML = '<span class="hint">Keine aktiven Moderatoren.</span>'; return; }
|
||||||
|
for (const { p, c } of rows) {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = 'chip';
|
||||||
|
chip.appendChild(avatarEl(p, 'plain'));
|
||||||
|
const t = document.createElement('span');
|
||||||
|
t.textContent = shortName(p);
|
||||||
|
chip.appendChild(t);
|
||||||
|
const n = document.createElement('span');
|
||||||
|
n.className = 'count';
|
||||||
|
n.textContent = `${c.main + c.co}× (H ${c.main} · N ${c.co})`;
|
||||||
|
chip.appendChild(n);
|
||||||
|
box.appendChild(chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#prev-month').addEventListener('click', () => { state.month = new Date(state.month.getFullYear(), state.month.getMonth() - 1, 1); renderCalendar(); });
|
||||||
|
$('#next-month').addEventListener('click', () => { state.month = new Date(state.month.getFullYear(), state.month.getMonth() + 1, 1); renderCalendar(); });
|
||||||
|
$('#today-month').addEventListener('click', () => { state.month = startOfMonth(new Date()); renderCalendar(); });
|
||||||
|
$('#cal-grid').addEventListener('click', (e) => {
|
||||||
|
const cell = e.target.closest('.day[data-date]');
|
||||||
|
if (cell) openDayDialog(cell.dataset.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Tag-Dialog ----------
|
||||||
|
const dayDialog = $('#day-dialog');
|
||||||
|
const dayForm = $('#day-form');
|
||||||
|
let dayDialogDate = null;
|
||||||
|
|
||||||
|
function fillPersonSelect(select, role, date, selected) {
|
||||||
|
select.textContent = '';
|
||||||
|
const none = document.createElement('option');
|
||||||
|
none.value = '';
|
||||||
|
none.textContent = '– unbesetzt –';
|
||||||
|
select.appendChild(none);
|
||||||
|
for (const p of state.data.persons.filter((x) => x.active !== false).sort((a, b) => fullName(a).localeCompare(fullName(b)))) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = p.id;
|
||||||
|
const flags = [];
|
||||||
|
if (role === 'main' && !p.canMain) flags.push('keine Hauptmoderation');
|
||||||
|
if (role === 'co' && !p.canCo) flags.push('keine Nebenmoderation');
|
||||||
|
if (isAbsent(p.id, date)) flags.push('abwesend');
|
||||||
|
o.textContent = fullName(p) + (flags.length ? ` (${flags.join(', ')})` : '');
|
||||||
|
select.appendChild(o);
|
||||||
|
}
|
||||||
|
select.value = selected || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDayDialog(date) {
|
||||||
|
dayDialogDate = date;
|
||||||
|
const a = state.data.assignments[date] || {};
|
||||||
|
const hol = holidayFor(date);
|
||||||
|
$('#day-dialog-title').textContent = fmtLong(date);
|
||||||
|
$('#day-dialog-info').textContent = hol ? `${hol.name} – an diesem Tag findet kein Meeting statt. Der Feiertag lässt sich unter Einstellungen entfernen.` : '';
|
||||||
|
fillPersonSelect(dayForm.elements.main, 'main', date, a.main);
|
||||||
|
fillPersonSelect(dayForm.elements.co, 'co', date, a.co);
|
||||||
|
dayForm.elements.locked.checked = Boolean(a.locked);
|
||||||
|
dayForm.elements.off.checked = Boolean(a.off);
|
||||||
|
for (const el of [dayForm.elements.main, dayForm.elements.co, dayForm.elements.locked, dayForm.elements.off, $('#day-reroll'), $('#day-clear'), dayForm.querySelector('button[type="submit"]')]) el.disabled = Boolean(hol);
|
||||||
|
dayDialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
dayForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = dayForm.elements;
|
||||||
|
try {
|
||||||
|
await api(`api/assignments/${dayDialogDate}`, { method: 'PUT', body: { main: f.main.value || null, co: f.co.value || null, locked: f.locked.checked, off: f.off.checked } });
|
||||||
|
dayDialog.close();
|
||||||
|
await load();
|
||||||
|
toast('Tag gespeichert.');
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
$('#day-close').addEventListener('click', () => dayDialog.close());
|
||||||
|
$('#day-reroll').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
// Nur ein Vorschlag – gespeichert wird erst mit „Speichern"
|
||||||
|
const a = await api(`api/assignments/${dayDialogDate}/reroll`, { method: 'POST', body: { preview: true } });
|
||||||
|
dayForm.elements.main.value = a.main || '';
|
||||||
|
dayForm.elements.co.value = a.co || '';
|
||||||
|
dayForm.elements.off.checked = false;
|
||||||
|
toast('Vorschlag ausgelost – mit „Speichern" übernehmen.');
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
$('#day-clear').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await api(`api/assignments/${dayDialogDate}`, { method: 'PUT', body: { main: null, co: null, locked: false, off: false } });
|
||||||
|
dayDialog.close();
|
||||||
|
await load();
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Auslosen-Dialog ----------
|
||||||
|
const planDialog = $('#plan-dialog');
|
||||||
|
const planForm = $('#plan-form');
|
||||||
|
$('#open-plan').addEventListener('click', () => {
|
||||||
|
const next = new Date(state.month.getFullYear(), state.month.getMonth() + 2, 1);
|
||||||
|
planForm.elements.from.value = monthKey(state.month);
|
||||||
|
planForm.elements.to.value = monthKey(next);
|
||||||
|
$('#plan-result').hidden = true;
|
||||||
|
planDialog.showModal();
|
||||||
|
});
|
||||||
|
$('#plan-close').addEventListener('click', () => planDialog.close());
|
||||||
|
planForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const r = monthRange(planForm.elements.from.value, planForm.elements.to.value);
|
||||||
|
const out = $('#plan-result');
|
||||||
|
try {
|
||||||
|
const res = await api('api/schedule/generate', { method: 'POST', body: { ...r, overwrite: planForm.elements.overwrite.checked } });
|
||||||
|
await load();
|
||||||
|
out.className = 'result';
|
||||||
|
out.textContent = `${res.planned} Werktage geplant.` + (res.unfilled.length ? `\n${res.unfilled.length} Tag(e) konnten nicht voll besetzt werden: ${res.unfilled.map(fmtDate).join(', ')}` : ' Alle Tage besetzt.');
|
||||||
|
out.hidden = false;
|
||||||
|
} catch (err) { out.className = 'result error'; out.textContent = err.message; out.hidden = false; }
|
||||||
|
});
|
||||||
|
$('#plan-clear').addEventListener('click', async () => {
|
||||||
|
const r = monthRange(planForm.elements.from.value, planForm.elements.to.value);
|
||||||
|
if (!confirm(`Alle nicht fixierten Zuteilungen von ${fmtDate(r.from)} bis ${fmtDate(r.to)} löschen?`)) return;
|
||||||
|
try {
|
||||||
|
await api('api/schedule/clear', { method: 'POST', body: r });
|
||||||
|
await load();
|
||||||
|
$('#plan-result').className = 'result';
|
||||||
|
$('#plan-result').textContent = 'Zeitraum geleert.';
|
||||||
|
$('#plan-result').hidden = false;
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Nextcloud: Kalenderliste ----------
|
||||||
|
let calendarsPromise = null;
|
||||||
|
function loadCalendars(force = false) {
|
||||||
|
if (!calendarsPromise || force) calendarsPromise = api('api/nextcloud/calendars').catch((err) => { calendarsPromise = null; throw err; });
|
||||||
|
return calendarsPromise;
|
||||||
|
}
|
||||||
|
function calendarLabel(c) {
|
||||||
|
// Nextcloud hängt bei geteilten Kalendern den Besitzer schon an den Namen an
|
||||||
|
return `${c.name}${c.writable ? '' : ' – nur lesen'}`;
|
||||||
|
}
|
||||||
|
/** Select mit Kalendern füllen; onlyWritable filtert, extra fügt eine Option vorne an. */
|
||||||
|
async function fillCalendarSelect(select, { selected = '', onlyWritable = false, extra = null } = {}) {
|
||||||
|
select.textContent = '';
|
||||||
|
if (!state.data.nextcloud.configured) { select.appendChild(new Option('Nextcloud nicht konfiguriert', '')); select.disabled = true; return []; }
|
||||||
|
select.disabled = true;
|
||||||
|
select.appendChild(new Option('Lade Kalender …', ''));
|
||||||
|
try {
|
||||||
|
const { calendars } = await loadCalendars();
|
||||||
|
select.textContent = '';
|
||||||
|
if (extra) select.appendChild(new Option(extra.label, extra.value));
|
||||||
|
const list = calendars.filter((c) => !onlyWritable || c.writable);
|
||||||
|
for (const c of list) select.appendChild(new Option(calendarLabel(c), c.href));
|
||||||
|
if (selected && list.some((c) => c.href === selected)) select.value = selected;
|
||||||
|
else if (!extra && list.length) select.value = list[0].href;
|
||||||
|
select.disabled = false;
|
||||||
|
return list;
|
||||||
|
} catch (err) {
|
||||||
|
select.textContent = '';
|
||||||
|
select.appendChild(new Option(`Fehler: ${err.message}`, ''));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function selectedCalendarName(select) {
|
||||||
|
return select.selectedOptions[0]?.textContent || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Nextcloud-Dialog ----------
|
||||||
|
const ncDialog = $('#nextcloud-dialog');
|
||||||
|
const ncForm = $('#nextcloud-form');
|
||||||
|
$('#open-nextcloud').addEventListener('click', async () => {
|
||||||
|
const nc = state.data.nextcloud;
|
||||||
|
const next = new Date(state.month.getFullYear(), state.month.getMonth() + 2, 1);
|
||||||
|
ncForm.elements.from.value = monthKey(state.month);
|
||||||
|
ncForm.elements.to.value = monthKey(next);
|
||||||
|
ncForm.elements.removeForeign.checked = false;
|
||||||
|
$('#nextcloud-info').textContent = nc.configured
|
||||||
|
? `${nc.baseUrl} · angemeldet als ${nc.user}`
|
||||||
|
: 'Nextcloud ist nicht konfiguriert. NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_APP_PASSWORD als Umgebungsvariablen setzen.';
|
||||||
|
$('#nextcloud-go').disabled = !nc.configured;
|
||||||
|
$('#nextcloud-test').disabled = !nc.configured;
|
||||||
|
$('#nextcloud-result').hidden = true;
|
||||||
|
ncDialog.showModal();
|
||||||
|
const list = await fillCalendarSelect(ncForm.elements.target, {
|
||||||
|
selected: state.data.settings.targetCalendarHref,
|
||||||
|
onlyWritable: true,
|
||||||
|
extra: { label: `Neuen Kalender „${state.data.settings.calendarDisplayName}" anlegen (${nc.calendar})`, value: '' },
|
||||||
|
});
|
||||||
|
// Noch kein Ziel gewählt: vorhandenen Morning-Meeting-Kalender vorschlagen
|
||||||
|
if (!state.data.settings.targetCalendarHref) {
|
||||||
|
const guess = list.find((c) => /morning|meeting|moderation/i.test(c.name));
|
||||||
|
if (guess) ncForm.elements.target.value = guess.href;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ncForm.elements.target.addEventListener('change', async () => {
|
||||||
|
const sel = ncForm.elements.target;
|
||||||
|
try {
|
||||||
|
await api('api/settings', { method: 'PUT', body: { targetCalendarHref: sel.value, targetCalendarName: sel.value ? selectedCalendarName(sel) : '' } });
|
||||||
|
state.data.settings.targetCalendarHref = sel.value;
|
||||||
|
state.data.settings.targetCalendarName = sel.value ? selectedCalendarName(sel) : '';
|
||||||
|
renderSettings();
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
$('#nextcloud-close').addEventListener('click', () => ncDialog.close());
|
||||||
|
$('#nextcloud-test').addEventListener('click', async () => {
|
||||||
|
const out = $('#nextcloud-result');
|
||||||
|
const btn = $('#nextcloud-test');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api('api/nextcloud/test', { method: 'POST' });
|
||||||
|
out.className = 'result';
|
||||||
|
const writable = r.calendars.filter((c) => c.writable).length;
|
||||||
|
out.textContent = `Verbindung OK – angemeldet als ${r.displayName || r.userId} (ID ${r.userId}).\n${r.calendars.length} Kalender sichtbar, ${writable} davon beschreibbar.`;
|
||||||
|
await fillCalendarSelect(ncForm.elements.target, { selected: ncForm.elements.target.value || state.data.settings.targetCalendarHref, onlyWritable: true, extra: { label: `Neuen Kalender „${state.data.settings.calendarDisplayName}" anlegen`, value: '' } });
|
||||||
|
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
|
||||||
|
out.hidden = false;
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
ncForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const r = monthRange(ncForm.elements.from.value, ncForm.elements.to.value);
|
||||||
|
const out = $('#nextcloud-result');
|
||||||
|
const btn = $('#nextcloud-go');
|
||||||
|
btn.disabled = true;
|
||||||
|
out.className = 'result';
|
||||||
|
out.textContent = 'Schreibe Termine …';
|
||||||
|
out.hidden = false;
|
||||||
|
try {
|
||||||
|
const sel = ncForm.elements.target;
|
||||||
|
const res = await api('api/nextcloud/sync', { method: 'POST', body: { ...r, removeForeign: ncForm.elements.removeForeign.checked, targetCalendarHref: sel.value, targetCalendarName: sel.value ? selectedCalendarName(sel) : '' } });
|
||||||
|
await load();
|
||||||
|
const parts = [`${res.created ? 'Kalender angelegt. ' : ''}${res.written} Termin(e) geschrieben, ${res.removed} entfernt.`];
|
||||||
|
if (res.moved) parts.push(`${res.moved} Termin(e) aus dem vorherigen Zielkalender entfernt.`);
|
||||||
|
if (res.foreignRemoved) parts.push(`${res.foreignRemoved} fremde Termin(e) im Zeitraum gelöscht.`);
|
||||||
|
else if (res.foreign) parts.push(`Hinweis: ${res.foreign} fremde Termin(e) im Zeitraum vorhanden (nicht entfernt).`);
|
||||||
|
if (res.foreignRecurring) parts.push(`${res.foreignRecurring} wiederkehrende fremde Serie(n) bleiben unangetastet – bei Bedarf in Nextcloud löschen.`);
|
||||||
|
if (res.errors.length) parts.push(`Fehler:\n${res.errors.join('\n')}`);
|
||||||
|
out.textContent = parts.join('\n');
|
||||||
|
if (res.errors.length) out.className = 'result error';
|
||||||
|
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Personen ----------
|
||||||
|
const personForm = $('#person-form');
|
||||||
|
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr'];
|
||||||
|
function renderPersons() {
|
||||||
|
const list = $('#person-list');
|
||||||
|
list.textContent = '';
|
||||||
|
const persons = [...state.data.persons].sort((a, b) => fullName(a).localeCompare(fullName(b)));
|
||||||
|
if (persons.length === 0) { list.innerHTML = '<li class="hint">Noch niemand angelegt.</li>'; return; }
|
||||||
|
for (const p of persons) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
if (p.active === false) li.classList.add('inactive');
|
||||||
|
const av = avatarEl(p, 'plain');
|
||||||
|
av.title = 'Foto hochladen (Klick)';
|
||||||
|
av.tabIndex = 0;
|
||||||
|
av.setAttribute('role', 'button');
|
||||||
|
av.setAttribute('aria-label', `Foto für ${fullName(p)} hochladen`);
|
||||||
|
av.addEventListener('click', () => choosePhoto(p.id));
|
||||||
|
av.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); choosePhoto(p.id); } });
|
||||||
|
li.appendChild(av);
|
||||||
|
const who = document.createElement('div');
|
||||||
|
who.className = 'who';
|
||||||
|
const strong = document.createElement('strong');
|
||||||
|
strong.textContent = fullName(p);
|
||||||
|
who.appendChild(strong);
|
||||||
|
if (p.email || p.note) { const s = document.createElement('small'); s.textContent = [p.email, p.note].filter(Boolean).join(' · '); who.appendChild(s); }
|
||||||
|
const tags = document.createElement('div');
|
||||||
|
tags.className = 'tags';
|
||||||
|
if (p.canMain) tags.appendChild(tag('Haupt', 'main'));
|
||||||
|
if (p.canCo) tags.appendChild(tag('Neben', 'co'));
|
||||||
|
if (!p.canMain && !p.canCo) tags.appendChild(tag('Nicht-Moderator', 'off'));
|
||||||
|
if (p.freeDays && p.freeDays.length) tags.appendChild(tag(`frei: ${p.freeDays.map((d) => WEEKDAYS[d - 1]).join(', ')}`));
|
||||||
|
if (p.active === false) tags.appendChild(tag('Inaktiv'));
|
||||||
|
who.appendChild(tags);
|
||||||
|
li.appendChild(who);
|
||||||
|
const edit = document.createElement('button');
|
||||||
|
edit.type = 'button';
|
||||||
|
edit.textContent = 'Bearbeiten';
|
||||||
|
edit.addEventListener('click', () => editPerson(p));
|
||||||
|
li.appendChild(edit);
|
||||||
|
if (p.hasPhoto) {
|
||||||
|
const rm = document.createElement('button');
|
||||||
|
rm.type = 'button';
|
||||||
|
rm.className = 'ghost';
|
||||||
|
rm.textContent = 'Foto entfernen';
|
||||||
|
rm.addEventListener('click', () => run(() => api(`api/persons/${p.id}/photo`, { method: 'DELETE' }), 'Foto entfernt.'));
|
||||||
|
li.appendChild(rm);
|
||||||
|
}
|
||||||
|
const del = document.createElement('button');
|
||||||
|
del.type = 'button';
|
||||||
|
del.className = 'ghost danger';
|
||||||
|
del.textContent = 'Löschen';
|
||||||
|
del.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`${fullName(p)} wirklich löschen? Zuteilungen dieser Person werden geleert.`)) return;
|
||||||
|
try { await api(`api/persons/${p.id}`, { method: 'DELETE' }); await load(); toast('Person gelöscht.'); } catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
li.appendChild(del);
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function tag(text, cls = '') { const s = document.createElement('span'); s.className = `tag ${cls}`; s.textContent = text; return s; }
|
||||||
|
|
||||||
|
function editPerson(p) {
|
||||||
|
const f = personForm.elements;
|
||||||
|
f.id.value = p.id;
|
||||||
|
f.firstName.value = p.firstName;
|
||||||
|
f.lastName.value = p.lastName;
|
||||||
|
f.email.value = p.email || '';
|
||||||
|
f.note.value = p.note || '';
|
||||||
|
f.canMain.checked = Boolean(p.canMain);
|
||||||
|
f.canCo.checked = Boolean(p.canCo);
|
||||||
|
f.active.checked = p.active !== false;
|
||||||
|
for (const cb of personForm.querySelectorAll('input[name="freeDays"]')) cb.checked = (p.freeDays || []).includes(Number(cb.value));
|
||||||
|
$('#person-submit').textContent = 'Änderungen speichern';
|
||||||
|
$('#person-cancel').hidden = false;
|
||||||
|
personForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
f.firstName.focus();
|
||||||
|
}
|
||||||
|
function resetPersonForm() {
|
||||||
|
personForm.reset();
|
||||||
|
personForm.elements.id.value = '';
|
||||||
|
$('#person-submit').textContent = 'Person anlegen';
|
||||||
|
$('#person-cancel').hidden = true;
|
||||||
|
}
|
||||||
|
$('#person-cancel').addEventListener('click', resetPersonForm);
|
||||||
|
personForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = personForm.elements;
|
||||||
|
const freeDays = [...personForm.querySelectorAll('input[name="freeDays"]:checked')].map((cb) => Number(cb.value));
|
||||||
|
const body = { firstName: f.firstName.value, lastName: f.lastName.value, email: f.email.value, note: f.note.value, canMain: f.canMain.checked, canCo: f.canCo.checked, active: f.active.checked, freeDays };
|
||||||
|
try {
|
||||||
|
if (f.id.value) await api(`api/persons/${f.id.value}`, { method: 'PUT', body });
|
||||||
|
else await api('api/persons', { method: 'POST', body });
|
||||||
|
resetPersonForm();
|
||||||
|
await load();
|
||||||
|
toast('Gespeichert.');
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactsDialog = $('#contacts-dialog');
|
||||||
|
const contactsForm = $('#contacts-form');
|
||||||
|
let contactsData = null;
|
||||||
|
async function loadContacts(addressbook = '') {
|
||||||
|
const info = $('#contacts-info');
|
||||||
|
const list = $('#contacts-list');
|
||||||
|
list.textContent = '';
|
||||||
|
info.textContent = 'Lade Kontakte …';
|
||||||
|
$('#contacts-go').disabled = true;
|
||||||
|
try {
|
||||||
|
contactsData = await api(`api/nextcloud/contacts${addressbook ? `?addressbook=${encodeURIComponent(addressbook)}` : ''}`);
|
||||||
|
const sel = contactsForm.elements.addressbook;
|
||||||
|
sel.textContent = '';
|
||||||
|
for (const b of contactsData.addressbooks) sel.appendChild(new Option(b.system ? `${b.name} (alle Nextcloud-Konten)` : b.name, b.href));
|
||||||
|
sel.value = contactsData.addressbook;
|
||||||
|
const fresh = contactsData.contacts.filter((c) => !c.existingId);
|
||||||
|
info.textContent = contactsData.contacts.length
|
||||||
|
? `${contactsData.contacts.length} Kontakte, ${fresh.length} davon noch nicht angelegt. Fotos werden übernommen, wenn vorhanden.`
|
||||||
|
: 'Keine Kontakte gefunden.';
|
||||||
|
for (const c of contactsData.contacts) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
if (c.existingId) li.classList.add('exists');
|
||||||
|
const label = document.createElement('label');
|
||||||
|
const cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.value = c.uid;
|
||||||
|
cb.disabled = Boolean(c.existingId);
|
||||||
|
cb.checked = !c.existingId;
|
||||||
|
label.appendChild(cb);
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = `${c.firstName} ${c.lastName}`.trim();
|
||||||
|
label.appendChild(text);
|
||||||
|
const small = document.createElement('small');
|
||||||
|
small.textContent = [c.email, c.existingId ? 'bereits angelegt' : '', c.hasPhoto ? '' : 'kein Foto'].filter(Boolean).join(' · ');
|
||||||
|
label.appendChild(small);
|
||||||
|
li.appendChild(label);
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
$('#contacts-go').disabled = fresh.length === 0;
|
||||||
|
} catch (err) {
|
||||||
|
info.textContent = `Fehler: ${err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#open-contacts').addEventListener('click', () => {
|
||||||
|
$('#contacts-result').hidden = true;
|
||||||
|
contactsDialog.showModal();
|
||||||
|
if (!state.data.nextcloud.configured) { $('#contacts-info').textContent = 'Nextcloud ist nicht konfiguriert.'; $('#contacts-go').disabled = true; return; }
|
||||||
|
loadContacts();
|
||||||
|
});
|
||||||
|
contactsForm.elements.addressbook.addEventListener('change', (e) => loadContacts(e.target.value));
|
||||||
|
$('#contacts-close').addEventListener('click', () => contactsDialog.close());
|
||||||
|
$('#contacts-all').addEventListener('click', () => { for (const cb of contactsForm.querySelectorAll('#contacts-list input:not(:disabled)')) cb.checked = true; });
|
||||||
|
$('#contacts-none').addEventListener('click', () => { for (const cb of contactsForm.querySelectorAll('#contacts-list input:not(:disabled)')) cb.checked = false; });
|
||||||
|
contactsForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const uids = [...contactsForm.querySelectorAll('#contacts-list input:checked:not(:disabled)')].map((cb) => cb.value);
|
||||||
|
const out = $('#contacts-result');
|
||||||
|
if (uids.length === 0) { out.className = 'result error'; out.textContent = 'Nichts ausgewählt.'; out.hidden = false; return; }
|
||||||
|
const btn = $('#contacts-go');
|
||||||
|
btn.disabled = true;
|
||||||
|
out.className = 'result';
|
||||||
|
out.textContent = `Lege ${uids.length} Person(en) an und hole Fotos …`;
|
||||||
|
out.hidden = false;
|
||||||
|
try {
|
||||||
|
const res = await api('api/nextcloud/contacts/import', { method: 'POST', body: { uids, addressbook: contactsForm.elements.addressbook.value } });
|
||||||
|
await load();
|
||||||
|
out.textContent = `${res.created} Person(en) angelegt, ${res.withPhoto} mit Foto.${res.skipped ? ` ${res.skipped} übersprungen.` : ''}`;
|
||||||
|
await loadContacts(contactsForm.elements.addressbook.value);
|
||||||
|
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
let photoTarget = null;
|
||||||
|
function choosePhoto(id) { photoTarget = id; $('#photo-input').value = ''; $('#photo-input').click(); }
|
||||||
|
$('#photo-input').addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file || !photoTarget) return;
|
||||||
|
try {
|
||||||
|
const blob = await squareJpeg(file, 320);
|
||||||
|
await api(`api/persons/${photoTarget}/photo`, { method: 'PUT', raw: { body: blob, type: 'image/jpeg' } });
|
||||||
|
await load();
|
||||||
|
toast('Foto gespeichert.');
|
||||||
|
} catch (err) { toast(`Foto konnte nicht verarbeitet werden: ${err.message}`, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Bild im Browser quadratisch zuschneiden und verkleinern – spart Speicher und Bandbreite. */
|
||||||
|
async function squareJpeg(file, size) {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const side = Math.min(bitmap.width, bitmap.height);
|
||||||
|
const sx = (bitmap.width - side) / 2;
|
||||||
|
const sy = (bitmap.height - side) / 2;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
canvas.getContext('2d').drawImage(bitmap, sx, sy, side, side, 0, 0, size, size);
|
||||||
|
return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas leer'))), 'image/jpeg', 0.86));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Abwesenheiten ----------
|
||||||
|
const absenceForm = $('#absence-form');
|
||||||
|
const TYPE_LABEL = { urlaub: 'Urlaub', krankheit: 'Krankheit', frei: 'Frei', sonstiges: 'Sonstiges' };
|
||||||
|
const absenceImport = $('#absence-import');
|
||||||
|
let absenceCalendarsLoaded = false;
|
||||||
|
async function prepareAbsenceImport() {
|
||||||
|
if (!absenceImport.elements.from.value) {
|
||||||
|
const now = startOfMonth(new Date());
|
||||||
|
absenceImport.elements.from.value = monthKey(now);
|
||||||
|
absenceImport.elements.to.value = monthKey(new Date(now.getFullYear(), now.getMonth() + 3, 1));
|
||||||
|
}
|
||||||
|
if (absenceCalendarsLoaded) return;
|
||||||
|
absenceCalendarsLoaded = true;
|
||||||
|
const list = await fillCalendarSelect(absenceImport.elements.calendarHref, { selected: state.data.settings.absenceCalendarHref });
|
||||||
|
if (!state.data.settings.absenceCalendarHref) {
|
||||||
|
const guess = list.find((c) => /urlaub|abwesen|krank|ferien|vacation|absence/i.test(c.name));
|
||||||
|
if (guess) absenceImport.elements.calendarHref.value = guess.href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
absenceImport.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = absenceImport.elements;
|
||||||
|
const out = $('#absence-import-result');
|
||||||
|
const r = monthRange(f.from.value, f.to.value);
|
||||||
|
const btn = $('#absence-import-go');
|
||||||
|
btn.disabled = true;
|
||||||
|
out.className = 'result';
|
||||||
|
out.textContent = 'Lese Kalender …';
|
||||||
|
out.hidden = false;
|
||||||
|
try {
|
||||||
|
const res = await api('api/nextcloud/absences/import', { method: 'POST', body: { ...r, calendarHref: f.calendarHref.value, calendarName: selectedCalendarName(f.calendarHref) } });
|
||||||
|
await load();
|
||||||
|
const parts = [`${res.imported} Abwesenheit(en) übernommen (${res.events} Termine gelesen, ${res.replaced} frühere Importe ersetzt).`];
|
||||||
|
if (res.unmatched.length) parts.push(`Nicht zugeordnet – Person fehlt oder Name weicht ab:\n${res.unmatched.map((u) => `• ${u.name} (${u.count}×)`).join('\n')}`);
|
||||||
|
out.textContent = parts.join('\n');
|
||||||
|
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
function renderAbsences() {
|
||||||
|
const sel = absenceForm.elements.personId;
|
||||||
|
const current = sel.value;
|
||||||
|
sel.textContent = '';
|
||||||
|
for (const p of [...state.data.persons].filter((p) => p.active !== false).sort((a, b) => fullName(a).localeCompare(fullName(b)))) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = p.id;
|
||||||
|
o.textContent = fullName(p);
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
if (current) sel.value = current;
|
||||||
|
const list = $('#absence-list');
|
||||||
|
list.textContent = '';
|
||||||
|
const showPast = $('#absence-show-past').checked;
|
||||||
|
const today = todayIso();
|
||||||
|
const items = state.data.absences.filter((a) => showPast || a.to >= today).sort((a, b) => a.from.localeCompare(b.from));
|
||||||
|
if (items.length === 0) { list.innerHTML = '<li class="hint">Keine Abwesenheiten eingetragen.</li>'; return; }
|
||||||
|
for (const a of items) {
|
||||||
|
const p = personById(a.personId);
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.appendChild(avatarEl(p, 'plain small'));
|
||||||
|
const grow = document.createElement('div');
|
||||||
|
grow.className = 'grow';
|
||||||
|
const strong = document.createElement('strong');
|
||||||
|
strong.textContent = fullName(p);
|
||||||
|
grow.appendChild(strong);
|
||||||
|
const small = document.createElement('small');
|
||||||
|
small.innerHTML = ` <span class="type-${a.type}">${TYPE_LABEL[a.type] || a.type}</span> · ${fmtDate(a.from)} – ${fmtDate(a.to)}${a.note ? ' · ' : ''}`;
|
||||||
|
small.appendChild(document.createTextNode(a.note || ''));
|
||||||
|
grow.appendChild(small);
|
||||||
|
li.appendChild(grow);
|
||||||
|
if (a.source === 'nextcloud') li.appendChild(tag('Nextcloud'));
|
||||||
|
const conflicts = Object.entries(state.data.assignments).filter(([d, x]) => d >= a.from && d <= a.to && !x.off && (x.main === a.personId || x.co === a.personId));
|
||||||
|
if (conflicts.length) {
|
||||||
|
const warn = document.createElement('span');
|
||||||
|
warn.className = 'tag off';
|
||||||
|
warn.textContent = `${conflicts.length}× eingeplant!`;
|
||||||
|
warn.title = 'Diese Person ist an Tagen im Kalender eingeplant, an denen sie abwesend ist. Im Kalender neu auslosen.';
|
||||||
|
li.appendChild(warn);
|
||||||
|
}
|
||||||
|
const del = document.createElement('button');
|
||||||
|
del.type = 'button';
|
||||||
|
del.className = 'ghost danger';
|
||||||
|
del.textContent = 'Löschen';
|
||||||
|
del.addEventListener('click', () => run(() => api(`api/absences/${a.id}`, { method: 'DELETE' }), 'Abwesenheit gelöscht.'));
|
||||||
|
li.appendChild(del);
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#absence-show-past').addEventListener('change', renderAbsences);
|
||||||
|
absenceForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = absenceForm.elements;
|
||||||
|
try {
|
||||||
|
await api('api/absences', { method: 'POST', body: { personId: f.personId.value, type: f.type.value, from: f.from.value, to: f.to.value, note: f.note.value } });
|
||||||
|
f.note.value = '';
|
||||||
|
await load();
|
||||||
|
toast('Abwesenheit eingetragen.');
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
absenceForm.elements.from.addEventListener('change', () => { if (!absenceForm.elements.to.value || absenceForm.elements.to.value < absenceForm.elements.from.value) absenceForm.elements.to.value = absenceForm.elements.from.value; });
|
||||||
|
|
||||||
|
// ---------- Einstellungen ----------
|
||||||
|
const settingsForm = $('#settings-form');
|
||||||
|
function renderSettings() {
|
||||||
|
const s = state.data.settings;
|
||||||
|
const f = settingsForm.elements;
|
||||||
|
const sel = f.bundesland;
|
||||||
|
if (sel.options.length === 0) {
|
||||||
|
for (const [code, name] of Object.entries(state.data.bundeslaender)) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = code;
|
||||||
|
o.textContent = name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.meetingTime.value = s.meetingTime || '';
|
||||||
|
f.durationMinutes.value = s.durationMinutes;
|
||||||
|
f.bundesland.value = s.bundesland || '';
|
||||||
|
f.historyWindowDays.value = s.historyWindowDays;
|
||||||
|
f.calendarDisplayName.value = s.calendarDisplayName;
|
||||||
|
f.inviteAttendees.checked = Boolean(s.inviteAttendees);
|
||||||
|
if (!$('#holiday-generate').elements.year.value) $('#holiday-generate').elements.year.value = new Date().getFullYear();
|
||||||
|
|
||||||
|
const list = $('#holiday-list');
|
||||||
|
list.textContent = '';
|
||||||
|
const today = todayIso();
|
||||||
|
const items = state.data.holidays.filter((h) => h.date >= today.slice(0, 4) + '-01-01');
|
||||||
|
if (items.length === 0) list.innerHTML = '<li class="hint">Keine Feiertage hinterlegt – oben für das Jahr erzeugen.</li>';
|
||||||
|
for (const h of items) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const grow = document.createElement('span');
|
||||||
|
grow.className = 'grow';
|
||||||
|
grow.textContent = `${fmtDate(h.date)} – ${h.name}`;
|
||||||
|
li.appendChild(grow);
|
||||||
|
if (!h.auto) li.appendChild(tag('manuell'));
|
||||||
|
const del = document.createElement('button');
|
||||||
|
del.type = 'button';
|
||||||
|
del.className = 'ghost danger';
|
||||||
|
del.textContent = 'Entfernen';
|
||||||
|
del.addEventListener('click', () => run(() => api(`api/holidays/${h.date}`, { method: 'DELETE' }), 'Eintrag entfernt.'));
|
||||||
|
li.appendChild(del);
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nc = state.data.nextcloud;
|
||||||
|
const box = $('#nextcloud-status');
|
||||||
|
box.innerHTML = nc.configured
|
||||||
|
? `<p class="status-ok">Konfiguriert: <code>${escapeHtml(nc.baseUrl)}</code>, angemeldet als <code>${escapeHtml(nc.user)}</code>.</p>`
|
||||||
|
+ `<p>Zielkalender: <strong>${s.targetCalendarHref ? escapeHtml(s.targetCalendarName || s.targetCalendarHref) : `neuer Kalender „${escapeHtml(s.calendarDisplayName)}" (${escapeHtml(nc.calendar)})`}</strong> – wählbar im Dialog „Nextcloud …" im Kalender.</p>`
|
||||||
|
+ `<p>Quellkalender für Abwesenheiten: <strong>${s.absenceCalendarHref ? escapeHtml(s.absenceCalendarName || s.absenceCalendarHref) : 'noch keiner'}</strong> – wählbar unter „Abwesenheiten".</p>`
|
||||||
|
: `<p class="status-warn">Nicht konfiguriert.</p><p class="hint">Umgebungsvariablen setzen: <code>NEXTCLOUD_URL</code>, <code>NEXTCLOUD_USER</code>, <code>NEXTCLOUD_APP_PASSWORD</code> (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional <code>NEXTCLOUD_CALENDAR</code>. Bis dahin steht der ICS-Download zur Verfügung.</p>`;
|
||||||
|
}
|
||||||
|
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||||
|
|
||||||
|
settingsForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = settingsForm.elements;
|
||||||
|
try {
|
||||||
|
await api('api/settings', { method: 'PUT', body: {
|
||||||
|
meetingTime: f.meetingTime.value, durationMinutes: Number(f.durationMinutes.value), bundesland: f.bundesland.value,
|
||||||
|
historyWindowDays: Number(f.historyWindowDays.value), calendarDisplayName: f.calendarDisplayName.value, inviteAttendees: f.inviteAttendees.checked,
|
||||||
|
} });
|
||||||
|
await load();
|
||||||
|
toast('Einstellungen gespeichert.');
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
$('#holiday-generate').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api('api/holidays/generate', { method: 'POST', body: { year: Number(e.target.elements.year.value) } });
|
||||||
|
await load();
|
||||||
|
toast('Feiertage erzeugt.');
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
$('#holiday-add').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = e.target.elements;
|
||||||
|
try {
|
||||||
|
await api('api/holidays', { method: 'POST', body: { date: f.date.value, name: f.name.value } });
|
||||||
|
f.name.value = '';
|
||||||
|
await load();
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Start ----------
|
||||||
|
function renderAll() {
|
||||||
|
renderCalendar();
|
||||||
|
renderPersons();
|
||||||
|
renderAbsences();
|
||||||
|
renderSettings();
|
||||||
|
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
|
||||||
|
}
|
||||||
|
showView(location.hash.slice(1));
|
||||||
|
load().catch((err) => toast(`Laden fehlgeschlagen: ${err.message}`, true));
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<title>Rotatory – Morning-Meeting-Moderation</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8E%99%3C/text%3E%3C/svg%3E">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<h1><span aria-hidden="true">🎙</span> Rotatory</h1>
|
||||||
|
<nav aria-label="Bereiche">
|
||||||
|
<a href="#kalender" data-view="kalender">Kalender</a>
|
||||||
|
<a href="#personen" data-view="personen">Personen</a>
|
||||||
|
<a href="#abwesenheiten" data-view="abwesenheiten">Abwesenheiten</a>
|
||||||
|
<a href="#einstellungen" data-view="einstellungen">Einstellungen</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
<!-- Kalender -->
|
||||||
|
<section id="view-kalender" class="view" aria-labelledby="cal-title">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="month-nav">
|
||||||
|
<button type="button" id="prev-month" aria-label="Vorheriger Monat">‹</button>
|
||||||
|
<h2 id="cal-title">Monat</h2>
|
||||||
|
<button type="button" id="next-month" aria-label="Nächster Monat">›</button>
|
||||||
|
<button type="button" id="today-month" class="ghost">Heute</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="open-plan" class="primary">Auslosen …</button>
|
||||||
|
<button type="button" id="open-nextcloud">Nextcloud …</button>
|
||||||
|
<a id="download-ics" class="button ghost" href="#" download>ICS</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="cal-hint" class="hint" hidden></p>
|
||||||
|
<div class="calendar" aria-labelledby="cal-title">
|
||||||
|
<div class="weekdays" aria-hidden="true">
|
||||||
|
<div>Mo</div><div>Di</div><div>Mi</div><div>Do</div><div>Fr</div>
|
||||||
|
</div>
|
||||||
|
<div id="cal-grid" class="days"></div>
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span><span class="badge main">H</span> Hauptmoderation</span>
|
||||||
|
<span><span class="badge co">N</span> Nebenmoderation</span>
|
||||||
|
<span><span class="lock" aria-hidden="true">🔒</span> fixiert</span>
|
||||||
|
<span><span class="avatar empty small" aria-hidden="true">?</span> unbesetzt</span>
|
||||||
|
</div>
|
||||||
|
<section class="stats" aria-labelledby="stats-title">
|
||||||
|
<h3 id="stats-title">Einsätze in diesem Monat</h3>
|
||||||
|
<div id="stats"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Personen -->
|
||||||
|
<section id="view-personen" class="view" hidden aria-labelledby="persons-title">
|
||||||
|
<h2 id="persons-title">Personen</h2>
|
||||||
|
<form id="person-form" class="card form-grid" autocomplete="off">
|
||||||
|
<input type="hidden" name="id">
|
||||||
|
<label>Vorname <input name="firstName" required maxlength="60"></label>
|
||||||
|
<label>Nachname <input name="lastName" required maxlength="60"></label>
|
||||||
|
<label>E-Mail (optional, für Kalendereinladungen) <input name="email" type="email" maxlength="120"></label>
|
||||||
|
<label>Notiz / Grund (optional) <input name="note" maxlength="300" placeholder="z. B. Azubi, nur Nebenmoderation"></label>
|
||||||
|
<fieldset class="checks">
|
||||||
|
<legend>Rolle in der Rotation</legend>
|
||||||
|
<label><input type="checkbox" name="canMain" checked> Hauptmoderation möglich</label>
|
||||||
|
<label><input type="checkbox" name="canCo" checked> Nebenmoderation möglich</label>
|
||||||
|
<label><input type="checkbox" name="active" checked> Aktiv (in der Belegschaft)</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="checks">
|
||||||
|
<legend>Feste freie Wochentage (Teilzeit)</legend>
|
||||||
|
<label><input type="checkbox" name="freeDays" value="1"> Mo</label>
|
||||||
|
<label><input type="checkbox" name="freeDays" value="2"> Di</label>
|
||||||
|
<label><input type="checkbox" name="freeDays" value="3"> Mi</label>
|
||||||
|
<label><input type="checkbox" name="freeDays" value="4"> Do</label>
|
||||||
|
<label><input type="checkbox" name="freeDays" value="5"> Fr</label>
|
||||||
|
</fieldset>
|
||||||
|
<p class="hint">Beide Rollen abwählen = Nicht-Moderator (bleibt angelegt, wird nie ausgelost).</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="primary" id="person-submit">Person anlegen</button>
|
||||||
|
<button type="button" class="ghost" id="person-cancel" hidden>Abbrechen</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button type="button" id="open-contacts">Aus Nextcloud importieren …</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<ul id="person-list" class="person-list"></ul>
|
||||||
|
<input type="file" id="photo-input" accept="image/*" hidden>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Abwesenheiten -->
|
||||||
|
<section id="view-abwesenheiten" class="view" hidden aria-labelledby="absences-title">
|
||||||
|
<h2 id="absences-title">Abwesenheiten</h2>
|
||||||
|
<form id="absence-form" class="card form-grid" autocomplete="off">
|
||||||
|
<label>Person <select name="personId" required></select></label>
|
||||||
|
<label>Art
|
||||||
|
<select name="type">
|
||||||
|
<option value="urlaub">Urlaub</option>
|
||||||
|
<option value="krankheit">Krankheit (geplant)</option>
|
||||||
|
<option value="frei">Frei (Gleittag, Teilzeit)</option>
|
||||||
|
<option value="sonstiges">Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Von <input type="date" name="from" required></label>
|
||||||
|
<label>Bis <input type="date" name="to" required></label>
|
||||||
|
<label class="wide">Notiz (optional) <input name="note" maxlength="200"></label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="primary">Abwesenheit eintragen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form id="absence-import" class="card form-grid" autocomplete="off">
|
||||||
|
<h3 class="wide">Aus Nextcloud-Kalender übernehmen</h3>
|
||||||
|
<label class="wide">Quellkalender (Termine wie „Vorname Nachname (Urlaub)", „… (krank)", „… (frei)")
|
||||||
|
<select name="calendarHref" required><option value="">– Kalender laden …</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Von Monat <input type="month" name="from" required></label>
|
||||||
|
<label>Bis Monat <input type="month" name="to" required></label>
|
||||||
|
<p class="hint">Bereits importierte Einträge im Zeitraum werden ersetzt, manuelle bleiben. Namen, die keiner Person zugeordnet werden können, werden aufgelistet.</p>
|
||||||
|
<p id="absence-import-result" class="result wide" hidden></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="primary" id="absence-import-go">Importieren</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="hint">Abwesenheiten wirken auf die nächste Auslosung. Bereits ausgeloste Tage werden markiert und lassen sich im Kalender neu auslosen.</p>
|
||||||
|
<label class="inline"><input type="checkbox" id="absence-show-past"> Vergangene anzeigen</label>
|
||||||
|
<ul id="absence-list" class="list"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Einstellungen -->
|
||||||
|
<section id="view-einstellungen" class="view" hidden aria-labelledby="settings-title">
|
||||||
|
<h2 id="settings-title">Einstellungen</h2>
|
||||||
|
<form id="settings-form" class="card form-grid" autocomplete="off">
|
||||||
|
<label>Uhrzeit des Meetings (leer = ganztägig) <input type="time" name="meetingTime"></label>
|
||||||
|
<label>Dauer in Minuten <input type="number" name="durationMinutes" min="5" max="480"></label>
|
||||||
|
<label>Bundesland (für Feiertage) <select name="bundesland"></select></label>
|
||||||
|
<label>Fairness-Fenster in Tagen <input type="number" name="historyWindowDays" min="0" max="365"></label>
|
||||||
|
<label>Kalendername in Nextcloud <input name="calendarDisplayName" maxlength="60"></label>
|
||||||
|
<label class="inline wide"><input type="checkbox" name="inviteAttendees"> Moderatoren als Teilnehmer eintragen (E-Mail nötig; Nextcloud verschickt dann Einladungen)</label>
|
||||||
|
<div class="form-actions"><button type="submit" class="primary">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Feiertage und betriebsfreie Tage</h3>
|
||||||
|
<div class="card">
|
||||||
|
<form id="holiday-generate" class="row">
|
||||||
|
<label>Gesetzliche Feiertage erzeugen für <input type="number" name="year" min="2000" max="2100" required></label>
|
||||||
|
<button type="submit">Erzeugen</button>
|
||||||
|
</form>
|
||||||
|
<form id="holiday-add" class="row">
|
||||||
|
<label>Tag <input type="date" name="date" required></label>
|
||||||
|
<label>Bezeichnung <input name="name" maxlength="80" placeholder="z. B. Betriebsausflug"></label>
|
||||||
|
<button type="submit">Hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
<ul id="holiday-list" class="list compact"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Nextcloud</h3>
|
||||||
|
<div class="card" id="nextcloud-status"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Dialog: Tag bearbeiten -->
|
||||||
|
<dialog id="day-dialog">
|
||||||
|
<form method="dialog" class="dialog-form" id="day-form">
|
||||||
|
<h2 id="day-dialog-title">Tag</h2>
|
||||||
|
<p id="day-dialog-info" class="hint"></p>
|
||||||
|
<label>Hauptmoderation <select name="main"></select></label>
|
||||||
|
<label>Nebenmoderation <select name="co"></select></label>
|
||||||
|
<label class="inline"><input type="checkbox" name="locked"> Fixieren (bleibt bei jeder Auslosung erhalten)</label>
|
||||||
|
<label class="inline"><input type="checkbox" name="off"> Kein Meeting an diesem Tag</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" id="day-reroll">🎲 Neu auslosen</button>
|
||||||
|
<button type="button" id="day-clear" class="ghost danger">Entfernen</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button type="button" id="day-close" class="ghost">Abbrechen</button>
|
||||||
|
<button type="submit" class="primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Dialog: Auslosen -->
|
||||||
|
<dialog id="plan-dialog">
|
||||||
|
<form method="dialog" class="dialog-form" id="plan-form">
|
||||||
|
<h2>Moderation auslosen</h2>
|
||||||
|
<label>Von Monat <input type="month" name="from" required></label>
|
||||||
|
<label>Bis Monat <input type="month" name="to" required></label>
|
||||||
|
<label class="inline"><input type="checkbox" name="overwrite"> Bereits ausgeloste, nicht fixierte Tage neu auslosen</label>
|
||||||
|
<p class="hint">Werktage ohne Feiertag. Abwesende werden übersprungen, niemand zwei Werktage in Folge, Einsätze möglichst gleich verteilt.</p>
|
||||||
|
<p id="plan-result" class="result" hidden></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" id="plan-clear" class="ghost danger">Zeitraum leeren</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button type="button" class="ghost" id="plan-close">Schließen</button>
|
||||||
|
<button type="submit" class="primary">Auslosen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Dialog: Nextcloud -->
|
||||||
|
<dialog id="nextcloud-dialog">
|
||||||
|
<form method="dialog" class="dialog-form" id="nextcloud-form">
|
||||||
|
<h2>In Nextcloud schreiben</h2>
|
||||||
|
<p id="nextcloud-info" class="hint"></p>
|
||||||
|
<label>Zielkalender
|
||||||
|
<select name="target"><option value="">– Kalender laden …</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Von Monat <input type="month" name="from" required></label>
|
||||||
|
<label>Bis Monat <input type="month" name="to" required></label>
|
||||||
|
<label class="inline"><input type="checkbox" name="removeForeign"> Andere Termine im Zeitraum aus dem Zielkalender entfernen (z. B. alte Morning-Meeting-Termine)</label>
|
||||||
|
<p class="hint">Je Werktag ein Termin. Wiederholtes Schreiben aktualisiert bestehende Termine; entfallene Tage werden im Kalender gelöscht.</p>
|
||||||
|
<p id="nextcloud-result" class="result" hidden></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" id="nextcloud-test">Verbindung testen</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button type="button" class="ghost" id="nextcloud-close">Schließen</button>
|
||||||
|
<button type="submit" class="primary" id="nextcloud-go">Go – schreiben</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Dialog: Kontakte importieren -->
|
||||||
|
<dialog id="contacts-dialog">
|
||||||
|
<form method="dialog" class="dialog-form" id="contacts-form">
|
||||||
|
<h2>Personen aus Nextcloud</h2>
|
||||||
|
<label>Adressbuch <select name="addressbook"></select></label>
|
||||||
|
<p id="contacts-info" class="hint">Lade Kontakte …</p>
|
||||||
|
<div class="contacts-tools">
|
||||||
|
<button type="button" class="ghost" id="contacts-all">Alle neuen wählen</button>
|
||||||
|
<button type="button" class="ghost" id="contacts-none">Keine</button>
|
||||||
|
</div>
|
||||||
|
<ul id="contacts-list" class="contacts-list"></ul>
|
||||||
|
<p id="contacts-result" class="result" hidden></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button type="button" class="ghost" id="contacts-close">Schließen</button>
|
||||||
|
<button type="submit" class="primary" id="contacts-go">Ausgewählte anlegen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
||||||
|
<script type="module" src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1c2130;
|
||||||
|
--muted: #5d6578;
|
||||||
|
--line: #dde1ea;
|
||||||
|
--accent: #2f6fed;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--main: #2f6fed;
|
||||||
|
--co: #e08a1e;
|
||||||
|
--danger: #c93b3b;
|
||||||
|
--holiday: #eef0f5;
|
||||||
|
--today: #fff6d6;
|
||||||
|
--warn: #b25e00;
|
||||||
|
--ok: #1d7a3e;
|
||||||
|
--radius: 10px;
|
||||||
|
--cell-min: 64px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #14171f;
|
||||||
|
--card: #1e222c;
|
||||||
|
--text: #e8eaf0;
|
||||||
|
--muted: #a0a7b8;
|
||||||
|
--line: #313746;
|
||||||
|
--holiday: #1a1d25;
|
||||||
|
--today: #3a3420;
|
||||||
|
--accent: #5b8dff;
|
||||||
|
--main: #5b8dff;
|
||||||
|
--co: #f0a23a;
|
||||||
|
--danger: #ff7b7b;
|
||||||
|
--warn: #f2b45a;
|
||||||
|
--ok: #5fcf7f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { color-scheme: light dark; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
h1, h2, h3 { margin: 0 0 .5rem; line-height: 1.2; }
|
||||||
|
h1 { font-size: 1.25rem; }
|
||||||
|
h2 { font-size: 1.2rem; }
|
||||||
|
h3 { font-size: 1rem; margin-top: 1.5rem; }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: center;
|
||||||
|
padding: .6rem 1rem; background: var(--card); border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky; top: 0; z-index: 5;
|
||||||
|
}
|
||||||
|
.topbar nav { display: flex; gap: .25rem; flex-wrap: wrap; }
|
||||||
|
.topbar nav a {
|
||||||
|
text-decoration: none; color: var(--muted); padding: .35rem .7rem; border-radius: 999px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.topbar nav a[aria-current="page"] { background: var(--accent); color: var(--accent-text); }
|
||||||
|
.topbar nav a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, .day:focus-visible {
|
||||||
|
outline: 3px solid var(--accent); outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main { padding: 1rem; max-width: 1100px; margin: 0 auto; }
|
||||||
|
.view > h2 { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; margin-bottom: 1rem; }
|
||||||
|
.form-grid { display: grid; gap: .75rem; grid-template-columns: 1fr; }
|
||||||
|
@media (min-width: 640px) { .form-grid { grid-template-columns: 1fr 1fr; } .form-grid .wide, .form-grid fieldset, .form-grid .form-actions, .form-grid .hint { grid-column: 1 / -1; } }
|
||||||
|
label { display: flex; flex-direction: column; gap: .25rem; font-size: .9rem; color: var(--muted); }
|
||||||
|
label.inline { flex-direction: row; align-items: center; gap: .5rem; color: var(--text); }
|
||||||
|
input, select, button, .button {
|
||||||
|
font: inherit; border-radius: 8px; border: 1px solid var(--line); padding: .5rem .65rem; background: var(--card); color: var(--text);
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
input[type="checkbox"] { min-height: 0; width: 1.1rem; height: 1.1rem; accent-color: var(--accent); }
|
||||||
|
/* Felder dürfen nie breiter werden als ihr Container – lange Kalendernamen in <select> sonst schon */
|
||||||
|
label > input:not([type="checkbox"]), label > select { width: 100%; min-width: 0; max-width: 100%; }
|
||||||
|
.form-grid > *, .dialog-form > *, .row > label { min-width: 0; }
|
||||||
|
.row > label { flex: 1 1 180px; }
|
||||||
|
.row > button { flex: 0 0 auto; }
|
||||||
|
fieldset.checks { border: 1px solid var(--line); border-radius: 8px; display: flex; flex-wrap: wrap; gap: .75rem 1.5rem; padding: .5rem .75rem .75rem; margin: 0; }
|
||||||
|
fieldset.checks legend { font-size: .85rem; color: var(--muted); padding: 0 .25rem; }
|
||||||
|
fieldset.checks label { flex-direction: row; align-items: center; gap: .4rem; color: var(--text); }
|
||||||
|
button, .button { cursor: pointer; font-weight: 500; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: .35rem; }
|
||||||
|
button:hover, .button:hover { border-color: var(--accent); }
|
||||||
|
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||||
|
button.ghost, .button.ghost { background: transparent; }
|
||||||
|
button.danger { color: var(--danger); }
|
||||||
|
button:disabled { opacity: .5; cursor: wait; }
|
||||||
|
.form-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||||
|
.form-actions .spacer { flex: 1; }
|
||||||
|
.row { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: end; margin-bottom: .75rem; }
|
||||||
|
.hint { color: var(--muted); font-size: .88rem; margin: .25rem 0 .75rem; }
|
||||||
|
.result { padding: .6rem .8rem; border-radius: 8px; background: var(--holiday); font-size: .9rem; white-space: pre-line; }
|
||||||
|
.result.error { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Kalender */
|
||||||
|
.toolbar { display: flex; flex-wrap: wrap; gap: .75rem; justify-content: space-between; align-items: center; margin-bottom: .75rem; }
|
||||||
|
.month-nav { display: flex; align-items: center; gap: .4rem; }
|
||||||
|
.month-nav h2 { min-width: 11ch; text-align: center; margin: 0; }
|
||||||
|
.month-nav button { min-width: 40px; }
|
||||||
|
.actions { display: flex; gap: .4rem; flex-wrap: wrap; }
|
||||||
|
.calendar { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
|
||||||
|
.weekdays, .days { display: grid; grid-template-columns: repeat(5, 1fr); }
|
||||||
|
.weekdays div { text-align: center; font-size: .8rem; color: var(--muted); padding: .4rem 0; border-bottom: 1px solid var(--line); text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.day {
|
||||||
|
min-height: var(--cell-min); padding: .3rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line);
|
||||||
|
display: flex; flex-direction: column; gap: .25rem; text-align: left; background: transparent; border-radius: 0; color: inherit;
|
||||||
|
cursor: pointer; align-items: stretch; justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.day:nth-child(5n) { border-right: 0; }
|
||||||
|
.day.outside { background: var(--holiday); cursor: default; opacity: .55; }
|
||||||
|
.day.holiday { background: var(--holiday); }
|
||||||
|
.day.today { background: var(--today); }
|
||||||
|
.day.past { opacity: .7; }
|
||||||
|
.day:hover:not(.outside) { border-color: var(--line); box-shadow: inset 0 0 0 2px var(--accent); }
|
||||||
|
.day .num { font-size: .8rem; color: var(--muted); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.day .num .lock { font-size: .7rem; }
|
||||||
|
.day .label { font-size: .72rem; color: var(--muted); line-height: 1.2; overflow-wrap: anywhere; }
|
||||||
|
.day .label.off { color: var(--warn); font-weight: 500; }
|
||||||
|
.day .pair { display: flex; gap: .3rem; align-items: flex-end; justify-content: center; flex-wrap: wrap; }
|
||||||
|
.slot { display: flex; flex-direction: column; align-items: center; gap: .1rem; min-width: 0; }
|
||||||
|
.slot .name { font-size: .68rem; line-height: 1.1; text-align: center; max-width: 5.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.slot .name.absent { color: var(--danger); text-decoration: line-through; }
|
||||||
|
.avatar.absent { border-color: var(--danger); box-shadow: 0 0 0 2px var(--danger); }
|
||||||
|
.avatar {
|
||||||
|
width: 52px; height: 52px; border-radius: 50%; object-fit: cover; background: var(--holiday);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; font-weight: 600; color: #fff; font-size: 1rem;
|
||||||
|
border: 3px solid var(--main); position: relative; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.avatar.co { width: 42px; height: 42px; border-color: var(--co); font-size: .85rem; }
|
||||||
|
.avatar.empty { border-style: dashed; color: var(--muted); background: transparent; }
|
||||||
|
.avatar.small { width: 24px; height: 24px; border-width: 2px; font-size: .7rem; }
|
||||||
|
.avatar.plain { border-color: var(--line); }
|
||||||
|
.slot .avatar-wrap { position: relative; display: inline-block; }
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%;
|
||||||
|
font-size: .65rem; font-weight: 700; color: #fff; background: var(--main);
|
||||||
|
}
|
||||||
|
.badge.co { background: var(--co); }
|
||||||
|
.slot .badge { position: absolute; bottom: -2px; right: -4px; border: 2px solid var(--card); width: 20px; height: 20px; }
|
||||||
|
.legend { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; margin: .75rem 0; font-size: .85rem; color: var(--muted); align-items: center; }
|
||||||
|
.legend > span { display: inline-flex; align-items: center; gap: .35rem; }
|
||||||
|
.stats { margin-top: .5rem; }
|
||||||
|
.stats h3 { margin-top: .5rem; }
|
||||||
|
#stats { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||||
|
.chip { display: inline-flex; align-items: center; gap: .4rem; padding: .25rem .6rem .25rem .3rem; border: 1px solid var(--line); border-radius: 999px; background: var(--card); font-size: .85rem; }
|
||||||
|
.chip .avatar { width: 26px; height: 26px; border-width: 2px; font-size: .7rem; }
|
||||||
|
.chip .count { color: var(--muted); }
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
:root { --cell-min: 78px; }
|
||||||
|
main { padding: .5rem; }
|
||||||
|
.avatar { width: 34px; height: 34px; border-width: 2px; font-size: .75rem; }
|
||||||
|
.avatar.co { width: 28px; height: 28px; font-size: .65rem; }
|
||||||
|
.slot .badge { width: 15px; height: 15px; font-size: .55rem; }
|
||||||
|
.slot .name { display: none; }
|
||||||
|
.day { padding: .2rem; }
|
||||||
|
.day .pair { flex-direction: column; align-items: center; gap: .15rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Listen */
|
||||||
|
.list, .person-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .5rem; }
|
||||||
|
.list li, .person-list li {
|
||||||
|
display: flex; align-items: center; gap: .75rem; padding: .6rem .8rem; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.list.compact li { padding: .35rem .6rem; }
|
||||||
|
.person-list .avatar { width: 56px; height: 56px; cursor: pointer; }
|
||||||
|
.person-list .avatar.plain:hover { border-color: var(--accent); }
|
||||||
|
.person-list .who { flex: 1; min-width: 160px; }
|
||||||
|
.person-list .who strong { display: block; }
|
||||||
|
.person-list .who small, .list small { color: var(--muted); }
|
||||||
|
.tags { display: flex; gap: .3rem; flex-wrap: wrap; margin-top: .2rem; }
|
||||||
|
.tag { font-size: .72rem; padding: .1rem .5rem; border-radius: 999px; background: var(--holiday); color: var(--muted); }
|
||||||
|
.tag.main { background: var(--main); color: #fff; }
|
||||||
|
.tag.co { background: var(--co); color: #fff; }
|
||||||
|
.tag.off { background: var(--danger); color: #fff; }
|
||||||
|
.list li .grow { flex: 1; min-width: 160px; }
|
||||||
|
.list li button, .person-list li button { min-height: 34px; padding: .3rem .6rem; font-size: .85rem; }
|
||||||
|
.person-list li.inactive { opacity: .6; }
|
||||||
|
.type-urlaub { color: var(--ok); } .type-krankheit { color: var(--danger); } .type-frei { color: var(--accent); } .type-sonstiges { color: var(--muted); }
|
||||||
|
.contacts-list { list-style: none; margin: 0; padding: 0; max-height: 50vh; overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
|
||||||
|
.contacts-list li { display: flex; align-items: center; gap: .6rem; padding: .4rem .6rem; border-bottom: 1px solid var(--line); }
|
||||||
|
.contacts-list li:last-child { border-bottom: 0; }
|
||||||
|
.contacts-list li.exists { opacity: .55; }
|
||||||
|
.contacts-list label { flex-direction: row; align-items: center; gap: .6rem; color: var(--text); flex: 1; cursor: pointer; }
|
||||||
|
.contacts-list small { color: var(--muted); }
|
||||||
|
.contacts-tools { display: flex; gap: .5rem; }
|
||||||
|
.form-grid h3 { margin: 0; }
|
||||||
|
|
||||||
|
/* Dialoge */
|
||||||
|
dialog { border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--card); color: var(--text); width: min(92vw, 520px); max-width: calc(100vw - 2rem); box-sizing: border-box; }
|
||||||
|
dialog::backdrop { background: rgba(0,0,0,.45); }
|
||||||
|
.dialog-form { display: grid; gap: .75rem; padding: 1.25rem; }
|
||||||
|
.dialog-form h2 { margin: 0; }
|
||||||
|
.dialog-form .form-actions { margin-top: .5rem; }
|
||||||
|
|
||||||
|
.toast { position: fixed; bottom: calc(1rem + env(safe-area-inset-bottom, 0px)); left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: .6rem 1rem; border-radius: 999px; font-size: .9rem; z-index: 20; max-width: 90vw; }
|
||||||
|
.toast.error { background: var(--danger); color: #fff; }
|
||||||
|
.status-ok { color: var(--ok); } .status-warn { color: var(--warn); }
|
||||||
|
code { background: var(--holiday); padding: .1rem .3rem; border-radius: 4px; font-size: .85em; }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
/** Lädt eine .env-Datei in process.env, ohne bereits gesetzte Variablen zu überschreiben. */
|
||||||
|
export function loadEnvFile(file) {
|
||||||
|
if (!fs.existsSync(file)) return false;
|
||||||
|
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let value = m[2];
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
||||||
|
else if (value.startsWith('#')) value = ''; // "KEY= # optional" – Kommentar ist kein Wert
|
||||||
|
else value = value.replace(/\s+#.*$/, '').trim();
|
||||||
|
if (process.env[m[1]] === undefined) process.env[m[1]] = value;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { addDays } from './scheduler.js';
|
||||||
|
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
const iso = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
|
||||||
|
|
||||||
|
/** Ostersonntag nach der Gauß'schen Osterformel (Meeus/Jones/Butcher). */
|
||||||
|
export function easterSunday(year) {
|
||||||
|
const a = year % 19;
|
||||||
|
const b = Math.floor(year / 100);
|
||||||
|
const c = year % 100;
|
||||||
|
const d = Math.floor(b / 4);
|
||||||
|
const e = b % 4;
|
||||||
|
const f = Math.floor((b + 8) / 25);
|
||||||
|
const g = Math.floor((b - f + 1) / 3);
|
||||||
|
const h = (19 * a + b - d - g + 15) % 30;
|
||||||
|
const i = Math.floor(c / 4);
|
||||||
|
const k = c % 4;
|
||||||
|
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||||
|
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||||
|
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||||
|
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||||
|
return iso(year, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUNDESLAENDER = {
|
||||||
|
'': 'Nur bundesweite Feiertage',
|
||||||
|
BW: 'Baden-Württemberg', BY: 'Bayern', BE: 'Berlin', BB: 'Brandenburg', HB: 'Bremen',
|
||||||
|
HH: 'Hamburg', HE: 'Hessen', MV: 'Mecklenburg-Vorpommern', NI: 'Niedersachsen',
|
||||||
|
NW: 'Nordrhein-Westfalen', RP: 'Rheinland-Pfalz', SL: 'Saarland', SN: 'Sachsen',
|
||||||
|
ST: 'Sachsen-Anhalt', SH: 'Schleswig-Holstein', TH: 'Thüringen',
|
||||||
|
};
|
||||||
|
|
||||||
|
function bussUndBettag(year) {
|
||||||
|
// Mittwoch vor dem 23. November
|
||||||
|
let d = iso(year, 11, 22);
|
||||||
|
while (new Date(d + 'T00:00:00Z').getUTCDay() !== 3) d = addDays(d, -1);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gesetzliche Feiertage eines Jahres, bundesweit plus die des gewählten Bundeslands. */
|
||||||
|
export function germanHolidays(year, state = '') {
|
||||||
|
const easter = easterSunday(year);
|
||||||
|
const all = [
|
||||||
|
{ date: iso(year, 1, 1), name: 'Neujahr' },
|
||||||
|
{ date: iso(year, 1, 6), name: 'Heilige Drei Könige', states: ['BW', 'BY', 'ST'] },
|
||||||
|
{ date: iso(year, 3, 8), name: 'Internationaler Frauentag', states: ['BE', 'MV'] },
|
||||||
|
{ date: addDays(easter, -2), name: 'Karfreitag' },
|
||||||
|
{ date: addDays(easter, 1), name: 'Ostermontag' },
|
||||||
|
{ date: iso(year, 5, 1), name: 'Tag der Arbeit' },
|
||||||
|
{ date: addDays(easter, 39), name: 'Christi Himmelfahrt' },
|
||||||
|
{ date: addDays(easter, 50), name: 'Pfingstmontag' },
|
||||||
|
{ date: addDays(easter, 60), name: 'Fronleichnam', states: ['BW', 'BY', 'HE', 'NW', 'RP', 'SL'] },
|
||||||
|
{ date: iso(year, 8, 15), name: 'Mariä Himmelfahrt', states: ['SL', 'BY'] },
|
||||||
|
{ date: iso(year, 9, 20), name: 'Weltkindertag', states: ['TH'] },
|
||||||
|
{ date: iso(year, 10, 3), name: 'Tag der Deutschen Einheit' },
|
||||||
|
{ date: iso(year, 10, 31), name: 'Reformationstag', states: ['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'] },
|
||||||
|
{ date: iso(year, 11, 1), name: 'Allerheiligen', states: ['BW', 'BY', 'NW', 'RP', 'SL'] },
|
||||||
|
{ date: bussUndBettag(year), name: 'Buß- und Bettag', states: ['SN'] },
|
||||||
|
{ date: iso(year, 12, 25), name: '1. Weihnachtstag' },
|
||||||
|
{ date: iso(year, 12, 26), name: '2. Weihnachtstag' },
|
||||||
|
];
|
||||||
|
return all
|
||||||
|
.filter((h) => !h.states || h.states.includes(state))
|
||||||
|
.map(({ date, name }) => ({ date, name, auto: true }))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
// Lesen von iCalendar (VEVENT) und vCard – nur was Rotatory braucht, ohne Abhängigkeiten.
|
||||||
|
|
||||||
|
const berlinDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||||
|
|
||||||
|
/** Gefaltete Zeilen (CRLF + Leerzeichen/Tab) wieder zusammenfügen. */
|
||||||
|
export function unfold(text) {
|
||||||
|
return String(text).replace(/\r?\n[ \t]/g, '').split(/\r?\n/).filter((l) => l.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Content-Line in Name, Parameter und Wert zerlegen. */
|
||||||
|
export function parseLine(line) {
|
||||||
|
let i = 0;
|
||||||
|
let inQuotes = false;
|
||||||
|
while (i < line.length && (inQuotes || line[i] !== ':')) {
|
||||||
|
if (line[i] === '"') inQuotes = !inQuotes;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
const head = line.slice(0, i);
|
||||||
|
const value = line.slice(i + 1);
|
||||||
|
const [rawName, ...rawParams] = head.split(';');
|
||||||
|
const params = {};
|
||||||
|
for (const p of rawParams) {
|
||||||
|
const eq = p.indexOf('=');
|
||||||
|
if (eq < 0) { params[p.toUpperCase()] = ''; continue; }
|
||||||
|
params[p.slice(0, eq).toUpperCase()] = p.slice(eq + 1).replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
return { name: rawName.toUpperCase(), params, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unescapeText(s) {
|
||||||
|
// Ein Durchgang, damit '\\\\n' (maskierter Backslash + n) nicht zum Zeilenumbruch wird
|
||||||
|
return String(s ?? '').replace(/\\([\\;,nN])/g, (m, c) => (c === 'n' || c === 'N' ? '\n' : c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datum/Zeit-Wert → { date: 'YYYY-MM-DD' (lokal Europe/Berlin), allDay, instant: Date|null }. */
|
||||||
|
export function toLocalDate(value, params = {}) {
|
||||||
|
const v = String(value).trim();
|
||||||
|
if (params.VALUE === 'DATE' || /^\d{8}$/.test(v)) {
|
||||||
|
return { date: `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`, allDay: true, instant: null };
|
||||||
|
}
|
||||||
|
const m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})?(Z)?$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const [, y, mo, d, h, mi, s = '00', z] = m;
|
||||||
|
if (z) {
|
||||||
|
const instant = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s));
|
||||||
|
return { date: berlinDate.format(instant), allDay: false, instant };
|
||||||
|
}
|
||||||
|
// Wandzeit (TZID oder floating): Datum wie angegeben verwenden
|
||||||
|
return { date: `${y}-${mo}-${d}`, allDay: false, instant: new Date(+y, +mo - 1, +d, +h, +mi, +s) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(iso, n) {
|
||||||
|
const d = new Date(iso + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() + n);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDuration(s) {
|
||||||
|
const m = String(s).match(/^(-)?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const sign = m[1] ? -1 : 1;
|
||||||
|
const days = (+m[2] || 0) * 7 + (+m[3] || 0);
|
||||||
|
const seconds = (+m[4] || 0) * 3600 + (+m[5] || 0) * 60 + (+m[6] || 0);
|
||||||
|
return { days: sign * days, seconds: sign * seconds };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle VEVENTs eines iCalendar-Textes als { uid, summary, from, to, allDay, rrule, recurrenceId, status, description }.
|
||||||
|
* from/to sind lokale Kalendertage (inklusiv), Zeitzone Europe/Berlin.
|
||||||
|
*/
|
||||||
|
export function parseEvents(text) {
|
||||||
|
const events = [];
|
||||||
|
let cur = null;
|
||||||
|
let depth = [];
|
||||||
|
for (const line of unfold(text)) {
|
||||||
|
const { name, params, value } = parseLine(line);
|
||||||
|
if (name === 'BEGIN') { depth.push(value.toUpperCase()); if (value.toUpperCase() === 'VEVENT') cur = { props: [] }; continue; }
|
||||||
|
if (name === 'END') {
|
||||||
|
const closed = depth.pop();
|
||||||
|
if (closed === 'VEVENT' && cur) { const ev = finishEvent(cur.props); if (ev) events.push(ev); cur = null; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cur && depth[depth.length - 1] === 'VEVENT') cur.props.push({ name, params, value });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishEvent(props) {
|
||||||
|
const get = (n) => props.find((p) => p.name === n);
|
||||||
|
const start = get('DTSTART');
|
||||||
|
if (!start) return null;
|
||||||
|
const s = toLocalDate(start.value, start.params);
|
||||||
|
if (!s) return null;
|
||||||
|
let to = s.date;
|
||||||
|
const end = get('DTEND');
|
||||||
|
const dur = get('DURATION');
|
||||||
|
if (end) {
|
||||||
|
const e = toLocalDate(end.value, end.params);
|
||||||
|
if (e) {
|
||||||
|
if (e.allDay) to = addDays(e.date, -1);
|
||||||
|
else if (e.instant) to = s.allDay ? e.date : berlinOrWall(new Date(e.instant.getTime() - 1000), end.params, end.value);
|
||||||
|
else to = e.date;
|
||||||
|
}
|
||||||
|
} else if (dur) {
|
||||||
|
const d = parseDuration(dur.value);
|
||||||
|
if (d) {
|
||||||
|
if (s.allDay) to = addDays(s.date, Math.max(0, d.days - 1));
|
||||||
|
else {
|
||||||
|
const endInstant = new Date(s.instant.getTime() + (d.days * 86400 + d.seconds) * 1000 - 1000);
|
||||||
|
to = berlinOrWall(endInstant, start.params, start.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (to < s.date) to = s.date;
|
||||||
|
return {
|
||||||
|
uid: get('UID')?.value || '',
|
||||||
|
summary: unescapeText(get('SUMMARY')?.value || ''),
|
||||||
|
description: unescapeText(get('DESCRIPTION')?.value || ''),
|
||||||
|
from: s.date,
|
||||||
|
to,
|
||||||
|
allDay: s.allDay,
|
||||||
|
rrule: get('RRULE')?.value || null,
|
||||||
|
recurrenceId: get('RECURRENCE-ID')?.value || null,
|
||||||
|
status: (get('STATUS')?.value || '').toUpperCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function berlinOrWall(instant, params, originalValue) {
|
||||||
|
if (/Z$/.test(originalValue)) return berlinDate.format(instant);
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${instant.getFullYear()}-${pad(instant.getMonth() + 1)}-${pad(instant.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** vCards (3.0/4.0) → [{ uid, fn, firstName, lastName, email, cloudId, photo }] */
|
||||||
|
export function parseVCards(text) {
|
||||||
|
const cards = [];
|
||||||
|
let cur = null;
|
||||||
|
for (const line of unfold(text)) {
|
||||||
|
const { name, params, value } = parseLine(line);
|
||||||
|
if (name === 'BEGIN' && value.toUpperCase() === 'VCARD') { cur = []; continue; }
|
||||||
|
if (name === 'END' && value.toUpperCase() === 'VCARD') { if (cur) cards.push(finishCard(cur)); cur = null; continue; }
|
||||||
|
if (cur) cur.push({ name, params, value });
|
||||||
|
}
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitEscaped(value, sep = ';') {
|
||||||
|
const out = [];
|
||||||
|
let buf = '';
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
if (value[i] === '\\' && i + 1 < value.length) { buf += value[i] + value[i + 1]; i += 1; continue; }
|
||||||
|
if (value[i] === sep) { out.push(buf); buf = ''; continue; }
|
||||||
|
buf += value[i];
|
||||||
|
}
|
||||||
|
out.push(buf);
|
||||||
|
return out.map(unescapeText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishCard(props) {
|
||||||
|
const get = (n) => props.find((p) => p.name === n);
|
||||||
|
const fn = unescapeText(get('FN')?.value || '').trim();
|
||||||
|
let firstName = '';
|
||||||
|
let lastName = '';
|
||||||
|
const n = get('N');
|
||||||
|
if (n) {
|
||||||
|
const parts = splitEscaped(n.value);
|
||||||
|
lastName = (parts[0] || '').trim();
|
||||||
|
firstName = [parts[1], parts[2]].filter(Boolean).join(' ').trim();
|
||||||
|
}
|
||||||
|
if (!firstName && !lastName && fn) {
|
||||||
|
const idx = fn.lastIndexOf(' ');
|
||||||
|
if (idx > 0) { firstName = fn.slice(0, idx).trim(); lastName = fn.slice(idx + 1).trim(); } else lastName = fn;
|
||||||
|
} else if (!firstName && fn && fn !== lastName) {
|
||||||
|
firstName = fn.replace(lastName, '').trim();
|
||||||
|
}
|
||||||
|
const emails = props.filter((p) => p.name === 'EMAIL').map((p) => unescapeText(p.value).trim().toLowerCase()).filter(Boolean);
|
||||||
|
const photoProp = get('PHOTO');
|
||||||
|
let photo = null;
|
||||||
|
if (photoProp) {
|
||||||
|
const v = photoProp.value.trim();
|
||||||
|
const enc = (photoProp.params.ENCODING || '').toUpperCase();
|
||||||
|
const dataUrl = v.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/is);
|
||||||
|
if (dataUrl) photo = { kind: 'inline', mime: dataUrl[1] || 'image/jpeg', base64: dataUrl[2].replace(/\s+/g, '') };
|
||||||
|
else if (enc === 'B' || enc === 'BASE64') photo = { kind: 'inline', mime: `image/${(photoProp.params.TYPE || 'jpeg').toLowerCase().replace(/^image\//, '')}`, base64: v.replace(/\s+/g, '') };
|
||||||
|
else if (/^https?:\/\//i.test(v)) photo = { kind: 'url', url: v };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
uid: unescapeText(get('UID')?.value || '').trim(),
|
||||||
|
fn,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: emails[0] || '',
|
||||||
|
cloudId: unescapeText(get('CLOUD')?.value || get('X-CLOUD')?.value || '').trim(),
|
||||||
|
photo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Titel eines Abwesenheitstermins deuten: "Vorname Nachname (Urlaub)" → { name, kind }.
|
||||||
|
* kind: urlaub | krankheit | frei | sonstiges
|
||||||
|
*/
|
||||||
|
export function parseAbsenceSummary(summary) {
|
||||||
|
const s = String(summary || '').trim();
|
||||||
|
const m = s.match(/^(.*?)\s*[\(\[]([^\)\]]*)[\)\]]\s*$/);
|
||||||
|
const name = (m ? m[1] : s).trim();
|
||||||
|
const raw = (m ? m[2] : '').trim();
|
||||||
|
const k = raw.toLowerCase();
|
||||||
|
let kind = 'sonstiges';
|
||||||
|
if (/urlaub|vacation|holiday|ferien/.test(k)) kind = 'urlaub';
|
||||||
|
else if (/krank|sick|kur|reha/.test(k)) kind = 'krankheit';
|
||||||
|
else if (/frei|off|gleit|elternzeit|home/.test(k)) kind = 'frei';
|
||||||
|
return { name, kind, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeName(s) {
|
||||||
|
return String(s || '').normalize('NFC').toLowerCase()
|
||||||
|
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordnet einen Namen einer Person zu (Vor+Nach, Nach+Vor, eindeutiger Nachname/Vorname). */
|
||||||
|
export function matchPerson(name, persons) {
|
||||||
|
const key = normalizeName(name);
|
||||||
|
if (!key) return null;
|
||||||
|
for (const p of persons) {
|
||||||
|
const f = normalizeName(p.firstName);
|
||||||
|
const l = normalizeName(p.lastName);
|
||||||
|
if (key === `${f} ${l}`.trim() || key === `${l} ${f}`.trim()) return p;
|
||||||
|
}
|
||||||
|
const tokens = key.split(' ');
|
||||||
|
if (tokens.length === 1) {
|
||||||
|
const byLast = persons.filter((p) => normalizeName(p.lastName) === key);
|
||||||
|
if (byLast.length === 1) return byLast[0];
|
||||||
|
const byFirst = persons.filter((p) => normalizeName(p.firstName) === key);
|
||||||
|
if (byFirst.length === 1) return byFirst[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zusammenhängende oder überlappende Zeiträume je Person und Art verschmelzen. */
|
||||||
|
export function mergeAbsences(items) {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const a of items) {
|
||||||
|
const k = `${a.personId}|${a.type}`;
|
||||||
|
if (!groups.has(k)) groups.set(k, []);
|
||||||
|
groups.get(k).push(a);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const list of groups.values()) {
|
||||||
|
list.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
||||||
|
let cur = null;
|
||||||
|
for (const a of list) {
|
||||||
|
if (cur && a.from <= addDays(cur.to, 1)) { if (a.to > cur.to) cur.to = a.to; continue; }
|
||||||
|
cur = { ...a };
|
||||||
|
out.push(cur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.from.localeCompare(b.from));
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
// iCalendar-Erzeugung (RFC 5545) ohne Abhängigkeiten.
|
||||||
|
|
||||||
|
const CRLF = '\r\n';
|
||||||
|
|
||||||
|
export function escapeText(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/;/g, '\\;')
|
||||||
|
.replace(/,/g, '\\,')
|
||||||
|
.replace(/\r?\n/g, '\\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeilen länger als 75 Oktette werden mit CRLF + Leerzeichen gefaltet. */
|
||||||
|
export function foldLine(line) {
|
||||||
|
const bytes = Buffer.from(line, 'utf8');
|
||||||
|
if (bytes.length <= 75) return line;
|
||||||
|
const parts = [];
|
||||||
|
let start = 0;
|
||||||
|
let limit = 75;
|
||||||
|
while (start < bytes.length) {
|
||||||
|
let end = Math.min(start + limit, bytes.length);
|
||||||
|
// Nicht mitten in einer UTF-8-Sequenz trennen
|
||||||
|
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end -= 1;
|
||||||
|
parts.push((parts.length ? ' ' : '') + bytes.subarray(start, end).toString('utf8'));
|
||||||
|
start = end;
|
||||||
|
limit = 74;
|
||||||
|
}
|
||||||
|
return parts.join(CRLF);
|
||||||
|
}
|
||||||
|
|
||||||
|
const VTIMEZONE_BERLIN = [
|
||||||
|
'BEGIN:VTIMEZONE',
|
||||||
|
'TZID:Europe/Berlin',
|
||||||
|
'BEGIN:DAYLIGHT',
|
||||||
|
'TZOFFSETFROM:+0100',
|
||||||
|
'TZOFFSETTO:+0200',
|
||||||
|
'TZNAME:CEST',
|
||||||
|
'DTSTART:19700329T020000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
|
||||||
|
'END:DAYLIGHT',
|
||||||
|
'BEGIN:STANDARD',
|
||||||
|
'TZOFFSETFROM:+0200',
|
||||||
|
'TZOFFSETTO:+0100',
|
||||||
|
'TZNAME:CET',
|
||||||
|
'DTSTART:19701025T030000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
|
||||||
|
'END:STANDARD',
|
||||||
|
'END:VTIMEZONE',
|
||||||
|
];
|
||||||
|
|
||||||
|
function stamp(d = new Date()) {
|
||||||
|
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMinutes(hhmm, minutes) {
|
||||||
|
const [h, m] = hhmm.split(':').map(Number);
|
||||||
|
const total = h * 60 + m + minutes;
|
||||||
|
const day = Math.floor(total / (24 * 60));
|
||||||
|
const rest = total % (24 * 60);
|
||||||
|
return { day, time: `${String(Math.floor(rest / 60)).padStart(2, '0')}${String(rest % 60).padStart(2, '0')}00` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein VEVENT als Array von (ungefalteten) Zeilen.
|
||||||
|
* time = 'HH:MM' oder leer für ganztägig.
|
||||||
|
*/
|
||||||
|
export function buildEventLines({ uid, date, summary, description = '', time = '', durationMinutes = 15, attendees = [], organizer = null, sequence = 0, now = new Date() }) {
|
||||||
|
const compact = date.replace(/-/g, '');
|
||||||
|
const lines = ['BEGIN:VEVENT', `UID:${uid}`, `DTSTAMP:${stamp(now)}`, `SEQUENCE:${sequence}`];
|
||||||
|
if (time) {
|
||||||
|
const start = addMinutes(time, 0).time;
|
||||||
|
const end = addMinutes(time, durationMinutes);
|
||||||
|
const endDate = end.day ? addDaysCompact(date, end.day) : compact;
|
||||||
|
lines.push(`DTSTART;TZID=Europe/Berlin:${compact}T${start}`);
|
||||||
|
lines.push(`DTEND;TZID=Europe/Berlin:${endDate}T${end.time}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`DTSTART;VALUE=DATE:${compact}`);
|
||||||
|
lines.push(`DTEND;VALUE=DATE:${addDaysCompact(date, 1)}`);
|
||||||
|
}
|
||||||
|
lines.push(`SUMMARY:${escapeText(summary)}`);
|
||||||
|
if (description) lines.push(`DESCRIPTION:${escapeText(description)}`);
|
||||||
|
// Teilnehmer nur mit ORGANIZER – ohne ihn verschickt Nextcloud keine Einladungen und iCal-Clients meckern
|
||||||
|
if (attendees.length > 0 && organizer?.email) {
|
||||||
|
lines.push(`ORGANIZER;CN=${escapeParam(organizer.name || organizer.email)}:mailto:${organizer.email}`);
|
||||||
|
for (const a of attendees) {
|
||||||
|
lines.push(`ATTENDEE;CN=${escapeParam(a.name)};ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=FALSE:mailto:${a.email}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('TRANSP:OPAQUE', 'END:VEVENT');
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeParam(s) {
|
||||||
|
return '"' + String(s ?? '').replace(/["\r\n]/g, '') + '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDaysCompact(iso, n) {
|
||||||
|
const d = new Date(iso + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() + n);
|
||||||
|
return d.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollständiger Kalender aus einer Liste von Event-Zeilenarrays.
|
||||||
|
* method nur für Dateien setzen (z. B. PUBLISH) – CalDAV-Objekte dürfen laut RFC 4791 keine METHOD tragen,
|
||||||
|
* Nextcloud lehnt einen PUT damit ab.
|
||||||
|
*/
|
||||||
|
export function buildCalendar(eventLineArrays, { name = 'Morning Meeting', method = '' } = {}) {
|
||||||
|
const lines = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Rotatory//Morning Meeting//DE',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
...(method ? [`METHOD:${method}`] : []),
|
||||||
|
`X-WR-CALNAME:${escapeText(name)}`,
|
||||||
|
'X-WR-TIMEZONE:Europe/Berlin',
|
||||||
|
...VTIMEZONE_BERLIN,
|
||||||
|
...eventLineArrays.flat(),
|
||||||
|
'END:VCALENDAR',
|
||||||
|
];
|
||||||
|
return lines.map(foldLine).join(CRLF) + CRLF;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventUid(date, host = 'rotatory.local') {
|
||||||
|
return `rotatory-${date}@${host}`;
|
||||||
|
}
|
||||||
+612
@@ -0,0 +1,612 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { loadEnvFile } from './env.js';
|
||||||
|
import { createStore, imageMime } from './store.js';
|
||||||
|
import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js';
|
||||||
|
import { germanHolidays, BUNDESLAENDER } from './holidays.js';
|
||||||
|
import { buildEventLines, buildCalendar, eventUid } from './ics.js';
|
||||||
|
import { nextcloudConfigFromEnv, createNextcloudClient } from './nextcloud.js';
|
||||||
|
import { parseEvents, parseVCards, parseAbsenceSummary, matchPerson, mergeAbsences } from './ical.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
loadEnvFile(path.join(__dirname, '..', '.env'));
|
||||||
|
|
||||||
|
const dataDir = process.env.VCH_DATA_DIR || path.join(__dirname, '..', 'data');
|
||||||
|
const store = createStore(dataDir);
|
||||||
|
const nc = nextcloudConfigFromEnv();
|
||||||
|
if (String(process.env.NEXTCLOUD_INSECURE_TLS).toLowerCase() === 'true') {
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
console.warn('Warnung: TLS-Zertifikate werden nicht geprüft (NEXTCLOUD_INSECURE_TLS=true).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
app.set('etag', false);
|
||||||
|
|
||||||
|
// Healthcheck ohne Passwort, damit die Plattform den Prüfling erreichen kann
|
||||||
|
app.get('/health', (req, res) => res.type('text/plain').send('ok'));
|
||||||
|
|
||||||
|
// Optionaler Zugriffsschutz
|
||||||
|
const appPassword = (process.env.APP_PASSWORD || '').trim();
|
||||||
|
if (appPassword) {
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const header = req.headers.authorization || '';
|
||||||
|
const given = header.startsWith('Basic ') ? Buffer.from(header.slice(6), 'base64').toString('utf8').split(':').slice(1).join(':') : '';
|
||||||
|
const a = Buffer.from(given);
|
||||||
|
const b = Buffer.from(appPassword);
|
||||||
|
if (a.length === b.length && timingSafeEqual(a, b)) return next();
|
||||||
|
res.set('WWW-Authenticate', 'Basic realm="Rotatory", charset="UTF-8"');
|
||||||
|
res.status(401).send('Zugang nur mit Passwort.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
|
||||||
|
const S = () => store.state;
|
||||||
|
const clean = (s, max = 120) => String(s ?? '').trim().slice(0, max);
|
||||||
|
const bad = (res, msg) => res.status(400).json({ error: msg });
|
||||||
|
const blockedDays = () => new Set(S().holidays.map((h) => h.date));
|
||||||
|
const ABSENCE_TYPES = ['urlaub', 'krankheit', 'frei', 'sonstiges'];
|
||||||
|
|
||||||
|
// Beim ersten Start: Feiertage für dieses und nächstes Jahr vorbelegen
|
||||||
|
if (S().holidays.length === 0 && !S().settings.holidaysSeeded) {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
for (const h of [...germanHolidays(year, S().settings.bundesland), ...germanHolidays(year + 1, S().settings.bundesland)]) S().holidays.push(h);
|
||||||
|
S().settings.holidaysSeeded = true;
|
||||||
|
store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gehört eine Kalender-/Adressbuch-URL zur konfigurierten Nextcloud (gleicher Origin, gleicher Pfadstamm)? */
|
||||||
|
function isOurNextcloudUrl(href) {
|
||||||
|
if (!nc.baseUrl || !href) return false;
|
||||||
|
try {
|
||||||
|
const a = new URL(href);
|
||||||
|
const b = new URL(nc.baseUrl + '/');
|
||||||
|
return a.origin === b.origin && a.pathname.startsWith(b.pathname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function personsForScheduling() {
|
||||||
|
return S().persons.map((p) => ({ id: p.id, canMain: Boolean(p.canMain), canCo: Boolean(p.canCo), active: p.active !== false, freeDays: p.freeDays || [] }));
|
||||||
|
}
|
||||||
|
function personLabel(p) {
|
||||||
|
return p ? `${p.firstName} ${p.lastName}`.trim() : 'unbesetzt';
|
||||||
|
}
|
||||||
|
function publicPerson(p) {
|
||||||
|
return { ...p, hasPhoto: store.hasPhoto(p.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Gesamtzustand ----------
|
||||||
|
app.get('/api/state', (req, res) => {
|
||||||
|
const st = S();
|
||||||
|
res.json({
|
||||||
|
persons: st.persons.map(publicPerson),
|
||||||
|
absences: st.absences,
|
||||||
|
holidays: st.holidays,
|
||||||
|
assignments: st.assignments,
|
||||||
|
exported: st.exported,
|
||||||
|
settings: st.settings,
|
||||||
|
bundeslaender: BUNDESLAENDER,
|
||||||
|
absenceTypes: ABSENCE_TYPES,
|
||||||
|
nextcloud: { configured: nc.configured, calendar: nc.calendar, user: nc.user, baseUrl: nc.baseUrl },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Personen ----------
|
||||||
|
function readPerson(body, existing = {}) {
|
||||||
|
const firstName = clean(body.firstName);
|
||||||
|
const lastName = clean(body.lastName);
|
||||||
|
if (!firstName || !lastName) return { error: 'Vor- und Nachname sind Pflicht.' };
|
||||||
|
const email = clean(body.email).toLowerCase();
|
||||||
|
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'E-Mail-Adresse ist ungültig.' };
|
||||||
|
let freeDays = existing.freeDays || [];
|
||||||
|
if (body.freeDays !== undefined) {
|
||||||
|
if (!Array.isArray(body.freeDays)) return { error: 'freeDays muss eine Liste sein.' };
|
||||||
|
freeDays = [...new Set(body.freeDays.map(Number).filter((d) => Number.isInteger(d) && d >= 1 && d <= 5))].sort();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
person: {
|
||||||
|
...existing,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
canMain: body.canMain !== undefined ? Boolean(body.canMain) : existing.canMain ?? true,
|
||||||
|
canCo: body.canCo !== undefined ? Boolean(body.canCo) : existing.canCo ?? true,
|
||||||
|
active: body.active !== undefined ? Boolean(body.active) : existing.active ?? true,
|
||||||
|
freeDays,
|
||||||
|
note: body.note !== undefined ? clean(body.note, 300) : existing.note || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/persons', (req, res) => {
|
||||||
|
const r = readPerson(req.body || {});
|
||||||
|
if (r.error) return bad(res, r.error);
|
||||||
|
const person = { id: randomUUID(), ...r.person, photoVersion: 0 };
|
||||||
|
S().persons.push(person);
|
||||||
|
store.save();
|
||||||
|
res.status(201).json(publicPerson(person));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/persons/:id', (req, res) => {
|
||||||
|
const p = S().persons.find((x) => x.id === req.params.id);
|
||||||
|
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||||||
|
const r = readPerson(req.body || {}, p);
|
||||||
|
if (r.error) return bad(res, r.error);
|
||||||
|
Object.assign(p, r.person);
|
||||||
|
store.save();
|
||||||
|
res.json(publicPerson(p));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/persons/:id', (req, res) => {
|
||||||
|
const st = S();
|
||||||
|
const idx = st.persons.findIndex((x) => x.id === req.params.id);
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||||||
|
const id = st.persons[idx].id;
|
||||||
|
st.persons.splice(idx, 1);
|
||||||
|
st.absences = st.absences.filter((a) => a.personId !== id);
|
||||||
|
for (const a of Object.values(st.assignments)) {
|
||||||
|
if (a.main === id) a.main = null;
|
||||||
|
if (a.co === id) a.co = null;
|
||||||
|
}
|
||||||
|
store.deletePhoto(id);
|
||||||
|
store.save();
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/persons/:id/photo', express.raw({ type: ['image/jpeg', 'image/png', 'image/webp'], limit: '5mb' }), (req, res) => {
|
||||||
|
const p = S().persons.find((x) => x.id === req.params.id);
|
||||||
|
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||||||
|
if (!Buffer.isBuffer(req.body) || req.body.length === 0 || !imageMime(req.body)) return bad(res, 'Kein gültiges Bild übermittelt (erlaubt: JPEG, PNG, WebP).');
|
||||||
|
store.savePhoto(p.id, req.body);
|
||||||
|
p.photoVersion = (p.photoVersion || 0) + 1;
|
||||||
|
store.save();
|
||||||
|
res.json(publicPerson(p));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/persons/:id/photo', (req, res) => {
|
||||||
|
const p = S().persons.find((x) => x.id === req.params.id);
|
||||||
|
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||||||
|
store.deletePhoto(p.id);
|
||||||
|
p.photoVersion = (p.photoVersion || 0) + 1;
|
||||||
|
store.save();
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/photos/:id', (req, res) => {
|
||||||
|
const id = String(req.params.id).replace(/[^a-f0-9-]/g, '');
|
||||||
|
const buf = store.readPhoto(id);
|
||||||
|
if (!buf) return res.status(404).end();
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.type(imageMime(buf) || 'application/octet-stream');
|
||||||
|
res.send(buf);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Abwesenheiten ----------
|
||||||
|
app.post('/api/absences', (req, res) => {
|
||||||
|
const { personId, from, to, type = 'urlaub', note } = req.body || {};
|
||||||
|
if (!S().persons.some((p) => p.id === personId)) return bad(res, 'Person nicht gefunden.');
|
||||||
|
if (!isValidDate(from) || !isValidDate(to)) return bad(res, 'Ungültiges Datum.');
|
||||||
|
if (to < from) return bad(res, 'Das Ende liegt vor dem Beginn.');
|
||||||
|
if (!ABSENCE_TYPES.includes(type)) return bad(res, 'Unbekannte Abwesenheitsart.');
|
||||||
|
const absence = { id: randomUUID(), personId, from, to, type, note: clean(note, 200), source: 'manuell' };
|
||||||
|
S().absences.push(absence);
|
||||||
|
store.save();
|
||||||
|
res.status(201).json(absence);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/absences/:id', (req, res) => {
|
||||||
|
const st = S();
|
||||||
|
const before = st.absences.length;
|
||||||
|
st.absences = st.absences.filter((a) => a.id !== req.params.id);
|
||||||
|
if (st.absences.length === before) return res.status(404).json({ error: 'Abwesenheit nicht gefunden.' });
|
||||||
|
store.save();
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Feiertage / betriebsfreie Tage ----------
|
||||||
|
function upsertHoliday(h) {
|
||||||
|
const st = S();
|
||||||
|
st.holidays = st.holidays.filter((x) => x.date !== h.date);
|
||||||
|
st.holidays.push(h);
|
||||||
|
st.holidays.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/holidays', (req, res) => {
|
||||||
|
const { date, name } = req.body || {};
|
||||||
|
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
|
||||||
|
upsertHoliday({ date, name: clean(name) || 'Kein Meeting', auto: false });
|
||||||
|
store.save();
|
||||||
|
res.status(201).json(S().holidays);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/holidays/generate', (req, res) => {
|
||||||
|
const year = Number(req.body?.year);
|
||||||
|
if (!Number.isInteger(year) || year < 2000 || year > 2100) return bad(res, 'Ungültiges Jahr.');
|
||||||
|
for (const h of germanHolidays(year, S().settings.bundesland)) upsertHoliday(h);
|
||||||
|
store.save();
|
||||||
|
res.json(S().holidays);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/holidays/:date', (req, res) => {
|
||||||
|
const st = S();
|
||||||
|
st.holidays = st.holidays.filter((h) => h.date !== req.params.date);
|
||||||
|
store.save();
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Einstellungen ----------
|
||||||
|
app.put('/api/settings', (req, res) => {
|
||||||
|
const s = S().settings;
|
||||||
|
const b = req.body || {};
|
||||||
|
if (b.meetingTime !== undefined) {
|
||||||
|
if (b.meetingTime !== '' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(b.meetingTime)) return bad(res, 'Uhrzeit im Format HH:MM angeben (oder leer für ganztägig).');
|
||||||
|
s.meetingTime = b.meetingTime;
|
||||||
|
}
|
||||||
|
if (b.durationMinutes !== undefined) {
|
||||||
|
const d = Number(b.durationMinutes);
|
||||||
|
if (!Number.isInteger(d) || d < 5 || d > 480) return bad(res, 'Dauer zwischen 5 und 480 Minuten.');
|
||||||
|
s.durationMinutes = d;
|
||||||
|
}
|
||||||
|
if (b.bundesland !== undefined) {
|
||||||
|
if (!Object.hasOwn(BUNDESLAENDER, b.bundesland)) return bad(res, 'Unbekanntes Bundesland.');
|
||||||
|
s.bundesland = b.bundesland;
|
||||||
|
}
|
||||||
|
if (b.calendarDisplayName !== undefined) s.calendarDisplayName = clean(b.calendarDisplayName) || 'Morning Meeting';
|
||||||
|
if (b.inviteAttendees !== undefined) s.inviteAttendees = Boolean(b.inviteAttendees);
|
||||||
|
if (b.historyWindowDays !== undefined) {
|
||||||
|
const d = Number(b.historyWindowDays);
|
||||||
|
if (!Number.isInteger(d) || d < 0 || d > 365) return bad(res, 'Fairness-Fenster zwischen 0 und 365 Tagen.');
|
||||||
|
s.historyWindowDays = d;
|
||||||
|
}
|
||||||
|
for (const key of ['targetCalendarHref', 'absenceCalendarHref']) {
|
||||||
|
if (b[key] === undefined) continue;
|
||||||
|
const href = clean(b[key], 500);
|
||||||
|
if (href && !isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
|
||||||
|
s[key] = href;
|
||||||
|
const nameKey = key.replace('Href', 'Name');
|
||||||
|
s[nameKey] = href ? clean(b[nameKey], 120) : '';
|
||||||
|
}
|
||||||
|
store.save();
|
||||||
|
res.json(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Planung ----------
|
||||||
|
function parseRange(body) {
|
||||||
|
const { from, to } = body || {};
|
||||||
|
if (!isValidDate(from) || !isValidDate(to)) return { error: 'Ungültiger Zeitraum.' };
|
||||||
|
if (to < from) return { error: 'Das Ende liegt vor dem Beginn.' };
|
||||||
|
if (addDays(from, 400) < to) return { error: 'Höchstens 400 Tage auf einmal.' };
|
||||||
|
return { from, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/schedule/generate', (req, res) => {
|
||||||
|
const r = parseRange(req.body);
|
||||||
|
if (r.error) return bad(res, r.error);
|
||||||
|
const overwrite = Boolean(req.body.overwrite);
|
||||||
|
const st = S();
|
||||||
|
const dates = workingDays(r.from, r.to, blockedDays());
|
||||||
|
const existing = {};
|
||||||
|
for (const d of dates) {
|
||||||
|
const ex = st.assignments[d];
|
||||||
|
if (!ex) continue;
|
||||||
|
// Ohne „überschreiben": volle Tage behalten, halb besetzte nur auffüllen
|
||||||
|
existing[d] = overwrite ? { ...ex } : { ...ex, keep: Boolean(ex.main && ex.co), fill: Boolean(ex.main || ex.co) };
|
||||||
|
}
|
||||||
|
const generated = generateSchedule({
|
||||||
|
dates,
|
||||||
|
persons: personsForScheduling(),
|
||||||
|
absences: st.absences,
|
||||||
|
existing,
|
||||||
|
history: st.assignments,
|
||||||
|
blocked: blockedDays(),
|
||||||
|
historyWindowDays: st.settings.historyWindowDays,
|
||||||
|
});
|
||||||
|
Object.assign(st.assignments, generated);
|
||||||
|
for (const d of Object.keys(st.assignments)) {
|
||||||
|
if (d >= r.from && d <= r.to && !dates.includes(d) && !st.assignments[d].off) delete st.assignments[d];
|
||||||
|
}
|
||||||
|
store.save();
|
||||||
|
const unfilled = dates.filter((d) => !st.assignments[d].off && (!st.assignments[d].main || !st.assignments[d].co));
|
||||||
|
res.json({ assignments: st.assignments, planned: dates.length, unfilled });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/schedule/clear', (req, res) => {
|
||||||
|
const r = parseRange(req.body);
|
||||||
|
if (r.error) return bad(res, r.error);
|
||||||
|
const st = S();
|
||||||
|
for (const d of Object.keys(st.assignments)) {
|
||||||
|
if (d >= r.from && d <= r.to && !st.assignments[d].locked) delete st.assignments[d];
|
||||||
|
}
|
||||||
|
store.save();
|
||||||
|
res.json({ assignments: st.assignments });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/assignments/:date', (req, res) => {
|
||||||
|
const date = req.params.date;
|
||||||
|
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
|
||||||
|
const st = S();
|
||||||
|
const b = req.body || {};
|
||||||
|
const valid = (id) => id === null || id === undefined || st.persons.some((p) => p.id === id);
|
||||||
|
if (!valid(b.main) || !valid(b.co)) return bad(res, 'Person nicht gefunden.');
|
||||||
|
if (b.main && b.main === b.co) return bad(res, 'Haupt- und Nebenmoderator müssen verschieden sein.');
|
||||||
|
const current = st.assignments[date] || {};
|
||||||
|
st.assignments[date] = {
|
||||||
|
main: b.main !== undefined ? b.main : current.main ?? null,
|
||||||
|
co: b.co !== undefined ? b.co : current.co ?? null,
|
||||||
|
locked: b.locked !== undefined ? Boolean(b.locked) : Boolean(current.locked),
|
||||||
|
off: b.off !== undefined ? Boolean(b.off) : Boolean(current.off),
|
||||||
|
};
|
||||||
|
store.save();
|
||||||
|
res.json(st.assignments[date]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/assignments/:date/reroll', (req, res) => {
|
||||||
|
const date = req.params.date;
|
||||||
|
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
|
||||||
|
const st = S();
|
||||||
|
const history = { ...st.assignments };
|
||||||
|
delete history[date];
|
||||||
|
const generated = generateSchedule({
|
||||||
|
dates: [date],
|
||||||
|
persons: personsForScheduling(),
|
||||||
|
absences: st.absences,
|
||||||
|
history,
|
||||||
|
blocked: blockedDays(),
|
||||||
|
historyWindowDays: st.settings.historyWindowDays,
|
||||||
|
});
|
||||||
|
// preview: nur Vorschlag liefern, Speichern übernimmt der Dialog
|
||||||
|
if (!req.body?.preview) {
|
||||||
|
st.assignments[date] = generated[date];
|
||||||
|
store.save();
|
||||||
|
}
|
||||||
|
res.json(generated[date]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats', (req, res) => {
|
||||||
|
const r = parseRange(req.query);
|
||||||
|
if (r.error) return bad(res, r.error);
|
||||||
|
res.json(countAssignments(S().assignments, r.from, r.to));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Export ----------
|
||||||
|
function eventsForRange(from, to) {
|
||||||
|
const st = S();
|
||||||
|
const byId = Object.fromEntries(st.persons.map((p) => [p.id, p]));
|
||||||
|
const blocked = blockedDays();
|
||||||
|
const organizer = st.settings.inviteAttendees && /@/.test(nc.user) ? { name: 'Morning Meeting (Rotatory)', email: nc.user } : null;
|
||||||
|
const events = [];
|
||||||
|
for (const [date, a] of Object.entries(st.assignments).sort()) {
|
||||||
|
if (date < from || date > to || a.off || blocked.has(date) || (!a.main && !a.co)) continue;
|
||||||
|
const main = byId[a.main];
|
||||||
|
const co = byId[a.co];
|
||||||
|
const attendees = organizer
|
||||||
|
? [main, co].filter((p) => p && p.email).map((p) => ({ name: personLabel(p), email: p.email }))
|
||||||
|
: [];
|
||||||
|
events.push({
|
||||||
|
date,
|
||||||
|
uid: eventUid(date, nc.baseUrl ? new URL(nc.baseUrl).hostname : 'rotatory.local'),
|
||||||
|
summary: `Morning Meeting: ${personLabel(main)} / ${personLabel(co)}`,
|
||||||
|
description: `Hauptmoderation: ${personLabel(main)}\nNebenmoderation: ${personLabel(co)}\n\nGeplant mit Rotatory.`,
|
||||||
|
time: st.settings.meetingTime,
|
||||||
|
durationMinutes: st.settings.durationMinutes,
|
||||||
|
attendees,
|
||||||
|
organizer,
|
||||||
|
sequence: (st.exported[date]?.sequence || 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/export.ics', (req, res) => {
|
||||||
|
const r = parseRange(req.query);
|
||||||
|
if (r.error) return bad(res, r.error);
|
||||||
|
const ics = buildCalendar(eventsForRange(r.from, r.to).map(buildEventLines), { name: S().settings.calendarDisplayName, method: 'PUBLISH' });
|
||||||
|
res.set('Content-Type', 'text/calendar; charset=utf-8');
|
||||||
|
res.set('Content-Disposition', `attachment; filename="morning-meeting-${r.from}-${r.to}.ics"`);
|
||||||
|
res.send(ics);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Nextcloud ----------
|
||||||
|
function nextcloudClient() {
|
||||||
|
if (!nc.configured) throw new Error('Nextcloud ist nicht konfiguriert: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_APP_PASSWORD setzen.');
|
||||||
|
return createNextcloudClient({ ...nc, displayName: S().settings.calendarDisplayName });
|
||||||
|
}
|
||||||
|
const ncError = (res, err) => res.status(502).json({ error: err.message });
|
||||||
|
|
||||||
|
app.post('/api/nextcloud/test', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const r = await nextcloudClient().test();
|
||||||
|
res.json({ ...r, target: S().settings.targetCalendarHref, absenceSource: S().settings.absenceCalendarHref });
|
||||||
|
} catch (err) { ncError(res, err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/nextcloud/calendars', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ calendars: await nextcloudClient().listCalendars(), defaultId: nc.calendar });
|
||||||
|
} catch (err) { ncError(res, err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/nextcloud/sync', async (req, res) => {
|
||||||
|
const r = parseRange(req.body);
|
||||||
|
if (r.error) return bad(res, r.error);
|
||||||
|
const removeForeign = Boolean(req.body.removeForeign);
|
||||||
|
const st = S();
|
||||||
|
if (req.body.targetCalendarHref !== undefined) {
|
||||||
|
const href = clean(req.body.targetCalendarHref, 500);
|
||||||
|
if (href && !isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
|
||||||
|
st.settings.targetCalendarHref = href;
|
||||||
|
st.settings.targetCalendarName = href ? clean(req.body.targetCalendarName, 120) : '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const client = nextcloudClient();
|
||||||
|
const { href, created } = await client.ensureCalendar(st.settings.targetCalendarHref || null);
|
||||||
|
// Zielwechsel: Termine im Zeitraum, die noch in einem anderen Kalender liegen, dort entfernen (sie werden gleich neu geschrieben)
|
||||||
|
const moved = [];
|
||||||
|
for (const [date, info] of Object.entries(st.exported)) {
|
||||||
|
if (date >= r.from && date <= r.to && info.calendarHref && info.calendarHref !== href) {
|
||||||
|
try { await client.deleteEvent(info.calendarHref, info.uid); moved.push(date); delete st.exported[date]; } catch { /* alter Kalender ggf. weg */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const events = eventsForRange(r.from, r.to);
|
||||||
|
const written = [];
|
||||||
|
const errors = [];
|
||||||
|
for (const ev of events) {
|
||||||
|
const sequence = (st.exported[ev.date]?.sequence || 0) + 1;
|
||||||
|
try {
|
||||||
|
await client.putEvent(href, ev.uid, buildCalendar([buildEventLines({ ...ev, sequence })]));
|
||||||
|
st.exported[ev.date] = { uid: ev.uid, sequence, at: new Date().toISOString(), calendarHref: href };
|
||||||
|
written.push(ev.date);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`${ev.date}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const removed = [];
|
||||||
|
const stillThere = new Set(events.map((e) => e.date));
|
||||||
|
for (const [date, info] of Object.entries(st.exported)) {
|
||||||
|
if (date < r.from || date > r.to || stillThere.has(date)) continue;
|
||||||
|
try {
|
||||||
|
await client.deleteEvent(href, info.uid);
|
||||||
|
delete st.exported[date];
|
||||||
|
removed.push(date);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`${date}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fremde Termine im Zeitraum (z. B. aus einer früheren Planung mit einem anderen Werkzeug)
|
||||||
|
let foreign = 0;
|
||||||
|
let foreignRemoved = 0;
|
||||||
|
let foreignRecurring = 0;
|
||||||
|
try {
|
||||||
|
const existing = await client.queryEvents(href, r.from, r.to);
|
||||||
|
for (const item of existing) {
|
||||||
|
const evs = parseEvents(item.ics);
|
||||||
|
if (evs.length === 0 || evs.some((e) => e.uid.startsWith('rotatory-'))) continue;
|
||||||
|
if (!evs.some((e) => e.to >= r.from && e.from <= r.to)) continue; // Server filtert grob, wir genau
|
||||||
|
if (evs.some((e) => e.rrule)) { foreignRecurring += 1; continue; } // ganze Serien nie löschen
|
||||||
|
foreign += 1;
|
||||||
|
if (removeForeign) {
|
||||||
|
try { await client.deleteEvent(href, item.href); foreignRemoved += 1; } catch (err) { errors.push(`Fremdtermin: ${err.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`Fremde Termine konnten nicht geprüft werden: ${err.message}`);
|
||||||
|
}
|
||||||
|
store.save();
|
||||||
|
res.json({ created, written: written.length, removed: removed.length, moved: moved.length, foreign, foreignRemoved, foreignRecurring, errors, calendarUrl: href });
|
||||||
|
} catch (err) {
|
||||||
|
store.save();
|
||||||
|
ncError(res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Abwesenheiten aus einem Nextcloud-Kalender lesen ("Vorname Nachname (Urlaub)"). */
|
||||||
|
app.post('/api/nextcloud/absences/import', async (req, res) => {
|
||||||
|
const r = parseRange(req.body);
|
||||||
|
if (r.error) return bad(res, r.error);
|
||||||
|
const st = S();
|
||||||
|
const href = clean(req.body.calendarHref, 500) || st.settings.absenceCalendarHref;
|
||||||
|
if (!href) return bad(res, 'Kein Quellkalender gewählt.');
|
||||||
|
if (!isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
|
||||||
|
try {
|
||||||
|
const client = nextcloudClient();
|
||||||
|
const items = await client.queryEvents(href, r.from, r.to, { expand: true });
|
||||||
|
const found = [];
|
||||||
|
const unmatched = new Map();
|
||||||
|
let cancelled = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
for (const ev of parseEvents(item.ics)) {
|
||||||
|
if (ev.status === 'CANCELLED') { cancelled += 1; continue; }
|
||||||
|
if (ev.to < r.from || ev.from > r.to) continue;
|
||||||
|
const { name, kind } = parseAbsenceSummary(ev.summary);
|
||||||
|
const person = matchPerson(name, st.persons);
|
||||||
|
if (!person) { unmatched.set(name, (unmatched.get(name) || 0) + 1); continue; }
|
||||||
|
found.push({ personId: person.id, from: ev.from, to: ev.to, type: kind });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const merged = mergeAbsences(found).map((a) => ({ id: randomUUID(), ...a, note: '', source: 'nextcloud' }));
|
||||||
|
// Früher importierte Abwesenheiten, die den Zeitraum berühren, ersetzen
|
||||||
|
const before = st.absences.length;
|
||||||
|
st.absences = st.absences.filter((a) => !(a.source === 'nextcloud' && a.from <= r.to && a.to >= r.from));
|
||||||
|
const replaced = before - st.absences.length;
|
||||||
|
st.absences.push(...merged);
|
||||||
|
if (req.body.calendarHref) { st.settings.absenceCalendarHref = href; st.settings.absenceCalendarName = clean(req.body.calendarName, 120); }
|
||||||
|
store.save();
|
||||||
|
res.json({ imported: merged.length, replaced, cancelled, events: items.length, unmatched: [...unmatched.entries()].map(([name, count]) => ({ name, count })), absences: st.absences });
|
||||||
|
} catch (err) { ncError(res, err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Kontakte (Nextcloud-Konten) zur Auswahl anbieten. */
|
||||||
|
app.get('/api/nextcloud/contacts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = nextcloudClient();
|
||||||
|
const books = await client.listAddressBooks();
|
||||||
|
if (books.length === 0) return res.json({ addressbooks: [], contacts: [] });
|
||||||
|
const wanted = clean(req.query.addressbook, 500);
|
||||||
|
const book = books.find((b) => b.href === wanted) || books.find((b) => b.system) || books[0];
|
||||||
|
const cards = await client.queryContacts(book.href);
|
||||||
|
const st = S();
|
||||||
|
const contacts = [];
|
||||||
|
for (const raw of cards) {
|
||||||
|
for (const c of parseVCards(raw)) {
|
||||||
|
if (!c.firstName && !c.lastName) continue;
|
||||||
|
const existing = st.persons.find((p) => (c.email && p.email === c.email) || (p.firstName.toLowerCase() === c.firstName.toLowerCase() && p.lastName.toLowerCase() === c.lastName.toLowerCase()));
|
||||||
|
contacts.push({ uid: c.uid, firstName: c.firstName, lastName: c.lastName, email: c.email, hasPhoto: Boolean(c.photo) || book.system, existingId: existing?.id || null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contacts.sort((a, b) => `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`, 'de'));
|
||||||
|
res.json({ addressbooks: books, addressbook: book.href, contacts });
|
||||||
|
} catch (err) { ncError(res, err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ausgewählte Kontakte als Personen anlegen, Fotos mitnehmen. */
|
||||||
|
app.post('/api/nextcloud/contacts/import', async (req, res) => {
|
||||||
|
const uids = Array.isArray(req.body?.uids) ? req.body.uids.map((u) => clean(u, 200)).filter(Boolean) : [];
|
||||||
|
if (uids.length === 0) return bad(res, 'Keine Kontakte ausgewählt.');
|
||||||
|
try {
|
||||||
|
const client = nextcloudClient();
|
||||||
|
const books = await client.listAddressBooks();
|
||||||
|
const wanted = clean(req.body.addressbook, 500);
|
||||||
|
const book = books.find((b) => b.href === wanted) || books.find((b) => b.system) || books[0];
|
||||||
|
if (!book) return bad(res, 'Kein Adressbuch verfügbar.');
|
||||||
|
const cards = (await client.queryContacts(book.href)).flatMap(parseVCards);
|
||||||
|
const st = S();
|
||||||
|
const created = [];
|
||||||
|
const photos = [];
|
||||||
|
const skipped = [];
|
||||||
|
for (const uid of uids) {
|
||||||
|
const c = cards.find((x) => x.uid === uid);
|
||||||
|
if (!c || (!c.firstName && !c.lastName)) { skipped.push(uid); continue; }
|
||||||
|
const r = readPerson({ firstName: c.firstName || c.lastName, lastName: c.lastName || c.firstName, email: c.email, note: '' });
|
||||||
|
if (r.error) { skipped.push(uid); continue; }
|
||||||
|
const person = { id: randomUUID(), ...r.person, photoVersion: 0, nextcloudUid: uid };
|
||||||
|
st.persons.push(person);
|
||||||
|
created.push(person.id);
|
||||||
|
let img = null;
|
||||||
|
if (book.system) img = await client.fetchAvatar(uid).catch(() => null);
|
||||||
|
if (!img && c.photo?.kind === 'inline') { const buffer = Buffer.from(c.photo.base64, 'base64'); if (imageMime(buffer)) img = { buffer }; }
|
||||||
|
if (!img && c.photo?.kind === 'url') img = await client.fetchBinary(c.photo.url).catch(() => null);
|
||||||
|
if (img && imageMime(img.buffer)) { store.savePhoto(person.id, img.buffer); person.photoVersion = 1; photos.push(person.id); }
|
||||||
|
}
|
||||||
|
store.save();
|
||||||
|
res.json({ created: created.length, withPhoto: photos.length, skipped: skipped.length, persons: st.persons.map(publicPerson) });
|
||||||
|
} catch (err) { ncError(res, err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api', (req, res) => res.status(404).json({ error: 'Unbekannter Endpunkt.' }));
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (res.headersSent) return next(err);
|
||||||
|
const status = err.status || err.statusCode || 500;
|
||||||
|
res.status(status).json({ error: status === 500 ? 'Interner Fehler.' : err.message });
|
||||||
|
if (status === 500) console.error(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT) || 3000;
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Rotatory läuft auf Port ${port}, Daten in ${dataDir}, Nextcloud ${nc.configured ? 'konfiguriert' : 'nicht konfiguriert'}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// CalDAV/CardDAV-Client für Nextcloud ohne Abhängigkeiten: Discovery, Kalender, Termine, Adressbuch.
|
||||||
|
|
||||||
|
export function nextcloudConfigFromEnv(env = process.env) {
|
||||||
|
const baseUrl = (env.NEXTCLOUD_URL || '').trim().replace(/\/+$/, '');
|
||||||
|
const user = (env.NEXTCLOUD_USER || '').trim();
|
||||||
|
const password = (env.NEXTCLOUD_APP_PASSWORD || '').trim();
|
||||||
|
const calendar = (env.NEXTCLOUD_CALENDAR || 'morning-meeting').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
||||||
|
return { baseUrl, user, password, calendar, configured: Boolean(baseUrl && user && password) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- XML-Hilfen (namespace-präfix-unabhängig) ----------
|
||||||
|
export function decodeXml(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (m, h) => String.fromCodePoint(parseInt(h, 16)))
|
||||||
|
.replace(/&#(\d+);/g, (m, d) => String.fromCodePoint(Number(d)))
|
||||||
|
.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
export function encodeXml(s) {
|
||||||
|
return String(s ?? '').replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]));
|
||||||
|
}
|
||||||
|
// Lokaler Name mit beliebigem Präfix; das Ende ist erreicht, wenn kein Namenszeichen folgt ("write" ≠ "write-properties").
|
||||||
|
const tag = (local) => `(?:[A-Za-z0-9_-]+:)?${local}(?![A-Za-z0-9_-])`;
|
||||||
|
export function xmlResponses(xml) {
|
||||||
|
return [...String(xml).matchAll(new RegExp(`<${tag('response')}[^>]*>([\\s\\S]*?)</${tag('response')}\\s*>`, 'g'))].map((m) => m[1]);
|
||||||
|
}
|
||||||
|
export function xmlProp(block, local) {
|
||||||
|
const m = String(block ?? '').match(new RegExp(`<${tag(local)}[^>]*>([\\s\\S]*?)</${tag(local)}\\s*>`));
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
export function xmlHasEmpty(block, local) {
|
||||||
|
return new RegExp(`<${tag(local)}[^>]*/>`).test(String(block ?? ''));
|
||||||
|
}
|
||||||
|
export function xmlAll(block, local) {
|
||||||
|
return [...String(block ?? '').matchAll(new RegExp(`<${tag(local)}[^>]*>([\\s\\S]*?)</${tag(local)}\\s*>`, 'g'))].map((m) => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROPFIND_BODY = (props) => `<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"><d:prop>${props}</d:prop></d:propfind>`;
|
||||||
|
|
||||||
|
export function createNextcloudClient({ baseUrl, user, password, calendar = 'morning-meeting', displayName = 'Morning Meeting', fetchImpl = fetch }) {
|
||||||
|
const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
|
||||||
|
const abs = (href) => new URL(href, baseUrl + '/').toString();
|
||||||
|
let discovered = null;
|
||||||
|
|
||||||
|
async function request(method, url, { headers = {}, body } = {}) {
|
||||||
|
try {
|
||||||
|
return await fetchImpl(url, { method, headers: { Authorization: auth, 'User-Agent': 'Rotatory/1.0', ...headers }, body, redirect: 'manual' });
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Nextcloud nicht erreichbar: ${err.cause?.message || err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function dav(method, url, body, depth = '0') {
|
||||||
|
const res = await request(method, url, { headers: { Depth: depth, 'Content-Type': 'application/xml; charset=utf-8' }, body });
|
||||||
|
if (res.status === 401) throw new Error('Nextcloud lehnt die Zugangsdaten ab (401). Benutzername oder App-Passwort prüfen.');
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
async function propfind(url, props, depth = '0') {
|
||||||
|
const res = await dav('PROPFIND', url, PROPFIND_BODY(props), depth);
|
||||||
|
return { status: res.status, xml: res.status === 207 ? await res.text() : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
/** Principal, Kalender- und Adressbuch-Home des angemeldeten Kontos ermitteln. */
|
||||||
|
async discover() {
|
||||||
|
if (discovered) return discovered;
|
||||||
|
const root = await propfind(abs('remote.php/dav/'), '<d:current-user-principal/>');
|
||||||
|
if (root.status !== 207) throw new Error(`Nextcloud antwortet mit HTTP ${root.status} auf ${abs('remote.php/dav/')}`);
|
||||||
|
const principalHref = decodeXml(xmlProp(xmlProp(root.xml, 'current-user-principal'), 'href'));
|
||||||
|
if (!principalHref) throw new Error('Nextcloud nennt keinen Principal – ist das eine Nextcloud-URL?');
|
||||||
|
const p = await propfind(abs(principalHref), '<c:calendar-home-set/><card:addressbook-home-set/><d:displayname/>');
|
||||||
|
if (p.status !== 207) throw new Error(`Principal nicht lesbar: HTTP ${p.status}`);
|
||||||
|
const calendarHome = decodeXml(xmlProp(xmlProp(p.xml, 'calendar-home-set'), 'href'));
|
||||||
|
const addressbookHome = decodeXml(xmlProp(xmlProp(p.xml, 'addressbook-home-set'), 'href'));
|
||||||
|
if (!calendarHome) throw new Error('Kein Kalender-Home gefunden – ist die Kalender-App aktiviert?');
|
||||||
|
discovered = {
|
||||||
|
principalHref,
|
||||||
|
userId: decodeURIComponent(principalHref.replace(/\/+$/, '').split('/').pop()),
|
||||||
|
displayName: decodeXml(xmlProp(p.xml, 'displayname') || ''),
|
||||||
|
calendarHome: abs(calendarHome),
|
||||||
|
addressbookHome: addressbookHome ? abs(addressbookHome) : null,
|
||||||
|
};
|
||||||
|
return discovered;
|
||||||
|
},
|
||||||
|
|
||||||
|
async test() {
|
||||||
|
const d = await client.discover();
|
||||||
|
const calendars = await client.listCalendars();
|
||||||
|
return { ok: true, userId: d.userId, displayName: d.displayName, calendarHome: d.calendarHome, calendars };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Alle Ereigniskalender (eigene und geteilte) mit Schreibrecht-Information. */
|
||||||
|
async listCalendars() {
|
||||||
|
const d = await client.discover();
|
||||||
|
const r = await propfind(d.calendarHome, '<d:displayname/><d:resourcetype/><d:owner/><d:current-user-privilege-set/><c:supported-calendar-component-set/><oc:owner-displayname/>', '1');
|
||||||
|
if (r.status !== 207) throw new Error(`Kalenderliste nicht lesbar: HTTP ${r.status}`);
|
||||||
|
const out = [];
|
||||||
|
for (const block of xmlResponses(r.xml)) {
|
||||||
|
const href = decodeXml(xmlProp(block, 'href') || '');
|
||||||
|
const rt = xmlProp(block, 'resourcetype') || '';
|
||||||
|
if (!xmlHasEmpty(rt, 'calendar')) continue;
|
||||||
|
const comps = xmlProp(block, 'supported-calendar-component-set') || '';
|
||||||
|
const supportsEvents = !comps || /name="VEVENT"/.test(comps);
|
||||||
|
if (!supportsEvents) continue;
|
||||||
|
const priv = xmlProp(block, 'current-user-privilege-set') || '';
|
||||||
|
const writable = xmlHasEmpty(priv, 'write') || xmlHasEmpty(priv, 'write-content') || xmlHasEmpty(priv, 'all');
|
||||||
|
const ownerHref = decodeXml(xmlProp(xmlProp(block, 'owner'), 'href') || '');
|
||||||
|
const ownerId = ownerHref ? decodeURIComponent(ownerHref.replace(/\/+$/, '').split('/').pop()) : '';
|
||||||
|
out.push({
|
||||||
|
href: abs(href),
|
||||||
|
id: href.replace(/\/+$/, '').split('/').pop(),
|
||||||
|
name: decodeXml(xmlProp(block, 'displayname') || ''),
|
||||||
|
writable,
|
||||||
|
shared: ownerId !== d.userId,
|
||||||
|
ownerName: decodeXml(xmlProp(block, 'owner-displayname') || '') || ownerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Zielkalender bereitstellen: vorhandener (href) oder eigener Kalender, der bei Bedarf angelegt wird. */
|
||||||
|
async ensureCalendar(targetHref) {
|
||||||
|
const d = await client.discover();
|
||||||
|
if (targetHref) {
|
||||||
|
const r = await propfind(targetHref, '<d:resourcetype/><d:current-user-privilege-set/>');
|
||||||
|
if (r.status !== 207) throw new Error(`Zielkalender nicht erreichbar (HTTP ${r.status}). In den Nextcloud-Einstellungen neu wählen.`);
|
||||||
|
return { href: targetHref, created: false };
|
||||||
|
}
|
||||||
|
const href = `${d.calendarHome}${encodeURIComponent(calendar)}/`;
|
||||||
|
const r = await propfind(href, '<d:resourcetype/>');
|
||||||
|
if (r.status === 207) return { href, created: false };
|
||||||
|
if (r.status !== 404) throw new Error(`Kalender-Abfrage fehlgeschlagen: HTTP ${r.status}`);
|
||||||
|
const body = '<?xml version="1.0" encoding="utf-8"?>'
|
||||||
|
+ '<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"><d:set><d:prop>'
|
||||||
|
+ `<d:displayname>${encodeXml(displayName)}</d:displayname>`
|
||||||
|
+ '<a:calendar-color>#2f6fed</a:calendar-color>'
|
||||||
|
+ '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
|
||||||
|
+ '</d:prop></d:set></c:mkcalendar>';
|
||||||
|
const mk = await dav('MKCALENDAR', href, body);
|
||||||
|
if (mk.status < 200 || mk.status >= 300) throw new Error(`Kalender konnte nicht angelegt werden: HTTP ${mk.status}`);
|
||||||
|
return { href, created: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
async putEvent(calendarHref, uid, ics) {
|
||||||
|
const res = await request('PUT', `${calendarHref}${encodeURIComponent(uid)}.ics`, { headers: { 'Content-Type': 'text/calendar; charset=utf-8' }, body: ics });
|
||||||
|
if (res.status < 200 || res.status >= 300) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Termin ${uid} nicht gespeichert: HTTP ${res.status} ${text.replace(/<[^>]+>/g, ' ').trim().slice(0, 160)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteEvent(calendarHref, uidOrHref) {
|
||||||
|
const url = /^https?:\/\//.test(uidOrHref) ? uidOrHref : `${calendarHref}${encodeURIComponent(uidOrHref)}.ics`;
|
||||||
|
const res = await request('DELETE', url);
|
||||||
|
if (res.status === 404) return false;
|
||||||
|
if (res.status < 200 || res.status >= 300) throw new Error(`Termin nicht gelöscht: HTTP ${res.status}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Termine eines Kalenders im Zeitraum (ISO-Tage, inklusiv). expand: Wiederholungen serverseitig auflösen. */
|
||||||
|
async queryEvents(calendarHref, from, to, { expand = false } = {}) {
|
||||||
|
const start = `${from.replace(/-/g, '')}T000000Z`;
|
||||||
|
const endDate = new Date(to + 'T00:00:00Z');
|
||||||
|
endDate.setUTCDate(endDate.getUTCDate() + 1);
|
||||||
|
// Der Server filtert grob (UTC-Mitternacht); Aufrufer prüfen die Überlappung mit den lokalen Tagen selbst.
|
||||||
|
const end = `${endDate.toISOString().slice(0, 10).replace(/-/g, '')}T000000Z`;
|
||||||
|
const data = expand ? `<c:calendar-data><c:expand start="${start}" end="${end}"/></c:calendar-data>` : '<c:calendar-data/>';
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/>${data}</d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"><c:time-range start="${start}" end="${end}"/></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>`;
|
||||||
|
const res = await dav('REPORT', calendarHref, body, '1');
|
||||||
|
if (res.status !== 207) throw new Error(`Terminabfrage fehlgeschlagen: HTTP ${res.status}`);
|
||||||
|
const xml = await res.text();
|
||||||
|
return xmlResponses(xml).map((block) => ({
|
||||||
|
href: abs(decodeXml(xmlProp(block, 'href') || '')),
|
||||||
|
etag: decodeXml(xmlProp(block, 'getetag') || ''),
|
||||||
|
ics: decodeXml(xmlProp(block, 'calendar-data') || ''),
|
||||||
|
})).filter((e) => e.ics);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAddressBooks() {
|
||||||
|
const d = await client.discover();
|
||||||
|
if (!d.addressbookHome) return [];
|
||||||
|
const r = await propfind(d.addressbookHome, '<d:displayname/><d:resourcetype/>', '1');
|
||||||
|
if (r.status !== 207) throw new Error(`Adressbuchliste nicht lesbar: HTTP ${r.status}`);
|
||||||
|
const out = [];
|
||||||
|
for (const block of xmlResponses(r.xml)) {
|
||||||
|
const rt = xmlProp(block, 'resourcetype') || '';
|
||||||
|
if (!xmlHasEmpty(rt, 'addressbook')) continue;
|
||||||
|
const href = decodeXml(xmlProp(block, 'href') || '');
|
||||||
|
const id = href.replace(/\/+$/, '').split('/').pop();
|
||||||
|
out.push({ href: abs(href), id, name: decodeXml(xmlProp(block, 'displayname') || id), system: id === 'z-server-generated--system' });
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => Number(b.system) - Number(a.system) || a.name.localeCompare(b.name, 'de'));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Alle vCards eines Adressbuchs als Text. */
|
||||||
|
async queryContacts(addressbookHref) {
|
||||||
|
const body = '<?xml version="1.0" encoding="utf-8"?><card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><card:address-data/></d:prop></card:addressbook-query>';
|
||||||
|
const res = await dav('REPORT', addressbookHref, body, '1');
|
||||||
|
if (res.status !== 207) throw new Error(`Adressbuch nicht lesbar: HTTP ${res.status}`);
|
||||||
|
const xml = await res.text();
|
||||||
|
return xmlResponses(xml).map((block) => decodeXml(xmlProp(block, 'address-data') || '')).filter(Boolean);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Profilbild eines Nextcloud-Kontos; null, wenn keines gesetzt ist (Buchstaben-Avatar zählt nicht). */
|
||||||
|
async fetchAvatar(userId, size = 256) {
|
||||||
|
const res = await request('GET', abs(`index.php/avatar/${encodeURIComponent(userId)}/${size}`));
|
||||||
|
if (res.status !== 200) return null;
|
||||||
|
const custom = res.headers.get('x-nc-iscustomavatar');
|
||||||
|
if (custom !== null && custom !== '1') return null;
|
||||||
|
const type = res.headers.get('content-type') || '';
|
||||||
|
if (!type.startsWith('image/')) return null;
|
||||||
|
return { buffer: Buffer.from(await res.arrayBuffer()), mime: type.split(';')[0] };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Bild von einer URL – nur innerhalb der eigenen Nextcloud, damit das App-Passwort nie an fremde Hosts geht. */
|
||||||
|
async fetchBinary(url) {
|
||||||
|
let target;
|
||||||
|
try { target = new URL(url); } catch { return null; }
|
||||||
|
if (target.origin !== new URL(baseUrl).origin) return null;
|
||||||
|
const res = await request('GET', url);
|
||||||
|
if (res.status !== 200) return null;
|
||||||
|
const type = (res.headers.get('content-type') || '').split(';')[0];
|
||||||
|
if (!type.startsWith('image/')) return null;
|
||||||
|
return { buffer: Buffer.from(await res.arrayBuffer()), mime: type };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Reine Planungslogik ohne Abhängigkeiten. Datumsangaben sind immer ISO-Strings (YYYY-MM-DD).
|
||||||
|
|
||||||
|
export function addDays(iso, n) {
|
||||||
|
const d = new Date(iso + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() + n);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeekend(iso) {
|
||||||
|
const day = new Date(iso + 'T00:00:00Z').getUTCDay();
|
||||||
|
return day === 0 || day === 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO-Wochentag 1 (Mo) … 7 (So). */
|
||||||
|
export function isoWeekday(iso) {
|
||||||
|
const day = new Date(iso + 'T00:00:00Z').getUTCDay();
|
||||||
|
return day === 0 ? 7 : day;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidDate(iso) {
|
||||||
|
return typeof iso === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(iso) && !Number.isNaN(Date.parse(iso + 'T00:00:00Z'))
|
||||||
|
&& new Date(iso + 'T00:00:00Z').toISOString().slice(0, 10) === iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Werktage (Mo–Fr) im Bereich, ohne die blockierten Tage (Feiertage, betriebsfrei). */
|
||||||
|
export function workingDays(from, to, blocked = new Set()) {
|
||||||
|
const out = [];
|
||||||
|
for (let d = from; d <= to; d = addDays(d, 1)) {
|
||||||
|
if (!isWeekend(d) && !blocked.has(d)) out.push(d);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAbsent(personId, date, absences) {
|
||||||
|
return absences.some((a) => a.personId === personId && a.from <= date && a.to >= date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verfügbar = aktiv, nicht abwesend und kein fester freier Wochentag (Teilzeit). */
|
||||||
|
export function isAvailable(person, date, absences) {
|
||||||
|
if (person.active === false) return false;
|
||||||
|
if (Array.isArray(person.freeDays) && person.freeDays.includes(isoWeekday(date))) return false;
|
||||||
|
return !isAbsent(person.id, date, absences);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt Zuteilungen für die angegebenen Tage.
|
||||||
|
*
|
||||||
|
* - persons: [{ id, canMain, canCo, active, freeDays }]
|
||||||
|
* - absences: [{ personId, from, to }]
|
||||||
|
* - existing: { date: { main, co, locked, off, keep, fill } } – locked/off/keep bleiben unverändert,
|
||||||
|
* fill: vorhandene Slots behalten, nur leere auslosen
|
||||||
|
* - blocked: Set blockierter Tage (Feiertage), damit „nicht zwei Werktage in Folge" darüber hinweg gilt
|
||||||
|
* - history: { date: { main, co, off } } – frühere Zuteilungen für die Fairness-Zählung
|
||||||
|
* - rng: Zufallsfunktion [0,1), austauschbar für Tests
|
||||||
|
*
|
||||||
|
* Fairness: Wer im Betrachtungsfenster am seltensten dran war, wird bevorzugt; unter Gleichen
|
||||||
|
* entscheidet die Rollenverteilung, danach der Zufall. Niemand moderiert an zwei Werktagen
|
||||||
|
* hintereinander, wenn es sich vermeiden lässt, und dasselbe Paar wiederholt sich ungern.
|
||||||
|
*/
|
||||||
|
export function generateSchedule({ dates, persons, absences = [], existing = {}, history = {}, blocked = new Set(), rng = Math.random, historyWindowDays = 60 }) {
|
||||||
|
const result = {};
|
||||||
|
const counts = new Map();
|
||||||
|
const stat = (id) => {
|
||||||
|
if (!counts.has(id)) counts.set(id, { total: 0, main: 0, co: 0, lastPartner: null });
|
||||||
|
return counts.get(id);
|
||||||
|
};
|
||||||
|
const bump = (id, role, partner) => {
|
||||||
|
const s = stat(id);
|
||||||
|
s.total += 1;
|
||||||
|
s[role] += 1;
|
||||||
|
s.lastPartner = partner || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = dates[0];
|
||||||
|
if (first) {
|
||||||
|
const windowStart = addDays(first, -historyWindowDays);
|
||||||
|
const past = Object.entries(history).filter(([date, a]) => date < first && date >= windowStart && a && !a.off).sort();
|
||||||
|
for (const [, a] of past) {
|
||||||
|
if (a.main) bump(a.main, 'main', a.co);
|
||||||
|
if (a.co) bump(a.co, 'co', a.main);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedOn = (date) => {
|
||||||
|
const a = result[date] || history[date];
|
||||||
|
return a && !a.off ? [a.main, a.co].filter(Boolean) : [];
|
||||||
|
};
|
||||||
|
const previousWorkingDay = (date) => {
|
||||||
|
let d = addDays(date, -1);
|
||||||
|
while (isWeekend(d) || blocked.has(d)) d = addDays(d, -1);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
const active = persons.filter((p) => p.active !== false);
|
||||||
|
|
||||||
|
for (const date of dates) {
|
||||||
|
const ex = existing[date];
|
||||||
|
if (ex && (ex.locked || ex.off || ex.keep)) {
|
||||||
|
const { keep, fill, ...rest } = ex;
|
||||||
|
result[date] = { ...rest };
|
||||||
|
if (!ex.off) {
|
||||||
|
if (ex.main) bump(ex.main, 'main', ex.co);
|
||||||
|
if (ex.co) bump(ex.co, 'co', ex.main);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fixedMain = ex && ex.fill ? ex.main || null : null;
|
||||||
|
const fixedCo = ex && ex.fill ? ex.co || null : null;
|
||||||
|
const yesterday = assignedOn(previousWorkingDay(date));
|
||||||
|
const available = active.filter((p) => isAvailable(p, date, absences));
|
||||||
|
const main = fixedMain || pick(available.filter((p) => p.canMain && p.id !== fixedCo), 'main', yesterday, null, stat, rng);
|
||||||
|
const co = fixedCo || pick(available.filter((p) => p.canCo && p.id !== main), 'co', yesterday, main, stat, rng);
|
||||||
|
result[date] = { main, co, locked: false };
|
||||||
|
if (main) bump(main, 'main', co);
|
||||||
|
if (co) bump(co, 'co', main);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(candidates, role, yesterday, partner, stat, rng) {
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
let pool = candidates.filter((p) => !yesterday.includes(p.id));
|
||||||
|
if (pool.length === 0) pool = candidates;
|
||||||
|
|
||||||
|
const minTotal = Math.min(...pool.map((p) => stat(p.id).total));
|
||||||
|
pool = pool.filter((p) => stat(p.id).total === minTotal);
|
||||||
|
|
||||||
|
const minRole = Math.min(...pool.map((p) => stat(p.id)[role]));
|
||||||
|
pool = pool.filter((p) => stat(p.id)[role] === minRole);
|
||||||
|
|
||||||
|
if (partner) {
|
||||||
|
const fresh = pool.filter((p) => stat(p.id).lastPartner !== partner);
|
||||||
|
if (fresh.length > 0) pool = fresh;
|
||||||
|
}
|
||||||
|
return pool[Math.floor(rng() * pool.length)].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zählt Einsätze je Person im Bereich – für die Statistik in der Oberfläche. */
|
||||||
|
export function countAssignments(assignments, from, to) {
|
||||||
|
const out = {};
|
||||||
|
for (const [date, a] of Object.entries(assignments)) {
|
||||||
|
if (date < from || date > to || !a || a.off) continue;
|
||||||
|
for (const [role, id] of [['main', a.main], ['co', a.co]]) {
|
||||||
|
if (!id) continue;
|
||||||
|
out[id] = out[id] || { main: 0, co: 0, total: 0 };
|
||||||
|
out[id][role] += 1;
|
||||||
|
out[id].total += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DEFAULT_STATE = () => ({
|
||||||
|
persons: [],
|
||||||
|
absences: [],
|
||||||
|
holidays: [],
|
||||||
|
assignments: {},
|
||||||
|
exported: {},
|
||||||
|
settings: {
|
||||||
|
meetingTime: '09:30',
|
||||||
|
durationMinutes: 15,
|
||||||
|
bundesland: 'BY',
|
||||||
|
calendarDisplayName: 'Morning Meeting',
|
||||||
|
inviteAttendees: false,
|
||||||
|
historyWindowDays: 60,
|
||||||
|
holidaysSeeded: false,
|
||||||
|
targetCalendarHref: '',
|
||||||
|
targetCalendarName: '',
|
||||||
|
absenceCalendarHref: '',
|
||||||
|
absenceCalendarName: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Bildtyp anhand der ersten Bytes erkennen. */
|
||||||
|
export function imageMime(buffer) {
|
||||||
|
if (!buffer || buffer.length < 12) return null;
|
||||||
|
if (buffer[0] === 0xff && buffer[1] === 0xd8) return 'image/jpeg';
|
||||||
|
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return 'image/png';
|
||||||
|
if (buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
|
||||||
|
if (buffer.subarray(0, 4).toString('ascii') === 'GIF8') return 'image/gif';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einfacher JSON-Speicher mit atomarem Schreiben. Fotos liegen daneben im Ordner photos/. */
|
||||||
|
export function createStore(dataDir) {
|
||||||
|
const file = path.join(dataDir, 'rotatory.json');
|
||||||
|
const photoDir = path.join(dataDir, 'photos');
|
||||||
|
fs.mkdirSync(photoDir, { recursive: true });
|
||||||
|
|
||||||
|
let state = DEFAULT_STATE();
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
const loaded = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
state = { ...state, ...loaded, settings: { ...state.settings, ...(loaded.settings || {}) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
const tmp = file + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoPath = (id) => path.join(photoDir, `${id}.jpg`);
|
||||||
|
return {
|
||||||
|
get state() { return state; },
|
||||||
|
save,
|
||||||
|
photoPath,
|
||||||
|
hasPhoto: (id) => fs.existsSync(photoPath(id)),
|
||||||
|
readPhoto: (id) => (fs.existsSync(photoPath(id)) ? fs.readFileSync(photoPath(id)) : null),
|
||||||
|
savePhoto: (id, buffer) => fs.writeFileSync(photoPath(id), buffer),
|
||||||
|
deletePhoto: (id) => { try { fs.unlinkSync(photoPath(id)); } catch {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { loadEnvFile } from '../server/env.js';
|
||||||
|
|
||||||
|
test('.env laden ohne bestehende Variablen zu überschreiben', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rot-'));
|
||||||
|
const file = path.join(dir, '.env');
|
||||||
|
fs.writeFileSync(file, '# Kommentar\nROT_A=eins\nROT_B="zwei drei" \nROT_C=vier # optional\nexport ROT_D=\'fünf\'\nROT_E=\nROT_F= # optional Erklärung\nROT_G=wert # Kommentar\n');
|
||||||
|
process.env.ROT_A = 'vorher';
|
||||||
|
assert.equal(loadEnvFile(file), true);
|
||||||
|
assert.equal(process.env.ROT_A, 'vorher');
|
||||||
|
assert.equal(process.env.ROT_B, 'zwei drei');
|
||||||
|
assert.equal(process.env.ROT_C, 'vier');
|
||||||
|
assert.equal(process.env.ROT_D, 'fünf');
|
||||||
|
assert.equal(process.env.ROT_E, '');
|
||||||
|
assert.equal(process.env.ROT_F, '', 'Kommentar nach leerem Wert ist kein Wert');
|
||||||
|
assert.equal(process.env.ROT_G, 'wert');
|
||||||
|
assert.equal(loadEnvFile(path.join(dir, 'fehlt')), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { easterSunday, germanHolidays } from '../server/holidays.js';
|
||||||
|
|
||||||
|
test('Ostersonntag bekannter Jahre', () => {
|
||||||
|
assert.equal(easterSunday(2024), '2024-03-31');
|
||||||
|
assert.equal(easterSunday(2025), '2025-04-20');
|
||||||
|
assert.equal(easterSunday(2026), '2026-04-05');
|
||||||
|
assert.equal(easterSunday(2027), '2027-03-28');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundesweite Feiertage 2026', () => {
|
||||||
|
const dates = Object.fromEntries(germanHolidays(2026).map((h) => [h.name, h.date]));
|
||||||
|
assert.equal(dates['Karfreitag'], '2026-04-03');
|
||||||
|
assert.equal(dates['Ostermontag'], '2026-04-06');
|
||||||
|
assert.equal(dates['Christi Himmelfahrt'], '2026-05-14');
|
||||||
|
assert.equal(dates['Pfingstmontag'], '2026-05-25');
|
||||||
|
assert.equal(dates['Tag der Deutschen Einheit'], '2026-10-03');
|
||||||
|
assert.equal(Object.keys(dates).length, 9);
|
||||||
|
assert.ok(!('Fronleichnam' in dates));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Landesfeiertage nur im passenden Bundesland', () => {
|
||||||
|
const nw = germanHolidays(2026, 'NW').map((h) => h.name);
|
||||||
|
assert.ok(nw.includes('Fronleichnam'));
|
||||||
|
assert.ok(nw.includes('Allerheiligen'));
|
||||||
|
assert.ok(!nw.includes('Reformationstag'));
|
||||||
|
const sn = Object.fromEntries(germanHolidays(2026, 'SN').map((h) => [h.name, h.date]));
|
||||||
|
assert.equal(sn['Buß- und Bettag'], '2026-11-18');
|
||||||
|
assert.ok('Reformationstag' in sn);
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { unescapeText, unfold, parseLine, parseEvents, parseVCards, parseAbsenceSummary, matchPerson, mergeAbsences, toLocalDate } from '../server/ical.js';
|
||||||
|
|
||||||
|
test('unfold und parseLine', () => {
|
||||||
|
const lines = unfold('SUMMARY:Ein sehr\r\n langer Titel\r\nDTSTART;TZID=Europe/Berlin:20261005T093000\r\n');
|
||||||
|
assert.deepEqual(lines, ['SUMMARY:Ein sehr langer Titel', 'DTSTART;TZID=Europe/Berlin:20261005T093000']);
|
||||||
|
const p = parseLine('ATTENDEE;CN="Max, Mustermann";ROLE=REQ-PARTICIPANT:mailto:max@example.de');
|
||||||
|
assert.equal(p.name, 'ATTENDEE');
|
||||||
|
assert.equal(p.params.CN, 'Max, Mustermann');
|
||||||
|
assert.equal(p.value, 'mailto:max@example.de');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unescapeText löst \\; \\, \\n und \\\\ auf', () => {
|
||||||
|
assert.equal(unescapeText('a\\;b\\,c\\nd\\\\e'), 'a;b,c\nd\\e');
|
||||||
|
assert.equal(unescapeText('x\\\\ny'), 'x\\ny', 'maskierter Backslash gefolgt von n ist kein Zeilenumbruch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toLocalDate: Datum, UTC nach Berlin, Wandzeit', () => {
|
||||||
|
assert.deepEqual(toLocalDate('20261005', { VALUE: 'DATE' }).date, '2026-10-05');
|
||||||
|
assert.equal(toLocalDate('20261005T220000Z').date, '2026-10-06'); // 22:00 UTC = 00:00 MESZ am Folgetag
|
||||||
|
assert.equal(toLocalDate('20261205T230000Z').date, '2026-12-06'); // Winterzeit
|
||||||
|
assert.equal(toLocalDate('20261005T093000', { TZID: 'Europe/Berlin' }).date, '2026-10-05');
|
||||||
|
assert.equal(toLocalDate('kaputt'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ICS = (body) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`;
|
||||||
|
|
||||||
|
test('ganztägige Urlaubswoche: DTEND exklusiv', () => {
|
||||||
|
const [ev] = parseEvents(ICS('BEGIN:VEVENT\r\nUID:a\r\nSUMMARY:Stefan Frühmorgen (Urlaub)\r\nDTSTART;VALUE=DATE:20260914\r\nDTEND;VALUE=DATE:20260919\r\nEND:VEVENT'));
|
||||||
|
assert.equal(ev.from, '2026-09-14');
|
||||||
|
assert.equal(ev.to, '2026-09-18');
|
||||||
|
assert.equal(ev.allDay, true);
|
||||||
|
assert.equal(ev.summary, 'Stefan Frühmorgen (Urlaub)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Termin mit Uhrzeit, Termin bis Mitternacht, DURATION, mehrere VEVENTs', () => {
|
||||||
|
const evs = parseEvents(ICS([
|
||||||
|
'BEGIN:VEVENT\r\nUID:b\r\nSUMMARY:Libor Herudek (Urlaub)\r\nDTSTART;TZID=Europe/Berlin:20260918T120000\r\nDTEND;TZID=Europe/Berlin:20260918T160000\r\nEND:VEVENT',
|
||||||
|
'BEGIN:VEVENT\r\nUID:c\r\nSUMMARY:X (krank)\r\nDTSTART:20260917T220000Z\r\nDTEND:20260918T220000Z\r\nEND:VEVENT',
|
||||||
|
'BEGIN:VEVENT\r\nUID:d\r\nSUMMARY:Y (frei)\r\nDTSTART;VALUE=DATE:20260921\r\nDURATION:P2D\r\nEND:VEVENT',
|
||||||
|
'BEGIN:VEVENT\r\nUID:e\r\nSUMMARY:Z\r\nDTSTART;VALUE=DATE:20260922\r\nEND:VEVENT',
|
||||||
|
].join('\r\n')));
|
||||||
|
assert.equal(evs.length, 4);
|
||||||
|
assert.deepEqual([evs[0].from, evs[0].to], ['2026-09-18', '2026-09-18']);
|
||||||
|
assert.deepEqual([evs[1].from, evs[1].to], ['2026-09-18', '2026-09-18']); // 00:00–24:00 MESZ
|
||||||
|
assert.deepEqual([evs[2].from, evs[2].to], ['2026-09-21', '2026-09-22']);
|
||||||
|
assert.deepEqual([evs[3].from, evs[3].to], ['2026-09-22', '2026-09-22']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RRULE und RECURRENCE-ID werden durchgereicht, Alarme stören nicht', () => {
|
||||||
|
const [ev] = parseEvents(ICS('BEGIN:VEVENT\r\nUID:r\r\nSUMMARY:A (frei)\r\nDTSTART;VALUE=DATE:20260914\r\nRRULE:FREQ=WEEKLY;BYDAY=MO\r\nBEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:x\r\nEND:VALARM\r\nEND:VEVENT'));
|
||||||
|
assert.equal(ev.rrule, 'FREQ=WEEKLY;BYDAY=MO');
|
||||||
|
assert.equal(ev.description, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vCard 3.0 mit Base64-Foto und vCard 4.0 mit data-URI', () => {
|
||||||
|
const v3 = 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:ferdinand\r\nFN:Ferdinand Urban\r\nN:Urban;Ferdinand;;;\r\nEMAIL;TYPE=INTERNET:urban@team-tradeo.de\r\nPHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ\r\n SkZJRg==\r\nCLOUD:ferdinand@cld2.tradeo.de\r\nEND:VCARD\r\n';
|
||||||
|
const [c] = parseVCards(v3);
|
||||||
|
assert.equal(c.uid, 'ferdinand');
|
||||||
|
assert.equal(c.firstName, 'Ferdinand');
|
||||||
|
assert.equal(c.lastName, 'Urban');
|
||||||
|
assert.equal(c.email, 'urban@team-tradeo.de');
|
||||||
|
assert.deepEqual(c.photo, { kind: 'inline', mime: 'image/jpeg', base64: '/9j/4AAQSkZJRg==' });
|
||||||
|
const v4 = 'BEGIN:VCARD\r\nVERSION:4.0\r\nUID:x\r\nFN:Anna Maria Ciesla\r\nPHOTO:data:image/png;base64,iVBORw0KGgo=\r\nEND:VCARD\r\n';
|
||||||
|
const [d] = parseVCards(v4);
|
||||||
|
assert.equal(d.firstName, 'Anna Maria');
|
||||||
|
assert.equal(d.lastName, 'Ciesla');
|
||||||
|
assert.equal(d.photo.mime, 'image/png');
|
||||||
|
assert.equal(d.photo.base64, 'iVBORw0KGgo=');
|
||||||
|
const v4url = 'BEGIN:VCARD\r\nVERSION:4.0\r\nUID:y\r\nFN:Solo\r\nPHOTO;VALUE=URI:https://cloud.example.de/avatar.png\r\nEND:VCARD\r\n';
|
||||||
|
const [e] = parseVCards(v4url);
|
||||||
|
assert.equal(e.lastName, 'Solo');
|
||||||
|
assert.equal(e.firstName, '');
|
||||||
|
assert.deepEqual(e.photo, { kind: 'url', url: 'https://cloud.example.de/avatar.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Abwesenheitstitel deuten', () => {
|
||||||
|
assert.deepEqual(parseAbsenceSummary('Stefan Frühmorgen (Urlaub)'), { name: 'Stefan Frühmorgen', kind: 'urlaub', raw: 'Urlaub' });
|
||||||
|
assert.equal(parseAbsenceSummary('Carmine Mastromarino (krank)').kind, 'krankheit');
|
||||||
|
assert.equal(parseAbsenceSummary('Alex Koop (frei)').kind, 'frei');
|
||||||
|
assert.equal(parseAbsenceSummary('Hans Holzmann [Home Office]').kind, 'frei');
|
||||||
|
assert.deepEqual(parseAbsenceSummary('Nur Name'), { name: 'Nur Name', kind: 'sonstiges', raw: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Namen zuordnen: Vor+Nach, Nach+Vor, Umlaute, eindeutiger Nachname', () => {
|
||||||
|
const persons = [
|
||||||
|
{ id: '1', firstName: 'Stefan', lastName: 'Frühmorgen' },
|
||||||
|
{ id: '2', firstName: 'Michael', lastName: 'Lotter' },
|
||||||
|
{ id: '3', firstName: 'Michael', lastName: 'Schuster' },
|
||||||
|
];
|
||||||
|
assert.equal(matchPerson('Stefan Frühmorgen', persons).id, '1');
|
||||||
|
assert.equal(matchPerson('Fruehmorgen, Stefan', persons).id, '1');
|
||||||
|
assert.equal(matchPerson('FRÜHMORGEN STEFAN', persons).id, '1');
|
||||||
|
assert.equal(matchPerson('Stefan Fru\u0308hmorgen', persons).id, '1', 'NFD-Umlaut (u + Trema)');
|
||||||
|
assert.equal(matchPerson('Lotter', persons).id, '2');
|
||||||
|
assert.equal(matchPerson('Michael', persons), null);
|
||||||
|
assert.equal(matchPerson('Unbekannt Person', persons), null);
|
||||||
|
assert.equal(matchPerson('', persons), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Abwesenheiten verschmelzen: angrenzend und überlappend, getrennt nach Art', () => {
|
||||||
|
const merged = mergeAbsences([
|
||||||
|
{ personId: 'a', type: 'urlaub', from: '2026-10-05', to: '2026-10-06' },
|
||||||
|
{ personId: 'a', type: 'urlaub', from: '2026-10-07', to: '2026-10-09' },
|
||||||
|
{ personId: 'a', type: 'urlaub', from: '2026-10-08', to: '2026-10-08' },
|
||||||
|
{ personId: 'a', type: 'frei', from: '2026-10-05', to: '2026-10-05' },
|
||||||
|
{ personId: 'a', type: 'urlaub', from: '2026-10-19', to: '2026-10-19' },
|
||||||
|
{ personId: 'b', type: 'urlaub', from: '2026-10-05', to: '2026-10-05' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(merged.map((m) => `${m.personId}/${m.type}/${m.from}/${m.to}`), [
|
||||||
|
'a/urlaub/2026-10-05/2026-10-09', 'a/frei/2026-10-05/2026-10-05', 'b/urlaub/2026-10-05/2026-10-05', 'a/urlaub/2026-10-19/2026-10-19',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildEventLines, buildCalendar, escapeText, foldLine, eventUid } from '../server/ics.js';
|
||||||
|
|
||||||
|
test('escapeText maskiert Sonderzeichen', () => {
|
||||||
|
assert.equal(escapeText('a,b;c\\d\ne'), 'a\\,b\\;c\\\\d\\ne');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('foldLine faltet lange Zeilen nach 75 Oktetten', () => {
|
||||||
|
const long = 'X'.repeat(200);
|
||||||
|
const folded = foldLine(long);
|
||||||
|
const lines = folded.split('\r\n');
|
||||||
|
assert.ok(lines.length > 1);
|
||||||
|
assert.ok(lines.every((l) => Buffer.byteLength(l) <= 75));
|
||||||
|
assert.ok(lines.slice(1).every((l) => l.startsWith(' ')));
|
||||||
|
assert.equal(lines.map((l, i) => (i ? l.slice(1) : l)).join(''), long);
|
||||||
|
// Umlaute werden nicht zerschnitten
|
||||||
|
const umlaut = foldLine('Ä'.repeat(100));
|
||||||
|
assert.equal(umlaut.split('\r\n').map((l, i) => (i ? l.slice(1) : l)).join(''), 'Ä'.repeat(100));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Termin mit Uhrzeit und Zeitzone', () => {
|
||||||
|
const lines = buildEventLines({ uid: 'u1', date: '2026-10-05', summary: 'Morning Meeting: A / B', time: '09:00', durationMinutes: 15, sequence: 2, now: new Date('2026-09-15T10:00:00Z') });
|
||||||
|
assert.ok(lines.includes('DTSTART;TZID=Europe/Berlin:20261005T090000'));
|
||||||
|
assert.ok(lines.includes('DTEND;TZID=Europe/Berlin:20261005T091500'));
|
||||||
|
assert.ok(lines.includes('SEQUENCE:2'));
|
||||||
|
assert.ok(lines.includes('DTSTAMP:20260915T100000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ganztägiger Termin ohne Uhrzeit', () => {
|
||||||
|
const lines = buildEventLines({ uid: 'u1', date: '2026-10-30', summary: 'x', time: '' });
|
||||||
|
assert.ok(lines.includes('DTSTART;VALUE=DATE:20261030'));
|
||||||
|
assert.ok(lines.includes('DTEND;VALUE=DATE:20261031'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kalender ist wohlgeformt, ohne METHOD für CalDAV, mit ORGANIZER vor Teilnehmern', () => {
|
||||||
|
const ev = buildEventLines({ uid: eventUid('2026-10-05'), date: '2026-10-05', summary: 'S', time: '09:00', attendees: [{ name: 'Max Mustermann', email: 'max@example.de' }], organizer: { name: 'Rotatory', email: 'team@example.de' } });
|
||||||
|
const ics = buildCalendar([ev]);
|
||||||
|
assert.ok(!ics.includes('METHOD:'), 'CalDAV-Objekte dürfen keine METHOD tragen');
|
||||||
|
assert.ok(buildCalendar([ev], { method: 'PUBLISH' }).includes('METHOD:PUBLISH'));
|
||||||
|
assert.ok(ics.includes('ORGANIZER;CN="Rotatory":mailto:team@example.de'));
|
||||||
|
assert.ok(ics.indexOf('ORGANIZER') < ics.indexOf('ATTENDEE'));
|
||||||
|
const noOrganizer = buildCalendar([buildEventLines({ uid: 'x', date: '2026-10-05', summary: 'S', attendees: [{ name: 'A', email: 'a@example.de' }] })]);
|
||||||
|
assert.ok(!noOrganizer.includes('ATTENDEE'), 'ohne ORGANIZER keine Teilnehmer');
|
||||||
|
assert.ok(ics.startsWith('BEGIN:VCALENDAR\r\n'));
|
||||||
|
assert.ok(ics.endsWith('END:VCALENDAR\r\n'));
|
||||||
|
assert.ok(ics.includes('BEGIN:VTIMEZONE'));
|
||||||
|
assert.ok(ics.includes('UID:rotatory-2026-10-05@rotatory.local'));
|
||||||
|
assert.ok(ics.includes('ATTENDEE;CN="Max Mustermann"'));
|
||||||
|
assert.ok(ics.split('\r\n').every((l) => Buffer.byteLength(l) <= 75));
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createNextcloudClient, nextcloudConfigFromEnv, decodeXml, xmlResponses, xmlProp, xmlHasEmpty } from '../server/nextcloud.js';
|
||||||
|
|
||||||
|
const PRIV_RW = '<d:current-user-privilege-set><d:privilege><d:write/></d:privilege><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>';
|
||||||
|
const PRIV_RO = '<d:current-user-privilege-set><d:privilege><d:write-properties/></d:privilege><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>';
|
||||||
|
const cal = (href, name, priv, owner, comps = '<cal:comp name="VEVENT"/>') => `<d:response><d:href>${href}</d:href><d:propstat><d:prop><d:displayname>${name}</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype><d:owner><d:href>/remote.php/dav/principals/users/${owner}/</d:href></d:owner>${priv}<cal:supported-calendar-component-set>${comps}</cal:supported-calendar-component-set><oc:owner-displayname>${owner.toUpperCase()}</oc:owner-displayname></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`;
|
||||||
|
|
||||||
|
/** Simulierte Nextcloud: Login-Name ist die E-Mail, Benutzer-ID "ferdinand". */
|
||||||
|
function fakeNextcloud() {
|
||||||
|
const calls = [];
|
||||||
|
const store = new Map();
|
||||||
|
let created = false;
|
||||||
|
const res = (status, text = '', headers = {}) => ({ status, text: async () => text, headers: { get: (k) => headers[k.toLowerCase()] ?? null }, arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0, 0, 0, 0, 0]).buffer });
|
||||||
|
const fetchImpl = async (url, opts) => {
|
||||||
|
calls.push({ method: opts.method, url, auth: opts.headers.Authorization, body: opts.body, depth: opts.headers.Depth });
|
||||||
|
const u = new URL(url);
|
||||||
|
if (opts.method === 'PROPFIND') {
|
||||||
|
if (u.pathname === '/remote.php/dav/') return res(207, '<d:multistatus xmlns:d="DAV:"><d:response><d:href>/remote.php/dav/</d:href><d:propstat><d:prop><d:current-user-principal><d:href>/remote.php/dav/principals/users/ferdinand/</d:href></d:current-user-principal></d:prop></d:propstat></d:response></d:multistatus>');
|
||||||
|
if (u.pathname === '/remote.php/dav/principals/users/ferdinand/') return res(207, '<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:response><d:href>/remote.php/dav/principals/users/ferdinand/</d:href><d:propstat><d:prop><cal:calendar-home-set><d:href>/remote.php/dav/calendars/ferdinand/</d:href></cal:calendar-home-set><card:addressbook-home-set><d:href>/remote.php/dav/addressbooks/users/ferdinand/</d:href></card:addressbook-home-set><d:displayname>Ferdinand Urban</d:displayname></d:prop></d:propstat></d:response></d:multistatus>');
|
||||||
|
if (u.pathname === '/remote.php/dav/calendars/ferdinand/' && opts.headers.Depth === '1') return res(207, `<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:oc="http://owncloud.org/ns"><d:response><d:href>/remote.php/dav/calendars/ferdinand/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>${cal('/remote.php/dav/calendars/ferdinand/personal/', 'Persönlich', PRIV_RW, 'ferdinand')}${cal('/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/', 'Morning Meeting (Hildegard Kau)', PRIV_RW, 'hilde')}${cal('/remote.php/dav/calendars/ferdinand/geburtstage_shared_by_hilde/', 'Geburtstage & Co.', PRIV_RO, 'hilde')}${cal('/remote.php/dav/calendars/ferdinand/app-generated--deck--board-1/', 'Deck', PRIV_RO, 'ferdinand', '<cal:comp name="VTODO"/>')}</d:multistatus>`);
|
||||||
|
if (u.pathname === '/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/') return res(207, '<d:multistatus xmlns:d="DAV:"><d:response><d:href>x</d:href></d:response></d:multistatus>');
|
||||||
|
if (u.pathname === '/remote.php/dav/calendars/ferdinand/morning-meeting/') return created ? res(207, '<d:multistatus xmlns:d="DAV:"><d:response><d:href>x</d:href></d:response></d:multistatus>') : res(404);
|
||||||
|
if (u.pathname === '/remote.php/dav/addressbooks/users/ferdinand/' && opts.headers.Depth === '1') return res(207, '<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:response><d:href>/remote.php/dav/addressbooks/users/ferdinand/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response><d:response><d:href>/remote.php/dav/addressbooks/users/ferdinand/kontakte/</d:href><d:propstat><d:prop><d:displayname>Kontakte</d:displayname><d:resourcetype><d:collection/><card:addressbook/></d:resourcetype></d:prop></d:propstat></d:response><d:response><d:href>/remote.php/dav/addressbooks/users/ferdinand/z-server-generated--system/</d:href><d:propstat><d:prop><d:displayname>Konten</d:displayname><d:resourcetype><d:collection/><card:addressbook/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>');
|
||||||
|
return res(404);
|
||||||
|
}
|
||||||
|
if (opts.method === 'MKCALENDAR') { created = true; return res(201); }
|
||||||
|
if (opts.method === 'PUT') { const existed = store.has(url); store.set(url, opts.body); return res(existed ? 204 : 201); }
|
||||||
|
if (opts.method === 'DELETE') return store.delete(url) ? res(204) : res(404);
|
||||||
|
if (opts.method === 'REPORT') {
|
||||||
|
if (u.pathname.endsWith('/z-server-generated--system/')) return res(207, '<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:response><d:href>/x/ferdinand.vcf</d:href><d:propstat><d:prop><card:address-data>BEGIN:VCARD \nVERSION:3.0 \nUID:ferdinand \nFN:Ferdinand Urban \nN:Urban;Ferdinand;;; \nEND:VCARD \n</card:address-data></d:prop></d:propstat></d:response></d:multistatus>');
|
||||||
|
const expand = /<c:expand/.test(opts.body);
|
||||||
|
return res(207, `<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav"><d:response><d:href>${u.pathname}abc.ics</d:href><d:propstat><d:prop><d:getetag>"1"</d:getetag><cal:calendar-data>BEGIN:VCALENDAR \nBEGIN:VEVENT \nUID:${expand ? 'expanded' : 'master'} \nSUMMARY:Alex Koop (frei) \nDTSTART;VALUE=DATE:20261005 \nDTEND;VALUE=DATE:20261006 \nEND:VEVENT \nEND:VCALENDAR \n</cal:calendar-data></d:prop></d:propstat></d:response></d:multistatus>`);
|
||||||
|
}
|
||||||
|
if (opts.method === 'GET' && u.pathname.startsWith('/index.php/avatar/')) {
|
||||||
|
return u.pathname.includes('/ferdinand/') ? res(200, '', { 'content-type': 'image/png', 'x-nc-iscustomavatar': '1' }) : res(200, '', { 'content-type': 'image/png', 'x-nc-iscustomavatar': '0' });
|
||||||
|
}
|
||||||
|
return res(405);
|
||||||
|
};
|
||||||
|
return { fetchImpl, calls, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mk = (fake) => createNextcloudClient({ baseUrl: 'https://cld2.example.de', user: 'urban@team-tradeo.de', password: 'geheim', calendar: 'morning-meeting', displayName: 'Morning <Meeting>', fetchImpl: fake.fetchImpl });
|
||||||
|
|
||||||
|
test('Konfiguration aus Umgebung, Kalender-ID wird normalisiert', () => {
|
||||||
|
const c = nextcloudConfigFromEnv({ NEXTCLOUD_URL: 'https://cloud.example.de/', NEXTCLOUD_USER: 'team', NEXTCLOUD_APP_PASSWORD: 'x', NEXTCLOUD_CALENDAR: 'Morning Meeting!' });
|
||||||
|
assert.equal(c.baseUrl, 'https://cloud.example.de');
|
||||||
|
assert.equal(c.calendar, 'morning-meeting-');
|
||||||
|
assert.ok(c.configured);
|
||||||
|
assert.ok(!nextcloudConfigFromEnv({}).configured);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('XML-Hilfen sind präfix-unabhängig und dekodieren Entities', () => {
|
||||||
|
const xml = '<D:multistatus xmlns:D="DAV:"><D:response><D:href>/a/</D:href><D:prop><D:displayname>A & B ö</D:displayname><D:resourcetype><D:collection/><C:calendar/></D:resourcetype></D:prop></D:response></D:multistatus>';
|
||||||
|
const [r] = xmlResponses(xml);
|
||||||
|
assert.equal(decodeXml(xmlProp(r, 'displayname')), 'A & B ö');
|
||||||
|
assert.ok(xmlHasEmpty(xmlProp(r, 'resourcetype'), 'calendar'));
|
||||||
|
assert.ok(!xmlHasEmpty(xmlProp(r, 'resourcetype'), 'addressbook'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Discovery: Login per E-Mail, Benutzer-ID aus dem Principal', async () => {
|
||||||
|
const fake = fakeNextcloud();
|
||||||
|
const client = mk(fake);
|
||||||
|
const d = await client.discover();
|
||||||
|
assert.equal(d.userId, 'ferdinand');
|
||||||
|
assert.equal(d.displayName, 'Ferdinand Urban');
|
||||||
|
assert.equal(d.calendarHome, 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/');
|
||||||
|
assert.equal(d.addressbookHome, 'https://cld2.example.de/remote.php/dav/addressbooks/users/ferdinand/');
|
||||||
|
assert.equal(fake.calls[0].auth, 'Basic ' + Buffer.from('urban@team-tradeo.de:geheim').toString('base64'));
|
||||||
|
await client.discover();
|
||||||
|
assert.equal(fake.calls.length, 2, 'Discovery wird gecacht');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kalenderliste: nur Ereigniskalender, Schreibrecht und Freigabe erkannt', async () => {
|
||||||
|
const client = mk(fakeNextcloud());
|
||||||
|
const cals = await client.listCalendars();
|
||||||
|
assert.deepEqual(cals.map((c) => c.id), ['geburtstage_shared_by_hilde', 'morning-meeting_shared_by_hilde', 'personal']);
|
||||||
|
const mm = cals.find((c) => c.id === 'morning-meeting_shared_by_hilde');
|
||||||
|
assert.equal(mm.writable, true);
|
||||||
|
assert.equal(mm.shared, true);
|
||||||
|
assert.equal(mm.ownerName, 'HILDE');
|
||||||
|
assert.equal(cals.find((c) => c.id === 'geburtstage_shared_by_hilde').writable, false);
|
||||||
|
assert.equal(cals.find((c) => c.id === 'personal').shared, false);
|
||||||
|
assert.equal(cals.find((c) => c.id === 'personal').name, 'Persönlich');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureCalendar: vorhandenes Ziel prüfen oder eigenen Kalender anlegen', async () => {
|
||||||
|
const fake = fakeNextcloud();
|
||||||
|
const client = mk(fake);
|
||||||
|
const target = 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/';
|
||||||
|
assert.deepEqual(await client.ensureCalendar(target), { href: target, created: false });
|
||||||
|
const own = await client.ensureCalendar(null);
|
||||||
|
assert.equal(own.created, true);
|
||||||
|
assert.equal(own.href, 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/morning-meeting/');
|
||||||
|
const mkc = fake.calls.find((c) => c.method === 'MKCALENDAR');
|
||||||
|
assert.ok(mkc.body.includes('<d:displayname>Morning <Meeting></d:displayname>'));
|
||||||
|
assert.deepEqual(await client.ensureCalendar(null), { href: own.href, created: false });
|
||||||
|
await assert.rejects(client.ensureCalendar('https://cld2.example.de/remote.php/dav/calendars/ferdinand/weg/'), /Zielkalender nicht erreichbar/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Termine schreiben, abfragen (expand) und löschen', async () => {
|
||||||
|
const fake = fakeNextcloud();
|
||||||
|
const client = mk(fake);
|
||||||
|
const href = 'https://cld2.example.de/remote.php/dav/calendars/ferdinand/morning-meeting_shared_by_hilde/';
|
||||||
|
await client.putEvent(href, 'rotatory-2026-10-05@cld2.example.de', 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n');
|
||||||
|
assert.equal(fake.calls.at(-1).url, href + 'rotatory-2026-10-05%40cld2.example.de.ics');
|
||||||
|
const events = await client.queryEvents(href, '2026-10-01', '2026-10-31', { expand: true });
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
assert.ok(events[0].ics.includes('UID:expanded'));
|
||||||
|
assert.ok(events[0].ics.includes('\r\n'), 'Zeilenumbrüche aus wiederhergestellt');
|
||||||
|
const report = fake.calls.at(-1);
|
||||||
|
assert.ok(report.body.includes('<c:expand start="20261001T000000Z" end="20261101T000000Z"/>'));
|
||||||
|
assert.ok(report.body.includes('<c:time-range start="20261001T000000Z" end="20261101T000000Z"/>'));
|
||||||
|
assert.equal(await client.deleteEvent(href, 'rotatory-2026-10-05@cld2.example.de'), true);
|
||||||
|
assert.equal(await client.deleteEvent(href, 'rotatory-2026-10-05@cld2.example.de'), false);
|
||||||
|
assert.equal(await client.deleteEvent(href, href + 'irgendwas.ics'), false, 'href-Form akzeptiert');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Adressbücher, Kontakte und Avatare', async () => {
|
||||||
|
const client = mk(fakeNextcloud());
|
||||||
|
const books = await client.listAddressBooks();
|
||||||
|
assert.equal(books[0].system, true);
|
||||||
|
assert.equal(books[0].name, 'Konten');
|
||||||
|
assert.equal(books.length, 2);
|
||||||
|
const cards = await client.queryContacts(books[0].href);
|
||||||
|
assert.equal(cards.length, 1);
|
||||||
|
assert.ok(cards[0].includes('FN:Ferdinand Urban'));
|
||||||
|
const avatar = await client.fetchAvatar('ferdinand');
|
||||||
|
assert.equal(avatar.mime, 'image/png');
|
||||||
|
assert.equal(await client.fetchAvatar('niemand'), null, 'Buchstaben-Avatar zählt nicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetchBinary schickt das App-Passwort nie an fremde Hosts', async () => {
|
||||||
|
const fake = fakeNextcloud();
|
||||||
|
const client = mk(fake);
|
||||||
|
const before = fake.calls.length;
|
||||||
|
assert.equal(await client.fetchBinary('https://evil.example/avatar.png'), null);
|
||||||
|
assert.equal(await client.fetchBinary('kein url'), null);
|
||||||
|
assert.equal(fake.calls.length, before, 'kein Request abgesetzt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falsche Zugangsdaten und Ausfall liefern verständliche Meldungen', async () => {
|
||||||
|
const client = createNextcloudClient({ baseUrl: 'https://c.example', user: 'u', password: 'p', fetchImpl: async () => ({ status: 401, text: async () => '' }) });
|
||||||
|
await assert.rejects(client.test(), /Zugangsdaten/);
|
||||||
|
const down = createNextcloudClient({ baseUrl: 'https://c.example', user: 'u', password: 'p', fetchImpl: async () => { throw new Error('fetch failed', { cause: new Error('ECONNREFUSED') }); } });
|
||||||
|
await assert.rejects(down.test(), /nicht erreichbar: ECONNREFUSED/);
|
||||||
|
const notNc = createNextcloudClient({ baseUrl: 'https://c.example', user: 'u', password: 'p', fetchImpl: async () => ({ status: 207, text: async () => '<d:multistatus xmlns:d="DAV:"></d:multistatus>' }) });
|
||||||
|
await assert.rejects(notNc.test(), /keinen Principal/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { workingDays, generateSchedule, isAbsent, addDays, isValidDate, countAssignments } from '../server/scheduler.js';
|
||||||
|
|
||||||
|
function seeded(seed = 42) {
|
||||||
|
let s = seed;
|
||||||
|
return () => { s = (s * 1664525 + 1013904223) % 4294967296; return s / 4294967296; };
|
||||||
|
}
|
||||||
|
const persons = (n, extra = {}) => Array.from({ length: n }, (_, i) => ({ id: `p${i}`, canMain: true, canCo: true, active: true, ...extra }));
|
||||||
|
|
||||||
|
test('workingDays liefert nur Mo–Fr ohne blockierte Tage', () => {
|
||||||
|
// 2026-09-14 ist ein Montag
|
||||||
|
const days = workingDays('2026-09-12', '2026-09-20', new Set(['2026-09-16']));
|
||||||
|
assert.deepEqual(days, ['2026-09-14', '2026-09-15', '2026-09-17', '2026-09-18']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isValidDate lehnt Unsinn ab', () => {
|
||||||
|
assert.ok(isValidDate('2026-02-28'));
|
||||||
|
assert.ok(!isValidDate('2026-02-30'));
|
||||||
|
assert.ok(!isValidDate('2026-2-3'));
|
||||||
|
assert.ok(!isValidDate('heute'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jeder Werktag bekommt zwei verschiedene Personen', () => {
|
||||||
|
const dates = workingDays('2026-10-01', '2026-10-31');
|
||||||
|
const result = generateSchedule({ dates, persons: persons(6), rng: seeded(1) });
|
||||||
|
for (const d of dates) {
|
||||||
|
assert.ok(result[d].main, `main fehlt am ${d}`);
|
||||||
|
assert.ok(result[d].co, `co fehlt am ${d}`);
|
||||||
|
assert.notEqual(result[d].main, result[d].co);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Einsätze sind fair verteilt (Differenz höchstens 1)', () => {
|
||||||
|
const dates = workingDays('2026-10-01', '2026-12-31');
|
||||||
|
const result = generateSchedule({ dates, persons: persons(7), rng: seeded(7) });
|
||||||
|
const counts = countAssignments(result, '2026-10-01', '2026-12-31');
|
||||||
|
const totals = Object.values(counts).map((c) => c.total);
|
||||||
|
assert.equal(totals.length, 7);
|
||||||
|
assert.ok(Math.max(...totals) - Math.min(...totals) <= 1, `Verteilung: ${totals}`);
|
||||||
|
// Rollen ebenfalls ausgewogen
|
||||||
|
for (const c of Object.values(counts)) assert.ok(Math.abs(c.main - c.co) <= 2, JSON.stringify(c));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Abwesende werden nicht eingeplant', () => {
|
||||||
|
const dates = workingDays('2026-10-05', '2026-10-09');
|
||||||
|
const absences = [{ personId: 'p0', from: '2026-10-06', to: '2026-10-08' }];
|
||||||
|
const result = generateSchedule({ dates, persons: persons(3), absences, rng: seeded(3) });
|
||||||
|
for (const d of ['2026-10-06', '2026-10-07', '2026-10-08']) {
|
||||||
|
assert.notEqual(result[d].main, 'p0');
|
||||||
|
assert.notEqual(result[d].co, 'p0');
|
||||||
|
}
|
||||||
|
assert.ok(isAbsent('p0', '2026-10-07', absences));
|
||||||
|
assert.ok(!isAbsent('p0', '2026-10-09', absences));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nicht-Moderatoren und Inaktive werden nie ausgelost, Nur-Neben nie Haupt', () => {
|
||||||
|
const ps = [
|
||||||
|
...persons(3),
|
||||||
|
{ id: 'none', canMain: false, canCo: false, active: true },
|
||||||
|
{ id: 'gone', canMain: true, canCo: true, active: false },
|
||||||
|
{ id: 'coonly', canMain: false, canCo: true, active: true },
|
||||||
|
];
|
||||||
|
const dates = workingDays('2026-10-01', '2026-11-30');
|
||||||
|
const result = generateSchedule({ dates, persons: ps, rng: seeded(9) });
|
||||||
|
const used = new Set();
|
||||||
|
for (const a of Object.values(result)) { used.add(a.main); used.add(a.co); assert.notEqual(a.main, 'coonly'); }
|
||||||
|
assert.ok(!used.has('none'));
|
||||||
|
assert.ok(!used.has('gone'));
|
||||||
|
assert.ok(used.has('coonly'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('niemand an zwei Werktagen hintereinander, wenn genug Leute da sind', () => {
|
||||||
|
const dates = workingDays('2026-10-01', '2026-10-31');
|
||||||
|
const result = generateSchedule({ dates, persons: persons(6), rng: seeded(11) });
|
||||||
|
for (let i = 1; i < dates.length; i += 1) {
|
||||||
|
const prev = [result[dates[i - 1]].main, result[dates[i - 1]].co];
|
||||||
|
const cur = [result[dates[i]].main, result[dates[i]].co];
|
||||||
|
for (const id of cur) assert.ok(!prev.includes(id), `${id} am ${dates[i - 1]} und ${dates[i]}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fixierte und freie Tage bleiben erhalten, keep-Tage ebenfalls', () => {
|
||||||
|
const dates = workingDays('2026-10-05', '2026-10-09');
|
||||||
|
const existing = {
|
||||||
|
'2026-10-05': { main: 'p2', co: 'p1', locked: true },
|
||||||
|
'2026-10-06': { off: true, main: null, co: null, locked: false },
|
||||||
|
'2026-10-07': { main: 'p0', co: 'p2', locked: false, keep: true },
|
||||||
|
};
|
||||||
|
const result = generateSchedule({ dates, persons: persons(4), existing, rng: seeded(5) });
|
||||||
|
assert.deepEqual(result['2026-10-05'], { main: 'p2', co: 'p1', locked: true });
|
||||||
|
assert.equal(result['2026-10-06'].off, true);
|
||||||
|
assert.deepEqual(result['2026-10-07'], { main: 'p0', co: 'p2', locked: false });
|
||||||
|
assert.ok(result['2026-10-08'].main);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Historie zählt für die Fairness – wer viel dran war, kommt seltener', () => {
|
||||||
|
const history = {};
|
||||||
|
// p0 und p1 waren in den letzten zwei Wochen jeden Tag dran
|
||||||
|
for (const d of workingDays('2026-09-14', '2026-09-25')) history[d] = { main: 'p0', co: 'p1' };
|
||||||
|
const dates = workingDays('2026-09-28', '2026-10-09');
|
||||||
|
const result = generateSchedule({ dates, persons: persons(6), history, rng: seeded(2) });
|
||||||
|
const counts = countAssignments(result, '2026-09-28', '2026-10-09');
|
||||||
|
const heavy = (counts.p0?.total || 0) + (counts.p1?.total || 0);
|
||||||
|
const light = ['p2', 'p3', 'p4', 'p5'].reduce((n, id) => n + (counts[id]?.total || 0), 0);
|
||||||
|
assert.equal(heavy, 0, `p0/p1 sollten aussetzen: ${JSON.stringify(counts)}`);
|
||||||
|
assert.equal(light, dates.length * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ohne Kandidaten bleibt der Platz leer statt zu scheitern', () => {
|
||||||
|
const result = generateSchedule({ dates: ['2026-10-05'], persons: [{ id: 'a', canMain: true, canCo: false, active: true }], rng: seeded(1) });
|
||||||
|
assert.equal(result['2026-10-05'].main, 'a');
|
||||||
|
assert.equal(result['2026-10-05'].co, null);
|
||||||
|
const empty = generateSchedule({ dates: ['2026-10-05'], persons: [] });
|
||||||
|
assert.equal(empty['2026-10-05'].main, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addDays über Monatsgrenzen', () => {
|
||||||
|
assert.equal(addDays('2026-01-31', 1), '2026-02-01');
|
||||||
|
assert.equal(addDays('2026-03-01', -1), '2026-02-28');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('feste freie Wochentage (Teilzeit) werden respektiert', async () => {
|
||||||
|
const { isAvailable, isoWeekday } = await import('../server/scheduler.js');
|
||||||
|
assert.equal(isoWeekday('2026-10-05'), 1);
|
||||||
|
assert.equal(isoWeekday('2026-10-11'), 7);
|
||||||
|
const ps = [...persons(4), { id: 'teilzeit', canMain: true, canCo: true, active: true, freeDays: [1, 5] }];
|
||||||
|
const dates = workingDays('2026-10-01', '2026-11-30');
|
||||||
|
const result = generateSchedule({ dates, persons: ps, rng: seeded(4) });
|
||||||
|
for (const d of dates) {
|
||||||
|
const wd = isoWeekday(d);
|
||||||
|
if (wd === 1 || wd === 5) assert.ok(result[d].main !== 'teilzeit' && result[d].co !== 'teilzeit', `Teilzeit am ${d}`);
|
||||||
|
}
|
||||||
|
assert.ok(Object.values(result).some((a) => a.main === 'teilzeit' || a.co === 'teilzeit'));
|
||||||
|
assert.ok(!isAvailable({ id: 'x', freeDays: [3] }, '2026-10-07', []));
|
||||||
|
assert.ok(isAvailable({ id: 'x', freeDays: [3] }, '2026-10-08', []));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('„nicht zwei Werktage in Folge" gilt auch über einen Feiertag hinweg', () => {
|
||||||
|
// Mi 2026-10-07 blockiert → Di und Do gelten als aufeinanderfolgend
|
||||||
|
const blocked = new Set(['2026-10-07']);
|
||||||
|
const dates = workingDays('2026-10-06', '2026-10-08', blocked);
|
||||||
|
assert.deepEqual(dates, ['2026-10-06', '2026-10-08']);
|
||||||
|
for (let seed = 1; seed <= 20; seed += 1) {
|
||||||
|
const result = generateSchedule({ dates, persons: persons(6), blocked, rng: seeded(seed) });
|
||||||
|
const tue = [result['2026-10-06'].main, result['2026-10-06'].co];
|
||||||
|
for (const id of [result['2026-10-08'].main, result['2026-10-08'].co]) assert.ok(!tue.includes(id), `seed ${seed}: ${id} Di und Do`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fill: halb besetzte Tage werden nur aufgefüllt, der vorhandene Slot bleibt', () => {
|
||||||
|
const existing = { '2026-10-05': { main: 'p2', co: null, locked: false, fill: true }, '2026-10-06': { main: null, co: 'p1', locked: false, fill: true } };
|
||||||
|
const result = generateSchedule({ dates: ['2026-10-05', '2026-10-06'], persons: persons(4), existing, rng: seeded(3) });
|
||||||
|
assert.equal(result['2026-10-05'].main, 'p2');
|
||||||
|
assert.ok(result['2026-10-05'].co && result['2026-10-05'].co !== 'p2');
|
||||||
|
assert.equal(result['2026-10-06'].co, 'p1');
|
||||||
|
assert.ok(result['2026-10-06'].main && result['2026-10-06'].main !== 'p1');
|
||||||
|
assert.equal('fill' in result['2026-10-05'], false);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user