Add browser-based web MVP with mobile upload flow

This commit is contained in:
2026-03-02 20:17:44 +01:00
parent e7c62c2628
commit b14cc39455
8 changed files with 352 additions and 0 deletions

View File

@@ -27,6 +27,15 @@ Interaktives Textmenü:
./start.sh
```
### 3) Web (MVP für Mobilgeräte)
Browser-Variante mit Upload + direktem ICS-Download:
```bash
./start_web.sh
```
Details siehe [WEB_README.md](WEB_README.md).
---
## ⚡ Schnellstart (60 Sekunden)
@@ -149,6 +158,7 @@ Die erzeugten `.ics`-Dateien lassen sich u. a. in folgende Kalender importieren:
- [INSTALL.md](INSTALL.md) - Linux-Installation mit Menüeintrag
- [WXPYTHON_README.md](WXPYTHON_README.md) - wxPython-spezifische Hinweise
- [WEB_README.md](WEB_README.md) - Web-Version (Browser/Mobil)
- [BUILD_STANDALONE.md](BUILD_STANDALONE.md) - Standalone-Builds/Packaging
- [QUICKSTART.md](QUICKSTART.md) - Kurzanleitung Kalender-Import
- [ZUSAMMENFASSUNG.md](ZUSAMMENFASSUNG.md) - Projekt- und Changelog-Übersicht

49
WEB_README.md Normal file
View File

@@ -0,0 +1,49 @@
# 🌐 Web-Version (MVP)
Diese Variante stellt den PDF-zu-ICS-Konverter im Browser bereit, damit die Nutzung auch auf mobilen Geräten möglich ist.
## Starten
### Linux/macOS
```bash
./start_web.sh
```
### Windows
Doppelklick auf `start_web.cmd`
Danach im Browser öffnen:
- Lokal: `http://localhost:8000`
- Im Netzwerk (z. B. Smartphone): `http://<IP-des-Rechners>:8000`
## Funktionen
- PDF-Datei hochladen
- Optional Ruhetage ausschließen
- Optional Urlaub ausschließen
- ICS-Datei direkt herunterladen
## Hinweise für mobile Nutzung
- Smartphone und Server müssen im gleichen Netzwerk sein (lokaler Betrieb)
- Bei Internet-Betrieb sollte HTTPS und ein Reverse Proxy (z. B. Nginx) genutzt werden
- Hochgeladene Dateien werden nur temporär verarbeitet
## Technischer Aufbau
- `web/app.py` FastAPI-Backend + Upload/Download-Endpunkte
- `web/templates/index.html` mobile Web-Oberfläche
- `web/requirements-web.txt` Web-spezifische Abhängigkeiten
## Produktion (Kurz)
Beispiel mit Uvicorn direkt:
```bash
.venv/bin/python -m uvicorn web.app:app --host 0.0.0.0 --port 8000
```
Empfohlen für Internet-Betrieb:
- Uvicorn hinter Nginx
- HTTPS aktivieren
- Upload-Größenlimit setzen
- Zugriff absichern (z. B. Basic Auth oder Login)

22
start_web.cmd Normal file
View File

@@ -0,0 +1,22 @@
@echo off
REM PDF zu ICS Web-MVP starten (Windows)
setlocal enabledelayedexpansion
cd /d "%~dp0"
if not exist ".venv" (
echo 📦 Python-Umgebung wird eingerichtet...
py -3 -m venv .venv --upgrade-deps
if errorlevel 1 (
python -m venv .venv --upgrade-deps
)
)
.venv\Scripts\python.exe -c "import fastapi" 2>nul
if errorlevel 1 (
echo 📚 Installiere Web-Abhängigkeiten...
call .venv\Scripts\python.exe -m pip install -q -r web\requirements-web.txt
)
echo 🌐 Starte Web-App auf http://0.0.0.0:8000
call .venv\Scripts\python.exe -m uvicorn web.app:app --host 0.0.0.0 --port 8000

31
start_web.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# PDF zu ICS Web-MVP starten (Linux/macOS)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "$SCRIPT_DIR"
PYTHON_CMD=""
if command -v python3 &> /dev/null; then
PYTHON_CMD="python3"
elif command -v python &> /dev/null; then
PYTHON_CMD="python"
else
echo "❌ Fehler: Python nicht gefunden!"
exit 1
fi
if [ ! -d ".venv" ]; then
echo "📦 Python-Umgebung wird eingerichtet..."
$PYTHON_CMD -m venv .venv --upgrade-deps || exit 1
fi
PYTHON_VENV=".venv/bin/python"
if ! $PYTHON_VENV -c "import fastapi" 2>/dev/null; then
echo "📚 Installiere Web-Abhängigkeiten..."
$PYTHON_VENV -m pip install -q -r web/requirements-web.txt || exit 1
fi
echo "🌐 Starte Web-App auf http://0.0.0.0:8000"
exec $PYTHON_VENV -m uvicorn web.app:app --host 0.0.0.0 --port 8000

