From bedfd05bb3fd379fdc1fc7c1d553e201b3cb3d56 Mon Sep 17 00:00:00 2001 From: clawd Date: Fri, 20 Feb 2026 10:17:09 +0000 Subject: [PATCH] =?UTF-8?q?v0.3.0=20=E2=80=94=20PDF=20Export=20+=20OTA=20D?= =?UTF-8?q?ownload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.go | 12 ++ frontend/src/components/InfoPage.tsx | 22 +- frontend/src/components/WeekPlanner.tsx | 121 ++++++++++- go.mod | 1 + go.sum | 2 + models.go | 1 + pdf_export.go | 275 ++++++++++++++++++++++++ updater.go | 75 ++++++- 8 files changed, 494 insertions(+), 15 deletions(-) create mode 100644 pdf_export.go diff --git a/app.go b/app.go index f81e2c9..8e70b3a 100644 --- a/app.go +++ b/app.go @@ -361,6 +361,18 @@ func (a *App) CheckForUpdate() (*UpdateInfo, error) { return a.updater.CheckForUpdate() } +// DownloadUpdate lädt ein verfügbares Update herunter +func (a *App) DownloadUpdate() (string, error) { + info, err := a.updater.CheckForUpdate() + if err != nil { + return "", fmt.Errorf("Update-Check fehlgeschlagen: %w", err) + } + if !info.Available { + return "Kein Update verfügbar.", nil + } + return a.updater.DownloadUpdate(*info) +} + // HILFSFUNKTIONEN // loadProductRelations lädt Allergene und Zusatzstoffe für ein Produkt diff --git a/frontend/src/components/InfoPage.tsx b/frontend/src/components/InfoPage.tsx index 1fdfa35..f45b0d6 100644 --- a/frontend/src/components/InfoPage.tsx +++ b/frontend/src/components/InfoPage.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; // Import der Wails-Funktionen (werden zur Laufzeit verfügbar sein) // @ts-ignore - Wails-Bindings werden zur Laufzeit generiert -import { CheckForUpdate } from '../../wailsjs/go/main/App'; +import { CheckForUpdate, DownloadUpdate } from '../../wailsjs/go/main/App'; interface UpdateInfo { available: boolean; @@ -15,6 +15,8 @@ interface UpdateInfo { export function InfoPage() { const [updateInfo, setUpdateInfo] = useState(null); const [checking, setChecking] = useState(false); + const [downloading, setDownloading] = useState(false); + const [downloadComplete, setDownloadComplete] = useState(false); const [error, setError] = useState(null); // Handle checking for updates @@ -32,10 +34,20 @@ export function InfoPage() { } }; - // Handle opening download URL - const handleDownload = () => { - if (updateInfo?.download_url) { - window.open(updateInfo.download_url, '_blank'); + // Handle downloading update + const handleDownloadUpdate = async () => { + if (!updateInfo?.download_url) return; + + setDownloading(true); + setError(null); + + try { + await DownloadUpdate(updateInfo.download_url); + setDownloadComplete(true); + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Herunterladen des Updates'); + } finally { + setDownloading(false); } }; diff --git a/frontend/src/components/WeekPlanner.tsx b/frontend/src/components/WeekPlanner.tsx index a51b60a..1a87300 100644 --- a/frontend/src/components/WeekPlanner.tsx +++ b/frontend/src/components/WeekPlanner.tsx @@ -5,6 +5,10 @@ import { SpecialDayDialog } from './SpecialDayDialog'; import { getWeekDays } from '../lib/weekHelper'; import { useWeekPlan } from '../hooks/useWeekPlan'; +// Import der Wails-Funktionen (werden zur Laufzeit verfügbar sein) +// @ts-ignore - Wails-Bindings werden zur Laufzeit generiert +import { ExportPDF } from '../../wailsjs/go/main/App'; + interface WeekPlannerProps { year: number; week: number; @@ -13,6 +17,9 @@ interface WeekPlannerProps { export function WeekPlanner({ year, week, className = '' }: WeekPlannerProps) { const [specialDayDialog, setSpecialDayDialog] = useState<{ day: WeekDay } | null>(null); + const [pdfExporting, setPdfExporting] = useState(false); + const [pdfSuccess, setPdfSuccess] = useState(null); + const [pdfError, setPdfError] = useState(null); const { weekPlan, @@ -69,6 +76,35 @@ export function WeekPlanner({ year, week, className = '' }: WeekPlannerProps) { await createWeekPlan(); }; + // Handle PDF export + const handlePdfExport = async () => { + if (!weekPlan) return; + + setPdfExporting(true); + setPdfError(null); + setPdfSuccess(null); + + try { + // ExportPDF nimmt weekPlanID und outputPath - Go-Seite öffnet Save-Dialog mit leer-string + await ExportPDF(weekPlan.id, ''); + setPdfSuccess('PDF wurde erfolgreich erstellt.'); + + // Success-Nachricht nach 5 Sekunden ausblenden + setTimeout(() => { + setPdfSuccess(null); + }, 5000); + } catch (err) { + setPdfError(err instanceof Error ? err.message : 'Fehler beim Erstellen des PDFs'); + + // Error-Nachricht nach 10 Sekunden ausblenden + setTimeout(() => { + setPdfError(null); + }, 10000); + } finally { + setPdfExporting(false); + } + }; + // Loading state if (loading && !weekPlan) { return ( @@ -173,15 +209,90 @@ export function WeekPlanner({ year, week, className = '' }: WeekPlannerProps) { Wochenplan KW {week} {year} -
- Erstellt: {new Date(weekPlan.created_at).toLocaleDateString('de-DE')} - {loading && ( -
- )} +
+ {/* PDF Export Button */} + + +
+ Erstellt: {new Date(weekPlan.created_at).toLocaleDateString('de-DE')} + {loading && ( +
+ )} +
+ {/* PDF Success Banner */} + {pdfSuccess && ( +
+
+
+ + + +
+
+

+ PDF erfolgreich erstellt +

+
+ {pdfSuccess} +
+
+ +
+
+
+
+ )} + + {/* PDF Error Banner */} + {pdfError && ( +
+
+
+ + + +
+
+

+ PDF-Export fehlgeschlagen +

+
+ {pdfError} +
+
+ +
+
+
+
+ )} + {/* Week Grid */}
{weekDays.map((date, index) => { diff --git a/go.mod b/go.mod index 2927c9c..0be0a72 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 toolchain go1.24.4 require ( + github.com/go-pdf/fpdf v0.9.0 github.com/jmoiron/sqlx v1.4.0 github.com/wailsapp/wails/v2 v2.11.0 modernc.org/sqlite v1.46.1 diff --git a/go.sum b/go.sum index 2bc428c..35b9644 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= diff --git a/models.go b/models.go index 3d253ea..7cdc4c3 100644 --- a/models.go +++ b/models.go @@ -65,6 +65,7 @@ type UpdateInfo struct { LatestVersion string `json:"latest_version"` DownloadURL string `json:"download_url,omitempty"` ReleaseNotes string `json:"release_notes,omitempty"` + Checksum string `json:"checksum,omitempty"` // SHA256 } // ProductImport repräsentiert ein Produkt beim Import aus JSON diff --git a/pdf_export.go b/pdf_export.go new file mode 100644 index 0000000..22d491e --- /dev/null +++ b/pdf_export.go @@ -0,0 +1,275 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/go-pdf/fpdf" +) + +// ExportPDF exportiert einen Wochenplan als PDF im Querformat A4 +func (a *App) ExportPDF(weekPlanID int, outputPath string) error { + // Plan aus DB laden + var plan WeekPlan + err := db.Get(&plan, "SELECT id, year, week, created_at FROM week_plans WHERE id = ?", weekPlanID) + if err != nil { + return fmt.Errorf("Wochenplan nicht gefunden: %w", err) + } + + entries, err := a.loadPlanEntries(plan.ID) + if err != nil { + return err + } + plan.Entries = entries + + specialDays, err := a.loadSpecialDays(plan.ID) + if err != nil { + return err + } + plan.SpecialDays = specialDays + + // Montag der KW berechnen + monday := isoWeekMonday(plan.Year, plan.Week) + friday := monday.AddDate(0, 0, 4) + + // PDF erstellen - Querformat A4 + pdf := fpdf.New("L", "mm", "A4", "") + pdf.SetAutoPageBreak(false, 0) + + // UTF-8 Font für deutsche Umlaute + pdf.AddUTF8Font("DejaVu", "", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf") + pdf.AddUTF8Font("DejaVu", "B", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf") + + pdf.AddPage() + + pageW, pageH := pdf.GetPageSize() + marginX := 10.0 + usableW := pageW - 2*marginX + + // === ÜBERSCHRIFT === + pdf.SetFont("DejaVu", "B", 16) + title := fmt.Sprintf("Wochenspeiseplan KW %d / %d", plan.Week, plan.Year) + pdf.CellFormat(usableW, 10, title, "", 0, "C", false, 0, "") + pdf.Ln(7) + + pdf.SetFont("DejaVu", "", 10) + dateRange := fmt.Sprintf("%s – %s", monday.Format("02.01.2006"), friday.Format("02.01.2006")) + pdf.CellFormat(usableW, 6, dateRange, "", 0, "C", false, 0, "") + pdf.Ln(10) + + tableTop := pdf.GetY() + + // === TABELLE === + colW := usableW / 5.0 + dayNames := []string{"Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"} + + // Sondertage-Map + specialMap := map[int]SpecialDay{} + for _, sd := range plan.SpecialDays { + specialMap[sd.Day] = sd + } + + // Einträge nach Tag+Meal gruppieren + type mealEntries struct { + fruehstueck []PlanEntry + vesper []PlanEntry + } + dayEntries := map[int]*mealEntries{} + for i := 1; i <= 5; i++ { + dayEntries[i] = &mealEntries{} + } + for _, e := range plan.Entries { + me := dayEntries[e.Day] + if me == nil { + continue + } + if e.Meal == "fruehstueck" { + me.fruehstueck = append(me.fruehstueck, e) + } else { + me.vesper = append(me.vesper, e) + } + } + + // Allergene & Zusatzstoffe sammeln + allergenSet := map[string]Allergen{} + additiveSet := map[string]Additive{} + for _, e := range plan.Entries { + if e.Product != nil { + for _, al := range e.Product.Allergens { + allergenSet[al.ID] = al + } + for _, ad := range e.Product.Additives { + additiveSet[ad.ID] = ad + } + } + } + + // Kopfzeile: Tage + headerH := 12.0 + pdf.SetFont("DejaVu", "B", 11) + pdf.SetFillColor(220, 220, 220) + for i, name := range dayNames { + d := monday.AddDate(0, 0, i) + label := fmt.Sprintf("%s, %s", name, d.Format("02.01.")) + x := marginX + float64(i)*colW + pdf.SetXY(x, tableTop) + pdf.CellFormat(colW, headerH, label, "1", 0, "C", true, 0, "") + } + pdf.Ln(-1) + + // Zeilen: Frühstück + Vesper + mealLabels := []string{"Frühstück", "Vesper"} + mealKeys := []string{"fruehstueck", "vesper"} + + // Berechne verfügbare Höhe für die 2 Mahlzeit-Zeilen + legendEstimate := 30.0 // Platz für Legende + Footer + availH := pageH - tableTop - headerH - legendEstimate + rowH := availH / 2.0 + if rowH > 60 { + rowH = 60 + } + + for mi, mealKey := range mealKeys { + rowTop := tableTop + headerH + float64(mi)*rowH + + for day := 1; day <= 5; day++ { + x := marginX + float64(day-1)*colW + + // Sondertag? + if sd, ok := specialMap[day]; ok { + pdf.SetFillColor(200, 200, 200) + pdf.SetXY(x, rowTop) + pdf.SetFont("DejaVu", "B", 10) + label := sd.Type + if sd.Label != nil && *sd.Label != "" { + label = *sd.Label + } + if mi == 0 { + pdf.CellFormat(colW, rowH, label, "1", 0, "C", true, 0, "") + } else { + pdf.CellFormat(colW, rowH, "", "1", 0, "C", true, 0, "") + } + continue + } + + // Meal-Label + pdf.SetFillColor(245, 245, 245) + pdf.SetXY(x, rowTop) + pdf.SetFont("DejaVu", "B", 9) + pdf.CellFormat(colW, 6, mealLabels[mi], "LTR", 0, "C", true, 0, "") + + // Einträge + me := dayEntries[day] + var items []PlanEntry + if mealKey == "fruehstueck" { + items = me.fruehstueck + } else { + items = me.vesper + } + + pdf.SetFont("DejaVu", "", 9) + contentTop := rowTop + 6 + contentH := rowH - 6 + pdf.SetXY(x+1, contentTop) + + for _, item := range items { + text := formatEntryText(item) + pdf.SetX(x + 1) + pdf.MultiCell(colW-2, 4, text, "", "L", false) + } + + // Rahmen um die ganze Zelle + pdf.Rect(x, rowTop, colW, rowH, "D") + _ = contentH + } + } + + // === LEGENDE === + legendY := tableTop + headerH + 2*rowH + 3 + pdf.SetXY(marginX, legendY) + + if len(allergenSet) > 0 { + pdf.SetFont("DejaVu", "B", 8) + pdf.CellFormat(0, 4, "Allergene:", "", 0, "L", false, 0, "") + pdf.Ln(4) + pdf.SetFont("DejaVu", "", 7) + var parts []string + for id, al := range allergenSet { + parts = append(parts, fmt.Sprintf("%s = %s", id, al.Name)) + } + pdf.MultiCell(usableW, 3.5, strings.Join(parts, " | "), "", "L", false) + pdf.Ln(1) + } + + if len(additiveSet) > 0 { + pdf.SetFont("DejaVu", "B", 8) + pdf.CellFormat(0, 4, "Zusatzstoffe:", "", 0, "L", false, 0, "") + pdf.Ln(4) + pdf.SetFont("DejaVu", "", 7) + var parts []string + for id, ad := range additiveSet { + parts = append(parts, fmt.Sprintf("%s = %s", id, ad.Name)) + } + pdf.MultiCell(usableW, 3.5, strings.Join(parts, " | "), "", "L", false) + } + + // === FOOTER === + pdf.SetFont("DejaVu", "", 8) + footerY := pageH - 8 + pdf.SetXY(marginX, footerY) + pdf.CellFormat(usableW/2, 5, fmt.Sprintf("Erstellt am %s", time.Now().Format("02.01.2006")), "", 0, "L", false, 0, "") + pdf.CellFormat(usableW/2, 5, "Seite 1", "", 0, "R", false, 0, "") + + return pdf.OutputFileAndClose(outputPath) +} + +// formatEntryText formatiert einen PlanEntry für die PDF-Ausgabe +func formatEntryText(e PlanEntry) string { + var text string + + if e.Product != nil { + text = e.Product.Name + + // Allergen/Zusatzstoff-Kürzel anhängen + var codes []string + for _, al := range e.Product.Allergens { + codes = append(codes, al.ID) + } + for _, ad := range e.Product.Additives { + codes = append(codes, ad.ID) + } + if len(codes) > 0 { + text += " (" + strings.Join(codes, ",") + ")" + } + + // Multiline: Inhaltsstoffe auf eigener Zeile + if e.Product.Multiline && len(codes) > 0 { + text = e.Product.Name + "\n [" + strings.Join(codes, ",") + "]" + } + } else if e.CustomText != nil { + text = *e.CustomText + } + + // Gruppenlabel + if e.GroupLabel != nil && *e.GroupLabel != "" { + text = fmt.Sprintf("[%s] %s", *e.GroupLabel, text) + } + + return text +} + +// isoWeekMonday gibt den Montag der ISO-Kalenderwoche zurück +func isoWeekMonday(year, week int) time.Time { + // 4. Januar liegt immer in KW 1 + jan4 := time.Date(year, 1, 4, 0, 0, 0, 0, time.Local) + // Wochentag von 4. Jan (0=So, 1=Mo, ...) + weekday := int(jan4.Weekday()) + if weekday == 0 { + weekday = 7 + } + // Montag der KW 1 + mondayKW1 := jan4.AddDate(0, 0, -(weekday - 1)) + // Montag der gewünschten KW + return mondayKW1.AddDate(0, 0, (week-1)*7) +} diff --git a/updater.go b/updater.go index 6940ca0..eca836e 100644 --- a/updater.go +++ b/updater.go @@ -1,9 +1,14 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "io" "net/http" + "os" + "path/filepath" "strconv" "strings" "time" @@ -39,6 +44,7 @@ type VersionResponse struct { Version string `json:"version"` DownloadURL string `json:"download_url"` ReleaseNotes string `json:"release_notes"` + Checksum string `json:"checksum"` } // CheckForUpdate prüft ob ein Update verfügbar ist @@ -67,14 +73,73 @@ func (u *Updater) CheckForUpdate() (*UpdateInfo, error) { LatestVersion: versionResp.Version, DownloadURL: versionResp.DownloadURL, ReleaseNotes: versionResp.ReleaseNotes, + Checksum: versionResp.Checksum, }, nil } -// DownloadUpdate lädt ein verfügbares Update herunter (Stub) -func (u *Updater) DownloadUpdate(downloadURL string) error { - // TODO: Implementierung für das Herunterladen und Installieren von Updates - // Für Phase 1 nur ein Stub - return fmt.Errorf("update download not implemented yet") +// DownloadUpdate lädt ein verfügbares Update herunter und verifiziert die Checksum +func (u *Updater) DownloadUpdate(info UpdateInfo) (string, error) { + if info.DownloadURL == "" { + return "", fmt.Errorf("keine Download-URL vorhanden") + } + + // Nur HTTPS erlauben + if !strings.HasPrefix(info.DownloadURL, "https://") { + return "", fmt.Errorf("nur HTTPS-Downloads sind erlaubt, URL: %s", info.DownloadURL) + } + + // Temporäres Verzeichnis + tmpDir, err := os.MkdirTemp("", "speiseplan-update-*") + if err != nil { + return "", fmt.Errorf("temporäres Verzeichnis konnte nicht erstellt werden: %w", err) + } + + // Dateiname aus URL + parts := strings.Split(info.DownloadURL, "/") + filename := parts[len(parts)-1] + if filename == "" { + filename = "speiseplan-update.exe" + } + destPath := filepath.Join(tmpDir, filename) + + // Download + resp, err := u.HTTPClient.Get(info.DownloadURL) + if err != nil { + return "", fmt.Errorf("Download fehlgeschlagen: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Download-Server antwortete mit Status %d", resp.StatusCode) + } + + outFile, err := os.Create(destPath) + if err != nil { + return "", fmt.Errorf("Datei konnte nicht erstellt werden: %w", err) + } + defer outFile.Close() + + // Gleichzeitig schreiben und Hash berechnen + hasher := sha256.New() + writer := io.MultiWriter(outFile, hasher) + + if _, err := io.Copy(writer, resp.Body); err != nil { + return "", fmt.Errorf("Download-Fehler: %w", err) + } + outFile.Close() + + // SHA256 verifizieren + if info.Checksum != "" { + actualHash := hex.EncodeToString(hasher.Sum(nil)) + expectedHash := strings.ToLower(strings.TrimSpace(info.Checksum)) + if actualHash != expectedHash { + os.Remove(destPath) + return "", fmt.Errorf("Checksum-Fehler: erwartet %s, erhalten %s", expectedHash, actualHash) + } + } + + msg := fmt.Sprintf("Update v%s wurde nach %s heruntergeladen. Bitte die App beenden und die neue Version starten.", info.LatestVersion, destPath) + return msg, nil } // compareVersions vergleicht zwei Versionsstrings