From 5a45aa0a93b8650cba2281a883381a9e8675113c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Thu, 7 May 2026 16:52:05 +0200 Subject: [PATCH] Simplify agent to backend-driven printing --- README.md | 224 +++++---- sql/create_label_print_queue.sql | 40 -- src/LabelPrintAgent/App/Program.cs | 6 +- src/LabelPrintAgent/App/SettingsForm.cs | 471 +++--------------- .../App/TrayApplicationContext.cs | 8 +- src/LabelPrintAgent/Backend/BackendClient.cs | 120 +++++ .../Backend/BackendLabelJob.cs | 11 + .../Backend/BackendPollingWorker.cs | 122 +++++ .../Backend/BackendReportRequest.cs | 8 + .../Configuration/AppSettings.cs | 132 +---- .../Configuration/SettingsStore.cs | 12 +- .../Database/DbConnectionSettings.cs | 10 - .../Database/MySqlLabelRepository.cs | 217 -------- src/LabelPrintAgent/Database/PrintJob.cs | 18 - src/LabelPrintAgent/LabelPrintAgent.csproj | 2 - src/LabelPrintAgent/Layout/LabelLayout.cs | 12 - src/LabelPrintAgent/Layout/LayoutElement.cs | 12 - .../Layout/LayoutElementJsonConverter.cs | 43 -- .../Layout/LayoutJsonSerializer.cs | 25 - .../Layout/LayoutValidationResult.cs | 13 - src/LabelPrintAgent/Layout/LayoutValidator.cs | 226 --------- src/LabelPrintAgent/Layout/LineElement.cs | 15 - src/LabelPrintAgent/Layout/QrCodeElement.cs | 12 - .../Layout/RectangleElement.cs | 14 - src/LabelPrintAgent/Layout/TextElement.cs | 23 - .../Numbering/NumberReservationResult.cs | 26 - .../Numbering/NumberReservationService.cs | 92 ---- .../Numbering/UrlTemplateFormatter.cs | 28 -- .../Rendering/LabelPayloadBuilder.cs | 32 -- .../Rendering/LabelRenderer.cs | 189 ------- .../Rendering/MmToPixelConverter.cs | 14 - .../Rendering/PreviewDataProvider.cs | 17 - src/LabelPrintAgent/Rendering/RenderResult.cs | 24 - .../Rendering/TemplateFormatter.cs | 113 ----- .../Worker/PrintJobProcessor.cs | 115 ----- 35 files changed, 473 insertions(+), 1973 deletions(-) delete mode 100644 sql/create_label_print_queue.sql create mode 100644 src/LabelPrintAgent/Backend/BackendClient.cs create mode 100644 src/LabelPrintAgent/Backend/BackendLabelJob.cs create mode 100644 src/LabelPrintAgent/Backend/BackendPollingWorker.cs create mode 100644 src/LabelPrintAgent/Backend/BackendReportRequest.cs delete mode 100644 src/LabelPrintAgent/Database/DbConnectionSettings.cs delete mode 100644 src/LabelPrintAgent/Database/MySqlLabelRepository.cs delete mode 100644 src/LabelPrintAgent/Database/PrintJob.cs delete mode 100644 src/LabelPrintAgent/Layout/LabelLayout.cs delete mode 100644 src/LabelPrintAgent/Layout/LayoutElement.cs delete mode 100644 src/LabelPrintAgent/Layout/LayoutElementJsonConverter.cs delete mode 100644 src/LabelPrintAgent/Layout/LayoutJsonSerializer.cs delete mode 100644 src/LabelPrintAgent/Layout/LayoutValidationResult.cs delete mode 100644 src/LabelPrintAgent/Layout/LayoutValidator.cs delete mode 100644 src/LabelPrintAgent/Layout/LineElement.cs delete mode 100644 src/LabelPrintAgent/Layout/QrCodeElement.cs delete mode 100644 src/LabelPrintAgent/Layout/RectangleElement.cs delete mode 100644 src/LabelPrintAgent/Layout/TextElement.cs delete mode 100644 src/LabelPrintAgent/Numbering/NumberReservationResult.cs delete mode 100644 src/LabelPrintAgent/Numbering/NumberReservationService.cs delete mode 100644 src/LabelPrintAgent/Numbering/UrlTemplateFormatter.cs delete mode 100644 src/LabelPrintAgent/Rendering/LabelPayloadBuilder.cs delete mode 100644 src/LabelPrintAgent/Rendering/LabelRenderer.cs delete mode 100644 src/LabelPrintAgent/Rendering/MmToPixelConverter.cs delete mode 100644 src/LabelPrintAgent/Rendering/PreviewDataProvider.cs delete mode 100644 src/LabelPrintAgent/Rendering/RenderResult.cs delete mode 100644 src/LabelPrintAgent/Rendering/TemplateFormatter.cs delete mode 100644 src/LabelPrintAgent/Worker/PrintJobProcessor.cs diff --git a/README.md b/README.md index 703777e..bac43a1 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,149 @@ # LabelPrintAgent -Windows-Tray-Anwendung zum Rendern und Drucken von Etiketten über installierte Windows-Drucker, z. B. einen Dymo LabelWriter. +Windows-Tray-Connector für den PaperlessManager. -## Aktueller Stand +Der Agent speichert keine Layouts, keine Nummernserver-Regeln und keine MySQL-Queue-Logik mehr. Das Backend entscheidet, welche Etiketten gedruckt werden, rendert bzw. liefert das fertige Etikett als Bild, und der Agent druckt dieses Bild über einen installierten Windows-Drucker. -Der Agent arbeitet jetzt mit lokalen `LabelTemplates` in `C:\ProgramData\LabelPrintAgent\settings.json`. - -Ein Queue-Job enthält nur noch: - -- `barcode_template_id` -- `payload_json` - -Die alte Zuordnung über `layout_key` gibt es nicht mehr. Stattdessen gilt: +## Ablauf ```text -label_print_queue.barcode_template_id --> lokales LabelTemplate im LabelPrintAgent --> darin enthaltenes Layout rendern +LabelPrintAgent +-> fragt alle X Sekunden PaperlessManager-Backend +-> erhält einen druckfertigen Etikettenjob +-> lädt/liest das Etikettbild +-> druckt über Windows-Drucker +-> meldet Erfolg oder Fehler ans Backend zurück ``` -Die Tabelle `barcode_templates` wird nicht verändert. Sie bleibt nur die fachliche Referenz für die `barcode_template_id`. +Optional kann später statt Polling Server-Sent Events ergänzt werden. Der aktuelle Stand nutzt bewusst Polling, weil es robuster und einfacher zu betreiben ist. -## LabelTemplates +## Backend-Vertrag -Ein lokales LabelTemplate enthält: +Der Agent fragt standardmäßig: -- `barcodeTemplateId` -- `name` -- `getNumberUrl` -- `numberPrintedUrl` -- `reservedNumberPayloadKey` -- `qrTemplate` -- `layout` +```http +GET /api/label-print-agent/jobs/next?agentId={agentId} +Authorization: Bearer {apiToken} +``` -Das Layout liegt vollständig eingebettet im Template. Es gibt keine separate Layout-Datei und keinen `layoutKey` mehr. +Wenn nichts zu drucken ist: -## Nummernserver +```http +204 No Content +``` -`getNumberUrl` wird per HTTP GET aufgerufen und gibt Plain Text zurück, z. B.: +Wenn ein Etikett vorhanden ist: + +```json +{ + "jobId": "12345", + "labelImageBase64": "...", + "labelImageContentType": "image/png", + "labelWidthMm": 57, + "labelHeightMm": 32 +} +``` + +Alternativ darf das Backend statt `labelImageBase64` eine URL liefern: + +```json +{ + "jobId": "12345", + "labelImageUrl": "/api/label-print-agent/jobs/12345/image", + "labelImageContentType": "image/png", + "labelWidthMm": 57, + "labelHeightMm": 32 +} +``` + +Der Agent meldet Erfolg: + +```http +POST /api/label-print-agent/jobs/{jobId}/printed +Authorization: Bearer {apiToken} +Content-Type: application/json + +{ + "agentId": "PC-BUERO", + "printerName": "DYMO LabelWriter 450" +} +``` + +Der Agent meldet Fehler: + +```http +POST /api/label-print-agent/jobs/{jobId}/error +Authorization: Bearer {apiToken} +Content-Type: application/json + +{ + "agentId": "PC-BUERO", + "printerName": "DYMO LabelWriter 450", + "errorMessage": "Drucker ist nicht verfügbar." +} +``` + +## Lokale Einstellungen + +Die Einstellungen liegen unter: ```text -123 +C:\ProgramData\LabelPrintAgent\settings.json ``` -Der Nummernserver liefert keine führenden Nullen. Die Datenbank speichert `reserved_number` als `BIGINT`, also z. B. `123`. - -Führende Nullen entstehen nur über Formatierung im Template: - -```text -{reservedNumber:0000000} -> 0000123 -{nummer:0000000} -> 0000123 -``` - -Nach erfolgreichem Windows-Druck wird `numberPrintedUrl` aufgerufen. Erst wenn diese Bestätigung erfolgreich war, darf der Job als `printed` markiert werden. - -Wenn der Druck erfolgreich war, aber `numberPrintedUrl` fehlschlägt, bleibt `reserved_number` erhalten und der Job geht auf `error`. Beim erneuten Drucken wird dieselbe Nummer wiederverwendet; es wird keine neue Nummer reserviert. - -## Payload-Erweiterung - -Vor dem Rendern wird `payload_json` erweitert: - -- `reservedNumber` -- der konfigurierte `reservedNumberPayloadKey`, z. B. `nummer` -- `qr`, wenn `qrTemplate` gesetzt ist -- `jobId` -- `barcodeTemplateId` - Beispiel: ```json { - "reservedNumber": 123, - "nummer": 123, - "qr": "bjoernprivat 0000123" + "backend": { + "baseUrl": "https://paperlessmanager.local", + "agentId": "PC-BUERO", + "encryptedApiToken": "", + "nextJobPath": "/api/label-print-agent/jobs/next", + "reportSuccessPath": "/api/label-print-agent/jobs/{jobId}/printed", + "reportErrorPath": "/api/label-print-agent/jobs/{jobId}/error" + }, + "printer": { + "printerName": "DYMO LabelWriter 450", + "labelWidthMm": 57, + "labelHeightMm": 32, + "dpi": 300 + }, + "worker": { + "enabled": true, + "pollIntervalSeconds": 5 + } } ``` -## Oberfläche +Der API-Token wird lokal mit Windows DPAPI verschlüsselt gespeichert. -Im Einstellungsdialog gibt es den Tab `Label-Templates`. +## Dymo-Druck -Dort kannst du: +Der Dymo LabelWriter muss in Windows als normaler Drucker eingerichtet sein. Das Backend liefert ein fertiges Bild für das Etikett, typischerweise PNG in `57 x 32 mm`. -- Templates anlegen -- Templates löschen -- `barcodeTemplateId` bearbeiten -- Nummernserver-URLs bearbeiten -- QR-Template bearbeiten -- eingebettetes Layout-JSON bearbeiten -- validieren -- Vorschau erzeugen -- Testdruck auslösen +Wichtig: -Die Vorschau verwendet eine Dummy-Nummer `123` und reserviert keine Nummer beim Nummernserver. +- Der Agent verwendet `PrintDocument`. +- Es wird kein ZPL, EPL oder TSPL verwendet. +- Das Bild wird auf die komplette Papierfläche gedruckt. +- Der Dymo-Treiber sollte auf das passende Etikettenformat eingestellt sein. -## Dymo-Testdruck +## Bedienung -Für den Dymo LabelWriter muss der Drucker in Windows als normaler Drucker eingerichtet sein. Stelle im Dymo-Treiber möglichst das passende Etikettenformat `57 x 32 mm` ein. +1. Anwendung starten. +2. Tray-Symbol öffnen. +3. Im Tab `Backend` BaseUrl, AgentId und optional API-Token eintragen. +4. Im Tab `Drucker` den Dymo LabelWriter auswählen. +5. Im Tab `Allgemein` Polling aktivieren und Intervall setzen. +6. Mit `Jetzt prüfen` kann sofort ein einzelner Backend-Poll ausgelöst werden. -Testdruck: +## Nicht mehr im Agent -1. Im Tab `Drucker` den Dymo LabelWriter auswählen. -2. `Speichern` klicken. -3. Im Tab `Label-Templates` ein Template auswählen. -4. `Vorschau` prüfen. -5. `Testdruck` klicken. +- keine Layout-JSON-Verwaltung +- keine lokalen LabelTemplates +- keine MySQL-Verbindung +- keine Nummernserver-URLs +- keine Nummernreservierung im Agent -Typische Fehler: - -- Falscher Drucker gewählt. -- Falsches Etikettenformat im Dymo-Treiber. -- Treiber skaliert auf A4/Letter. -- Etikett ist gedreht: Dymo-Treiber-Labelgröße und physische Orientierung prüfen. - -## Datenbank - -Die SQL-Datei liegt unter: - -```text -sql/create_label_print_queue.sql -``` - -Beispiel-Insert: - -```sql -INSERT INTO label_print_queue -(barcode_template_id, payload_json, status) -VALUES -( - 1, - JSON_OBJECT( - 'titel', 'Beleg privat', - 'beschreibung', 'Tankbeleg', - 'datum', '2026-05-07' - ), - 'pending' -); -``` - -## Noch offen - -Der automatische MySQL-Worker ist noch nicht aktiv verdrahtet. Die dafür benötigten Modell-, Repository-, Nummernserver- und Druckprozessor-Klassen sind vorbereitet. +Diese Verantwortung liegt vollständig im PaperlessManager-Backend. diff --git a/sql/create_label_print_queue.sql b/sql/create_label_print_queue.sql deleted file mode 100644 index 2538bea..0000000 --- a/sql/create_label_print_queue.sql +++ /dev/null @@ -1,40 +0,0 @@ -CREATE TABLE IF NOT EXISTS label_print_queue ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - - barcode_template_id INT(11) NOT NULL, - payload_json JSON NOT NULL, - - reserved_number BIGINT NULL, - number_reserved_at DATETIME NULL, - number_printed_at DATETIME NULL, - - status ENUM('pending','number_reserved','printing','printed','error','deleted') NOT NULL DEFAULT 'pending', - - printer_name VARCHAR(255) NULL, - attempts INT NOT NULL DEFAULT 0, - error_message TEXT NULL, - - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - locked_at DATETIME NULL, - printed_at DATETIME NULL, - - INDEX idx_label_print_queue_status_created (status, created_at), - INDEX idx_label_print_queue_barcode_template_id (barcode_template_id), - - CONSTRAINT fk_label_print_queue_barcode_template - FOREIGN KEY (barcode_template_id) - REFERENCES barcode_templates(Id) -); - -INSERT INTO label_print_queue -(barcode_template_id, payload_json, status) -VALUES -( - 1, - JSON_OBJECT( - 'titel', 'Beleg privat', - 'beschreibung', 'Tankbeleg', - 'datum', '2026-05-07' - ), - 'pending' -); diff --git a/src/LabelPrintAgent/App/Program.cs b/src/LabelPrintAgent/App/Program.cs index 95b9874..8ff92ff 100644 --- a/src/LabelPrintAgent/App/Program.cs +++ b/src/LabelPrintAgent/App/Program.cs @@ -1,3 +1,4 @@ +using LabelPrintAgent.Backend; using LabelPrintAgent.Configuration; using LabelPrintAgent.Logging; using LabelPrintAgent.Printing; @@ -21,8 +22,11 @@ internal static class Program LogSetup.Configure(settings.Paths.LogFolder); var printerService = new PrinterService(); + var backendClient = new BackendClient(settingsStore); + using var backendWorker = new BackendPollingWorker(settingsStore, backendClient, printerService); + backendWorker.Start(); - Application.Run(new TrayApplicationContext(settingsStore, printerService)); + Application.Run(new TrayApplicationContext(settingsStore, printerService, backendWorker)); } catch (Exception ex) { diff --git a/src/LabelPrintAgent/App/SettingsForm.cs b/src/LabelPrintAgent/App/SettingsForm.cs index bc3e4ea..35bde18 100644 --- a/src/LabelPrintAgent/App/SettingsForm.cs +++ b/src/LabelPrintAgent/App/SettingsForm.cs @@ -1,9 +1,6 @@ -using System.Drawing; +using LabelPrintAgent.Backend; using LabelPrintAgent.Configuration; -using LabelPrintAgent.Database; -using LabelPrintAgent.Layout; using LabelPrintAgent.Printing; -using LabelPrintAgent.Rendering; using Microsoft.Win32; using Serilog; @@ -16,113 +13,80 @@ internal sealed class SettingsForm : Form private readonly SettingsStore _settingsStore; private readonly PrinterService _printerService; - private readonly LabelRenderer _labelRenderer = new(); - private readonly LayoutValidator _layoutValidator = new(); + private readonly BackendPollingWorker _backendWorker; private readonly CheckBox _autoStartCheckBox = new() { Text = "Autostart aktivieren", AutoSize = true }; + private readonly CheckBox _workerEnabled = new() { Text = "Backend automatisch abfragen", AutoSize = true }; private readonly NumericUpDown _pollInterval = new() { Minimum = 1, Maximum = 3600, Value = 5, Width = 100 }; private readonly TextBox _programDataFolder = new() { ReadOnly = true, Width = 540 }; - private readonly TextBox _host = new() { Width = 260 }; - private readonly NumericUpDown _port = new() { Minimum = 1, Maximum = 65535, Value = 3306, Width = 100 }; - private readonly TextBox _database = new() { Width = 260 }; - private readonly TextBox _username = new() { Width = 260 }; - private readonly TextBox _password = new() { Width = 260, UseSystemPasswordChar = true }; + private readonly TextBox _backendBaseUrl = new() { Width = 420 }; + private readonly TextBox _agentId = new() { Width = 260 }; + private readonly TextBox _apiToken = new() { Width = 420, UseSystemPasswordChar = true }; + private readonly TextBox _nextJobPath = new() { Width = 420 }; + private readonly TextBox _reportSuccessPath = new() { Width = 420 }; + private readonly TextBox _reportErrorPath = new() { Width = 420 }; private readonly ComboBox _printer = new() { Width = 360, DropDownStyle = ComboBoxStyle.DropDownList }; private readonly NumericUpDown _labelWidth = new() { Minimum = 1, Maximum = 500, DecimalPlaces = 1, Value = 57, Width = 100 }; private readonly NumericUpDown _labelHeight = new() { Minimum = 1, Maximum = 500, DecimalPlaces = 1, Value = 32, Width = 100 }; - private readonly ListBox _templateList = new() { Width = 250, Height = 520 }; - private readonly NumericUpDown _barcodeTemplateId = new() { Minimum = 1, Maximum = int.MaxValue, Width = 100 }; - private readonly TextBox _templateName = new() { Width = 360 }; - private readonly TextBox _getNumberUrl = new() { Width = 600 }; - private readonly TextBox _numberPrintedUrl = new() { Width = 600 }; - private readonly TextBox _reservedNumberPayloadKey = new() { Width = 180 }; - private readonly TextBox _qrTemplate = new() { Width = 360 }; - private readonly TextBox _layoutJson = new() - { - Multiline = true, - ScrollBars = ScrollBars.Both, - WordWrap = false, - Font = new Font(FontFamily.GenericMonospace, 9), - Width = 610, - Height = 260 - }; - private readonly TextBox _templateMessages = new() + private readonly TextBox _status = new() { Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical, - Width = 930, - Height = 72 - }; - private readonly PictureBox _templatePreview = new() - { - BorderStyle = BorderStyle.FixedSingle, - SizeMode = PictureBoxSizeMode.Zoom, - BackColor = Color.White, - Width = 280, - Height = 170 - }; - - private readonly DataGridView _errorJobs = new() - { - ReadOnly = true, - AllowUserToAddRows = false, - AllowUserToDeleteRows = false, - AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, - SelectionMode = DataGridViewSelectionMode.FullRowSelect, Dock = DockStyle.Fill }; - public SettingsForm(SettingsStore settingsStore, PrinterService printerService) + public SettingsForm(SettingsStore settingsStore, PrinterService printerService, BackendPollingWorker backendWorker) { _settingsStore = settingsStore; _printerService = printerService; + _backendWorker = backendWorker; Text = "LabelPrintAgent Einstellungen"; - Width = 1240; - Height = 820; + Width = 900; + Height = 620; StartPosition = FormStartPosition.CenterScreen; var tabs = new TabControl { Dock = DockStyle.Fill }; tabs.TabPages.Add(CreateGeneralTab()); - tabs.TabPages.Add(CreateDatabaseTab()); + tabs.TabPages.Add(CreateBackendTab()); tabs.TabPages.Add(CreatePrinterTab()); - tabs.TabPages.Add(CreateLabelTemplatesTab()); - tabs.TabPages.Add(CreateErrorJobsTab()); + tabs.TabPages.Add(CreateStatusTab()); Controls.Add(tabs); Load += (_, _) => { LoadSettingsIntoUi(); LoadPrinters(); - LoadTemplates(); - LoadEmptyErrorJobsTable(); }; } private TabPage CreateGeneralTab() { var panel = CreatePaddedPanel(); - panel.Controls.Add(Row(20, Label("Autostart", 140), _autoStartCheckBox)); - panel.Controls.Add(Row(60, Label("Prüfintervall", 140), _pollInterval, Label("Sekunden", 80))); - panel.Controls.Add(Row(100, Label("Programmdaten", 140), _programDataFolder)); - panel.Controls.Add(ButtonAt("Speichern", 160, 150, (_, _) => SaveGeneral())); + panel.Controls.Add(Row(20, Label("Autostart", 160), _autoStartCheckBox)); + panel.Controls.Add(Row(60, Label("Worker", 160), _workerEnabled)); + panel.Controls.Add(Row(100, Label("Prüfintervall", 160), _pollInterval, Label("Sekunden", 80))); + panel.Controls.Add(Row(140, Label("Programmdaten", 160), _programDataFolder)); + panel.Controls.Add(ButtonAt("Speichern", 180, 190, (_, _) => SaveGeneral())); return new TabPage("Allgemein") { Controls = { panel } }; } - private TabPage CreateDatabaseTab() + private TabPage CreateBackendTab() { var panel = CreatePaddedPanel(); - panel.Controls.Add(Row(20, Label("Host", 120), _host)); - panel.Controls.Add(Row(60, Label("Port", 120), _port)); - panel.Controls.Add(Row(100, Label("Datenbank", 120), _database)); - panel.Controls.Add(Row(140, Label("Benutzer", 120), _username)); - panel.Controls.Add(Row(180, Label("Passwort", 120), _password)); - panel.Controls.Add(ButtonAt("Speichern", 140, 230, (_, _) => SaveDatabase())); - return new TabPage("Datenbank") { Controls = { panel } }; + panel.Controls.Add(Row(20, Label("BaseUrl", 160), _backendBaseUrl)); + panel.Controls.Add(Row(60, Label("AgentId", 160), _agentId)); + panel.Controls.Add(Row(100, Label("API Token", 160), _apiToken)); + panel.Controls.Add(Row(140, Label("NextJobPath", 160), _nextJobPath)); + panel.Controls.Add(Row(180, Label("ReportSuccessPath", 160), _reportSuccessPath)); + panel.Controls.Add(Row(220, Label("ReportErrorPath", 160), _reportErrorPath)); + panel.Controls.Add(ButtonAt("Speichern", 180, 275, (_, _) => SaveBackend(showMessage: true))); + panel.Controls.Add(ButtonAt("Jetzt prüfen", 300, 275, async (_, _) => await PollOnceFromUiAsync())); + return new TabPage("Backend") { Controls = { panel } }; } private TabPage CreatePrinterTab() @@ -133,51 +97,13 @@ internal sealed class SettingsForm : Form panel.Controls.Add(Row(100, Label("Höhe", 120), _labelHeight, Label("mm", 40))); panel.Controls.Add(ButtonAt("Drucker neu laden", 140, 150, (_, _) => LoadPrinters())); panel.Controls.Add(ButtonAt("Speichern", 300, 150, (_, _) => SavePrinter(showMessage: true))); - panel.Controls.Add(ButtonAt("Testdruck", 420, 150, async (_, _) => await PrintSelectedTemplateAsync())); return new TabPage("Drucker") { Controls = { panel } }; } - private TabPage CreateLabelTemplatesTab() + private TabPage CreateStatusTab() { - var panel = CreatePaddedPanel(); - _templateList.Left = 20; - _templateList.Top = 20; - _templateList.SelectedIndexChanged += (_, _) => LoadSelectedTemplateIntoUi(); - panel.Controls.Add(_templateList); - - panel.Controls.Add(RowAt(290, 20, Label("BarcodeTemplateId", 150), _barcodeTemplateId)); - panel.Controls.Add(RowAt(290, 60, Label("Name", 150), _templateName)); - panel.Controls.Add(RowAt(290, 100, Label("GetNumberUrl", 150), _getNumberUrl)); - panel.Controls.Add(RowAt(290, 140, Label("NumberPrintedUrl", 150), _numberPrintedUrl)); - panel.Controls.Add(RowAt(290, 180, Label("ReservedNumberKey", 150), _reservedNumberPayloadKey)); - panel.Controls.Add(RowAt(290, 220, Label("QrTemplate", 150), _qrTemplate)); - - _layoutJson.Left = 290; - _layoutJson.Top = 270; - panel.Controls.Add(_layoutJson); - _templatePreview.Left = 925; - _templatePreview.Top = 270; - panel.Controls.Add(_templatePreview); - _templateMessages.Left = 290; - _templateMessages.Top = 545; - panel.Controls.Add(_templateMessages); - - panel.Controls.Add(ButtonAt("Neu", 20, 560, (_, _) => NewTemplate())); - panel.Controls.Add(ButtonAt("Löschen", 120, 560, (_, _) => DeleteTemplate())); - panel.Controls.Add(ButtonAt("Validieren", 290, 640, (_, _) => ValidateCurrentTemplate(showSuccessMessage: true))); - panel.Controls.Add(ButtonAt("Speichern", 405, 640, (_, _) => SaveTemplate())); - panel.Controls.Add(ButtonAt("Vorschau", 520, 640, (_, _) => RenderTemplatePreview())); - panel.Controls.Add(ButtonAt("Testdruck", 630, 640, async (_, _) => await PrintCurrentTemplateAsync())); - return new TabPage("Label-Templates") { Controls = { panel } }; - } - - private TabPage CreateErrorJobsTab() - { - var page = new TabPage("Fehlerhafte Druckaufträge"); - var footer = new Panel { Dock = DockStyle.Bottom, Height = 48, Padding = new Padding(12) }; - footer.Controls.Add(LabelAt("Fehlerhafte Jobs werden angezeigt, sobald der MySQL-Worker aktiviert ist.", 12, 14, 620)); - page.Controls.Add(_errorJobs); - page.Controls.Add(footer); + var page = new TabPage("Status"); + page.Controls.Add(_status); return page; } @@ -185,13 +111,17 @@ internal sealed class SettingsForm : Form { var settings = _settingsStore.Load(); _autoStartCheckBox.Checked = IsAutostartEnabled(); + _workerEnabled.Checked = settings.Worker.Enabled; _pollInterval.Value = Math.Clamp(settings.Worker.PollIntervalSeconds, 1, 3600); _programDataFolder.Text = settings.Paths.BaseFolder; - _host.Text = settings.Database.Host; - _port.Value = settings.Database.Port; - _database.Text = settings.Database.Database; - _username.Text = settings.Database.Username; - _password.Text = _settingsStore.DecryptPassword(settings); + + _backendBaseUrl.Text = settings.Backend.BaseUrl; + _agentId.Text = settings.Backend.AgentId; + _apiToken.Text = _settingsStore.DecryptBackendApiToken(settings); + _nextJobPath.Text = settings.Backend.NextJobPath; + _reportSuccessPath.Text = settings.Backend.ReportSuccessPath; + _reportErrorPath.Text = settings.Backend.ReportErrorPath; + _labelWidth.Value = settings.Printer.LabelWidthMm; _labelHeight.Value = settings.Printer.LabelHeightMm; } @@ -214,65 +144,31 @@ internal sealed class SettingsForm : Form } } - private void LoadTemplates() - { - var settings = _settingsStore.Load(); - _templateList.Items.Clear(); - foreach (var template in settings.LabelTemplates.OrderBy(template => template.BarcodeTemplateId)) - { - _templateList.Items.Add(template); - } - - if (_templateList.Items.Count > 0) - { - _templateList.SelectedIndex = 0; - } - } - - private void LoadSelectedTemplateIntoUi() - { - if (_templateList.SelectedItem is not LabelTemplateConfig template) - { - return; - } - - _barcodeTemplateId.Value = Math.Max(1, template.BarcodeTemplateId); - _templateName.Text = template.Name; - _getNumberUrl.Text = template.GetNumberUrl; - _numberPrintedUrl.Text = template.NumberPrintedUrl; - _reservedNumberPayloadKey.Text = template.ReservedNumberPayloadKey; - _qrTemplate.Text = template.QrTemplate; - _layoutJson.Text = LayoutJsonSerializer.Serialize(template.Layout); - _templateMessages.Text = string.Empty; - } - - private void LoadEmptyErrorJobsTable() - { - _errorJobs.DataSource = new[] - { - new ErrorJobRow(0, 0, string.Empty, 0) - }; - } - private void SaveGeneral() { var settings = _settingsStore.Load(); + settings.Worker.Enabled = _workerEnabled.Checked; settings.Worker.PollIntervalSeconds = (int)_pollInterval.Value; _settingsStore.Save(settings); SetAutostart(_autoStartCheckBox.Checked); - MessageBox.Show("Allgemeine Einstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + AppendStatus("Allgemeine Einstellungen gespeichert."); } - private void SaveDatabase() + private void SaveBackend(bool showMessage) { var settings = _settingsStore.Load(); - settings.Database.Host = _host.Text.Trim(); - settings.Database.Port = (uint)_port.Value; - settings.Database.Database = _database.Text.Trim(); - settings.Database.Username = _username.Text.Trim(); - settings.Database.EncryptedPassword = _settingsStore.EncryptPassword(_password.Text); + settings.Backend.BaseUrl = _backendBaseUrl.Text.Trim(); + settings.Backend.AgentId = _agentId.Text.Trim(); + settings.Backend.EncryptedApiToken = _settingsStore.EncryptPassword(_apiToken.Text); + settings.Backend.NextJobPath = _nextJobPath.Text.Trim(); + settings.Backend.ReportSuccessPath = _reportSuccessPath.Text.Trim(); + settings.Backend.ReportErrorPath = _reportErrorPath.Text.Trim(); _settingsStore.Save(settings); - MessageBox.Show("Datenbankeinstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + AppendStatus("Backend-Einstellungen gespeichert."); + if (showMessage) + { + MessageBox.Show("Backend-Einstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } } private void SavePrinter(bool showMessage) @@ -282,236 +178,31 @@ internal sealed class SettingsForm : Form settings.Printer.LabelWidthMm = _labelWidth.Value; settings.Printer.LabelHeightMm = _labelHeight.Value; _settingsStore.Save(settings); + AppendStatus("Druckereinstellungen gespeichert."); if (showMessage) { MessageBox.Show("Druckereinstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); } } - private void NewTemplate() - { - var settings = _settingsStore.Load(); - var nextId = settings.LabelTemplates.Count == 0 ? 1 : settings.LabelTemplates.Max(template => template.BarcodeTemplateId) + 1; - var template = LabelTemplateConfig.CreateDefault(); - template.BarcodeTemplateId = nextId; - template.Name = $"Neues Template {nextId}"; - settings.LabelTemplates.Add(template); - _settingsStore.Save(settings); - LoadTemplates(); - _templateList.SelectedItem = _templateList.Items.Cast().First(item => item.BarcodeTemplateId == nextId); - } - - private void DeleteTemplate() - { - if (_templateList.SelectedItem is not LabelTemplateConfig selected) - { - return; - } - - var settings = _settingsStore.Load(); - settings.LabelTemplates.RemoveAll(template => template.BarcodeTemplateId == selected.BarcodeTemplateId); - if (settings.LabelTemplates.Count == 0) - { - settings.LabelTemplates.Add(LabelTemplateConfig.CreateDefault()); - } - - _settingsStore.Save(settings); - LoadTemplates(); - } - - private void SaveTemplate() - { - if (!ValidateCurrentTemplate(showSuccessMessage: false, out var template) || template is null) - { - return; - } - - var settings = _settingsStore.Load(); - var selectedTemplateId = _templateList.SelectedItem is LabelTemplateConfig selected - ? selected.BarcodeTemplateId - : (int?)null; - var existingIndex = selectedTemplateId.HasValue - ? settings.LabelTemplates.FindIndex(item => item.BarcodeTemplateId == selectedTemplateId.Value) - : settings.LabelTemplates.FindIndex(item => item.BarcodeTemplateId == template.BarcodeTemplateId); - if (existingIndex >= 0) - { - settings.LabelTemplates[existingIndex] = template; - } - else - { - settings.LabelTemplates.Add(template); - } - - _settingsStore.Save(settings); - _layoutJson.Text = LayoutJsonSerializer.Serialize(template.Layout); - LoadTemplates(); - _templateList.SelectedItem = _templateList.Items.Cast().First(item => item.BarcodeTemplateId == template.BarcodeTemplateId); - MessageBox.Show("Label-Template gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); - } - - private bool ValidateCurrentTemplate(bool showSuccessMessage) - { - return ValidateCurrentTemplate(showSuccessMessage, out _); - } - - private bool ValidateCurrentTemplate(bool showSuccessMessage, out LabelTemplateConfig? template) - { - template = null; - var errors = new List(); - try - { - var layout = LayoutJsonSerializer.Deserialize(_layoutJson.Text); - template = new LabelTemplateConfig - { - BarcodeTemplateId = (int)_barcodeTemplateId.Value, - Name = _templateName.Text.Trim(), - GetNumberUrl = _getNumberUrl.Text.Trim(), - NumberPrintedUrl = _numberPrintedUrl.Text.Trim(), - ReservedNumberPayloadKey = _reservedNumberPayloadKey.Text.Trim(), - QrTemplate = _qrTemplate.Text, - Layout = layout ?? new LabelLayout() - }; - - if (template.BarcodeTemplateId <= 0) errors.Add("BarcodeTemplateId muss größer als 0 sein."); - if (string.IsNullOrWhiteSpace(template.Name)) errors.Add("Name darf nicht leer sein."); - if (string.IsNullOrWhiteSpace(template.GetNumberUrl)) errors.Add("GetNumberUrl darf nicht leer sein."); - if (string.IsNullOrWhiteSpace(template.NumberPrintedUrl)) errors.Add("NumberPrintedUrl darf nicht leer sein."); - if (string.IsNullOrWhiteSpace(template.ReservedNumberPayloadKey)) errors.Add("ReservedNumberPayloadKey darf nicht leer sein."); - if (HasDuplicateBarcodeTemplateId(template.BarcodeTemplateId)) - { - errors.Add($"BarcodeTemplateId {template.BarcodeTemplateId} ist bereits in einem anderen Label-Template vorhanden."); - } - - if (!string.IsNullOrWhiteSpace(template.QrTemplate)) - { - _ = TemplateFormatter.Format(template.QrTemplate, BuildPreviewPayload(template, dummyNumber: 123), errors); - } - - errors.AddRange(_layoutValidator.Validate(template.Layout).Errors); - } - catch (Exception ex) - { - Log.Warning(ex, "Could not validate label template"); - errors.Add($"Layout-JSON konnte nicht gelesen werden: {ex.Message}"); - } - - if (errors.Count == 0) - { - _templateMessages.Text = string.Empty; - if (showSuccessMessage) - { - MessageBox.Show("Label-Template ist gültig.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); - } - - return true; - } - - var message = string.Join(Environment.NewLine, errors); - _templateMessages.Text = message; - MessageBox.Show(message, "Label-Template ist ungültig", MessageBoxButtons.OK, MessageBoxIcon.Warning); - return false; - } - - private bool HasDuplicateBarcodeTemplateId(int barcodeTemplateId) - { - var selectedTemplateId = _templateList.SelectedItem is LabelTemplateConfig selected - ? selected.BarcodeTemplateId - : (int?)null; - - return _settingsStore.Load().LabelTemplates.Any(template => - template.BarcodeTemplateId == barcodeTemplateId - && (!selectedTemplateId.HasValue || template.BarcodeTemplateId != selectedTemplateId.Value)); - } - - private void RenderTemplatePreview() + private async Task PollOnceFromUiAsync() { try { - if (!ValidateCurrentTemplate(showSuccessMessage: false, out var template) || template is null) - { - return; - } - - using var result = _labelRenderer.Render(template.Layout, BuildPreviewPayload(template, dummyNumber: 123)); - var oldImage = _templatePreview.Image; - _templatePreview.Image = (Bitmap)result.Image.Clone(); - oldImage?.Dispose(); - _templateMessages.Text = result.Warnings.Count == 0 - ? $"Vorschau erzeugt: {result.WidthPx} x {result.HeightPx} px" - : $"Vorschau erzeugt: {result.WidthPx} x {result.HeightPx} px{Environment.NewLine}" + string.Join(Environment.NewLine, result.Warnings); + SaveBackend(showMessage: false); + SavePrinter(showMessage: false); + AppendStatus("Backend wird geprüft..."); + await _backendWorker.PollOnceAsync(); + AppendStatus("Backend-Prüfung abgeschlossen."); } catch (Exception ex) { - Log.Error(ex, "Could not render label template preview"); - MessageBox.Show($"Vorschau konnte nicht erzeugt werden: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + Log.Error(ex, "Manual backend poll failed"); + AppendStatus($"Backend-Prüfung fehlgeschlagen: {ex.Message}"); + MessageBox.Show($"Backend-Prüfung fehlgeschlagen: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error); } } - private async Task PrintSelectedTemplateAsync() - { - if (_templateList.SelectedItem is LabelTemplateConfig template) - { - await PrintTemplateAsync(template); - } - else - { - MessageBox.Show("Bitte zuerst ein Label-Template auswählen.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); - } - } - - private async Task PrintCurrentTemplateAsync() - { - if (ValidateCurrentTemplate(showSuccessMessage: false, out var template) && template is not null) - { - await PrintTemplateAsync(template); - } - } - - private async Task PrintTemplateAsync(LabelTemplateConfig template) - { - SavePrinter(showMessage: false); - var printerName = GetSelectedPrinterName(); - if (string.IsNullOrWhiteSpace(printerName)) - { - MessageBox.Show("Bitte zuerst einen Drucker auswählen.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); - return; - } - - if (!_printerService.IsPrinterAvailable(printerName)) - { - MessageBox.Show($"Der Drucker '{printerName}' ist nicht verfügbar.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); - return; - } - - using var renderResult = _labelRenderer.Render(template.Layout, BuildPreviewPayload(template, dummyNumber: 123)); - var printResult = await _printerService.PrintAsync(renderResult.Image, printerName, template.Layout.WidthMm, template.Layout.HeightMm); - if (printResult.Success) - { - MessageBox.Show("Testdruck wurde an den Drucker gesendet.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); - return; - } - - MessageBox.Show($"Testdruck fehlgeschlagen: {printResult.ErrorMessage}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error); - } - - private static Dictionary BuildPreviewPayload(LabelTemplateConfig template, long dummyNumber) - { - var job = new PrintJob - { - Id = 0, - BarcodeTemplateId = template.BarcodeTemplateId, - PayloadJson = """ - { - "titel": "Beleg privat", - "beschreibung": "Tankbeleg", - "datum": "2026-05-07" - } - """, - ReservedNumber = dummyNumber - }; - return LabelPayloadBuilder.Build(job, template, dummyNumber); - } - private string? GetSelectedPrinterName() { return _printer.SelectedItem switch @@ -522,6 +213,11 @@ internal sealed class SettingsForm : Form }; } + private void AppendStatus(string message) + { + _status.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}"); + } + private bool IsAutostartEnabled() { using var key = Registry.CurrentUser.OpenSubKey(AutoStartRunKey, writable: false); @@ -545,28 +241,13 @@ internal sealed class SettingsForm : Form private static Label Label(string text, int width) => new() { Text = text, Width = width, TextAlign = ContentAlignment.MiddleLeft }; - private static Label LabelAt(string text, int left, int top, int width) - { - return new Label - { - Text = text, - Left = left, - Top = top, - Width = width, - Height = 24, - TextAlign = ContentAlignment.MiddleLeft - }; - } - - private static FlowLayoutPanel Row(int top, params Control[] controls) => RowAt(20, top, controls); - - private static FlowLayoutPanel RowAt(int left, int top, params Control[] controls) + private static FlowLayoutPanel Row(int top, params Control[] controls) { var row = new FlowLayoutPanel { - Left = left, + Left = 20, Top = top, - Width = 880, + Width = 760, Height = 32, FlowDirection = FlowDirection.LeftToRight, WrapContents = false @@ -581,6 +262,4 @@ internal sealed class SettingsForm : Form button.Click += click; return button; } - - private sealed record ErrorJobRow(long Id, int BarcodeTemplateId, string Fehler, int Versuche); } diff --git a/src/LabelPrintAgent/App/TrayApplicationContext.cs b/src/LabelPrintAgent/App/TrayApplicationContext.cs index b355625..ccca567 100644 --- a/src/LabelPrintAgent/App/TrayApplicationContext.cs +++ b/src/LabelPrintAgent/App/TrayApplicationContext.cs @@ -1,3 +1,4 @@ +using LabelPrintAgent.Backend; using LabelPrintAgent.Configuration; using LabelPrintAgent.Printing; using Serilog; @@ -9,14 +10,17 @@ internal sealed class TrayApplicationContext : ApplicationContext private readonly NotifyIcon _notifyIcon; private readonly SettingsStore _settingsStore; private readonly PrinterService _printerService; + private readonly BackendPollingWorker _backendWorker; private SettingsForm? _settingsForm; public TrayApplicationContext( SettingsStore settingsStore, - PrinterService printerService) + PrinterService printerService, + BackendPollingWorker backendWorker) { _settingsStore = settingsStore; _printerService = printerService; + _backendWorker = backendWorker; var menu = new ContextMenuStrip(); menu.Items.Add("Einstellungen", null, (_, _) => ShowSettings()); @@ -44,7 +48,7 @@ internal sealed class TrayApplicationContext : ApplicationContext { if (_settingsForm is null || _settingsForm.IsDisposed) { - _settingsForm = new SettingsForm(_settingsStore, _printerService); + _settingsForm = new SettingsForm(_settingsStore, _printerService, _backendWorker); } _settingsForm.Show(); diff --git a/src/LabelPrintAgent/Backend/BackendClient.cs b/src/LabelPrintAgent/Backend/BackendClient.cs new file mode 100644 index 0000000..2b49264 --- /dev/null +++ b/src/LabelPrintAgent/Backend/BackendClient.cs @@ -0,0 +1,120 @@ +using System.Drawing; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using LabelPrintAgent.Configuration; + +namespace LabelPrintAgent.Backend; + +public sealed class BackendClient +{ + private readonly SettingsStore _settingsStore; + private readonly HttpClient _httpClient; + + public BackendClient(SettingsStore settingsStore, HttpClient? httpClient = null) + { + _settingsStore = settingsStore; + _httpClient = httpClient ?? new HttpClient(); + } + + public async Task GetNextJobAsync(CancellationToken cancellationToken = default) + { + var settings = _settingsStore.Load(); + using var request = CreateRequest(HttpMethod.Get, BuildUrl(settings.Backend.BaseUrl, settings.Backend.NextJobPath, settings.Backend.AgentId), settings); + using var response = await _httpClient.SendAsync(request, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NoContent) + { + return null; + } + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + } + + public async Task GetLabelImageAsync(BackendLabelJob job, CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(job.LabelImageBase64)) + { + var bytes = Convert.FromBase64String(job.LabelImageBase64); + return CreateBitmap(bytes); + } + + if (string.IsNullOrWhiteSpace(job.LabelImageUrl)) + { + throw new InvalidOperationException("Backend-Job enthält weder labelImageBase64 noch labelImageUrl."); + } + + var settings = _settingsStore.Load(); + using var request = CreateRequest(HttpMethod.Get, MakeAbsoluteUrl(settings.Backend.BaseUrl, job.LabelImageUrl), settings); + var bytesFromUrl = await (await _httpClient.SendAsync(request, cancellationToken)).Content.ReadAsByteArrayAsync(cancellationToken); + return CreateBitmap(bytesFromUrl); + } + + public Task ReportPrintedAsync(string jobId, string printerName, CancellationToken cancellationToken = default) + { + var settings = _settingsStore.Load(); + var request = new BackendReportRequest + { + AgentId = settings.Backend.AgentId, + PrinterName = printerName + }; + return PostReportAsync(settings, settings.Backend.ReportSuccessPath, jobId, request, cancellationToken); + } + + public Task ReportErrorAsync(string jobId, string printerName, string errorMessage, CancellationToken cancellationToken = default) + { + var settings = _settingsStore.Load(); + var request = new BackendReportRequest + { + AgentId = settings.Backend.AgentId, + PrinterName = printerName, + ErrorMessage = errorMessage + }; + return PostReportAsync(settings, settings.Backend.ReportErrorPath, jobId, request, cancellationToken); + } + + private async Task PostReportAsync(AppSettings settings, string pathTemplate, string jobId, BackendReportRequest body, CancellationToken cancellationToken) + { + var path = pathTemplate.Replace("{jobId}", Uri.EscapeDataString(jobId), StringComparison.OrdinalIgnoreCase); + using var request = CreateRequest(HttpMethod.Post, MakeAbsoluteUrl(settings.Backend.BaseUrl, path), settings); + request.Content = JsonContent.Create(body); + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + private HttpRequestMessage CreateRequest(HttpMethod method, string url, AppSettings settings) + { + var request = new HttpRequestMessage(method, url); + var token = _settingsStore.DecryptBackendApiToken(settings); + if (!string.IsNullOrWhiteSpace(token)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + return request; + } + + private static string BuildUrl(string baseUrl, string path, string agentId) + { + var separator = path.Contains('?') ? '&' : '?'; + return $"{MakeAbsoluteUrl(baseUrl, path)}{separator}agentId={Uri.EscapeDataString(agentId)}"; + } + + private static string MakeAbsoluteUrl(string baseUrl, string pathOrUrl) + { + if (Uri.TryCreate(pathOrUrl, UriKind.Absolute, out var absolute)) + { + return absolute.ToString(); + } + + return new Uri(new Uri(EnsureTrailingSlash(baseUrl)), pathOrUrl.TrimStart('/')).ToString(); + } + + private static string EnsureTrailingSlash(string url) => url.EndsWith('/') ? url : url + "/"; + + private static Bitmap CreateBitmap(byte[] bytes) + { + using var stream = new MemoryStream(bytes); + return new Bitmap(stream); + } +} diff --git a/src/LabelPrintAgent/Backend/BackendLabelJob.cs b/src/LabelPrintAgent/Backend/BackendLabelJob.cs new file mode 100644 index 0000000..5e9f330 --- /dev/null +++ b/src/LabelPrintAgent/Backend/BackendLabelJob.cs @@ -0,0 +1,11 @@ +namespace LabelPrintAgent.Backend; + +public sealed class BackendLabelJob +{ + public string JobId { get; set; } = string.Empty; + public string? LabelImageBase64 { get; set; } + public string? LabelImageUrl { get; set; } + public string LabelImageContentType { get; set; } = "image/png"; + public double LabelWidthMm { get; set; } = 57; + public double LabelHeightMm { get; set; } = 32; +} diff --git a/src/LabelPrintAgent/Backend/BackendPollingWorker.cs b/src/LabelPrintAgent/Backend/BackendPollingWorker.cs new file mode 100644 index 0000000..7128169 --- /dev/null +++ b/src/LabelPrintAgent/Backend/BackendPollingWorker.cs @@ -0,0 +1,122 @@ +using LabelPrintAgent.Configuration; +using LabelPrintAgent.Printing; +using Serilog; + +namespace LabelPrintAgent.Backend; + +public sealed class BackendPollingWorker : IDisposable +{ + private readonly SettingsStore _settingsStore; + private readonly BackendClient _backendClient; + private readonly PrinterService _printerService; + private readonly SemaphoreSlim _semaphore = new(1, 1); + private CancellationTokenSource? _cancellationTokenSource; + private Task? _task; + + public BackendPollingWorker(SettingsStore settingsStore, BackendClient backendClient, PrinterService printerService) + { + _settingsStore = settingsStore; + _backendClient = backendClient; + _printerService = printerService; + } + + public void Start() + { + if (_task is not null) + { + return; + } + + _cancellationTokenSource = new CancellationTokenSource(); + _task = Task.Run(() => RunAsync(_cancellationTokenSource.Token)); + } + + public async Task PollOnceAsync(CancellationToken cancellationToken = default) + { + if (!await _semaphore.WaitAsync(0, cancellationToken)) + { + return; + } + + try + { + var settings = _settingsStore.Load(); + if (!settings.Worker.Enabled || string.IsNullOrWhiteSpace(settings.Backend.BaseUrl)) + { + return; + } + + var printerName = settings.Printer.PrinterName; + if (string.IsNullOrWhiteSpace(printerName) || !_printerService.IsPrinterAvailable(printerName)) + { + Log.Warning("No available printer configured for backend polling"); + return; + } + + var job = await _backendClient.GetNextJobAsync(cancellationToken); + if (job is null) + { + return; + } + + try + { + using var bitmap = await _backendClient.GetLabelImageAsync(job, cancellationToken); + var result = await _printerService.PrintAsync(bitmap, printerName, job.LabelWidthMm, job.LabelHeightMm, cancellationToken); + if (result.Success) + { + await _backendClient.ReportPrintedAsync(job.JobId, printerName, cancellationToken); + return; + } + + await _backendClient.ReportErrorAsync(job.JobId, printerName, result.ErrorMessage ?? "Druck fehlgeschlagen.", cancellationToken); + } + catch (Exception ex) + { + Log.Error(ex, "Could not process backend label job {JobId}", job.JobId); + await _backendClient.ReportErrorAsync(job.JobId, printerName, ex.Message, cancellationToken); + } + } + finally + { + _semaphore.Release(); + } + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await PollOnceAsync(cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Log.Error(ex, "Backend polling failed"); + } + + var interval = Math.Max(1, _settingsStore.Load().Worker.PollIntervalSeconds); + await Task.Delay(TimeSpan.FromSeconds(interval), cancellationToken); + } + } + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + try + { + _task?.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + } + + _cancellationTokenSource?.Dispose(); + _semaphore.Dispose(); + } +} diff --git a/src/LabelPrintAgent/Backend/BackendReportRequest.cs b/src/LabelPrintAgent/Backend/BackendReportRequest.cs new file mode 100644 index 0000000..9e49111 --- /dev/null +++ b/src/LabelPrintAgent/Backend/BackendReportRequest.cs @@ -0,0 +1,8 @@ +namespace LabelPrintAgent.Backend; + +public sealed class BackendReportRequest +{ + public string AgentId { get; set; } = string.Empty; + public string? PrinterName { get; set; } + public string? ErrorMessage { get; set; } +} diff --git a/src/LabelPrintAgent/Configuration/AppSettings.cs b/src/LabelPrintAgent/Configuration/AppSettings.cs index fabf805..53cc089 100644 --- a/src/LabelPrintAgent/Configuration/AppSettings.cs +++ b/src/LabelPrintAgent/Configuration/AppSettings.cs @@ -1,141 +1,27 @@ -using LabelPrintAgent.Layout; - namespace LabelPrintAgent.Configuration; public sealed class AppSettings { - public DatabaseSettings Database { get; set; } = new(); + public BackendSettings Backend { get; set; } = new(); public PrinterSettings Printer { get; set; } = new(); public WorkerSettings Worker { get; set; } = new(); - public List LabelTemplates { get; set; } = [LabelTemplateConfig.CreateDefault()]; public PathSettings Paths { get; set; } = PathSettings.CreateDefault(); public void EnsureDirectories() { Directory.CreateDirectory(Paths.BaseFolder); - Directory.CreateDirectory(Paths.LayoutFolder); Directory.CreateDirectory(Paths.LogFolder); } } -public sealed class LabelTemplateConfig +public sealed class BackendSettings { - public int BarcodeTemplateId { get; set; } - public string Name { get; set; } = string.Empty; - public string GetNumberUrl { get; set; } = string.Empty; - public string NumberPrintedUrl { get; set; } = string.Empty; - public string ReservedNumberPayloadKey { get; set; } = "nummer"; - public string QrTemplate { get; set; } = string.Empty; - public LabelLayout Layout { get; set; } = new(); - - public override string ToString() - { - return $"{BarcodeTemplateId}: {Name}"; - } - - public static LabelTemplateConfig CreateDefault() - { - return new LabelTemplateConfig - { - BarcodeTemplateId = 1, - Name = "Beleg privat", - GetNumberUrl = "https://nummernserver.local/getNumber?type=privat", - NumberPrintedUrl = "https://nummernserver.local/numberPrinted?type=privat&number={reservedNumber}", - ReservedNumberPayloadKey = "nummer", - QrTemplate = "bjoernprivat {reservedNumber:0000000}", - Layout = new LabelLayout - { - Name = "Beleg privat", - WidthMm = 57, - HeightMm = 32, - Dpi = 300, - MarginMm = 3, - Orientation = "landscape", - Elements = - [ - new RectangleElement - { - XMm = 1.5, - YMm = 1.5, - WidthMm = 54, - HeightMm = 29, - StrokeWidthMm = 0.25, - Filled = false - }, - new TextElement - { - XMm = 3, - YMm = 3, - WidthMm = 36, - HeightMm = 8, - Value = "{titel}", - FontFamily = "Arial", - FontSizePt = 10, - MinFontSizePt = 6, - AutoShrink = true, - Bold = true, - HorizontalAlign = "left", - VerticalAlign = "top", - Multiline = true - }, - new TextElement - { - XMm = 3, - YMm = 12, - WidthMm = 36, - HeightMm = 8, - Value = "{beschreibung}", - FontFamily = "Arial", - FontSizePt = 8, - MinFontSizePt = 6, - AutoShrink = true, - HorizontalAlign = "left", - VerticalAlign = "top", - Multiline = true - }, - new QrCodeElement - { - XMm = 41, - YMm = 4, - SizeMm = 16, - Value = "{qr}" - }, - new LineElement - { - X1Mm = 3, - Y1Mm = 23, - X2Mm = 54, - Y2Mm = 23, - StrokeWidthMm = 0.3 - }, - new TextElement - { - XMm = 3, - YMm = 24, - WidthMm = 51, - HeightMm = 5, - Value = "Nr: {nummer:0000000} | {datum:dd.MM.yyyy}", - FontFamily = "Arial", - FontSizePt = 7, - MinFontSizePt = 5, - AutoShrink = true, - HorizontalAlign = "center", - VerticalAlign = "middle", - Multiline = false - } - ] - } - }; - } -} - -public sealed class DatabaseSettings -{ - public string Host { get; set; } = "10.1.10.xxx"; - public uint Port { get; set; } = 3306; - public string Database { get; set; } = "labeldb"; - public string Username { get; set; } = "label_user"; - public string EncryptedPassword { get; set; } = string.Empty; + public string BaseUrl { get; set; } = "https://paperlessmanager.local"; + public string AgentId { get; set; } = Environment.MachineName; + public string EncryptedApiToken { get; set; } = string.Empty; + public string NextJobPath { get; set; } = "/api/label-print-agent/jobs/next"; + public string ReportSuccessPath { get; set; } = "/api/label-print-agent/jobs/{jobId}/printed"; + public string ReportErrorPath { get; set; } = "/api/label-print-agent/jobs/{jobId}/error"; } public sealed class PrinterSettings @@ -148,13 +34,13 @@ public sealed class PrinterSettings public sealed class WorkerSettings { + public bool Enabled { get; set; } = true; public int PollIntervalSeconds { get; set; } = 5; } public sealed class PathSettings { public string BaseFolder { get; set; } = DefaultBaseFolder; - public string LayoutFolder { get; set; } = Path.Combine(DefaultBaseFolder, "layouts"); public string LogFolder { get; set; } = Path.Combine(DefaultBaseFolder, "logs"); public static string DefaultBaseFolder => diff --git a/src/LabelPrintAgent/Configuration/SettingsStore.cs b/src/LabelPrintAgent/Configuration/SettingsStore.cs index f5dd7f5..1725120 100644 --- a/src/LabelPrintAgent/Configuration/SettingsStore.cs +++ b/src/LabelPrintAgent/Configuration/SettingsStore.cs @@ -1,6 +1,5 @@ using System.Text.Json; using System.Text.Json.Serialization; -using LabelPrintAgent.Layout; using Serilog; namespace LabelPrintAgent.Configuration; @@ -11,8 +10,7 @@ public sealed class SettingsStore { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = { new LayoutElementJsonConverter() } + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly ProtectedStringService _protectedStringService; @@ -40,12 +38,6 @@ public sealed class SettingsStore var json = File.ReadAllText(SettingsFilePath); var settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new AppSettings(); settings.Paths ??= PathSettings.CreateDefault(); - if (settings.LabelTemplates is null || settings.LabelTemplates.Count == 0) - { - settings.LabelTemplates = [LabelTemplateConfig.CreateDefault()]; - Save(settings); - } - return settings; } catch (Exception ex) @@ -64,5 +56,5 @@ public sealed class SettingsStore public string EncryptPassword(string password) => _protectedStringService.Protect(password); - public string DecryptPassword(AppSettings settings) => _protectedStringService.Unprotect(settings.Database.EncryptedPassword); + public string DecryptBackendApiToken(AppSettings settings) => _protectedStringService.Unprotect(settings.Backend.EncryptedApiToken); } diff --git a/src/LabelPrintAgent/Database/DbConnectionSettings.cs b/src/LabelPrintAgent/Database/DbConnectionSettings.cs deleted file mode 100644 index 3fe0f59..0000000 --- a/src/LabelPrintAgent/Database/DbConnectionSettings.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace LabelPrintAgent.Database; - -public sealed class DbConnectionSettings -{ - public string Host { get; set; } = string.Empty; - public uint Port { get; set; } = 3306; - public string Database { get; set; } = string.Empty; - public string Username { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; -} diff --git a/src/LabelPrintAgent/Database/MySqlLabelRepository.cs b/src/LabelPrintAgent/Database/MySqlLabelRepository.cs deleted file mode 100644 index d579ab8..0000000 --- a/src/LabelPrintAgent/Database/MySqlLabelRepository.cs +++ /dev/null @@ -1,217 +0,0 @@ -using LabelPrintAgent.Configuration; -using MySqlConnector; - -namespace LabelPrintAgent.Database; - -public sealed class MySqlLabelRepository -{ - private readonly SettingsStore _settingsStore; - private readonly ProtectedStringService _protectedStringService; - - public MySqlLabelRepository(SettingsStore settingsStore, ProtectedStringService protectedStringService) - { - _settingsStore = settingsStore; - _protectedStringService = protectedStringService; - } - - public async Task TestConnectionAsync(CancellationToken cancellationToken = default) - { - await using var connection = new MySqlConnection(BuildConnectionString()); - await connection.OpenAsync(cancellationToken); - } - - public async Task> LoadPendingJobsAsync(int limit, CancellationToken cancellationToken = default) - { - return await LoadJobsAsync(""" - SELECT * - FROM label_print_queue - WHERE status = 'pending' - ORDER BY created_at - LIMIT @limit; - """, limit, cancellationToken); - } - - public async Task> LoadErrorJobsAsync(int limit = 100, CancellationToken cancellationToken = default) - { - return await LoadJobsAsync(""" - SELECT * - FROM label_print_queue - WHERE status = 'error' - ORDER BY created_at DESC - LIMIT @limit; - """, limit, cancellationToken); - } - - public async Task LoadByIdAsync(long id, CancellationToken cancellationToken = default) - { - await using var connection = new MySqlConnection(BuildConnectionString()); - await connection.OpenAsync(cancellationToken); - await using var command = connection.CreateCommand(); - command.CommandText = """ - SELECT * - FROM label_print_queue - WHERE id = @id; - """; - command.Parameters.AddWithValue("@id", id); - - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - return await reader.ReadAsync(cancellationToken) ? ReadJob(reader) : null; - } - - public async Task MarkAsPrintingAsync(long id, CancellationToken cancellationToken = default) - { - var affectedRows = await ExecuteAsync(""" - UPDATE label_print_queue - SET status = 'printing', - locked_at = NOW() - WHERE id = @id - AND status IN ('pending','number_reserved'); - """, id, cancellationToken); - return affectedRows == 1; - } - - public async Task MarkNumberReservedAsync(long id, long reservedNumber, CancellationToken cancellationToken = default) - { - await ExecuteAsync(""" - UPDATE label_print_queue - SET status = 'number_reserved', - reserved_number = @reservedNumber, - number_reserved_at = NOW(), - error_message = NULL - WHERE id = @id; - """, id, cancellationToken, ("@reservedNumber", reservedNumber)); - } - - public async Task MarkAsPrintedAsync(long id, string printerName, CancellationToken cancellationToken = default) - { - await ExecuteAsync(""" - UPDATE label_print_queue - SET status = 'printed', - printed_at = NOW(), - number_printed_at = NOW(), - printer_name = @printerName, - error_message = NULL - WHERE id = @id; - """, id, cancellationToken, ("@printerName", printerName)); - } - - public async Task MarkAsErrorAsync(long id, string errorMessage, CancellationToken cancellationToken = default) - { - await ExecuteAsync(""" - UPDATE label_print_queue - SET status = 'error', - error_message = @errorMessage, - attempts = attempts + 1 - WHERE id = @id; - """, id, cancellationToken, ("@errorMessage", errorMessage)); - } - - public async Task ResetToPendingAsync(long id, CancellationToken cancellationToken = default) - { - await ExecuteAsync(""" - UPDATE label_print_queue - SET status = 'pending', - error_message = NULL, - locked_at = NULL - WHERE id = @id - AND status = 'error'; - """, id, cancellationToken); - } - - public async Task MarkAsDeletedAsync(long id, CancellationToken cancellationToken = default) - { - await ExecuteAsync(""" - UPDATE label_print_queue - SET status = 'deleted', - error_message = 'Vom Benutzer verworfen' - WHERE id = @id - AND status = 'error'; - """, id, cancellationToken); - } - - private async Task> LoadJobsAsync(string sql, int limit, CancellationToken cancellationToken) - { - await using var connection = new MySqlConnection(BuildConnectionString()); - await connection.OpenAsync(cancellationToken); - await using var command = connection.CreateCommand(); - command.CommandText = sql; - command.Parameters.AddWithValue("@limit", limit); - - var jobs = new List(); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - jobs.Add(ReadJob(reader)); - } - - return jobs; - } - - private async Task ExecuteAsync(string sql, long id, CancellationToken cancellationToken, params (string Name, object Value)[] parameters) - { - await using var connection = new MySqlConnection(BuildConnectionString()); - await connection.OpenAsync(cancellationToken); - await using var command = connection.CreateCommand(); - command.CommandText = sql; - command.Parameters.AddWithValue("@id", id); - foreach (var parameter in parameters) - { - command.Parameters.AddWithValue(parameter.Name, parameter.Value); - } - - return await command.ExecuteNonQueryAsync(cancellationToken); - } - - private string BuildConnectionString() - { - var settings = _settingsStore.Load(); - var builder = new MySqlConnectionStringBuilder - { - Server = settings.Database.Host, - Port = settings.Database.Port, - Database = settings.Database.Database, - UserID = settings.Database.Username, - Password = _protectedStringService.Unprotect(settings.Database.EncryptedPassword), - SslMode = MySqlSslMode.Preferred - }; - return builder.ConnectionString; - } - - private static PrintJob ReadJob(MySqlDataReader reader) - { - return new PrintJob - { - Id = reader.GetInt64("id"), - BarcodeTemplateId = reader.GetInt32("barcode_template_id"), - PayloadJson = reader.GetString("payload_json"), - ReservedNumber = ReadNullableInt64(reader, "reserved_number"), - NumberReservedAt = ReadNullableDateTime(reader, "number_reserved_at"), - NumberPrintedAt = ReadNullableDateTime(reader, "number_printed_at"), - Status = reader.GetString("status"), - PrinterName = ReadNullableString(reader, "printer_name"), - Attempts = reader.GetInt32("attempts"), - ErrorMessage = ReadNullableString(reader, "error_message"), - CreatedAt = reader.GetDateTime("created_at"), - LockedAt = ReadNullableDateTime(reader, "locked_at"), - PrintedAt = ReadNullableDateTime(reader, "printed_at") - }; - } - - private static long? ReadNullableInt64(MySqlDataReader reader, string column) - { - var ordinal = reader.GetOrdinal(column); - return reader.IsDBNull(ordinal) ? null : reader.GetInt64(ordinal); - } - - private static DateTime? ReadNullableDateTime(MySqlDataReader reader, string column) - { - var ordinal = reader.GetOrdinal(column); - return reader.IsDBNull(ordinal) ? null : reader.GetDateTime(ordinal); - } - - private static string? ReadNullableString(MySqlDataReader reader, string column) - { - var ordinal = reader.GetOrdinal(column); - return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); - } -} diff --git a/src/LabelPrintAgent/Database/PrintJob.cs b/src/LabelPrintAgent/Database/PrintJob.cs deleted file mode 100644 index 1abfaef..0000000 --- a/src/LabelPrintAgent/Database/PrintJob.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace LabelPrintAgent.Database; - -public sealed class PrintJob -{ - public long Id { get; set; } - public int BarcodeTemplateId { get; set; } - public string PayloadJson { get; set; } = "{}"; - public long? ReservedNumber { get; set; } - public DateTime? NumberReservedAt { get; set; } - public DateTime? NumberPrintedAt { get; set; } - public string Status { get; set; } = "pending"; - public string? PrinterName { get; set; } - public int Attempts { get; set; } - public string? ErrorMessage { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime? LockedAt { get; set; } - public DateTime? PrintedAt { get; set; } -} diff --git a/src/LabelPrintAgent/LabelPrintAgent.csproj b/src/LabelPrintAgent/LabelPrintAgent.csproj index ee4dea9..ee34113 100644 --- a/src/LabelPrintAgent/LabelPrintAgent.csproj +++ b/src/LabelPrintAgent/LabelPrintAgent.csproj @@ -11,8 +11,6 @@ - - diff --git a/src/LabelPrintAgent/Layout/LabelLayout.cs b/src/LabelPrintAgent/Layout/LabelLayout.cs deleted file mode 100644 index 60ad177..0000000 --- a/src/LabelPrintAgent/Layout/LabelLayout.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace LabelPrintAgent.Layout; - -public sealed class LabelLayout -{ - public string Name { get; set; } = string.Empty; - public double WidthMm { get; set; } = 57; - public double HeightMm { get; set; } = 32; - public int Dpi { get; set; } = 300; - public double MarginMm { get; set; } = 3; - public string Orientation { get; set; } = "landscape"; - public List Elements { get; set; } = []; -} diff --git a/src/LabelPrintAgent/Layout/LayoutElement.cs b/src/LabelPrintAgent/Layout/LayoutElement.cs deleted file mode 100644 index 113810e..0000000 --- a/src/LabelPrintAgent/Layout/LayoutElement.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace LabelPrintAgent.Layout; - -public abstract class LayoutElement -{ - public string Type { get; set; } = string.Empty; - public double XMm { get; set; } - public double YMm { get; set; } -} - -internal sealed class UnknownLayoutElement : LayoutElement -{ -} diff --git a/src/LabelPrintAgent/Layout/LayoutElementJsonConverter.cs b/src/LabelPrintAgent/Layout/LayoutElementJsonConverter.cs deleted file mode 100644 index c559cad..0000000 --- a/src/LabelPrintAgent/Layout/LayoutElementJsonConverter.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace LabelPrintAgent.Layout; - -public sealed class LayoutElementJsonConverter : JsonConverter -{ - public override LayoutElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - using var document = JsonDocument.ParseValue(ref reader); - if (!document.RootElement.TryGetProperty("type", out var typeProperty)) - { - return new UnknownLayoutElement(); - } - - var raw = document.RootElement.GetRawText(); - var type = typeProperty.GetString()?.ToLowerInvariant() ?? string.Empty; - return type switch - { - "text" => WithType(JsonSerializer.Deserialize(raw, options), type), - "line" => WithType(JsonSerializer.Deserialize(raw, options), type), - "rectangle" => WithType(JsonSerializer.Deserialize(raw, options), type), - "qr" => WithType(JsonSerializer.Deserialize(raw, options), type), - _ => new UnknownLayoutElement { Type = type } - }; - } - - public override void Write(Utf8JsonWriter writer, LayoutElement value, JsonSerializerOptions options) - { - JsonSerializer.Serialize(writer, (object)value, value.GetType(), options); - } - - private static T WithType(T? element, string type) where T : LayoutElement - { - if (element is null) - { - throw new JsonException(); - } - - element.Type = type; - return element; - } -} diff --git a/src/LabelPrintAgent/Layout/LayoutJsonSerializer.cs b/src/LabelPrintAgent/Layout/LayoutJsonSerializer.cs deleted file mode 100644 index fdbd863..0000000 --- a/src/LabelPrintAgent/Layout/LayoutJsonSerializer.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace LabelPrintAgent.Layout; - -public static class LayoutJsonSerializer -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = { new LayoutElementJsonConverter() } - }; - - public static LabelLayout? Deserialize(string json) - { - return JsonSerializer.Deserialize(json, JsonOptions); - } - - public static string Serialize(LabelLayout layout) - { - return JsonSerializer.Serialize(layout, JsonOptions); - } -} diff --git a/src/LabelPrintAgent/Layout/LayoutValidationResult.cs b/src/LabelPrintAgent/Layout/LayoutValidationResult.cs deleted file mode 100644 index 3581ec5..0000000 --- a/src/LabelPrintAgent/Layout/LayoutValidationResult.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace LabelPrintAgent.Layout; - -public sealed class LayoutValidationResult -{ - public List Errors { get; } = []; - - public bool IsValid => Errors.Count == 0; - - public void AddError(string error) - { - Errors.Add(error); - } -} diff --git a/src/LabelPrintAgent/Layout/LayoutValidator.cs b/src/LabelPrintAgent/Layout/LayoutValidator.cs deleted file mode 100644 index 917709e..0000000 --- a/src/LabelPrintAgent/Layout/LayoutValidator.cs +++ /dev/null @@ -1,226 +0,0 @@ -namespace LabelPrintAgent.Layout; - -public sealed class LayoutValidator -{ - private static readonly HashSet Orientations = new(StringComparer.OrdinalIgnoreCase) - { - "landscape", - "portrait" - }; - - private static readonly HashSet HorizontalAlignments = new(StringComparer.OrdinalIgnoreCase) - { - "left", - "center", - "right" - }; - - private static readonly HashSet VerticalAlignments = new(StringComparer.OrdinalIgnoreCase) - { - "top", - "middle", - "bottom" - }; - - public LayoutValidationResult Validate(LabelLayout? layout) - { - var result = new LayoutValidationResult(); - if (layout is null) - { - result.AddError("Layout: JSON enthält kein Layout."); - return result; - } - - ValidateLayout(layout, result); - ValidateElements(layout, result); - return result; - } - - private static void ValidateLayout(LabelLayout layout, LayoutValidationResult result) - { - if (string.IsNullOrWhiteSpace(layout.Name)) - { - result.AddError("Layout: Name darf nicht leer sein."); - } - - if (layout.WidthMm <= 0) - { - result.AddError("Layout: WidthMm muss größer als 0 sein."); - } - - if (layout.HeightMm <= 0) - { - result.AddError("Layout: HeightMm muss größer als 0 sein."); - } - - if (layout.Dpi <= 0) - { - result.AddError("Layout: Dpi muss größer als 0 sein."); - } - - if (layout.MarginMm < 0) - { - result.AddError("Layout: MarginMm darf nicht negativ sein."); - } - - if (!Orientations.Contains(layout.Orientation)) - { - result.AddError("Layout: Orientation muss 'landscape' oder 'portrait' sein."); - } - - if (layout.Elements is null) - { - result.AddError("Layout: Elements darf nicht null sein."); - } - } - - private static void ValidateElements(LabelLayout layout, LayoutValidationResult result) - { - if (layout.Elements is null) - { - return; - } - - for (var i = 0; i < layout.Elements.Count; i++) - { - var index = i + 1; - var element = layout.Elements[i]; - switch (element) - { - case null: - result.AddError($"Element {index}: Element darf nicht null sein."); - break; - case TextElement text: - ValidateTextElement(layout, text, index, result); - break; - case LineElement line: - ValidateLineElement(layout, line, index, result); - break; - case RectangleElement rectangle: - ValidateRectangleElement(layout, rectangle, index, result); - break; - case QrCodeElement qrCode: - ValidateQrCodeElement(layout, qrCode, index, result); - break; - default: - result.AddError($"Element {index}: Unbekannter Elementtyp '{element.Type}'."); - break; - } - } - } - - private static void ValidateTextElement(LabelLayout layout, TextElement element, int index, LayoutValidationResult result) - { - if (!IsBoxInside(layout, element.XMm, element.YMm, element.WidthMm, element.HeightMm)) - { - result.AddError($"Element {index}: Textfeld liegt außerhalb des Etiketts."); - } - - if (element.WidthMm <= 0) - { - result.AddError($"Element {index}: Textfeld hat ungültige Breite."); - } - - if (element.HeightMm <= 0) - { - result.AddError($"Element {index}: Textfeld hat ungültige Höhe."); - } - - if (element.FontSizePt <= 0) - { - result.AddError($"Element {index}: Textfeld hat ungültige Schriftgröße."); - } - - if (element.MinFontSizePt <= 0) - { - result.AddError($"Element {index}: Textfeld hat ungültige Mindestschriftgröße."); - } - - if (element.MinFontSizePt > element.FontSizePt) - { - result.AddError($"Element {index}: MinFontSizePt darf nicht größer als FontSizePt sein."); - } - - if (!HorizontalAlignments.Contains(element.HorizontalAlign)) - { - result.AddError($"Element {index}: HorizontalAlign muss left, center oder right sein."); - } - - if (!VerticalAlignments.Contains(element.VerticalAlign)) - { - result.AddError($"Element {index}: VerticalAlign muss top, middle oder bottom sein."); - } - - if (string.IsNullOrWhiteSpace(element.Value)) - { - result.AddError($"Element {index}: Textfeld-Value darf nicht leer sein."); - } - } - - private static void ValidateLineElement(LabelLayout layout, LineElement element, int index, LayoutValidationResult result) - { - if (!IsPointInside(layout, element.X1Mm, element.Y1Mm) || !IsPointInside(layout, element.X2Mm, element.Y2Mm)) - { - result.AddError($"Element {index}: Linie liegt außerhalb des Etiketts."); - } - - if (element.StrokeWidthMm <= 0) - { - result.AddError($"Element {index}: Linie hat ungültige Strichstärke."); - } - } - - private static void ValidateRectangleElement(LabelLayout layout, RectangleElement element, int index, LayoutValidationResult result) - { - if (!IsBoxInside(layout, element.XMm, element.YMm, element.WidthMm, element.HeightMm)) - { - result.AddError($"Element {index}: Rechteck liegt außerhalb des Etiketts."); - } - - if (element.WidthMm <= 0) - { - result.AddError($"Element {index}: Rechteck hat ungültige Breite."); - } - - if (element.HeightMm <= 0) - { - result.AddError($"Element {index}: Rechteck hat ungültige Höhe."); - } - - if (element.StrokeWidthMm <= 0) - { - result.AddError($"Element {index}: Rechteck hat ungültige Strichstärke."); - } - } - - private static void ValidateQrCodeElement(LabelLayout layout, QrCodeElement element, int index, LayoutValidationResult result) - { - if (!IsBoxInside(layout, element.XMm, element.YMm, element.SizeMm, element.SizeMm)) - { - result.AddError($"Element {index}: QR-Code liegt außerhalb des Etiketts."); - } - - if (element.SizeMm <= 0) - { - result.AddError($"Element {index}: QR-Code hat ungültige Größe."); - } - - if (string.IsNullOrWhiteSpace(element.Value)) - { - result.AddError($"Element {index}: QR-Code-Value darf nicht leer sein."); - } - } - - private static bool IsPointInside(LabelLayout layout, double xMm, double yMm) - { - return xMm >= 0 && yMm >= 0 && xMm <= layout.WidthMm && yMm <= layout.HeightMm; - } - - private static bool IsBoxInside(LabelLayout layout, double xMm, double yMm, double widthMm, double heightMm) - { - return xMm >= 0 - && yMm >= 0 - && xMm + Math.Max(0, widthMm) <= layout.WidthMm - && yMm + Math.Max(0, heightMm) <= layout.HeightMm; - } -} diff --git a/src/LabelPrintAgent/Layout/LineElement.cs b/src/LabelPrintAgent/Layout/LineElement.cs deleted file mode 100644 index 4f6a063..0000000 --- a/src/LabelPrintAgent/Layout/LineElement.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace LabelPrintAgent.Layout; - -public sealed class LineElement : LayoutElement -{ - public LineElement() - { - Type = "line"; - } - - public double X1Mm { get; set; } - public double Y1Mm { get; set; } - public double X2Mm { get; set; } - public double Y2Mm { get; set; } - public double StrokeWidthMm { get; set; } = 0.3; -} diff --git a/src/LabelPrintAgent/Layout/QrCodeElement.cs b/src/LabelPrintAgent/Layout/QrCodeElement.cs deleted file mode 100644 index 8b59c48..0000000 --- a/src/LabelPrintAgent/Layout/QrCodeElement.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace LabelPrintAgent.Layout; - -public sealed class QrCodeElement : LayoutElement -{ - public QrCodeElement() - { - Type = "qr"; - } - - public double SizeMm { get; set; } - public string Value { get; set; } = string.Empty; -} diff --git a/src/LabelPrintAgent/Layout/RectangleElement.cs b/src/LabelPrintAgent/Layout/RectangleElement.cs deleted file mode 100644 index b8f3274..0000000 --- a/src/LabelPrintAgent/Layout/RectangleElement.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace LabelPrintAgent.Layout; - -public sealed class RectangleElement : LayoutElement -{ - public RectangleElement() - { - Type = "rectangle"; - } - - public double WidthMm { get; set; } - public double HeightMm { get; set; } - public double StrokeWidthMm { get; set; } = 0.3; - public bool Filled { get; set; } -} diff --git a/src/LabelPrintAgent/Layout/TextElement.cs b/src/LabelPrintAgent/Layout/TextElement.cs deleted file mode 100644 index da01353..0000000 --- a/src/LabelPrintAgent/Layout/TextElement.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace LabelPrintAgent.Layout; - -public sealed class TextElement : LayoutElement -{ - public TextElement() - { - Type = "text"; - } - - public double WidthMm { get; set; } - public double HeightMm { get; set; } - public string Value { get; set; } = string.Empty; - public string FontFamily { get; set; } = "Arial"; - public double FontSizePt { get; set; } = 9; - public double MinFontSizePt { get; set; } = 6; - public bool AutoShrink { get; set; } = true; - public bool Bold { get; set; } - public bool Italic { get; set; } - public bool Underline { get; set; } - public string HorizontalAlign { get; set; } = "left"; - public string VerticalAlign { get; set; } = "top"; - public bool Multiline { get; set; } = true; -} diff --git a/src/LabelPrintAgent/Numbering/NumberReservationResult.cs b/src/LabelPrintAgent/Numbering/NumberReservationResult.cs deleted file mode 100644 index 19c8be9..0000000 --- a/src/LabelPrintAgent/Numbering/NumberReservationResult.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace LabelPrintAgent.Numbering; - -public sealed class NumberReservationResult -{ - public bool Success { get; init; } - public long? ReservedNumber { get; init; } - public string? ErrorMessage { get; init; } - - public static NumberReservationResult Ok(long reservedNumber) - { - return new NumberReservationResult - { - Success = true, - ReservedNumber = reservedNumber - }; - } - - public static NumberReservationResult Fail(string errorMessage) - { - return new NumberReservationResult - { - Success = false, - ErrorMessage = errorMessage - }; - } -} diff --git a/src/LabelPrintAgent/Numbering/NumberReservationService.cs b/src/LabelPrintAgent/Numbering/NumberReservationService.cs deleted file mode 100644 index a86e757..0000000 --- a/src/LabelPrintAgent/Numbering/NumberReservationService.cs +++ /dev/null @@ -1,92 +0,0 @@ -using LabelPrintAgent.Configuration; -using LabelPrintAgent.Database; -using LabelPrintAgent.Rendering; -using Serilog; - -namespace LabelPrintAgent.Numbering; - -public sealed class NumberReservationService -{ - private readonly HttpClient _httpClient; - - public NumberReservationService(HttpClient? httpClient = null) - { - _httpClient = httpClient ?? new HttpClient(); - } - - public async Task ReserveNumberAsync( - PrintJob job, - LabelTemplateConfig template, - CancellationToken cancellationToken = default) - { - if (job.ReservedNumber.HasValue) - { - return NumberReservationResult.Ok(job.ReservedNumber.Value); - } - - if (string.IsNullOrWhiteSpace(template.GetNumberUrl)) - { - return NumberReservationResult.Fail("GetNumberUrl ist nicht konfiguriert."); - } - - try - { - var payload = LabelPayloadBuilder.Build(job, template); - var url = UrlTemplateFormatter.Format(template.GetNumberUrl, payload); - using var response = await _httpClient.GetAsync(url, cancellationToken); - var body = (await response.Content.ReadAsStringAsync(cancellationToken)).Trim(); - - if (!response.IsSuccessStatusCode) - { - return NumberReservationResult.Fail($"Nummernserver antwortete mit HTTP {(int)response.StatusCode}: {body}"); - } - - if (string.IsNullOrWhiteSpace(body)) - { - return NumberReservationResult.Fail("Nummernserver hat keine Nummer zurückgegeben."); - } - - if (!long.TryParse(body, out var reservedNumber)) - { - return NumberReservationResult.Fail($"Nummernserver-Antwort ist keine gültige Nummer: '{body}'."); - } - - return NumberReservationResult.Ok(reservedNumber); - } - catch (Exception ex) - { - Log.Error(ex, "Could not reserve number for job {JobId}", job.Id); - return NumberReservationResult.Fail(ex.Message); - } - } - - public async Task ConfirmPrintedAsync( - LabelTemplateConfig template, - Dictionary payload, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(template.NumberPrintedUrl)) - { - return NumberReservationResult.Ok(Convert.ToInt64(payload["reservedNumber"])); - } - - try - { - var url = UrlTemplateFormatter.Format(template.NumberPrintedUrl, payload); - using var response = await _httpClient.GetAsync(url, cancellationToken); - var body = (await response.Content.ReadAsStringAsync(cancellationToken)).Trim(); - - if (!response.IsSuccessStatusCode) - { - return NumberReservationResult.Fail($"Nummernserver-Bestätigung fehlgeschlagen mit HTTP {(int)response.StatusCode}: {body}"); - } - - return NumberReservationResult.Ok(Convert.ToInt64(payload["reservedNumber"])); - } - catch (Exception ex) - { - Log.Error(ex, "Could not confirm printed number"); - return NumberReservationResult.Fail(ex.Message); - } - } -} diff --git a/src/LabelPrintAgent/Numbering/UrlTemplateFormatter.cs b/src/LabelPrintAgent/Numbering/UrlTemplateFormatter.cs deleted file mode 100644 index eb681ac..0000000 --- a/src/LabelPrintAgent/Numbering/UrlTemplateFormatter.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Globalization; -using System.Text.RegularExpressions; - -namespace LabelPrintAgent.Numbering; - -public static partial class UrlTemplateFormatter -{ - public static string Format(string template, Dictionary payload) - { - return PlaceholderRegex().Replace(template, match => - { - var field = match.Groups["field"].Value; - var format = match.Groups["format"].Success ? match.Groups["format"].Value : null; - if (!payload.TryGetValue(field, out var value) || value is null) - { - return match.Value; - } - - var formatted = string.IsNullOrWhiteSpace(format) - ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty - : string.Format(CultureInfo.InvariantCulture, "{0:" + format + "}", value); - return Uri.EscapeDataString(formatted); - }); - } - - [GeneratedRegex(@"\{(?[A-Za-z0-9_]+)(:(?[^}]+))?\}")] - private static partial Regex PlaceholderRegex(); -} diff --git a/src/LabelPrintAgent/Rendering/LabelPayloadBuilder.cs b/src/LabelPrintAgent/Rendering/LabelPayloadBuilder.cs deleted file mode 100644 index 42e09af..0000000 --- a/src/LabelPrintAgent/Rendering/LabelPayloadBuilder.cs +++ /dev/null @@ -1,32 +0,0 @@ -using LabelPrintAgent.Configuration; -using LabelPrintAgent.Database; - -namespace LabelPrintAgent.Rendering; - -public static class LabelPayloadBuilder -{ - public static Dictionary Build(PrintJob job, LabelTemplateConfig template, long? dummyNumberWhenMissing = null) - { - var payload = TemplateFormatter.FromJson(job.PayloadJson); - var reservedNumber = job.ReservedNumber ?? dummyNumberWhenMissing; - - payload["jobId"] = job.Id; - payload["barcodeTemplateId"] = job.BarcodeTemplateId; - - if (reservedNumber.HasValue) - { - payload["reservedNumber"] = reservedNumber.Value; - if (!string.IsNullOrWhiteSpace(template.ReservedNumberPayloadKey)) - { - payload[template.ReservedNumberPayloadKey] = reservedNumber.Value; - } - } - - if (!string.IsNullOrWhiteSpace(template.QrTemplate)) - { - payload["qr"] = TemplateFormatter.Format(template.QrTemplate, payload); - } - - return payload; - } -} diff --git a/src/LabelPrintAgent/Rendering/LabelRenderer.cs b/src/LabelPrintAgent/Rendering/LabelRenderer.cs deleted file mode 100644 index c572037..0000000 --- a/src/LabelPrintAgent/Rendering/LabelRenderer.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System.Drawing; -using System.Drawing.Drawing2D; -using System.Drawing.Imaging; -using System.Drawing.Text; -using LabelPrintAgent.Layout; -using QRCoder; - -namespace LabelPrintAgent.Rendering; - -public sealed class LabelRenderer -{ - public RenderResult Render(LabelLayout layout, Dictionary payload) - { - var warnings = new List(); - var widthPx = MmToPixelConverter.MmToPx(layout.WidthMm, layout.Dpi); - var heightPx = MmToPixelConverter.MmToPx(layout.HeightMm, layout.Dpi); - var bitmap = new Bitmap(widthPx, heightPx, PixelFormat.Format32bppArgb); - bitmap.SetResolution(layout.Dpi, layout.Dpi); - - using var graphics = Graphics.FromImage(bitmap); - graphics.Clear(Color.White); - graphics.SmoothingMode = SmoothingMode.AntiAlias; - graphics.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; - graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; - graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; - - foreach (var element in layout.Elements) - { - switch (element) - { - case TextElement text: - DrawText(graphics, text, payload, layout.Dpi, warnings); - break; - case LineElement line: - DrawLine(graphics, line, layout.Dpi); - break; - case RectangleElement rectangle: - DrawRectangle(graphics, rectangle, layout.Dpi); - break; - case QrCodeElement qrCode: - DrawQrCode(graphics, qrCode, payload, layout.Dpi, warnings); - break; - } - } - - return new RenderResult(bitmap, warnings); - } - - private static void DrawText(Graphics graphics, TextElement element, Dictionary payload, int dpi, List warnings) - { - var text = TemplateFormatter.Format(element.Value, payload, warnings).Replace("\\n", Environment.NewLine); - var bounds = new RectangleF( - MmToPixelConverter.MmToPxFloat(element.XMm, dpi), - MmToPixelConverter.MmToPxFloat(element.YMm, dpi), - MmToPixelConverter.MmToPxFloat(element.WidthMm, dpi), - MmToPixelConverter.MmToPxFloat(element.HeightMm, dpi)); - - using var format = CreateStringFormat(element); - var style = CreateFontStyle(element); - using var clipRegion = graphics.Clip.Clone(); - graphics.SetClip(bounds); - - Font? font = null; - try - { - font = CreateFittingFont(graphics, element, text, bounds, format, style, warnings); - using var brush = new SolidBrush(Color.Black); - graphics.DrawString(text, font, brush, bounds, format); - } - finally - { - graphics.Clip = clipRegion; - font?.Dispose(); - } - } - - private static Font CreateFittingFont(Graphics graphics, TextElement element, string text, RectangleF bounds, StringFormat format, FontStyle style, List warnings) - { - var fontSize = element.FontSizePt; - Font? currentFont = null; - - while (true) - { - currentFont?.Dispose(); - currentFont = new Font(element.FontFamily, (float)fontSize, style, GraphicsUnit.Point); - var measured = graphics.MeasureString(text, currentFont, bounds.Size, format); - var fits = measured.Width <= bounds.Width + 1 && measured.Height <= bounds.Height + 1; - if (fits) - { - return currentFont; - } - - if (!element.AutoShrink || fontSize <= element.MinFontSizePt) - { - warnings.Add($"Text '{Shorten(text)}' passt nicht vollständig in den Elementbereich."); - return currentFont; - } - - fontSize = Math.Max(element.MinFontSizePt, fontSize - 0.5d); - } - } - - private static StringFormat CreateStringFormat(TextElement element) - { - return new StringFormat - { - Alignment = element.HorizontalAlign.ToLowerInvariant() switch - { - "center" => StringAlignment.Center, - "right" => StringAlignment.Far, - _ => StringAlignment.Near - }, - LineAlignment = element.VerticalAlign.ToLowerInvariant() switch - { - "middle" => StringAlignment.Center, - "bottom" => StringAlignment.Far, - _ => StringAlignment.Near - }, - FormatFlags = element.Multiline ? 0 : StringFormatFlags.NoWrap, - Trimming = StringTrimming.EllipsisCharacter - }; - } - - private static FontStyle CreateFontStyle(TextElement element) - { - var style = FontStyle.Regular; - if (element.Bold) style |= FontStyle.Bold; - if (element.Italic) style |= FontStyle.Italic; - if (element.Underline) style |= FontStyle.Underline; - return style; - } - - private static void DrawLine(Graphics graphics, LineElement element, int dpi) - { - using var pen = new Pen(Color.Black, Math.Max(1f, MmToPixelConverter.MmToPxFloat(element.StrokeWidthMm, dpi))); - graphics.DrawLine( - pen, - MmToPixelConverter.MmToPxFloat(element.X1Mm, dpi), - MmToPixelConverter.MmToPxFloat(element.Y1Mm, dpi), - MmToPixelConverter.MmToPxFloat(element.X2Mm, dpi), - MmToPixelConverter.MmToPxFloat(element.Y2Mm, dpi)); - } - - private static void DrawRectangle(Graphics graphics, RectangleElement element, int dpi) - { - var rectangle = new RectangleF( - MmToPixelConverter.MmToPxFloat(element.XMm, dpi), - MmToPixelConverter.MmToPxFloat(element.YMm, dpi), - MmToPixelConverter.MmToPxFloat(element.WidthMm, dpi), - MmToPixelConverter.MmToPxFloat(element.HeightMm, dpi)); - - if (element.Filled) - { - using var brush = new SolidBrush(Color.Black); - graphics.FillRectangle(brush, rectangle); - return; - } - - using var pen = new Pen(Color.Black, Math.Max(1f, MmToPixelConverter.MmToPxFloat(element.StrokeWidthMm, dpi))); - graphics.DrawRectangle(pen, rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height); - } - - private static void DrawQrCode(Graphics graphics, QrCodeElement element, Dictionary payload, int dpi, List warnings) - { - var value = TemplateFormatter.Format(element.Value, payload, warnings); - if (string.IsNullOrWhiteSpace(value)) - { - warnings.Add("QR-Code wurde nicht gerendert, weil der Wert leer ist."); - return; - } - - using var generator = new QRCodeGenerator(); - using var qrData = generator.CreateQrCode(value, QRCodeGenerator.ECCLevel.Q); - using var qrCode = new QRCode(qrData); - using var qrBitmap = qrCode.GetGraphic(12, Color.Black, Color.White, drawQuietZones: true); - - graphics.DrawImage( - qrBitmap, - MmToPixelConverter.MmToPxFloat(element.XMm, dpi), - MmToPixelConverter.MmToPxFloat(element.YMm, dpi), - MmToPixelConverter.MmToPxFloat(element.SizeMm, dpi), - MmToPixelConverter.MmToPxFloat(element.SizeMm, dpi)); - } - - private static string Shorten(string value) - { - return value.Length <= 40 ? value : value[..37] + "..."; - } -} diff --git a/src/LabelPrintAgent/Rendering/MmToPixelConverter.cs b/src/LabelPrintAgent/Rendering/MmToPixelConverter.cs deleted file mode 100644 index ff3a1b7..0000000 --- a/src/LabelPrintAgent/Rendering/MmToPixelConverter.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace LabelPrintAgent.Rendering; - -public static class MmToPixelConverter -{ - public static int MmToPx(double mm, int dpi) - { - return (int)Math.Round(mm / 25.4d * dpi); - } - - public static float MmToPxFloat(double mm, int dpi) - { - return (float)(mm / 25.4d * dpi); - } -} diff --git a/src/LabelPrintAgent/Rendering/PreviewDataProvider.cs b/src/LabelPrintAgent/Rendering/PreviewDataProvider.cs deleted file mode 100644 index 8a8fda7..0000000 --- a/src/LabelPrintAgent/Rendering/PreviewDataProvider.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace LabelPrintAgent.Rendering; - -public static class PreviewDataProvider -{ - public static Dictionary CreatePayload() - { - return new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["titel"] = "Beleg privat", - ["beschreibung"] = "Dokument 2026-000123", - ["nummer"] = "2026-000123", - ["datum"] = new DateTime(2026, 5, 7), - ["menge"] = 42.5m, - ["qr"] = "bjoernprivat 0000123" - }; - } -} diff --git a/src/LabelPrintAgent/Rendering/RenderResult.cs b/src/LabelPrintAgent/Rendering/RenderResult.cs deleted file mode 100644 index e52d6d0..0000000 --- a/src/LabelPrintAgent/Rendering/RenderResult.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Drawing; - -namespace LabelPrintAgent.Rendering; - -public sealed class RenderResult : IDisposable -{ - public RenderResult(Bitmap image, List warnings) - { - Image = image; - WidthPx = image.Width; - HeightPx = image.Height; - Warnings = warnings; - } - - public Bitmap Image { get; } - public int WidthPx { get; } - public int HeightPx { get; } - public List Warnings { get; } - - public void Dispose() - { - Image.Dispose(); - } -} diff --git a/src/LabelPrintAgent/Rendering/TemplateFormatter.cs b/src/LabelPrintAgent/Rendering/TemplateFormatter.cs deleted file mode 100644 index 7a66e50..0000000 --- a/src/LabelPrintAgent/Rendering/TemplateFormatter.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Globalization; -using System.Text.Json; -using System.Text.RegularExpressions; - -namespace LabelPrintAgent.Rendering; - -public static partial class TemplateFormatter -{ - public static string Format(string template, Dictionary payload) - { - return Format(template, payload, warnings: null); - } - - public static string Format(string template, Dictionary payload, List? warnings) - { - return PlaceholderRegex().Replace(template, match => - { - var field = match.Groups["field"].Value; - var format = match.Groups["format"].Success ? match.Groups["format"].Value : null; - - if (!payload.TryGetValue(field, out var rawValue) || rawValue is null) - { - warnings?.Add($"Platzhalter '{field}' wurde nicht in den Vorschau-Daten gefunden."); - return match.Value; - } - - var value = NormalizeValue(rawValue); - try - { - if (string.IsNullOrWhiteSpace(format)) - { - return Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty; - } - - return string.Format(CultureInfo.CurrentCulture, "{0:" + format + "}", value); - } - catch (FormatException) - { - warnings?.Add($"Platzhalter '{field}' hat ein ungültiges Format '{format}'."); - return Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty; - } - }); - } - - public static Dictionary FromJson(string payloadJson) - { - using var document = JsonDocument.Parse(payloadJson); - return FromJsonElement(document.RootElement); - } - - private static Dictionary FromJsonElement(JsonElement element) - { - var payload = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (element.ValueKind != JsonValueKind.Object) - { - return payload; - } - - foreach (var property in element.EnumerateObject()) - { - payload[property.Name] = ConvertJsonValue(property.Value); - } - - return payload; - } - - private static object? ConvertJsonValue(JsonElement value) - { - return value.ValueKind switch - { - JsonValueKind.String => ParseStringValue(value.GetString()), - JsonValueKind.Number when value.TryGetInt64(out var longValue) => longValue, - JsonValueKind.Number when value.TryGetDecimal(out var decimalValue) => decimalValue, - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - _ => value.ToString() - }; - } - - private static object? ParseStringValue(string? value) - { - if (value is null) - { - return null; - } - - if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dateTime)) - { - return dateTime; - } - - if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var decimalValue)) - { - return decimalValue; - } - - return value; - } - - private static object NormalizeValue(object value) - { - return value switch - { - JsonElement jsonElement => ConvertJsonValue(jsonElement) ?? string.Empty, - string stringValue => ParseStringValue(stringValue) ?? string.Empty, - _ => value - }; - } - - [GeneratedRegex(@"\{(?[A-Za-z0-9_]+)(:(?[^}]+))?\}")] - private static partial Regex PlaceholderRegex(); -} diff --git a/src/LabelPrintAgent/Worker/PrintJobProcessor.cs b/src/LabelPrintAgent/Worker/PrintJobProcessor.cs deleted file mode 100644 index f37af59..0000000 --- a/src/LabelPrintAgent/Worker/PrintJobProcessor.cs +++ /dev/null @@ -1,115 +0,0 @@ -using LabelPrintAgent.Configuration; -using LabelPrintAgent.Database; -using LabelPrintAgent.Layout; -using LabelPrintAgent.Numbering; -using LabelPrintAgent.Printing; -using LabelPrintAgent.Rendering; -using Serilog; - -namespace LabelPrintAgent.Worker; - -public sealed class PrintJobProcessor -{ - private readonly MySqlLabelRepository _repository; - private readonly PrinterService _printerService; - private readonly NumberReservationService _numberReservationService; - private readonly LabelRenderer _renderer; - private readonly LayoutValidator _layoutValidator = new(); - - public PrintJobProcessor( - MySqlLabelRepository repository, - PrinterService printerService, - NumberReservationService numberReservationService, - LabelRenderer renderer) - { - _repository = repository; - _printerService = printerService; - _numberReservationService = numberReservationService; - _renderer = renderer; - } - - public async Task PrintJobAsync( - PrintJob job, - LabelTemplateConfig template, - string printerName, - CancellationToken cancellationToken = default) - { - try - { - if (job.BarcodeTemplateId != template.BarcodeTemplateId) - { - throw new InvalidOperationException($"Job {job.Id} passt nicht zu LabelTemplate {template.BarcodeTemplateId}."); - } - - var validation = _layoutValidator.Validate(template.Layout); - if (!validation.IsValid) - { - throw new InvalidOperationException(string.Join(Environment.NewLine, validation.Errors)); - } - - if (!job.ReservedNumber.HasValue) - { - var reservation = await _numberReservationService.ReserveNumberAsync(job, template, cancellationToken); - if (!reservation.Success || !reservation.ReservedNumber.HasValue) - { - throw new InvalidOperationException(reservation.ErrorMessage ?? "Nummer konnte nicht reserviert werden."); - } - - await _repository.MarkNumberReservedAsync(job.Id, reservation.ReservedNumber.Value, cancellationToken); - job.ReservedNumber = reservation.ReservedNumber.Value; - } - - var payload = LabelPayloadBuilder.Build(job, template); - using var renderResult = _renderer.Render(template.Layout, payload); - - if (!await _repository.MarkAsPrintingAsync(job.Id, cancellationToken)) - { - throw new InvalidOperationException($"Job {job.Id} konnte nicht auf printing gesetzt werden."); - } - - var printResult = await _printerService.PrintAsync( - renderResult.Image, - printerName, - template.Layout.WidthMm, - template.Layout.HeightMm, - cancellationToken); - - if (!printResult.Success) - { - throw new InvalidOperationException(printResult.ErrorMessage ?? "Druck fehlgeschlagen."); - } - - var confirmResult = await _numberReservationService.ConfirmPrintedAsync(template, payload, cancellationToken); - if (!confirmResult.Success) - { - throw new InvalidOperationException(confirmResult.ErrorMessage ?? "Nummer konnte nicht als gedruckt bestätigt werden."); - } - - await _repository.MarkAsPrintedAsync(job.Id, printerName, cancellationToken); - return printResult; - } - catch (Exception ex) - { - Log.Error(ex, "Could not print job {JobId}", job.Id); - await _repository.MarkAsErrorAsync(job.Id, ex.Message, cancellationToken); - return PrintResult.Fail(printerName, ex.Message); - } - } - - public RenderResult RenderJobPreview(PrintJob job, LabelTemplateConfig template, long dummyNumber = 123) - { - if (job.BarcodeTemplateId != template.BarcodeTemplateId) - { - throw new InvalidOperationException($"Job {job.Id} passt nicht zu LabelTemplate {template.BarcodeTemplateId}."); - } - - var validation = _layoutValidator.Validate(template.Layout); - if (!validation.IsValid) - { - throw new InvalidOperationException(string.Join(Environment.NewLine, validation.Errors)); - } - - var payload = LabelPayloadBuilder.Build(job, template, dummyNumberWhenMissing: dummyNumber); - return _renderer.Render(template.Layout, payload); - } -}