diff --git a/README.md b/README.md index f4a2e08..721fb1c 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,44 @@ # LabelPrintAgent -Windows-Tray-Anwendung zum Rendern und Drucken von Etiketten aus JSON-Layouts. +Windows-Tray-Anwendung für den späteren Etikettendruck aus JSON-Layouts. -## Stand +## Etappe 1 -Der erste lauffähige Schritt enthält: +Dieser Stand ist ein lauffähiges Grundgerüst: -- .NET-9-Windows-Forms-Projekt mit Tray-Icon -- Einstellungsdialog mit Tabs für Allgemein, Datenbank, Drucker, Layout und Fehlerjobs +- .NET-9-Windows-Forms-Projekt +- Tray-Icon mit Kontextmenü +- Einstellungsdialog mit Tabs: + - Allgemein + - Datenbank + - Drucker + - Layout + - Fehlerhafte Druckaufträge - lokale Konfiguration unter `C:\ProgramData\LabelPrintAgent\settings.json` +- automatische Anlage von `C:\ProgramData\LabelPrintAgent` +- Layout-Ordner unter `C:\ProgramData\LabelPrintAgent\layouts` +- Log-Ordner unter `C:\ProgramData\LabelPrintAgent\logs` - verschlüsselte Passwortspeicherung per Windows DPAPI -- Druckerauswahl über installierte Windows-Drucker -- Layout-JSON unter `C:\ProgramData\LabelPrintAgent\layouts` +- Auflistung installierter Windows-Drucker - 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 +- Layout-JSON laden, validieren und speichern -## Öffnen und Starten +Noch nicht enthalten sind MySQL-Worker, echtes Drucken, QR-Code-Rendering und die vollständige Rendering-Engine. + +## Startanleitung 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`. +3. Das Tray-Symbol anklicken oder per Kontextmenü `Einstellungen` öffnen. +4. Im Tab `Drucker` einen installierten Windows-Drucker auswählen und speichern. +5. Im Tab `Layout` das Beispiel-Layout prüfen, bearbeiten und speichern. -## Datenbank +Beim ersten Start werden die Programmdatenordner und das Beispiel-Layout automatisch angelegt. -Die Tabelle kann mit folgendem Skript angelegt werden: +## Spätere Etappen -```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`: +Die SQL-Datei für die spätere Druckwarteschlange liegt bereits unter: ```text -{titel} -{nummer} -{datum:dd.MM.yyyy} -{menge:0.00} +sql/create_label_print_queue.sql ``` - -Formatangaben werden über C#-Formatstrings ausgewertet. diff --git a/src/LabelPrintAgent/App/Program.cs b/src/LabelPrintAgent/App/Program.cs index afbd9c6..190b2b4 100644 --- a/src/LabelPrintAgent/App/Program.cs +++ b/src/LabelPrintAgent/App/Program.cs @@ -1,10 +1,7 @@ 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; @@ -26,13 +23,9 @@ internal static class Program 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(); + var printerService = new PrinterService(); - Application.Run(new TrayApplicationContext(settingsStore, layoutStore, renderer, printerService, repository, worker)); + Application.Run(new TrayApplicationContext(settingsStore, layoutStore, printerService)); } catch (Exception ex) { diff --git a/src/LabelPrintAgent/App/SettingsForm.cs b/src/LabelPrintAgent/App/SettingsForm.cs index 9dac898..ae8b323 100644 --- a/src/LabelPrintAgent/App/SettingsForm.cs +++ b/src/LabelPrintAgent/App/SettingsForm.cs @@ -1,10 +1,7 @@ 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; @@ -17,10 +14,7 @@ internal sealed class SettingsForm : Form 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 }; @@ -37,8 +31,15 @@ internal sealed class SettingsForm : Form 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 TextBox _layoutJson = new() + { + Multiline = true, + ScrollBars = ScrollBars.Both, + WordWrap = false, + Font = new Font(FontFamily.GenericMonospace, 9), + Width = 900, + Height = 460 + }; private readonly DataGridView _errorJobs = new() { @@ -47,28 +48,18 @@ internal sealed class SettingsForm : Form 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) + public SettingsForm(SettingsStore settingsStore, LayoutStore layoutStore, PrinterService printerService) { _settingsStore = settingsStore; _layoutStore = layoutStore; - _renderer = renderer; _printerService = printerService; - _repository = repository; - _worker = worker; Text = "LabelPrintAgent Einstellungen"; - Width = 1120; - Height = 760; + Width = 1040; + Height = 720; StartPosition = FormStartPosition.CenterScreen; var tabs = new TabControl { Dock = DockStyle.Fill }; @@ -79,12 +70,12 @@ internal sealed class SettingsForm : Form tabs.TabPages.Add(CreateErrorJobsTab()); Controls.Add(tabs); - Load += async (_, _) => + Load += (_, _) => { LoadSettingsIntoUi(); LoadPrinters(); LoadLayouts(); - await RefreshErrorJobsAsync(); + LoadEmptyErrorJobsTable(); }; } @@ -106,8 +97,8 @@ internal sealed class SettingsForm : Form 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())); + panel.Controls.Add(ButtonAt("Speichern", 140, 230, (_, _) => SaveDatabase())); + panel.Controls.Add(LabelAt("Verbindungstest folgt in Etappe 2.", 140, 276, 340)); return new TabPage("Datenbank") { Controls = { panel } }; } @@ -118,10 +109,8 @@ internal sealed class SettingsForm : Form 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; + panel.Controls.Add(ButtonAt("Speichern", 300, 150, (_, _) => SavePrinter())); + panel.Controls.Add(LabelAt("Testdruck folgt in Etappe 2.", 140, 198, 340)); return new TabPage("Drucker") { Controls = { panel } }; } @@ -133,26 +122,19 @@ internal sealed class SettingsForm : Form 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())); + panel.Controls.Add(ButtonAt("Validieren", 20, 540, (_, _) => ValidateLayout())); + panel.Controls.Add(ButtonAt("Speichern", 130, 540, (_, _) => SaveLayout())); + panel.Controls.Add(LabelAt("Vorschau und Rendering folgen in Etappe 2.", 250, 546, 360)); 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())); + 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 umgesetzt ist.", 12, 14, 600)); page.Controls.Add(_errorJobs); - page.Controls.Add(panel); + page.Controls.Add(footer); return page; } @@ -219,7 +201,6 @@ internal sealed class SettingsForm : Form try { _layoutJson.Text = _layoutStore.LoadJsonByKey(key); - RenderPreview(); } catch (Exception ex) { @@ -227,6 +208,14 @@ internal sealed class SettingsForm : Form } } + private void LoadEmptyErrorJobsTable() + { + _errorJobs.DataSource = new[] + { + new ErrorJobRow(0, string.Empty, string.Empty, 0) + }; + } + private void SaveGeneral() { var settings = _settingsStore.Load(); @@ -248,20 +237,6 @@ internal sealed class SettingsForm : Form 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(); @@ -269,6 +244,7 @@ internal sealed class SettingsForm : Form settings.Printer.LabelWidthMm = _labelWidth.Value; settings.Printer.LabelHeightMm = _labelHeight.Value; _settingsStore.Save(settings); + MessageBox.Show("Druckereinstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); } private void ValidateLayout() @@ -298,115 +274,6 @@ internal sealed class SettingsForm : Form } } - 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); @@ -430,6 +297,19 @@ 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) { var row = new FlowLayoutPanel @@ -458,20 +338,5 @@ internal sealed class SettingsForm : Form 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; - } + private sealed record ErrorJobRow(long Id, string Layout, string Fehler, int Versuche); } diff --git a/src/LabelPrintAgent/App/TrayApplicationContext.cs b/src/LabelPrintAgent/App/TrayApplicationContext.cs index b1e0bb6..8a7bd8d 100644 --- a/src/LabelPrintAgent/App/TrayApplicationContext.cs +++ b/src/LabelPrintAgent/App/TrayApplicationContext.cs @@ -1,9 +1,6 @@ using LabelPrintAgent.Configuration; -using LabelPrintAgent.Database; using LabelPrintAgent.Layout; using LabelPrintAgent.Printing; -using LabelPrintAgent.Rendering; -using LabelPrintAgent.Worker; using Serilog; namespace LabelPrintAgent.App; @@ -13,26 +10,17 @@ 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) + PrinterService printerService) { _settingsStore = settingsStore; _layoutStore = layoutStore; - _renderer = renderer; _printerService = printerService; - _repository = repository; - _worker = worker; var menu = new ContextMenuStrip(); menu.Items.Add("Einstellungen", null, (_, _) => ShowSettings()); @@ -60,7 +48,7 @@ internal sealed class TrayApplicationContext : ApplicationContext { if (_settingsForm is null || _settingsForm.IsDisposed) { - _settingsForm = new SettingsForm(_settingsStore, _layoutStore, _renderer, _printerService, _repository, _worker); + _settingsForm = new SettingsForm(_settingsStore, _layoutStore, _printerService); } _settingsForm.Show(); 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 cef52f7..0000000 --- a/src/LabelPrintAgent/Database/MySqlLabelRepository.cs +++ /dev/null @@ -1,162 +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> 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 deleted file mode 100644 index 1134d4e..0000000 --- a/src/LabelPrintAgent/Database/PrintJob.cs +++ /dev/null @@ -1,15 +0,0 @@ -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 index 7e0487e..73cf90e 100644 --- a/src/LabelPrintAgent/LabelPrintAgent.csproj +++ b/src/LabelPrintAgent/LabelPrintAgent.csproj @@ -10,11 +10,7 @@ - - - - diff --git a/src/LabelPrintAgent/Printing/PrinterService.cs b/src/LabelPrintAgent/Printing/PrinterService.cs index f330053..37535ce 100644 --- a/src/LabelPrintAgent/Printing/PrinterService.cs +++ b/src/LabelPrintAgent/Printing/PrinterService.cs @@ -1,29 +1,11 @@ -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 deleted file mode 100644 index 6a1efa8..0000000 --- a/src/LabelPrintAgent/Printing/WindowsImagePrinter.cs +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index dfb265f..0000000 --- a/src/LabelPrintAgent/Rendering/LabelRenderer.cs +++ /dev/null @@ -1,165 +0,0 @@ -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 deleted file mode 100644 index 8541089..0000000 --- a/src/LabelPrintAgent/Rendering/RenderResult.cs +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 368b57f..0000000 --- a/src/LabelPrintAgent/Rendering/TemplateFormatter.cs +++ /dev/null @@ -1,47 +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, 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 deleted file mode 100644 index 8192c70..0000000 --- a/src/LabelPrintAgent/Worker/PrintJobWorker.cs +++ /dev/null @@ -1,126 +0,0 @@ -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(); - } -}