0
web/__init__.py Normal file
View File

111
web/app.py Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Web-MVP für PDF zu ICS Konverter
"""
import re
import sys
import tempfile
from pathlib import Path
import os
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from starlette.background import BackgroundTask
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from pdf_to_ics import create_ics_from_dienstplan, extract_dienstplan_data
app = FastAPI(title="PDF zu ICS Web")
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": None,
},
)
@app.post("/convert")
async def convert_pdf(
request: Request,
file: UploadFile = File(...),
exclude_rest: bool = Form(False),
exclude_vacation: bool = Form(False),
):
filename = file.filename or "dienstplan.pdf"
if not filename.lower().endswith(".pdf"):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": "Bitte eine PDF-Datei hochladen.",
},
status_code=400,
)
data = await file.read()
if not data:
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": "Die Datei ist leer.",
},
status_code=400,
)
with tempfile.TemporaryDirectory(prefix="pdf_to_ics_web_") as temp_dir:
temp_dir_path = Path(temp_dir)
pdf_path = temp_dir_path / "upload.pdf"
pdf_path.write_bytes(data)
dienstplan = extract_dienstplan_data(str(pdf_path))
if not dienstplan.get("events"):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": "Keine Dienstplan-Events in der PDF gefunden.",
},
status_code=422,
)
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", Path(filename).stem)
ics_filename = f"{safe_name}.ics"
ics_path = temp_dir_path / ics_filename
create_ics_from_dienstplan(
dienstplan,
str(ics_path),
exclude_rest=exclude_rest,
exclude_vacation=exclude_vacation,
)
content = ics_path.read_bytes()
response_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".ics", prefix="pdf_to_ics_out_")
response_tmp.write(content)
response_tmp.close()
return FileResponse(
path=response_tmp.name,
media_type="text/calendar",
filename=ics_filename,
headers={"Cache-Control": "no-store"},
background=BackgroundTask(lambda path: os.remove(path) if os.path.exists(path) else None, response_tmp.name),
)
@app.get("/health")
def health():
return {"status": "ok"}

9
web/requirements-web.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi>=0.115.0
uvicorn>=0.30.0
jinja2>=3.1.0
python-multipart>=0.0.9
pdfplumber
icalendar
pytz
pypdf2
packaging

120
web/templates/index.html Normal file
View File

@@ -0,0 +1,120 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PDF zu ICS (Web)</title>
<style>
:root { color-scheme: light; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
background: #f5f7fb;
color: #1f2937;
}
.wrap {
max-width: 520px;
margin: 0 auto;
padding: 20px 14px 28px;
}
.card {
background: #ffffff;
border-radius: 14px;
padding: 18px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
h1 {
margin: 0 0 10px;
font-size: 1.3rem;
}
p {
margin: 0 0 16px;
color: #4b5563;
line-height: 1.4;
}
label {
display: block;
font-weight: 600;
margin: 12px 0 8px;
}
input[type=file] {
width: 100%;
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 10px;
background: #fff;
box-sizing: border-box;
}
.options {
margin: 12px 0;
display: grid;
gap: 8px;
}
.option {
display: flex;
gap: 10px;
align-items: center;
font-weight: 500;
color: #111827;
}
button {
width: 100%;
border: 0;
border-radius: 10px;
padding: 12px;
font-size: 1rem;
font-weight: 700;
background: #2563eb;
color: #fff;
margin-top: 6px;
cursor: pointer;
}
button:active { transform: translateY(1px); }
.error {
margin: 0 0 12px;
padding: 10px;
border-radius: 10px;
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.hint {
margin-top: 12px;
font-size: 0.9rem;
color: #6b7280;
}
</style>
</head>
<body>
<main class="wrap">
<section class="card">
<h1>PDF zu ICS Konverter</h1>
<p>PDF hochladen, konvertieren und die ICS-Datei direkt herunterladen.</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form action="/convert" method="post" enctype="multipart/form-data">
<label for="file">Dienstplan-PDF</label>
<input id="file" name="file" type="file" accept="application/pdf,.pdf" required />
<div class="options">
<label class="option">
<input type="checkbox" name="exclude_rest" value="true" />
Ruhetage ausschließen
</label>
<label class="option">
<input type="checkbox" name="exclude_vacation" value="true" />
Urlaub ausschließen
</label>
</div>
<button type="submit">ICS erstellen</button>
</form>
<div class="hint">Hinweis: Die Datei wird nur temporär für die Konvertierung verarbeitet.</div>
</section>
</main>
</body>
</html>