From e2fe187c5d6de07c41006417fe2d77450ba87f39 Mon Sep 17 00:00:00 2001 From: data Date: Tue, 10 Feb 2026 15:03:39 +0100 Subject: [PATCH] V 2.0.2 - Grobsortierung Live-Streaming - Grobsortierung zeigt jetzt live den Fortschritt (wie Feinsortierung) - Neuer Streaming-Endpoint /api/grobsortierung/stream - Sticky Header mit laufender Statistik - Auto-Scroll zum aktuellen Eintrag Co-Authored-By: Claude Opus 4.5 --- Source/backend/app/routes/api.py | 200 +++++++++++++++++++++++++++++++ Source/frontend/static/js/app.js | 137 +++++++++++++++------ 2 files changed, 299 insertions(+), 38 deletions(-) diff --git a/Source/backend/app/routes/api.py b/Source/backend/app/routes/api.py index abeccbb..2d4b057 100755 --- a/Source/backend/app/routes/api.py +++ b/Source/backend/app/routes/api.py @@ -958,6 +958,206 @@ def verarbeite_ordner(id: int, db: Session = Depends(get_db)): return ergebnis +@router.get("/grobsortierung/stream") +async def grobsortierung_stream(db: Session = Depends(get_db)): + """Streaming-Endpoint für Grobsortierung aller aktiven Ordner mit Live-Updates""" + from ..models.database import SessionLocal + + # Aktive Ordner vorab laden + ordner_liste = db.query(QuellOrdner).filter(QuellOrdner.aktiv == True).all() + ordner_daten = [{ + "id": o.id, "name": o.name, "pfad": o.pfad, "ziel_ordner": o.ziel_ordner, + "dateitypen": o.dateitypen, "rekursiv": o.rekursiv, + "direkt_verschieben": getattr(o, 'direkt_verschieben', False), + "zugferd_behandlung": getattr(o, 'zugferd_behandlung', 'separieren'), + "ocr_aktivieren": getattr(o, 'ocr_aktivieren', True), + "original_sichern": getattr(o, 'original_sichern', None) + } for o in ordner_liste] + + # Regeln vorab laden + regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).order_by(SortierRegel.prioritaet).all() + regeln_dicts = [{ + "id": r.id, "name": r.name, "prioritaet": r.prioritaet, + "muster": r.muster, "extraktion": r.extraktion, + "schema": r.schema, "unterordner": r.unterordner + } for r in regeln] + + async def event_generator(): + def send_event(data): + return f"data: {json.dumps(data)}\n\n" + + if not ordner_daten: + yield send_event({"type": "fehler", "nachricht": "Keine aktiven Ordner konfiguriert"}) + return + + pdf_processor = PDFProcessor() + sorter = Sorter(regeln_dicts) if regeln_dicts else None + gesamt_stats = {"gesamt": 0, "sortiert": 0, "zugferd": 0, "fehler": 0} + + # Dateien zählen + total_dateien = 0 + for o in ordner_daten: + pfad = Path(o["pfad"]) + if pfad.exists(): + dateien = sammle_dateien_aus_pfad(str(pfad), o["dateitypen"], o["rekursiv"]) + total_dateien += len(dateien) + + yield send_event({"type": "start", "ordner_count": len(ordner_daten), "gesamt": total_dateien}) + await asyncio.sleep(0) + + session = SessionLocal() + try: + for quell_ordner in ordner_daten: + pfad = Path(quell_ordner["pfad"]) + if not pfad.exists(): + yield send_event({"type": "ordner_fehler", "ordner": quell_ordner["name"], "fehler": "Ordner existiert nicht"}) + await asyncio.sleep(0) + continue + + ziel_basis = Path(quell_ordner["ziel_ordner"]) + dateien = sammle_dateien_aus_pfad(str(pfad), quell_ordner["dateitypen"], quell_ordner["rekursiv"]) + + yield send_event({ + "type": "ordner", + "ordner": quell_ordner["name"], + "dateien": len(dateien) + }) + await asyncio.sleep(0) + + for datei in dateien: + gesamt_stats["gesamt"] += 1 + datei_info = {"original": datei.name, "ordner": quell_ordner["name"]} + + try: + ist_pdf = datei.suffix.lower() == ".pdf" + text = "" + ist_zugferd = False + ocr_gemacht = False + + # PDF verarbeiten + if ist_pdf: + pdf_result = pdf_processor.verarbeite( + str(datei), + ocr_erlaubt=quell_ordner.get("ocr_aktivieren", True), + original_backup_pfad=quell_ordner.get("original_sichern") + ) + if pdf_result.get("fehler"): + raise Exception(pdf_result["fehler"]) + text = pdf_result.get("text", "") + ist_zugferd = pdf_result.get("ist_zugferd", False) + ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False) + + # Direkt verschieben? + if quell_ordner["direkt_verschieben"]: + ziel_basis.mkdir(parents=True, exist_ok=True) + + # Original sichern + if quell_ordner["original_sichern"]: + import shutil + backup_dir = Path(quell_ordner["original_sichern"]) + backup_dir.mkdir(parents=True, exist_ok=True) + backup_pfad = backup_dir / datei.name + counter = 1 + while backup_pfad.exists(): + backup_pfad = backup_dir / f"{datei.stem}_{counter}{datei.suffix}" + counter += 1 + shutil.copy2(str(datei), str(backup_pfad)) + + neuer_pfad = ziel_basis / datei.name + counter = 1 + while neuer_pfad.exists(): + neuer_pfad = ziel_basis / f"{datei.stem}_{counter}{datei.suffix}" + counter += 1 + + datei.rename(neuer_pfad) + gesamt_stats["sortiert"] += 1 + datei_info["neuer_name"] = neuer_pfad.name + datei_info["status"] = "verschoben" + + session.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + neuer_pfad=str(neuer_pfad), + neuer_name=neuer_pfad.name, + ist_zugferd=ist_zugferd, + ocr_durchgefuehrt=ocr_gemacht, + status="direkt_verschoben" + )) + + yield send_event({"type": "datei_fertig", **datei_info}) + await asyncio.sleep(0) + continue + + # Mit Regeln sortieren + if sorter: + doc_info = { + "text": text, + "original_name": datei.name, + "absender": "", + "dateityp": datei.suffix.lower() + } + regel = sorter.finde_passende_regel(doc_info) + + if regel: + extrahiert = sorter.extrahiere_felder(regel, doc_info) + schema = regel.get("schema", "{datum} - Dokument.pdf") + if schema.endswith(".pdf"): + schema = schema[:-4] + datei.suffix + neuer_name = sorter.generiere_dateinamen({"schema": schema, **regel}, extrahiert) + + ziel = ziel_basis + if regel.get("unterordner"): + ziel = ziel / regel["unterordner"] + ziel.mkdir(parents=True, exist_ok=True) + + neuer_pfad = sorter.verschiebe_datei(str(datei), str(ziel), neuer_name) + gesamt_stats["sortiert"] += 1 + datei_info["neuer_name"] = neuer_name + datei_info["regel"] = regel.get("name") + + session.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + neuer_pfad=neuer_pfad, + neuer_name=neuer_name, + ist_zugferd=ist_zugferd, + ocr_durchgefuehrt=ocr_gemacht, + status="sortiert", + extrahierte_daten=extrahiert + )) + + yield send_event({"type": "datei_fertig", **datei_info}) + await asyncio.sleep(0) + continue + + # Keine Regel gefunden + datei_info["status"] = "keine_regel" + yield send_event({"type": "datei_keine_regel", **datei_info}) + await asyncio.sleep(0) + + except Exception as e: + gesamt_stats["fehler"] += 1 + datei_info["fehler"] = str(e) + yield send_event({"type": "datei_fehler", **datei_info}) + await asyncio.sleep(0) + + session.commit() + finally: + session.close() + + yield send_event({"type": "fertig", **gesamt_stats}) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + + # ============ Regeln ============ @router.get("/regeln") diff --git a/Source/frontend/static/js/app.js b/Source/frontend/static/js/app.js index b4221b9..13a2e9a 100755 --- a/Source/frontend/static/js/app.js +++ b/Source/frontend/static/js/app.js @@ -2869,55 +2869,116 @@ async function alleOrdnerVerarbeiten() { logContainer.innerHTML = '
Starte Grobsortierung...
'; try { - debugLog('Starte Grobsortierung aller Ordner...', 'info'); + debugLog('Starte Grobsortierung mit Live-Updates...', 'info'); - const ordner = await api('/ordner'); - const aktiveOrdner = ordner.filter(o => o.aktiv); + // Streaming-Endpoint verwenden für Live-Updates + const response = await fetch('/api/grobsortierung/stream'); - if (aktiveOrdner.length === 0) { - logContainer.innerHTML = '
Keine aktiven Ordner konfiguriert
'; - return; + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - logContainer.innerHTML = ''; - let gesamtSortiert = 0; - let gesamtFehler = 0; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let zusammenfassungDiv = null; + let gesamt = 0, sortiert = 0, fehler = 0; - for (const o of aktiveOrdner) { - debugLog(`Verarbeite: ${o.name}`, 'info'); - try { - const result = await api(`/ordner/${o.id}/verarbeiten`, { method: 'POST' }); - gesamtSortiert += result.sortiert || 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; - // Detaillierte Ausgabe pro Ordner - let ordnerHtml = `
- ✓ ${escapeHtml(o.name)}: ${result.sortiert || 0} sortiert, ${result.zugferd || 0} ZUGFeRD -
`; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); - // Dateien im Ordner anzeigen - if (result.verarbeitet && result.verarbeitet.length > 0) { - result.verarbeitet.forEach(d => { - const klasse = d.status === 'sortiert' || d.status === 'direkt_verschoben' ? 'success' : - (d.status === 'fehler' ? 'error' : 'info'); - ordnerHtml += `
- → ${escapeHtml(d.original)} → ${escapeHtml(d.neuer_name || d.status)} -
`; - }); + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + + switch (data.type) { + case 'start': + logContainer.innerHTML = `
+ Starte Grobsortierung... ${data.ordner_count} Ordner, ${data.gesamt} Dateien +
`; + zusammenfassungDiv = document.createElement('div'); + zusammenfassungDiv.className = 'log-entry success'; + zusammenfassungDiv.style.cssText = 'position: sticky; top: 0; background: var(--bg-secondary); border-bottom: 1px solid var(--border); z-index: 1;'; + zusammenfassungDiv.innerHTML = `Gesamt: 0 | Sortiert: 0 | Fehler: 0`; + logContainer.appendChild(zusammenfassungDiv); + break; + + case 'ordner': + logContainer.innerHTML += `
+ 📁 ${escapeHtml(data.ordner)} (${data.dateien} Dateien) +
`; + break; + + case 'datei_fertig': + sortiert++; + gesamt++; + + let text = escapeHtml(data.original || ''); + if (data.neuer_name) { + text += ` → ${escapeHtml(data.neuer_name)}`; + } + if (data.regel) { + text += ` [${escapeHtml(data.regel)}]`; + } + + logContainer.innerHTML += `
+ ✓ ${text} +
`; + + if (zusammenfassungDiv) { + zusammenfassungDiv.innerHTML = `Gesamt: ${gesamt} | Sortiert: ${sortiert} | Fehler: ${fehler}`; + } + break; + + case 'datei_keine_regel': + gesamt++; + logContainer.innerHTML += `
+ ⚠ ${escapeHtml(data.original)} - keine passende Regel +
`; + + if (zusammenfassungDiv) { + zusammenfassungDiv.innerHTML = `Gesamt: ${gesamt} | Sortiert: ${sortiert} | Fehler: ${fehler}`; + } + break; + + case 'datei_fehler': + case 'ordner_fehler': + fehler++; + gesamt++; + logContainer.innerHTML += `
+ ✗ ${escapeHtml(data.original || data.ordner)} (${escapeHtml(data.fehler || 'Fehler')}) +
`; + + if (zusammenfassungDiv) { + zusammenfassungDiv.innerHTML = `Gesamt: ${gesamt} | Sortiert: ${sortiert} | Fehler: ${fehler}`; + } + break; + + case 'fertig': + logContainer.innerHTML += `
+ ✓ Grobsortierung abgeschlossen +
`; + break; + } + + logContainer.scrollTop = logContainer.scrollHeight; + + } catch (parseError) { + console.error('SSE Parse-Fehler:', parseError, line); + } } - - logContainer.innerHTML += ordnerHtml; - } catch (error) { - gesamtFehler++; - logContainer.innerHTML += `
- ✗ ${escapeHtml(o.name)}: ${error.message} -
`; } } - // Zusammenfassung am Ende - logContainer.innerHTML += `
- Zusammenfassung: ${gesamtSortiert} Dateien sortiert, ${gesamtFehler} Fehler -
`; + if (gesamt === 0) { + logContainer.innerHTML = `
Keine Dateien zur Verarbeitung gefunden
`; + } debugLog('Grobsortierung abgeschlossen', 'success'); } catch (error) {