commit 7f3592682ca0acfb72f9e7bf144893a4727b54fa Author: Björn Pöttker Date: Thu May 7 13:28:02 2026 +0200 Initial LabelPrintAgent scaffold diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8ed465 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Build output +bin/ +obj/ + +# IDE/user files +.vs/ +.vscode/ +.idea/ +*.user +*.suo +*.rsuser + +# OS files +.DS_Store +Thumbs.db + +# Runtime data +logs/ +settings.json +layouts/ diff --git a/LabelPrintAgent.sln b/LabelPrintAgent.sln new file mode 100644 index 0000000..840c364 --- /dev/null +++ b/LabelPrintAgent.sln @@ -0,0 +1,21 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LabelPrintAgent", "src\LabelPrintAgent\LabelPrintAgent.csproj", "{7FEA11FE-3DB1-43CF-9A9F-4EDE96081623}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7FEA11FE-3DB1-43CF-9A9F-4EDE96081623}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7FEA11FE-3DB1-43CF-9A9F-4EDE96081623}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7FEA11FE-3DB1-43CF-9A9F-4EDE96081623}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7FEA11FE-3DB1-43CF-9A9F-4EDE96081623}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4a2e08 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# LabelPrintAgent + +Windows-Tray-Anwendung zum Rendern und Drucken von Etiketten aus JSON-Layouts. + +## Stand + +Der erste lauffähige Schritt enthält: + +- .NET-9-Windows-Forms-Projekt mit Tray-Icon +- Einstellungsdialog mit Tabs für Allgemein, Datenbank, Drucker, Layout und Fehlerjobs +- lokale Konfiguration unter `C:\ProgramData\LabelPrintAgent\settings.json` +- verschlüsselte Passwortspeicherung per Windows DPAPI +- Druckerauswahl über installierte Windows-Drucker +- Layout-JSON unter `C:\ProgramData\LabelPrintAgent\layouts` +- Beispiel-Layout `dymo_57x32_standard` +- Rendering mit 300 dpi als Bitmap +- Vorschau und Testdruck über `PrintDocument` +- Serilog-Dateilogs unter `C:\ProgramData\LabelPrintAgent\logs` +- vorbereiteter MySQL-Worker ohne automatischen Retry + +## Öffnen und Starten + +1. `LabelPrintAgent.sln` in Visual Studio oder Rider öffnen. +2. Auf einem Windows-Rechner bauen und starten. +3. Tray-Symbol anklicken, um die Einstellungen zu öffnen. +4. Im Tab `Drucker` den Dymo LabelWriter auswählen. +5. Im Tab `Layout` das Beispiel-Layout öffnen, `Vorschau` klicken und danach `Testdruck`. + +## Datenbank + +Die Tabelle kann mit folgendem Skript angelegt werden: + +```sql +sql/create_label_print_queue.sql +``` + +Der Worker lädt nur Jobs mit `status = 'pending'`. Vor dem Druck wird der Job auf `printing` gesetzt. Bei Erfolg setzt er `printed`, `printed_at` und `printer_name`. Bei Fehlern setzt er `error`, speichert `error_message` und erhöht `attempts`. + +Fehlerhafte Jobs werden nicht automatisch wiederholt. Im Tab `Fehlerhafte Druckaufträge` können sie manuell erneut auf `pending` gesetzt oder als `deleted` markiert werden. + +## Layout-Platzhalter + +Text- und QR-Werte unterstützen Platzhalter aus `payload_json`: + +```text +{titel} +{nummer} +{datum:dd.MM.yyyy} +{menge:0.00} +``` + +Formatangaben werden über C#-Formatstrings ausgewertet. diff --git a/sql/create_label_print_queue.sql b/sql/create_label_print_queue.sql new file mode 100644 index 0000000..4a2772a --- /dev/null +++ b/sql/create_label_print_queue.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS label_print_queue ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + layout_key VARCHAR(100) NOT NULL, + payload_json JSON NOT NULL, + status ENUM('pending','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_layout_key (layout_key) +); + +INSERT INTO label_print_queue +(layout_key, payload_json, status) +VALUES +( + 'dymo_57x32_standard', + JSON_OBJECT( + 'titel', 'Beleg privat', + 'beschreibung', 'Dokument 2026-000123', + 'nummer', '2026-000123', + 'datum', '2026-05-07', + 'qr', 'bjoernprivat 0000123' + ), + 'pending' +); diff --git a/src/LabelPrintAgent/App/Program.cs b/src/LabelPrintAgent/App/Program.cs new file mode 100644 index 0000000..afbd9c6 --- /dev/null +++ b/src/LabelPrintAgent/App/Program.cs @@ -0,0 +1,47 @@ +using LabelPrintAgent.Configuration; +using LabelPrintAgent.Layout; +using LabelPrintAgent.Logging; +using LabelPrintAgent.Rendering; +using LabelPrintAgent.Printing; +using LabelPrintAgent.Database; +using LabelPrintAgent.Worker; +using Serilog; + +namespace LabelPrintAgent.App; + +internal static class Program +{ + [STAThread] + private static void Main() + { + ApplicationConfiguration.Initialize(); + + try + { + var protectedStrings = new ProtectedStringService(); + var settingsStore = new SettingsStore(protectedStrings); + var settings = settingsStore.Load(); + settings.EnsureDirectories(); + LogSetup.Configure(settings.Paths.LogFolder); + LayoutStore.EnsureExampleLayout(settings.Paths.LayoutFolder); + + using var layoutStore = new LayoutStore(settings.Paths.LayoutFolder); + using var renderer = new LabelRenderer(); + var printerService = new PrinterService(new WindowsImagePrinter()); + var repository = new MySqlLabelRepository(settingsStore, protectedStrings); + using var worker = new PrintJobWorker(settingsStore, layoutStore, renderer, printerService, repository); + worker.Start(); + + Application.Run(new TrayApplicationContext(settingsStore, layoutStore, renderer, printerService, repository, worker)); + } + catch (Exception ex) + { + Log.Fatal(ex, "Fatal application startup error"); + MessageBox.Show(ex.Message, "LabelPrintAgent", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + Log.CloseAndFlush(); + } + } +} diff --git a/src/LabelPrintAgent/App/SettingsForm.cs b/src/LabelPrintAgent/App/SettingsForm.cs new file mode 100644 index 0000000..9dac898 --- /dev/null +++ b/src/LabelPrintAgent/App/SettingsForm.cs @@ -0,0 +1,477 @@ +using System.Drawing; +using LabelPrintAgent.Configuration; +using LabelPrintAgent.Database; +using LabelPrintAgent.Layout; +using LabelPrintAgent.Printing; +using LabelPrintAgent.Rendering; +using LabelPrintAgent.Worker; +using Microsoft.Win32; +using Serilog; + +namespace LabelPrintAgent.App; + +internal sealed class SettingsForm : Form +{ + private const string AutoStartRunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string AutoStartValueName = "LabelPrintAgent"; + + private readonly SettingsStore _settingsStore; + private readonly LayoutStore _layoutStore; + private readonly LabelRenderer _renderer; + private readonly PrinterService _printerService; + private readonly MySqlLabelRepository _repository; + private readonly PrintJobWorker _worker; + + private readonly CheckBox _autoStartCheckBox = new() { Text = "Autostart aktivieren", 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 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 ComboBox _layoutKeys = new() { Width = 280, DropDownStyle = ComboBoxStyle.DropDownList }; + private readonly TextBox _layoutJson = new() { Multiline = true, ScrollBars = ScrollBars.Both, WordWrap = false, Font = new Font(FontFamily.GenericMonospace, 9), Width = 620, Height = 360 }; + private readonly PictureBox _preview = new() { BorderStyle = BorderStyle.FixedSingle, SizeMode = PictureBoxSizeMode.Zoom, Width = 420, Height = 240, BackColor = Color.White }; + + private readonly DataGridView _errorJobs = new() + { + ReadOnly = true, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + MultiSelect = false, + Dock = DockStyle.Fill + }; + + public SettingsForm( + SettingsStore settingsStore, + LayoutStore layoutStore, + LabelRenderer renderer, + PrinterService printerService, + MySqlLabelRepository repository, + PrintJobWorker worker) + { + _settingsStore = settingsStore; + _layoutStore = layoutStore; + _renderer = renderer; + _printerService = printerService; + _repository = repository; + _worker = worker; + + Text = "LabelPrintAgent Einstellungen"; + Width = 1120; + Height = 760; + StartPosition = FormStartPosition.CenterScreen; + + var tabs = new TabControl { Dock = DockStyle.Fill }; + tabs.TabPages.Add(CreateGeneralTab()); + tabs.TabPages.Add(CreateDatabaseTab()); + tabs.TabPages.Add(CreatePrinterTab()); + tabs.TabPages.Add(CreateLayoutTab()); + tabs.TabPages.Add(CreateErrorJobsTab()); + Controls.Add(tabs); + + Load += async (_, _) => + { + LoadSettingsIntoUi(); + LoadPrinters(); + LoadLayouts(); + await RefreshErrorJobsAsync(); + }; + } + + 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())); + return new TabPage("Allgemein") { Controls = { panel } }; + } + + private TabPage CreateDatabaseTab() + { + 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("Verbindung testen", 140, 230, async (_, _) => await TestConnectionAsync())); + panel.Controls.Add(ButtonAt("Speichern", 310, 230, (_, _) => SaveDatabase())); + return new TabPage("Datenbank") { Controls = { panel } }; + } + + private TabPage CreatePrinterTab() + { + var panel = CreatePaddedPanel(); + panel.Controls.Add(Row(20, Label("Drucker", 120), _printer)); + panel.Controls.Add(Row(60, Label("Breite", 120), _labelWidth, Label("mm", 40))); + 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("Testdruck", 300, 150, (_, _) => PrintCurrentLayout())); + panel.Controls.Add(_preview); + _preview.Left = 520; + _preview.Top = 20; + return new TabPage("Drucker") { Controls = { panel } }; + } + + private TabPage CreateLayoutTab() + { + var panel = CreatePaddedPanel(); + panel.Controls.Add(Row(20, Label("Layout", 80), _layoutKeys)); + _layoutKeys.SelectedIndexChanged += (_, _) => LoadSelectedLayoutJson(); + panel.Controls.Add(_layoutJson); + _layoutJson.Left = 20; + _layoutJson.Top = 60; + panel.Controls.Add(_preview); + _preview.Left = 680; + _preview.Top = 60; + panel.Controls.Add(ButtonAt("Validieren", 20, 440, (_, _) => ValidateLayout())); + panel.Controls.Add(ButtonAt("Speichern", 130, 440, (_, _) => SaveLayout())); + panel.Controls.Add(ButtonAt("Vorschau", 240, 440, (_, _) => RenderPreview())); + panel.Controls.Add(ButtonAt("Testdruck", 350, 440, (_, _) => PrintCurrentLayout())); + return new TabPage("Layout") { Controls = { panel } }; + } + + private TabPage CreateErrorJobsTab() + { + var page = new TabPage("Fehlerhafte Druckaufträge"); + var panel = new Panel { Dock = DockStyle.Bottom, Height = 56, Padding = new Padding(12) }; + panel.Controls.Add(ButtonAt("Aktualisieren", 12, 12, async (_, _) => await RefreshErrorJobsAsync())); + panel.Controls.Add(ButtonAt("Vorschau", 140, 12, (_, _) => PreviewSelectedErrorJob())); + panel.Controls.Add(ButtonAt("Nochmal drucken", 240, 12, async (_, _) => await RequeueSelectedErrorJobAsync())); + panel.Controls.Add(ButtonAt("Löschen/verwerfen", 390, 12, async (_, _) => await DeleteSelectedErrorJobAsync())); + page.Controls.Add(_errorJobs); + page.Controls.Add(panel); + return page; + } + + private void LoadSettingsIntoUi() + { + var settings = _settingsStore.Load(); + _autoStartCheckBox.Checked = IsAutostartEnabled(); + _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); + _labelWidth.Value = settings.Printer.LabelWidthMm; + _labelHeight.Value = settings.Printer.LabelHeightMm; + } + + private void LoadPrinters() + { + var selected = _settingsStore.Load().Printer.PrinterName; + _printer.Items.Clear(); + foreach (var printer in _printerService.GetInstalledPrinters()) + { + _printer.Items.Add(printer); + } + + if (!string.IsNullOrWhiteSpace(selected) && _printer.Items.Contains(selected)) + { + _printer.SelectedItem = selected; + } + else if (_printer.Items.Count > 0) + { + _printer.SelectedIndex = 0; + } + } + + private void LoadLayouts() + { + var selected = _layoutKeys.SelectedItem?.ToString(); + _layoutKeys.Items.Clear(); + foreach (var key in _layoutStore.GetLayoutKeys()) + { + _layoutKeys.Items.Add(key); + } + + if (selected is not null && _layoutKeys.Items.Contains(selected)) + { + _layoutKeys.SelectedItem = selected; + } + else if (_layoutKeys.Items.Count > 0) + { + _layoutKeys.SelectedIndex = 0; + } + } + + private void LoadSelectedLayoutJson() + { + if (_layoutKeys.SelectedItem is not string key) + { + return; + } + + try + { + _layoutJson.Text = _layoutStore.LoadJsonByKey(key); + RenderPreview(); + } + catch (Exception ex) + { + ShowError(ex); + } + } + + private void SaveGeneral() + { + var settings = _settingsStore.Load(); + settings.Worker.PollIntervalSeconds = (int)_pollInterval.Value; + _settingsStore.Save(settings); + SetAutostart(_autoStartCheckBox.Checked); + MessageBox.Show("Allgemeine Einstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + private void SaveDatabase() + { + 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); + _settingsStore.Save(settings); + MessageBox.Show("Datenbankeinstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + private async Task TestConnectionAsync() + { + try + { + SaveDatabase(); + await _repository.TestConnectionAsync(); + MessageBox.Show("Verbindung erfolgreich.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + ShowError(ex); + } + } + + private void SavePrinter() + { + var settings = _settingsStore.Load(); + settings.Printer.PrinterName = _printer.SelectedItem?.ToString() ?? string.Empty; + settings.Printer.LabelWidthMm = _labelWidth.Value; + settings.Printer.LabelHeightMm = _labelHeight.Value; + _settingsStore.Save(settings); + } + + private void ValidateLayout() + { + try + { + _layoutStore.ValidateJson(_layoutJson.Text); + MessageBox.Show("Layout ist gültig.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + ShowError(ex); + } + } + + private void SaveLayout() + { + try + { + _layoutStore.SaveJson(_layoutJson.Text); + LoadLayouts(); + MessageBox.Show("Layout gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + ShowError(ex); + } + } + + private void RenderPreview() + { + try + { + var layout = _layoutStore.ValidateJson(_layoutJson.Text); + using var result = _renderer.Render(layout, LayoutStore.ExamplePayloadJson); + var old = _preview.Image; + _preview.Image = (Bitmap)result.Bitmap.Clone(); + old?.Dispose(); + } + catch (Exception ex) + { + ShowError(ex); + } + } + + private void PrintCurrentLayout() + { + try + { + SavePrinter(); + var layout = _layoutStore.ValidateJson(_layoutJson.Text); + var settings = _settingsStore.Load(); + using var result = _renderer.Render(layout, LayoutStore.ExamplePayloadJson); + _printerService.Print(result.Bitmap, settings.Printer.PrinterName, layout.WidthMm, layout.HeightMm); + MessageBox.Show("Testdruck wurde gesendet.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + ShowError(ex); + } + } + + private async Task RefreshErrorJobsAsync() + { + try + { + var jobs = await _repository.LoadErrorJobsAsync(); + _errorJobs.DataSource = jobs.Select(job => new ErrorJobRow(job)).ToList(); + } + catch (Exception ex) + { + Log.Warning(ex, "Could not load error jobs"); + _errorJobs.DataSource = Array.Empty(); + } + } + + private void PreviewSelectedErrorJob() + { + try + { + var job = GetSelectedErrorJob(); + var layout = _layoutStore.LoadByKey(job.Layout); + using var result = _renderer.Render(layout, job.PayloadJson); + var previewForm = new Form { Text = $"Vorschau Job {job.Id}", Width = 640, Height = 420, StartPosition = FormStartPosition.CenterParent }; + previewForm.Controls.Add(new PictureBox + { + Image = (Bitmap)result.Bitmap.Clone(), + Dock = DockStyle.Fill, + SizeMode = PictureBoxSizeMode.Zoom, + BackColor = Color.White + }); + previewForm.Show(this); + } + catch (Exception ex) + { + ShowError(ex); + } + } + + private async Task RequeueSelectedErrorJobAsync() + { + try + { + var job = GetSelectedErrorJob(); + await _repository.RequeueAsync(job.Id); + await _worker.PollOnceAsync(); + await RefreshErrorJobsAsync(); + } + catch (Exception ex) + { + ShowError(ex); + } + } + + private async Task DeleteSelectedErrorJobAsync() + { + try + { + var job = GetSelectedErrorJob(); + await _repository.MarkDeletedAsync(job.Id); + await RefreshErrorJobsAsync(); + } + catch (Exception ex) + { + ShowError(ex); + } + } + + private ErrorJobRow GetSelectedErrorJob() + { + if (_errorJobs.CurrentRow?.DataBoundItem is ErrorJobRow row) + { + return row; + } + + throw new InvalidOperationException("Bitte zuerst einen fehlerhaften Druckauftrag auswählen."); + } + + private bool IsAutostartEnabled() + { + using var key = Registry.CurrentUser.OpenSubKey(AutoStartRunKey, writable: false); + return key?.GetValue(AutoStartValueName) is string value && value.Length > 0; + } + + private static void SetAutostart(bool enabled) + { + using var key = Registry.CurrentUser.OpenSubKey(AutoStartRunKey, writable: true) + ?? Registry.CurrentUser.CreateSubKey(AutoStartRunKey); + if (!enabled) + { + key.DeleteValue(AutoStartValueName, throwOnMissingValue: false); + return; + } + + key.SetValue(AutoStartValueName, $"\"{Application.ExecutablePath}\""); + } + + private static Panel CreatePaddedPanel() => new() { Dock = DockStyle.Fill, Padding = new Padding(20), AutoScroll = true }; + + private static Label Label(string text, int width) => new() { Text = text, Width = width, TextAlign = ContentAlignment.MiddleLeft }; + + private static FlowLayoutPanel Row(int top, params Control[] controls) + { + var row = new FlowLayoutPanel + { + Left = 20, + Top = top, + Width = 760, + Height = 32, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false + }; + row.Controls.AddRange(controls); + return row; + } + + private static Button ButtonAt(string text, int left, int top, EventHandler click) + { + var button = new Button { Text = text, Left = left, Top = top, Width = Math.Max(100, text.Length * 8 + 24), Height = 32 }; + button.Click += click; + return button; + } + + private void ShowError(Exception ex) + { + Log.Error(ex, "Settings form action failed"); + MessageBox.Show(ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + + private sealed class ErrorJobRow + { + private readonly PrintJob _job; + + public ErrorJobRow(PrintJob job) + { + _job = job; + } + + public long Id => _job.Id; + public string Layout => _job.LayoutKey; + public DateTime ErstelltAm => _job.CreatedAt; + public string Fehler => _job.ErrorMessage ?? string.Empty; + public int Versuche => _job.Attempts; + public string PayloadJson => _job.PayloadJson; + } +} diff --git a/src/LabelPrintAgent/App/TrayApplicationContext.cs b/src/LabelPrintAgent/App/TrayApplicationContext.cs new file mode 100644 index 0000000..b1e0bb6 --- /dev/null +++ b/src/LabelPrintAgent/App/TrayApplicationContext.cs @@ -0,0 +1,84 @@ +using LabelPrintAgent.Configuration; +using LabelPrintAgent.Database; +using LabelPrintAgent.Layout; +using LabelPrintAgent.Printing; +using LabelPrintAgent.Rendering; +using LabelPrintAgent.Worker; +using Serilog; + +namespace LabelPrintAgent.App; + +internal sealed class TrayApplicationContext : ApplicationContext +{ + private readonly NotifyIcon _notifyIcon; + private readonly SettingsStore _settingsStore; + private readonly LayoutStore _layoutStore; + private readonly LabelRenderer _renderer; + private readonly PrinterService _printerService; + private readonly MySqlLabelRepository _repository; + private readonly PrintJobWorker _worker; + private SettingsForm? _settingsForm; + + public TrayApplicationContext( + SettingsStore settingsStore, + LayoutStore layoutStore, + LabelRenderer renderer, + PrinterService printerService, + MySqlLabelRepository repository, + PrintJobWorker worker) + { + _settingsStore = settingsStore; + _layoutStore = layoutStore; + _renderer = renderer; + _printerService = printerService; + _repository = repository; + _worker = worker; + + var menu = new ContextMenuStrip(); + menu.Items.Add("Einstellungen", null, (_, _) => ShowSettings()); + menu.Items.Add("Beenden", null, (_, _) => ExitThread()); + + _notifyIcon = new NotifyIcon + { + Text = "LabelPrintAgent", + Icon = SystemIcons.Application, + ContextMenuStrip = menu, + Visible = true + }; + _notifyIcon.MouseClick += (_, args) => + { + if (args.Button == MouseButtons.Left) + { + ShowSettings(); + } + }; + } + + private void ShowSettings() + { + try + { + if (_settingsForm is null || _settingsForm.IsDisposed) + { + _settingsForm = new SettingsForm(_settingsStore, _layoutStore, _renderer, _printerService, _repository, _worker); + } + + _settingsForm.Show(); + _settingsForm.WindowState = FormWindowState.Normal; + _settingsForm.Activate(); + } + catch (Exception ex) + { + Log.Error(ex, "Could not open settings form"); + MessageBox.Show(ex.Message, "LabelPrintAgent", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + protected override void ExitThreadCore() + { + _notifyIcon.Visible = false; + _notifyIcon.Dispose(); + _settingsForm?.Dispose(); + base.ExitThreadCore(); + } +} diff --git a/src/LabelPrintAgent/Configuration/AppSettings.cs b/src/LabelPrintAgent/Configuration/AppSettings.cs new file mode 100644 index 0000000..a052be4 --- /dev/null +++ b/src/LabelPrintAgent/Configuration/AppSettings.cs @@ -0,0 +1,52 @@ +namespace LabelPrintAgent.Configuration; + +public sealed class AppSettings +{ + public DatabaseSettings Database { get; set; } = new(); + public PrinterSettings Printer { get; set; } = new(); + public WorkerSettings Worker { get; set; } = new(); + 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 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 sealed class PrinterSettings +{ + public string PrinterName { get; set; } = string.Empty; + public decimal LabelWidthMm { get; set; } = 57; + public decimal LabelHeightMm { get; set; } = 32; + public int Dpi { get; set; } = 300; +} + +public sealed class WorkerSettings +{ + 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 => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "LabelPrintAgent"); + + public static string SettingsFilePath => Path.Combine(DefaultBaseFolder, "settings.json"); + + public static PathSettings CreateDefault() => new(); +} diff --git a/src/LabelPrintAgent/Configuration/ProtectedStringService.cs b/src/LabelPrintAgent/Configuration/ProtectedStringService.cs new file mode 100644 index 0000000..778ef65 --- /dev/null +++ b/src/LabelPrintAgent/Configuration/ProtectedStringService.cs @@ -0,0 +1,31 @@ +using System.Security.Cryptography; +using System.Text; + +namespace LabelPrintAgent.Configuration; + +public sealed class ProtectedStringService +{ + public string Protect(string plainText) + { + if (string.IsNullOrEmpty(plainText)) + { + return string.Empty; + } + + var data = Encoding.UTF8.GetBytes(plainText); + var encrypted = ProtectedData.Protect(data, optionalEntropy: null, DataProtectionScope.CurrentUser); + return Convert.ToBase64String(encrypted); + } + + public string Unprotect(string encryptedText) + { + if (string.IsNullOrWhiteSpace(encryptedText)) + { + return string.Empty; + } + + var data = Convert.FromBase64String(encryptedText); + var decrypted = ProtectedData.Unprotect(data, optionalEntropy: null, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(decrypted); + } +} diff --git a/src/LabelPrintAgent/Configuration/SettingsStore.cs b/src/LabelPrintAgent/Configuration/SettingsStore.cs new file mode 100644 index 0000000..588511a --- /dev/null +++ b/src/LabelPrintAgent/Configuration/SettingsStore.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Serilog; + +namespace LabelPrintAgent.Configuration; + +public sealed class SettingsStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly ProtectedStringService _protectedStringService; + + public SettingsStore(ProtectedStringService protectedStringService) + { + _protectedStringService = protectedStringService; + } + + public string SettingsFilePath => PathSettings.SettingsFilePath; + + public AppSettings Load() + { + Directory.CreateDirectory(PathSettings.DefaultBaseFolder); + + if (!File.Exists(SettingsFilePath)) + { + var defaults = new AppSettings(); + Save(defaults); + return defaults; + } + + try + { + var json = File.ReadAllText(SettingsFilePath); + var settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new AppSettings(); + settings.Paths ??= PathSettings.CreateDefault(); + return settings; + } + catch (Exception ex) + { + Log.Error(ex, "Could not load settings, using defaults"); + return new AppSettings(); + } + } + + public void Save(AppSettings settings) + { + settings.EnsureDirectories(); + var json = JsonSerializer.Serialize(settings, JsonOptions); + File.WriteAllText(SettingsFilePath, json); + } + + public string EncryptPassword(string password) => _protectedStringService.Protect(password); + + public string DecryptPassword(AppSettings settings) => _protectedStringService.Unprotect(settings.Database.EncryptedPassword); +} diff --git a/src/LabelPrintAgent/Database/DbConnectionSettings.cs b/src/LabelPrintAgent/Database/DbConnectionSettings.cs new file mode 100644 index 0000000..3fe0f59 --- /dev/null +++ b/src/LabelPrintAgent/Database/DbConnectionSettings.cs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..cef52f7 --- /dev/null +++ b/src/LabelPrintAgent/Database/MySqlLabelRepository.cs @@ -0,0 +1,162 @@ +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> LoadErrorJobsAsync(CancellationToken cancellationToken = default) + { + await using var connection = new MySqlConnection(BuildConnectionString()); + await connection.OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT id, layout_key, payload_json, status, printer_name, attempts, error_message, created_at, locked_at, printed_at + FROM label_print_queue + WHERE status = 'error' + ORDER BY created_at DESC + LIMIT 100; + """; + + var jobs = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + jobs.Add(ReadJob(reader)); + } + + return jobs; + } + + public async Task> LoadPendingJobsAsync(int limit, CancellationToken cancellationToken = default) + { + await using var connection = new MySqlConnection(BuildConnectionString()); + await connection.OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT id, layout_key, payload_json, status, printer_name, attempts, error_message, created_at, locked_at, printed_at + FROM label_print_queue + WHERE status = 'pending' + ORDER BY created_at ASC + LIMIT @limit; + """; + 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; + } + + public async Task MarkPrintingAsync(long id, CancellationToken cancellationToken = default) + { + var affectedRows = await ExecuteAsync(""" + UPDATE label_print_queue + SET status = 'printing', locked_at = CURRENT_TIMESTAMP + WHERE id = @id AND status = 'pending'; + """, id, cancellationToken); + return affectedRows == 1; + } + + public async Task MarkPrintedAsync(long id, string printerName, CancellationToken cancellationToken = default) + { + await ExecuteAsync(""" + UPDATE label_print_queue + SET status = 'printed', printer_name = @printerName, printed_at = CURRENT_TIMESTAMP, error_message = NULL + WHERE id = @id; + """, id, cancellationToken, ("@printerName", printerName)); + } + + public async Task MarkErrorAsync(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 RequeueAsync(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 MarkDeletedAsync(long id, CancellationToken cancellationToken = default) + { + await ExecuteAsync(""" + UPDATE label_print_queue + SET status = 'deleted' + WHERE id = @id AND status = 'error'; + """, id, cancellationToken); + } + + 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"), + LayoutKey = reader.GetString("layout_key"), + PayloadJson = reader.GetString("payload_json"), + Status = reader.GetString("status"), + PrinterName = reader.IsDBNull(reader.GetOrdinal("printer_name")) ? null : reader.GetString("printer_name"), + Attempts = reader.GetInt32("attempts"), + ErrorMessage = reader.IsDBNull(reader.GetOrdinal("error_message")) ? null : reader.GetString("error_message"), + CreatedAt = reader.GetDateTime("created_at"), + LockedAt = reader.IsDBNull(reader.GetOrdinal("locked_at")) ? null : reader.GetDateTime("locked_at"), + PrintedAt = reader.IsDBNull(reader.GetOrdinal("printed_at")) ? null : reader.GetDateTime("printed_at") + }; + } +} diff --git a/src/LabelPrintAgent/Database/PrintJob.cs b/src/LabelPrintAgent/Database/PrintJob.cs new file mode 100644 index 0000000..1134d4e --- /dev/null +++ b/src/LabelPrintAgent/Database/PrintJob.cs @@ -0,0 +1,15 @@ +namespace LabelPrintAgent.Database; + +public sealed class PrintJob +{ + public long Id { get; set; } + public string LayoutKey { get; set; } = string.Empty; + public string PayloadJson { 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 new file mode 100644 index 0000000..7e0487e --- /dev/null +++ b/src/LabelPrintAgent/LabelPrintAgent.csproj @@ -0,0 +1,20 @@ + + + WinExe + net9.0-windows + enable + enable + true + true + app.manifest + + + + + + + + + + + diff --git a/src/LabelPrintAgent/Layout/LabelLayout.cs b/src/LabelPrintAgent/Layout/LabelLayout.cs new file mode 100644 index 0000000..24a9781 --- /dev/null +++ b/src/LabelPrintAgent/Layout/LabelLayout.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace LabelPrintAgent.Layout; + +public sealed class LabelLayout +{ + public string Name { get; set; } = string.Empty; + public string Key { get; set; } = string.Empty; + public decimal WidthMm { get; set; } = 57; + public decimal HeightMm { get; set; } = 32; + public int Dpi { get; set; } = 300; + public decimal MarginMm { get; set; } = 3; + public string Orientation { get; set; } = "landscape"; + public List Elements { get; set; } = []; +} + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(TextElement), "text")] +[JsonDerivedType(typeof(LineElement), "line")] +[JsonDerivedType(typeof(RectangleElement), "rectangle")] +[JsonDerivedType(typeof(QrCodeElement), "qr")] +public abstract class LayoutElement +{ + public string Type { get; init; } = string.Empty; +} diff --git a/src/LabelPrintAgent/Layout/LayoutElement.cs b/src/LabelPrintAgent/Layout/LayoutElement.cs new file mode 100644 index 0000000..b1b816e --- /dev/null +++ b/src/LabelPrintAgent/Layout/LayoutElement.cs @@ -0,0 +1,15 @@ +namespace LabelPrintAgent.Layout; + +public enum HorizontalAlign +{ + Left, + Center, + Right +} + +public enum VerticalAlign +{ + Top, + Middle, + Bottom +} diff --git a/src/LabelPrintAgent/Layout/LayoutElementJsonConverter.cs b/src/LabelPrintAgent/Layout/LayoutElementJsonConverter.cs new file mode 100644 index 0000000..eccaafc --- /dev/null +++ b/src/LabelPrintAgent/Layout/LayoutElementJsonConverter.cs @@ -0,0 +1,31 @@ +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)) + { + throw new JsonException("Layout element has no type."); + } + + var raw = document.RootElement.GetRawText(); + return typeProperty.GetString()?.ToLowerInvariant() switch + { + "text" => JsonSerializer.Deserialize(raw, options) ?? throw new JsonException(), + "line" => JsonSerializer.Deserialize(raw, options) ?? throw new JsonException(), + "rectangle" => JsonSerializer.Deserialize(raw, options) ?? throw new JsonException(), + "qr" => JsonSerializer.Deserialize(raw, options) ?? throw new JsonException(), + _ => throw new JsonException($"Unknown layout element type '{typeProperty.GetString()}'.") + }; + } + + public override void Write(Utf8JsonWriter writer, LayoutElement value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, (object)value, value.GetType(), options); + } +} diff --git a/src/LabelPrintAgent/Layout/LayoutStore.cs b/src/LabelPrintAgent/Layout/LayoutStore.cs new file mode 100644 index 0000000..b76101e --- /dev/null +++ b/src/LabelPrintAgent/Layout/LayoutStore.cs @@ -0,0 +1,191 @@ +using System.Text.Json; + +namespace LabelPrintAgent.Layout; + +public sealed class LayoutStore : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new LayoutElementJsonConverter() } + }; + + private readonly string _layoutFolder; + + public LayoutStore(string layoutFolder) + { + _layoutFolder = layoutFolder; + Directory.CreateDirectory(_layoutFolder); + } + + public IReadOnlyList GetLayoutKeys() + { + return Directory.GetFiles(_layoutFolder, "*.json") + .Select(path => Load(path).Key) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .OrderBy(key => key) + .ToList(); + } + + public LabelLayout LoadByKey(string key) + { + foreach (var path in Directory.GetFiles(_layoutFolder, "*.json")) + { + var layout = Load(path); + if (string.Equals(layout.Key, key, StringComparison.OrdinalIgnoreCase)) + { + return layout; + } + } + + throw new FileNotFoundException($"Layout '{key}' wurde nicht gefunden."); + } + + public string LoadJsonByKey(string key) + { + foreach (var path in Directory.GetFiles(_layoutFolder, "*.json")) + { + var layout = Load(path); + if (string.Equals(layout.Key, key, StringComparison.OrdinalIgnoreCase)) + { + return File.ReadAllText(path); + } + } + + throw new FileNotFoundException($"Layout '{key}' wurde nicht gefunden."); + } + + public LabelLayout ValidateJson(string json) + { + return JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidOperationException("Layout-JSON ist leer."); + } + + public void SaveJson(string json) + { + var layout = ValidateJson(json); + if (string.IsNullOrWhiteSpace(layout.Key)) + { + throw new InvalidOperationException("Layout-Key fehlt."); + } + + File.WriteAllText(Path.Combine(_layoutFolder, $"{layout.Key}.json"), json); + } + + private static LabelLayout Load(string path) + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidOperationException($"Layout '{path}' ist ungültig."); + } + + public static void EnsureExampleLayout(string layoutFolder) + { + Directory.CreateDirectory(layoutFolder); + var path = Path.Combine(layoutFolder, "dymo_57x32_standard.json"); + if (File.Exists(path)) + { + return; + } + + File.WriteAllText(path, ExampleLayoutJson); + } + + public void Dispose() + { + } + + public const string ExamplePayloadJson = """ +{ + "titel": "Beleg privat", + "beschreibung": "Dokument 2026-000123", + "nummer": "2026-000123", + "datum": "2026-05-07", + "qr": "bjoernprivat 0000123" +} +"""; + + public const string ExampleLayoutJson = """ +{ + "name": "Dymo_57x32_Standard", + "key": "dymo_57x32_standard", + "widthMm": 57, + "heightMm": 32, + "dpi": 300, + "marginMm": 3, + "orientation": "landscape", + "elements": [ + { + "type": "text", + "xMm": 3, + "yMm": 3, + "widthMm": 36, + "heightMm": 8, + "value": "{titel}", + "fontFamily": "Arial", + "fontSizePt": 10, + "minFontSizePt": 6, + "autoShrink": true, + "bold": true, + "italic": false, + "underline": false, + "horizontalAlign": "left", + "verticalAlign": "top", + "multiline": true + }, + { + "type": "text", + "xMm": 3, + "yMm": 12, + "widthMm": 36, + "heightMm": 8, + "value": "{beschreibung}", + "fontFamily": "Arial", + "fontSizePt": 8, + "minFontSizePt": 6, + "autoShrink": true, + "bold": false, + "italic": false, + "underline": false, + "horizontalAlign": "left", + "verticalAlign": "top", + "multiline": true + }, + { + "type": "qr", + "xMm": 41, + "yMm": 4, + "sizeMm": 16, + "value": "{qr}" + }, + { + "type": "line", + "x1Mm": 3, + "y1Mm": 23, + "x2Mm": 54, + "y2Mm": 23, + "strokeWidthMm": 0.3 + }, + { + "type": "text", + "xMm": 3, + "yMm": 24, + "widthMm": 51, + "heightMm": 5, + "value": "Nr: {nummer} | {datum:dd.MM.yyyy}", + "fontFamily": "Arial", + "fontSizePt": 7, + "minFontSizePt": 5, + "autoShrink": true, + "bold": false, + "italic": false, + "underline": false, + "horizontalAlign": "center", + "verticalAlign": "middle", + "multiline": false + } + ] +} +"""; +} diff --git a/src/LabelPrintAgent/Layout/LineElement.cs b/src/LabelPrintAgent/Layout/LineElement.cs new file mode 100644 index 0000000..8e52513 --- /dev/null +++ b/src/LabelPrintAgent/Layout/LineElement.cs @@ -0,0 +1,10 @@ +namespace LabelPrintAgent.Layout; + +public sealed class LineElement : LayoutElement +{ + public decimal X1Mm { get; set; } + public decimal Y1Mm { get; set; } + public decimal X2Mm { get; set; } + public decimal Y2Mm { get; set; } + public decimal StrokeWidthMm { get; set; } = 0.3m; +} diff --git a/src/LabelPrintAgent/Layout/QrCodeElement.cs b/src/LabelPrintAgent/Layout/QrCodeElement.cs new file mode 100644 index 0000000..0287896 --- /dev/null +++ b/src/LabelPrintAgent/Layout/QrCodeElement.cs @@ -0,0 +1,9 @@ +namespace LabelPrintAgent.Layout; + +public sealed class QrCodeElement : LayoutElement +{ + public decimal XMm { get; set; } + public decimal YMm { get; set; } + public decimal SizeMm { get; set; } + public string Value { get; set; } = string.Empty; +} diff --git a/src/LabelPrintAgent/Layout/RectangleElement.cs b/src/LabelPrintAgent/Layout/RectangleElement.cs new file mode 100644 index 0000000..0a6ee63 --- /dev/null +++ b/src/LabelPrintAgent/Layout/RectangleElement.cs @@ -0,0 +1,11 @@ +namespace LabelPrintAgent.Layout; + +public sealed class RectangleElement : LayoutElement +{ + public decimal XMm { get; set; } + public decimal YMm { get; set; } + public decimal WidthMm { get; set; } + public decimal HeightMm { get; set; } + public decimal StrokeWidthMm { get; set; } = 0.3m; + public bool Filled { get; set; } +} diff --git a/src/LabelPrintAgent/Layout/TextElement.cs b/src/LabelPrintAgent/Layout/TextElement.cs new file mode 100644 index 0000000..511e2ca --- /dev/null +++ b/src/LabelPrintAgent/Layout/TextElement.cs @@ -0,0 +1,20 @@ +namespace LabelPrintAgent.Layout; + +public sealed class TextElement : LayoutElement +{ + public decimal XMm { get; set; } + public decimal YMm { get; set; } + public decimal WidthMm { get; set; } + public decimal HeightMm { get; set; } + public string Value { get; set; } = string.Empty; + public string FontFamily { get; set; } = "Arial"; + public float FontSizePt { get; set; } = 9; + public float 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/Logging/LogSetup.cs b/src/LabelPrintAgent/Logging/LogSetup.cs new file mode 100644 index 0000000..182da6b --- /dev/null +++ b/src/LabelPrintAgent/Logging/LogSetup.cs @@ -0,0 +1,19 @@ +using Serilog; + +namespace LabelPrintAgent.Logging; + +public static class LogSetup +{ + public static void Configure(string logFolder) + { + Directory.CreateDirectory(logFolder); + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.File( + Path.Combine(logFolder, "label-print-agent-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 30, + shared: true) + .CreateLogger(); + } +} diff --git a/src/LabelPrintAgent/Printing/PrinterService.cs b/src/LabelPrintAgent/Printing/PrinterService.cs new file mode 100644 index 0000000..f330053 --- /dev/null +++ b/src/LabelPrintAgent/Printing/PrinterService.cs @@ -0,0 +1,29 @@ +using System.Drawing; +using System.Drawing.Printing; + +namespace LabelPrintAgent.Printing; + +public sealed class PrinterService +{ + private readonly WindowsImagePrinter _printer; + + public PrinterService(WindowsImagePrinter printer) + { + _printer = printer; + } + + public IReadOnlyList GetInstalledPrinters() + { + return PrinterSettings.InstalledPrinters.Cast().OrderBy(name => name).ToList(); + } + + public void Print(Bitmap bitmap, string printerName, decimal widthMm, decimal heightMm) + { + if (string.IsNullOrWhiteSpace(printerName)) + { + throw new InvalidOperationException("Bitte zuerst einen Drucker auswählen."); + } + + _printer.Print(bitmap, printerName, widthMm, heightMm); + } +} diff --git a/src/LabelPrintAgent/Printing/WindowsImagePrinter.cs b/src/LabelPrintAgent/Printing/WindowsImagePrinter.cs new file mode 100644 index 0000000..6a1efa8 --- /dev/null +++ b/src/LabelPrintAgent/Printing/WindowsImagePrinter.cs @@ -0,0 +1,42 @@ +using System.Drawing; +using System.Drawing.Printing; + +namespace LabelPrintAgent.Printing; + +public sealed class WindowsImagePrinter +{ + public void Print(Bitmap bitmap, string printerName, decimal widthMm, decimal heightMm) + { + using var document = new PrintDocument(); + document.PrinterSettings.PrinterName = printerName; + if (!document.PrinterSettings.IsValid) + { + throw new InvalidOperationException($"Drucker '{printerName}' ist nicht verfügbar."); + } + + document.DocumentName = "LabelPrintAgent Testdruck"; + document.DefaultPageSettings.PaperSize = new PaperSize( + "Label 57x32mm", + MmToHundredthsOfInch(widthMm), + MmToHundredthsOfInch(heightMm)); + document.DefaultPageSettings.Landscape = widthMm > heightMm; + document.DefaultPageSettings.Margins = new Margins(0, 0, 0, 0); + document.PrintPage += (_, args) => + { + if (args.Graphics is null) + { + throw new InvalidOperationException("Druckgrafik konnte nicht initialisiert werden."); + } + + args.Graphics.DrawImage(bitmap, args.PageBounds); + args.HasMorePages = false; + }; + + document.Print(); + } + + private static int MmToHundredthsOfInch(decimal mm) + { + return (int)Math.Round((double)mm / 25.4d * 100d); + } +} diff --git a/src/LabelPrintAgent/Rendering/LabelRenderer.cs b/src/LabelPrintAgent/Rendering/LabelRenderer.cs new file mode 100644 index 0000000..dfb265f --- /dev/null +++ b/src/LabelPrintAgent/Rendering/LabelRenderer.cs @@ -0,0 +1,165 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Drawing.Text; +using System.Text.Json; +using LabelPrintAgent.Layout; +using QRCoder; + +namespace LabelPrintAgent.Rendering; + +public sealed class LabelRenderer : IDisposable +{ + public RenderResult Render(LabelLayout layout, string payloadJson) + { + using var payload = JsonDocument.Parse(payloadJson); + var width = MmToPixels(layout.WidthMm, layout.Dpi); + var height = MmToPixels(layout.HeightMm, layout.Dpi); + var bitmap = new Bitmap(width, height, 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.RootElement, layout.Dpi); + break; + case LineElement line: + DrawLine(graphics, line, layout.Dpi); + break; + case RectangleElement rectangle: + DrawRectangle(graphics, rectangle, layout.Dpi); + break; + case QrCodeElement qr: + DrawQr(graphics, qr, payload.RootElement, layout.Dpi); + break; + } + } + + return new RenderResult(bitmap); + } + + private static void DrawText(Graphics graphics, TextElement element, JsonElement payload, int dpi) + { + var text = TemplateFormatter.Format(element.Value, payload); + var bounds = new RectangleF( + MmToPixelsF(element.XMm, dpi), + MmToPixelsF(element.YMm, dpi), + MmToPixelsF(element.WidthMm, dpi), + MmToPixelsF(element.HeightMm, dpi)); + var style = FontStyle.Regular; + if (element.Bold) style |= FontStyle.Bold; + if (element.Italic) style |= FontStyle.Italic; + if (element.Underline) style |= FontStyle.Underline; + + using var format = BuildStringFormat(element); + var fontSize = element.FontSizePt; + Font? font = null; + try + { + while (true) + { + font?.Dispose(); + font = new Font(element.FontFamily, fontSize, style, GraphicsUnit.Point); + var measured = graphics.MeasureString(text, font, bounds.Size, format); + if (!element.AutoShrink || fontSize <= element.MinFontSizePt || (measured.Width <= bounds.Width && measured.Height <= bounds.Height)) + { + break; + } + + fontSize -= 0.5f; + } + + using var brush = new SolidBrush(Color.Black); + graphics.DrawString(text, font, brush, bounds, format); + } + finally + { + font?.Dispose(); + } + } + + private static StringFormat BuildStringFormat(TextElement element) + { + var format = new StringFormat + { + Trimming = StringTrimming.EllipsisCharacter, + FormatFlags = element.Multiline ? 0 : StringFormatFlags.NoWrap + }; + + format.Alignment = element.HorizontalAlign.ToLowerInvariant() switch + { + "center" => StringAlignment.Center, + "right" => StringAlignment.Far, + _ => StringAlignment.Near + }; + format.LineAlignment = element.VerticalAlign.ToLowerInvariant() switch + { + "middle" => StringAlignment.Center, + "bottom" => StringAlignment.Far, + _ => StringAlignment.Near + }; + + return format; + } + + private static void DrawLine(Graphics graphics, LineElement element, int dpi) + { + using var pen = new Pen(Color.Black, Math.Max(1, MmToPixelsF(element.StrokeWidthMm, dpi))); + graphics.DrawLine( + pen, + MmToPixelsF(element.X1Mm, dpi), + MmToPixelsF(element.Y1Mm, dpi), + MmToPixelsF(element.X2Mm, dpi), + MmToPixelsF(element.Y2Mm, dpi)); + } + + private static void DrawRectangle(Graphics graphics, RectangleElement element, int dpi) + { + var rect = new RectangleF( + MmToPixelsF(element.XMm, dpi), + MmToPixelsF(element.YMm, dpi), + MmToPixelsF(element.WidthMm, dpi), + MmToPixelsF(element.HeightMm, dpi)); + if (element.Filled) + { + using var brush = new SolidBrush(Color.Black); + graphics.FillRectangle(brush, rect); + return; + } + + using var pen = new Pen(Color.Black, Math.Max(1, MmToPixelsF(element.StrokeWidthMm, dpi))); + graphics.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height); + } + + private static void DrawQr(Graphics graphics, QrCodeElement element, JsonElement payload, int dpi) + { + var text = TemplateFormatter.Format(element.Value, payload); + using var generator = new QRCodeGenerator(); + using var qrData = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.Q); + using var qrCode = new QRCode(qrData); + using var qrBitmap = qrCode.GetGraphic(12, Color.Black, Color.White, drawQuietZones: true); + graphics.DrawImage( + qrBitmap, + MmToPixelsF(element.XMm, dpi), + MmToPixelsF(element.YMm, dpi), + MmToPixelsF(element.SizeMm, dpi), + MmToPixelsF(element.SizeMm, dpi)); + } + + private static int MmToPixels(decimal mm, int dpi) => (int)Math.Round((double)mm / 25.4d * dpi); + + private static float MmToPixelsF(decimal mm, int dpi) => (float)((double)mm / 25.4d * dpi); + + public void Dispose() + { + } +} diff --git a/src/LabelPrintAgent/Rendering/RenderResult.cs b/src/LabelPrintAgent/Rendering/RenderResult.cs new file mode 100644 index 0000000..8541089 --- /dev/null +++ b/src/LabelPrintAgent/Rendering/RenderResult.cs @@ -0,0 +1,18 @@ +using System.Drawing; + +namespace LabelPrintAgent.Rendering; + +public sealed class RenderResult : IDisposable +{ + public RenderResult(Bitmap bitmap) + { + Bitmap = bitmap; + } + + public Bitmap Bitmap { get; } + + public void Dispose() + { + Bitmap.Dispose(); + } +} diff --git a/src/LabelPrintAgent/Rendering/TemplateFormatter.cs b/src/LabelPrintAgent/Rendering/TemplateFormatter.cs new file mode 100644 index 0000000..368b57f --- /dev/null +++ b/src/LabelPrintAgent/Rendering/TemplateFormatter.cs @@ -0,0 +1,47 @@ +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, JsonElement 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.TryGetProperty(field, out var value)) + { + return string.Empty; + } + + var typedValue = ConvertJsonValue(value); + if (format is null) + { + return Convert.ToString(typedValue, CultureInfo.CurrentCulture) ?? string.Empty; + } + + return string.Format(CultureInfo.CurrentCulture, "{0:" + format + "}", typedValue); + }); + } + + private static object? ConvertJsonValue(JsonElement value) + { + return value.ValueKind switch + { + JsonValueKind.String when DateTime.TryParse(value.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var date) => date, + JsonValueKind.String => 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, + _ => value.ToString() + }; + } + + [GeneratedRegex(@"\{(?[A-Za-z0-9_]+)(:(?[^}]+))?\}")] + private static partial Regex PlaceholderRegex(); +} diff --git a/src/LabelPrintAgent/Worker/PrintJobWorker.cs b/src/LabelPrintAgent/Worker/PrintJobWorker.cs new file mode 100644 index 0000000..8192c70 --- /dev/null +++ b/src/LabelPrintAgent/Worker/PrintJobWorker.cs @@ -0,0 +1,126 @@ +using LabelPrintAgent.Configuration; +using LabelPrintAgent.Database; +using LabelPrintAgent.Layout; +using LabelPrintAgent.Printing; +using LabelPrintAgent.Rendering; +using Serilog; + +namespace LabelPrintAgent.Worker; + +public sealed class PrintJobWorker : IDisposable +{ + private readonly SettingsStore _settingsStore; + private readonly LayoutStore _layoutStore; + private readonly LabelRenderer _renderer; + private readonly PrinterService _printerService; + private readonly MySqlLabelRepository _repository; + private readonly SemaphoreSlim _semaphore = new(1, 1); + private CancellationTokenSource? _cancellationTokenSource; + private Task? _task; + + public PrintJobWorker( + SettingsStore settingsStore, + LayoutStore layoutStore, + LabelRenderer renderer, + PrinterService printerService, + MySqlLabelRepository repository) + { + _settingsStore = settingsStore; + _layoutStore = layoutStore; + _renderer = renderer; + _printerService = printerService; + _repository = repository; + } + + public void Start() + { + if (_task is not null) + { + return; + } + + _cancellationTokenSource = new CancellationTokenSource(); + _task = Task.Run(() => RunAsync(_cancellationTokenSource.Token)); + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await PollOnceAsync(cancellationToken); + } + catch (Exception ex) + { + Log.Error(ex, "Print worker poll failed"); + } + + var settings = _settingsStore.Load(); + await Task.Delay(TimeSpan.FromSeconds(Math.Max(1, settings.Worker.PollIntervalSeconds)), cancellationToken); + } + } + + public async Task PollOnceAsync(CancellationToken cancellationToken = default) + { + if (!await _semaphore.WaitAsync(0, cancellationToken)) + { + return; + } + + try + { + var settings = _settingsStore.Load(); + if (string.IsNullOrWhiteSpace(settings.Database.Host) || string.IsNullOrWhiteSpace(settings.Printer.PrinterName)) + { + return; + } + + var jobs = await _repository.LoadPendingJobsAsync(5, cancellationToken); + foreach (var job in jobs) + { + await ProcessJobAsync(job, settings, cancellationToken); + } + } + finally + { + _semaphore.Release(); + } + } + + private async Task ProcessJobAsync(PrintJob job, AppSettings settings, CancellationToken cancellationToken) + { + try + { + if (!await _repository.MarkPrintingAsync(job.Id, cancellationToken)) + { + return; + } + + var layout = _layoutStore.LoadByKey(job.LayoutKey); + using var result = _renderer.Render(layout, job.PayloadJson); + _printerService.Print(result.Bitmap, settings.Printer.PrinterName, layout.WidthMm, layout.HeightMm); + await _repository.MarkPrintedAsync(job.Id, settings.Printer.PrinterName, cancellationToken); + } + catch (Exception ex) + { + Log.Error(ex, "Could not print job {JobId}", job.Id); + await _repository.MarkErrorAsync(job.Id, ex.Message, cancellationToken); + } + } + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + try + { + _task?.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + } + + _cancellationTokenSource?.Dispose(); + _semaphore.Dispose(); + } +} diff --git a/src/LabelPrintAgent/app.manifest b/src/LabelPrintAgent/app.manifest new file mode 100644 index 0000000..02ecb26 --- /dev/null +++ b/src/LabelPrintAgent/app.manifest @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + +