v0.3.0 — PDF Export + OTA Download
This commit is contained in:
12
app.go
12
app.go
@@ -361,6 +361,18 @@ func (a *App) CheckForUpdate() (*UpdateInfo, error) {
|
|||||||
return a.updater.CheckForUpdate()
|
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
|
// HILFSFUNKTIONEN
|
||||||
|
|
||||||
// loadProductRelations lädt Allergene und Zusatzstoffe für ein Produkt
|
// loadProductRelations lädt Allergene und Zusatzstoffe für ein Produkt
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
// Import der Wails-Funktionen (werden zur Laufzeit verfügbar sein)
|
// Import der Wails-Funktionen (werden zur Laufzeit verfügbar sein)
|
||||||
// @ts-ignore - Wails-Bindings werden zur Laufzeit generiert
|
// @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 {
|
interface UpdateInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -15,6 +15,8 @@ interface UpdateInfo {
|
|||||||
export function InfoPage() {
|
export function InfoPage() {
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [downloadComplete, setDownloadComplete] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Handle checking for updates
|
// Handle checking for updates
|
||||||
@@ -32,10 +34,20 @@ export function InfoPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle opening download URL
|
// Handle downloading update
|
||||||
const handleDownload = () => {
|
const handleDownloadUpdate = async () => {
|
||||||
if (updateInfo?.download_url) {
|
if (!updateInfo?.download_url) return;
|
||||||
window.open(updateInfo.download_url, '_blank');
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { SpecialDayDialog } from './SpecialDayDialog';
|
|||||||
import { getWeekDays } from '../lib/weekHelper';
|
import { getWeekDays } from '../lib/weekHelper';
|
||||||
import { useWeekPlan } from '../hooks/useWeekPlan';
|
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 {
|
interface WeekPlannerProps {
|
||||||
year: number;
|
year: number;
|
||||||
week: number;
|
week: number;
|
||||||
@@ -13,6 +17,9 @@ interface WeekPlannerProps {
|
|||||||
|
|
||||||
export function WeekPlanner({ year, week, className = '' }: WeekPlannerProps) {
|
export function WeekPlanner({ year, week, className = '' }: WeekPlannerProps) {
|
||||||
const [specialDayDialog, setSpecialDayDialog] = useState<{ day: WeekDay } | null>(null);
|
const [specialDayDialog, setSpecialDayDialog] = useState<{ day: WeekDay } | null>(null);
|
||||||
|
const [pdfExporting, setPdfExporting] = useState(false);
|
||||||
|
const [pdfSuccess, setPdfSuccess] = useState<string | null>(null);
|
||||||
|
const [pdfError, setPdfError] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
weekPlan,
|
weekPlan,
|
||||||
@@ -69,6 +76,35 @@ export function WeekPlanner({ year, week, className = '' }: WeekPlannerProps) {
|
|||||||
await createWeekPlan();
|
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
|
// Loading state
|
||||||
if (loading && !weekPlan) {
|
if (loading && !weekPlan) {
|
||||||
return (
|
return (
|
||||||
@@ -173,15 +209,90 @@ export function WeekPlanner({ year, week, className = '' }: WeekPlannerProps) {
|
|||||||
Wochenplan KW {week} {year}
|
Wochenplan KW {week} {year}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
<div className="flex items-center space-x-4">
|
||||||
<span>Erstellt: {new Date(weekPlan.created_at).toLocaleDateString('de-DE')}</span>
|
{/* PDF Export Button */}
|
||||||
{loading && (
|
<button
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
onClick={handlePdfExport}
|
||||||
)}
|
disabled={pdfExporting || loading}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
aria-label="PDF-Datei vom Wochenplan erstellen"
|
||||||
|
>
|
||||||
|
{pdfExporting ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
) : (
|
||||||
|
<span>📄</span>
|
||||||
|
)}
|
||||||
|
<span>{pdfExporting ? 'Erstelle PDF...' : 'PDF erstellen'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||||
|
<span>Erstellt: {new Date(weekPlan.created_at).toLocaleDateString('de-DE')}</span>
|
||||||
|
{loading && (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* PDF Success Banner */}
|
||||||
|
{pdfSuccess && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-md p-4 mb-6" aria-live="polite">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-green-800">
|
||||||
|
PDF erfolgreich erstellt
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-green-700">
|
||||||
|
{pdfSuccess}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setPdfSuccess(null)}
|
||||||
|
className="text-sm text-green-800 hover:text-green-600 focus:outline-none focus:underline"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PDF Error Banner */}
|
||||||
|
{pdfError && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6" aria-live="polite">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.996-.833-2.764 0L3.732 16c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-800">
|
||||||
|
PDF-Export fehlgeschlagen
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-red-700">
|
||||||
|
{pdfError}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setPdfError(null)}
|
||||||
|
className="text-sm text-red-800 hover:text-red-600 focus:outline-none focus:underline"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Week Grid */}
|
{/* Week Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 lg:gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 lg:gap-6">
|
||||||
{weekDays.map((date, index) => {
|
{weekDays.map((date, index) => {
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -5,6 +5,7 @@ go 1.24.0
|
|||||||
toolchain go1.24.4
|
toolchain go1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-pdf/fpdf v0.9.0
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
modernc.org/sqlite v1.46.1
|
modernc.org/sqlite v1.46.1
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
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 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
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=
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type UpdateInfo struct {
|
|||||||
LatestVersion string `json:"latest_version"`
|
LatestVersion string `json:"latest_version"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
ReleaseNotes string `json:"release_notes,omitempty"`
|
ReleaseNotes string `json:"release_notes,omitempty"`
|
||||||
|
Checksum string `json:"checksum,omitempty"` // SHA256
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductImport repräsentiert ein Produkt beim Import aus JSON
|
// ProductImport repräsentiert ein Produkt beim Import aus JSON
|
||||||
|
|||||||
275
pdf_export.go
Normal file
275
pdf_export.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
75
updater.go
75
updater.go
@@ -1,9 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -39,6 +44,7 @@ type VersionResponse struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
DownloadURL string `json:"download_url"`
|
DownloadURL string `json:"download_url"`
|
||||||
ReleaseNotes string `json:"release_notes"`
|
ReleaseNotes string `json:"release_notes"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckForUpdate prüft ob ein Update verfügbar ist
|
// CheckForUpdate prüft ob ein Update verfügbar ist
|
||||||
@@ -67,14 +73,73 @@ func (u *Updater) CheckForUpdate() (*UpdateInfo, error) {
|
|||||||
LatestVersion: versionResp.Version,
|
LatestVersion: versionResp.Version,
|
||||||
DownloadURL: versionResp.DownloadURL,
|
DownloadURL: versionResp.DownloadURL,
|
||||||
ReleaseNotes: versionResp.ReleaseNotes,
|
ReleaseNotes: versionResp.ReleaseNotes,
|
||||||
|
Checksum: versionResp.Checksum,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadUpdate lädt ein verfügbares Update herunter (Stub)
|
// DownloadUpdate lädt ein verfügbares Update herunter und verifiziert die Checksum
|
||||||
func (u *Updater) DownloadUpdate(downloadURL string) error {
|
func (u *Updater) DownloadUpdate(info UpdateInfo) (string, error) {
|
||||||
// TODO: Implementierung für das Herunterladen und Installieren von Updates
|
if info.DownloadURL == "" {
|
||||||
// Für Phase 1 nur ein Stub
|
return "", fmt.Errorf("keine Download-URL vorhanden")
|
||||||
return fmt.Errorf("update download not implemented yet")
|
}
|
||||||
|
|
||||||
|
// 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
|
// compareVersions vergleicht zwei Versionsstrings
|
||||||
|
|||||||
Reference in New Issue
Block a user