Initial LabelPrintAgent scaffold
This commit is contained in:
+20
@@ -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/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ErrorJobRow>();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<AppSettings>(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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<IReadOnlyList<PrintJob>> 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<PrintJob>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
jobs.Add(ReadJob(reader));
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PrintJob>> 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<PrintJob>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
jobs.Add(ReadJob(reader));
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
public async Task<bool> 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<int> 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")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageReference Include="MySqlConnector" Version="2.4.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<LayoutElement> 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;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public enum HorizontalAlign
|
||||
{
|
||||
Left,
|
||||
Center,
|
||||
Right
|
||||
}
|
||||
|
||||
public enum VerticalAlign
|
||||
{
|
||||
Top,
|
||||
Middle,
|
||||
Bottom
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public sealed class LayoutElementJsonConverter : JsonConverter<LayoutElement>
|
||||
{
|
||||
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<TextElement>(raw, options) ?? throw new JsonException(),
|
||||
"line" => JsonSerializer.Deserialize<LineElement>(raw, options) ?? throw new JsonException(),
|
||||
"rectangle" => JsonSerializer.Deserialize<RectangleElement>(raw, options) ?? throw new JsonException(),
|
||||
"qr" => JsonSerializer.Deserialize<QrCodeElement>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<LabelLayout>(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<LabelLayout>(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
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<string> GetInstalledPrinters()
|
||||
{
|
||||
return PrinterSettings.InstalledPrinters.Cast<string>().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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(@"\{(?<field>[A-Za-z0-9_]+)(:(?<format>[^}]+))?\}")]
|
||||
private static partial Regex PlaceholderRegex();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="LabelPrintAgent.app"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
Reference in New Issue
Block a user