From 593b46e759ab663f1c5683fa65baac3851a0fde3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:48:33 +0000 Subject: [PATCH 1/3] feat(files): print station configuration, exit PIN and scanner sequencing Add StationConfigLoader (station.json next to the labels, a folder, a single label or the ProgramData policy file), validated with StationConfig.* message keys; StationPin (PBKDF2-SHA256 hash and constant-time verify); and the UI-free ScanSequencer that turns Enter, Tab and Shift+Tab into field moves, print and label selection. --- src/Rotula.Files/Station/ScanSequencer.cs | 127 +++++++++ src/Rotula.Files/Station/StationConfig.cs | 137 ++++++++++ .../Station/StationConfigLoader.cs | 252 ++++++++++++++++++ src/Rotula.Files/Station/StationPin.cs | 81 ++++++ tests/Rotula.Tests/StationConfigTests.cs | 226 ++++++++++++++++ tests/Rotula.Tests/StationScanTests.cs | 147 ++++++++++ 6 files changed, 970 insertions(+) create mode 100644 src/Rotula.Files/Station/ScanSequencer.cs create mode 100644 src/Rotula.Files/Station/StationConfig.cs create mode 100644 src/Rotula.Files/Station/StationConfigLoader.cs create mode 100644 src/Rotula.Files/Station/StationPin.cs create mode 100644 tests/Rotula.Tests/StationConfigTests.cs create mode 100644 tests/Rotula.Tests/StationScanTests.cs diff --git a/src/Rotula.Files/Station/ScanSequencer.cs b/src/Rotula.Files/Station/ScanSequencer.cs new file mode 100644 index 0000000..b8fdc02 --- /dev/null +++ b/src/Rotula.Files/Station/ScanSequencer.cs @@ -0,0 +1,127 @@ +namespace Rotula.Files.Station; + +/// The key that ended an entry: keyboard-wedge scanners send their code followed by Enter or Tab. +public enum ScanTerminator +{ + /// Enter (the usual scanner suffix). + Enter, + /// Tab. + Tab, + /// Shift+Tab: the operator goes back one field. + ShiftTab, +} + +/// What the station does after a terminator. +public enum ScanStepKind +{ + /// Stay in the field (its answer was refused; the next scan replaces it). + Stay, + /// Move the keyboard to the field . + MoveTo, + /// The last field is done: move the keyboard to the quantity and the Print button, without printing. + FocusPrint, + /// The last field is done and "print on last scan" is on: print now. + Print, +} + +/// One decision of the . +/// What to do. +/// The field to focus (for and ), otherwise -1. +public readonly record struct ScanStep(ScanStepKind Kind, int FieldIndex = -1); + +/// Outcome of a scan on the label list. +public enum LabelScanKind +{ + /// Nothing was scanned (an empty entry). + None, + /// The code is the key of a label, or the only label the search finds: open it. + Open, + /// No label has this key and the search does not narrow the list to one: say so and keep the list. + Unknown, +} + +/// +/// The scanner logic of the print station, without any window: keyboard-wedge scanners type the code and end it with Enter or Tab, which is all +/// the station relies on (no timing guesses). The window reports each terminator; this class decides where the keyboard goes next and when to print. +/// +/// +/// Questions. Enter or Tab in field i moves to field i + 1; Shift+Tab moves back. A refused answer keeps the keyboard in its +/// field so the operator can scan again. On the last field the station prints at once when "print on last scan" is on; otherwise the keyboard moves to +/// the Print button, and the operator presses it (or Enter there). +/// Choosing a label. On the label list, a scan is matched against the labels' keys (ignoring case and surrounding spaces). With +/// "scan to select" off, or no key matching, a search that leaves exactly one label opens it as well, so typing a name and pressing Enter works too. +/// +public sealed class ScanSequencer +{ + /// Creates the sequencer. + /// The terminator of the last question prints at once. + /// Scanning a label's key on the label list opens the label. + public ScanSequencer(bool printOnLastScan, bool scanToSelect) + { + PrintOnLastScan = printOnLastScan; + ScanToSelect = scanToSelect; + } + + /// The terminator of the last question prints at once. + public bool PrintOnLastScan { get; } + + /// Scanning a label's key on the label list opens the label. + public bool ScanToSelect { get; } + + /// Decides what happens after a terminator in a question field. + /// The field the terminator was typed in (0-based). + /// How many question fields the label has. + /// The key that ended the entry. + /// False when the answer of this field breaks the label's own rules (required, length, pattern...). + public ScanStep Next(int fieldIndex, int fieldCount, ScanTerminator terminator, bool answerAccepted) + { + if (fieldCount <= 0) return new ScanStep(PrintOnLastScan && terminator == ScanTerminator.Enter ? ScanStepKind.Print : ScanStepKind.FocusPrint); + fieldIndex = Math.Clamp(fieldIndex, 0, fieldCount - 1); + if (terminator == ScanTerminator.ShiftTab) return new ScanStep(ScanStepKind.MoveTo, Math.Max(0, fieldIndex - 1)); + // A refused scan stays where it is: moving on would leave a wrong value behind a field the operator no longer looks at. + if (!answerAccepted) return new ScanStep(ScanStepKind.Stay, fieldIndex); + if (fieldIndex < fieldCount - 1) return new ScanStep(ScanStepKind.MoveTo, fieldIndex + 1); + return new ScanStep(PrintOnLastScan ? ScanStepKind.Print : ScanStepKind.FocusPrint); + } + + /// + /// Decides which label a scan (or a typed search ended with Enter) on the label list opens. + /// + /// The label type. + /// What was scanned or typed. + /// Every label of the station. + /// The scan key of a label (null when it has none). + /// The labels the search box shows for . + /// The label to open, when the result is . + public LabelScanKind SelectLabel(string? code, IEnumerable labels, Func keyOf, IReadOnlyList searchHits, out T? match) + where T : class + { + match = null; + string scanned = Clean(code); + if (scanned.Length == 0) return LabelScanKind.None; + if (ScanToSelect) + { + match = labels.FirstOrDefault(l => keyOf(l) is { } key && string.Equals(Clean(key), scanned, StringComparison.OrdinalIgnoreCase)); + if (match is not null) return LabelScanKind.Open; + } + if (searchHits.Count == 1) + { + match = searchHits[0]; + return LabelScanKind.Open; + } + return LabelScanKind.Unknown; + } + + /// + /// What a scanner typed, without the characters a scanner may add around the code (control characters such as a stray CR or a GS separator at the + /// ends) and surrounding spaces. Inner GS separators of a GS1 code are kept. + /// + public static string Clean(string? scanned) + { + if (string.IsNullOrEmpty(scanned)) return string.Empty; + int start = 0, end = scanned.Length; + while (start < end && (char.IsControl(scanned[start]) || char.IsWhiteSpace(scanned[start]))) start++; + while (end > start && (char.IsControl(scanned[end - 1]) || char.IsWhiteSpace(scanned[end - 1]))) end--; + return scanned[start..end]; + } +} diff --git a/src/Rotula.Files/Station/StationConfig.cs b/src/Rotula.Files/Station/StationConfig.cs new file mode 100644 index 0000000..2352e79 --- /dev/null +++ b/src/Rotula.Files/Station/StationConfig.cs @@ -0,0 +1,137 @@ +using Rotula.Core.Validation; + +namespace Rotula.Files.Station; + +/// +/// The message keys of the print station configuration. The app shows them as Issue.<key>; the arguments are described on each +/// constant. Errors stop the station from opening; warnings are shown on the station screen and the station still opens. +/// +public static class StationKeys +{ + /// Error: the configuration file or folder does not exist. Args: the path. + public const string NotFound = "StationConfig.NotFound"; + + /// Error: the configuration file could not be read (locked, no permission). Args: the path. + public const string ReadFailed = "StationConfig.ReadFailed"; + + /// Error: the configuration file is not valid JSON or has a setting of the wrong type or an unknown name. Args: the path, the line number, the setting's JSON path. + public const string InvalidJson = "StationConfig.InvalidJson"; + + /// Error: the file was written for a newer version of Rotula. Args: the version in the file. + public const string NewerVersion = "StationConfig.NewerVersion"; + + /// Error: no label can be offered (none listed, none found in the folder, or none of the listed ones exists). + public const string NoLabels = "StationConfig.NoLabels"; + + /// Warning: a listed label file does not exist and is left out. Args: the path as written. + public const string LabelMissing = "StationConfig.LabelMissing"; + + /// Error: a listed file is not a .rotula label. Args: the path as written. + public const string LabelNotRotula = "StationConfig.LabelNotRotula"; + + /// Error: a label entry has no file. Args: the position of the entry (1-based). + public const string LabelNoFile = "StationConfig.LabelNoFile"; + + /// Error: two labels share one scan key. Args: the key. + public const string DuplicateKey = "StationConfig.DuplicateKey"; + + /// Error: a label (or the default) names a printer the file does not define. Args: the printer name. + public const string UnknownPrinter = "StationConfig.UnknownPrinter"; + + /// Error: a printer has neither a Windows printer nor a host, or both. Args: the printer name. + public const string PrinterTarget = "StationConfig.PrinterTarget"; + + /// Error: a printer's port is outside 1-65535. Args: the printer name. + public const string PrinterPort = "StationConfig.PrinterPort"; + + /// Error: a printer's resolution is not 203, 300 or 600. Args: the printer name. + public const string PrinterDpi = "StationConfig.PrinterDpi"; + + /// Error: a printer's darkness is outside 0-30 or its speed outside 1-14. Args: the printer name. + public const string PrinterProfile = "StationConfig.PrinterProfile"; + + /// Error: the station's copies settings are outside 1 to , or the default is above the maximum. + public const string Copies = "StationConfig.Copies"; + + /// Error: the copies settings of one label are outside 1 to , or its default is above its maximum. Args: the label name. + public const string LabelCopies = "StationConfig.LabelCopies"; + + /// Error: the exit PIN is not a hash made by Rotula (a PIN must never be stored as plain text). + public const string PinHash = "StationConfig.PinHash"; +} + +/// A printer the station sends to. Exactly one of and is set. +/// The name used in the configuration file. +/// An installed Windows printer queue (USB, shared, network queue), or null. +/// Host name or IP address of a network printer reached directly on , or null. +/// TCP port of a network printer. +/// Printhead resolution (203, 300 or 600); null prints at the label's own resolution. +/// Darkness 0-30 sent before the label, or null to leave the printer as it is. +/// Print speed 1-14, or null to leave the printer as it is. +/// Ask the printer for its status before and after every job (on by default at a station). +public sealed record StationPrinter(string Name, string? WindowsPrinter, string? Host, int Port, int? Dpi, int? Darkness, int? Speed, bool CheckStatus); + +/// One label offered at the station. +/// Full path of the .rotula file. +/// Name shown on the tile (the file name when the configuration gives none). +/// Code that selects this label when it is scanned on the label list, or null. +/// Where it prints; null means the printer last used in the designer. +/// Copies shown when the label opens; null uses the label's own quantity (within the maximum). +/// The most copies one print may have. +/// The operator may print the records of the data the label was saved with. +/// After printing, every answer stays for the next label instead of going back to its default. +/// Answers (by prompt name) that stay after printing even when is false. +public sealed record StationLabel(string Path, string Name, string? Key, StationPrinter? Printer, int? DefaultCopies, int MaxCopies, bool AllowDataMerge, + bool KeepAllValues, IReadOnlyList KeepFields); + +/// A checked print station configuration, ready to open. +public sealed class StationConfig +{ + /// The configuration format this version reads and writes. + public const int CurrentFormatVersion = 1; + + /// Largest number of copies any setting may allow (the Print dialog's limit). + public const int CopiesLimit = 99_999_999; + + /// Maximum copies per print when the configuration does not say. + public const int DefaultMaxCopies = 100; + + /// The file name the station looks for in a folder, and in the shared policy folder. + public const string FileName = "station.json"; + + /// The configuration file that was read, or null when the station runs on defaults (a folder of labels without a station.json). + public string? ConfigPath { get; init; } + + /// The folder the labels are offered from. + public string LabelFolder { get; init; } = string.Empty; + + /// Title shown at the top of the station; empty uses the product's own title. + public string Title { get; init; } = string.Empty; + + /// The labels, in the order of the file (or by name for a folder). + public IReadOnlyList Labels { get; init; } = Array.Empty(); + + /// Hash of the exit PIN (), or null when the station can be closed without one. + public string? ExitPinHash { get; init; } + + /// The scanner's terminator on the last question prints at once. + public bool PrintOnLastScan { get; init; } = true; + + /// Scanning a label's key on the label list opens that label. + public bool ScanToSelect { get; init; } = true; + + /// The station fills the whole screen without a title bar. + public bool FullScreen { get; init; } = true; +} + +/// Outcome of reading a station configuration: the configuration (null when an error stops it) and every problem found. +/// The configuration, or null when holds an error. +/// Errors and warnings with message keys. +public sealed record StationLoadResult(StationConfig? Config, IReadOnlyList Issues) +{ + /// True when the station can open. + public bool CanOpen => Config is not null; + + /// The warnings to show on the station screen. + public IReadOnlyList Warnings => Issues.Where(i => i.Severity == ValidationSeverity.Warning).ToList(); +} diff --git a/src/Rotula.Files/Station/StationConfigLoader.cs b/src/Rotula.Files/Station/StationConfigLoader.cs new file mode 100644 index 0000000..2ec1026 --- /dev/null +++ b/src/Rotula.Files/Station/StationConfigLoader.cs @@ -0,0 +1,252 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Rotula.Core.Validation; + +namespace Rotula.Files.Station; + +/// +/// Reads and checks a print station configuration (station.json). The format is described in docs/print-station.md. Every problem is a +/// with a key, never an exception, so the app can say in plain words what to fix. +/// +/// +/// What the station is started with decides where the configuration comes from: +/// a .json file is the configuration; a folder uses its station.json or, without one, offers every label in the folder with default +/// settings; a .rotula file offers that label only, with the settings of a station.json next to it when there is one; nothing at all +/// uses the shared policy file %ProgramData%\Rotula\station.json. The configuration is written by whoever sets up the station, not +/// opened from an untrusted label, so its paths are used as written (relative ones from the configuration's folder). +/// +public static class StationConfigLoader +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + // A misspelt setting ("maxCopy") must not be ignored silently: the station would run without the limit the administrator meant. + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + /// The shared policy file used when the station is started without a path. + public static string PolicyPath => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Rotula", StationConfig.FileName); + + /// Reads the configuration for what the station was started with (see the remarks of the class); null or empty uses . + public static StationLoadResult Load(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return LoadFile(PolicyPath, null); + string full; + try { full = Path.GetFullPath(path.Trim().Trim('"')); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return Fail(StationKeys.NotFound, path); + } + if (Directory.Exists(full)) + { + string json = Path.Combine(full, StationConfig.FileName); + return File.Exists(json) ? LoadFile(json, null) : FromFolder(full); + } + if (!File.Exists(full)) return Fail(StationKeys.NotFound, full); + if (IsLabelFile(full)) + { + string json = Path.Combine(Path.GetDirectoryName(full)!, StationConfig.FileName); + return File.Exists(json) ? LoadFile(json, full) : FromFolder(Path.GetDirectoryName(full)!, full); + } + return LoadFile(full, null); + } + + /// Checks configuration text as if it were the file (relative label paths start at its folder). + /// The file content. + /// Where the file is (or would be). + /// When set, the station offers only this label file. + public static StationLoadResult Parse(string json, string configPath, string? onlyLabel = null) + { + StationFileDto? dto; + try { dto = JsonSerializer.Deserialize(json, Options); } + catch (JsonException ex) + { + // The line number and the setting's place in the file let the administrator find the mistake. + return Fail(StationKeys.InvalidJson, configPath, ex.LineNumber is { } line ? (line + 1).ToString(System.Globalization.CultureInfo.InvariantCulture) : "?", + ex.Path ?? string.Empty); + } + if (dto is null) return Fail(StationKeys.InvalidJson, configPath, "1", string.Empty); + return Build(dto, configPath, onlyLabel); + } + + private static StationLoadResult LoadFile(string configPath, string? onlyLabel) + { + if (!File.Exists(configPath)) return Fail(StationKeys.NotFound, configPath); + string json; + try { json = File.ReadAllText(configPath); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return Fail(StationKeys.ReadFailed, configPath); + } + return Parse(json, configPath, onlyLabel); + } + + /// A folder without a configuration: every label in it, default settings, the designer's printer. + private static StationLoadResult FromFolder(string folder, string? onlyLabel = null) => + Build(new StationFileDto(), Path.Combine(folder, StationConfig.FileName), onlyLabel, fileExists: false); + + private static StationLoadResult Build(StationFileDto dto, string configPath, string? onlyLabel, bool fileExists = true) + { + var issues = new List(); + void Error(string key, params string[] args) => issues.Add(new ValidationIssue(ValidationSeverity.Error, key, null, args)); + string baseDir = Path.GetDirectoryName(Path.GetFullPath(configPath))!; + + if (dto.FormatVersion > StationConfig.CurrentFormatVersion) + return Fail(StationKeys.NewerVersion, dto.FormatVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + // ---- printers + var printers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (name, p) in dto.Printers ?? new Dictionary()) + { + if (p is null) { Error(StationKeys.PrinterTarget, name); continue; } + bool hasQueue = !string.IsNullOrWhiteSpace(p.WindowsPrinter), hasHost = !string.IsNullOrWhiteSpace(p.Host); + if (hasQueue == hasHost) Error(StationKeys.PrinterTarget, name); + if (p.Port is < 1 or > 65535) Error(StationKeys.PrinterPort, name); + if (p.Dpi is not null and not (203 or 300 or 600)) Error(StationKeys.PrinterDpi, name); + if (p.Darkness is < 0 or > 30 || p.Speed is < 1 or > 14) Error(StationKeys.PrinterProfile, name); + printers[name] = new StationPrinter(name, hasQueue ? p.WindowsPrinter!.Trim() : null, hasHost ? p.Host!.Trim() : null, + p.Port ?? 9100, p.Dpi, p.Darkness, p.Speed, p.CheckStatus ?? true); + } + StationPrinter? Printer(string? name) + { + if (string.IsNullOrWhiteSpace(name)) return null; + if (printers.TryGetValue(name.Trim(), out var found)) return found; + Error(StationKeys.UnknownPrinter, name.Trim()); + return null; + } + var defaultPrinter = Printer(dto.DefaultPrinter); + // With one printer and no default, that printer is the default: the common single-printer station needs no extra line. + if (defaultPrinter is null && string.IsNullOrWhiteSpace(dto.DefaultPrinter) && printers.Count == 1) defaultPrinter = printers.Values.First(); + + // ---- copies + int maxCopies = dto.MaxCopies ?? StationConfig.DefaultMaxCopies; + if (!CopiesOk(dto.DefaultCopies, maxCopies)) Error(StationKeys.Copies); + + // ---- PIN + string? pin = string.IsNullOrWhiteSpace(dto.ExitPinHash) ? null : dto.ExitPinHash.Trim(); + if (pin is not null && !StationPin.IsHash(pin)) Error(StationKeys.PinHash); + + // ---- labels + string folder = string.IsNullOrWhiteSpace(dto.LabelFolder) ? baseDir : Resolve(baseDir, dto.LabelFolder); + var entries = new List(); + if (dto.Labels is { Count: > 0 } listed) entries.AddRange(listed); + else if (Directory.Exists(folder)) + { + // Without a list, every label in the folder (not its sub-folders), by name, so the order is the same on every PC. + foreach (var file in Directory.EnumerateFiles(folder, "*.rotula").OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)) + entries.Add(new StationLabelDto { File = file }); + } + else if (dto.LabelFolder is not null) Error(StationKeys.NotFound, folder); + + var labels = new List(); + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < entries.Count; i++) + { + var e = entries[i]; + if (e is null || string.IsNullOrWhiteSpace(e.File)) { Error(StationKeys.LabelNoFile, (i + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)); continue; } + string path = Resolve(folder, e.File); + if (!IsLabelFile(path)) { Error(StationKeys.LabelNotRotula, e.File); continue; } + if (onlyLabel is not null && !string.Equals(path, Path.GetFullPath(onlyLabel), StringComparison.OrdinalIgnoreCase)) continue; + if (!File.Exists(path)) { issues.Add(new ValidationIssue(ValidationSeverity.Warning, StationKeys.LabelMissing, null, e.File)); continue; } + string name = string.IsNullOrWhiteSpace(e.Name) ? Path.GetFileNameWithoutExtension(path) : e.Name.Trim(); + string? key = string.IsNullOrWhiteSpace(e.Key) ? null : e.Key.Trim(); + if (key is not null && !keys.Add(key)) Error(StationKeys.DuplicateKey, key); + int max = e.MaxCopies ?? maxCopies; + int? def = e.DefaultCopies ?? dto.DefaultCopies; + if ((e.MaxCopies is not null || e.DefaultCopies is not null) && !CopiesOk(def, max)) Error(StationKeys.LabelCopies, name); + labels.Add(new StationLabel(path, name, key, string.IsNullOrWhiteSpace(e.Printer) ? defaultPrinter : Printer(e.Printer), def, max, + e.AllowDataMerge ?? dto.AllowDataMerge ?? false, e.KeepValues ?? dto.KeepValues ?? false, + (e.KeepFields ?? new List()).Where(f => !string.IsNullOrWhiteSpace(f)).Select(f => f.Trim()).ToList())); + } + // The label the station was started with is offered even when the configuration next to it does not list it. + if (onlyLabel is not null && labels.Count == 0 && File.Exists(onlyLabel) && !issues.Any(x => x.Severity == ValidationSeverity.Error)) + { + string full = Path.GetFullPath(onlyLabel); + labels.Add(new StationLabel(full, Path.GetFileNameWithoutExtension(full), null, defaultPrinter, dto.DefaultCopies, maxCopies, + dto.AllowDataMerge ?? false, dto.KeepValues ?? false, Array.Empty())); + } + if (labels.Count == 0 && !issues.Any(x => x.Severity == ValidationSeverity.Error)) Error(StationKeys.NoLabels); + + if (issues.Any(x => x.Severity == ValidationSeverity.Error)) return new StationLoadResult(null, issues); + var config = new StationConfig + { + ConfigPath = fileExists ? Path.GetFullPath(configPath) : null, + LabelFolder = folder, + Title = dto.Title?.Trim() ?? string.Empty, + Labels = labels, + ExitPinHash = pin, + PrintOnLastScan = dto.PrintOnLastScan ?? true, + ScanToSelect = dto.ScanToSelect ?? true, + FullScreen = dto.FullScreen ?? true, + }; + return new StationLoadResult(config, issues); + } + + private static bool CopiesOk(int? defaultCopies, int max) => + max is >= 1 and <= StationConfig.CopiesLimit && (defaultCopies is null || (defaultCopies >= 1 && defaultCopies <= max)); + + private static bool IsLabelFile(string path) => path.EndsWith(".rotula", StringComparison.OrdinalIgnoreCase); + + private static string Resolve(string baseDir, string path) + { + string p = path.Trim(); + try { return Path.GetFullPath(Path.IsPathRooted(p) ? p : Path.Combine(baseDir, p)); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return Path.Combine(baseDir, p); + } + } + + private static StationLoadResult Fail(string key, params string[] args) => + new(null, new[] { new ValidationIssue(ValidationSeverity.Error, key, null, args) }); + + // ------------------------------------------------------------------------------------ file shape + + private sealed class StationFileDto + { + public int FormatVersion { get; set; } = 1; + public string? Title { get; set; } + public string? LabelFolder { get; set; } + public List? Labels { get; set; } + public Dictionary? Printers { get; set; } + public string? DefaultPrinter { get; set; } + public int? DefaultCopies { get; set; } + public int? MaxCopies { get; set; } + public bool? AllowDataMerge { get; set; } + public bool? KeepValues { get; set; } + public string? ExitPinHash { get; set; } + public bool? PrintOnLastScan { get; set; } + public bool? ScanToSelect { get; set; } + public bool? FullScreen { get; set; } + [JsonPropertyName("$schema")] + public string? Schema { get; set; } + } + + private sealed class StationLabelDto + { + public string? File { get; set; } + public string? Name { get; set; } + public string? Key { get; set; } + public string? Printer { get; set; } + public int? DefaultCopies { get; set; } + public int? MaxCopies { get; set; } + public bool? AllowDataMerge { get; set; } + public bool? KeepValues { get; set; } + public List? KeepFields { get; set; } + } + + private sealed class StationPrinterDto + { + public string? WindowsPrinter { get; set; } + public string? Host { get; set; } + public int? Port { get; set; } + public int? Dpi { get; set; } + public int? Darkness { get; set; } + public int? Speed { get; set; } + public bool? CheckStatus { get; set; } + } +} diff --git a/src/Rotula.Files/Station/StationPin.cs b/src/Rotula.Files/Station/StationPin.cs new file mode 100644 index 0000000..f946663 --- /dev/null +++ b/src/Rotula.Files/Station/StationPin.cs @@ -0,0 +1,81 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Rotula.Files.Station; + +/// +/// The exit PIN of the print station. The configuration only ever holds a salted PBKDF2-SHA256 hash +/// (pbkdf2-sha256$<iterations>$<salt>$<hash>, both in Base64), so a station.json that is read by everyone on the PC does not +/// give the PIN away. Comparison runs in constant time. +/// +public static class StationPin +{ + /// The prefix of every hash this version writes. + public const string Scheme = "pbkdf2-sha256"; + + /// Iterations of a new hash (the OWASP recommendation for PBKDF2-SHA256). + public const int DefaultIterations = 600_000; + + /// Shortest PIN accepted. + public const int MinLength = 4; + + /// Longest PIN accepted. + public const int MaxLength = 32; + + // Bounds for a hash read from a file: fewer iterations would be weak, many more would let a crafted file freeze the station on every attempt. + private const int MinIterations = 10_000; + private const int MaxIterations = 10_000_000; + private const int SaltBytes = 16; + private const int HashBytes = 32; + + /// True when may be used: 4 to 32 characters without control characters, and not only spaces. + public static bool IsValidPin(string? pin) => + pin is not null && pin.Trim().Length is >= MinLength and <= MaxLength && !pin.Any(char.IsControl); + + /// Hashes a new PIN with a random salt. Throws when is false. + /// The PIN as typed (surrounding spaces are ignored). + /// PBKDF2 iterations; tests pass a smaller number to stay fast. + public static string Hash(string pin, int iterations = DefaultIterations) + { + if (!IsValidPin(pin)) throw new ArgumentException("The PIN must have 4 to 32 characters.", nameof(pin)); + if (iterations is < MinIterations or > MaxIterations) throw new ArgumentOutOfRangeException(nameof(iterations)); + byte[] salt = RandomNumberGenerator.GetBytes(SaltBytes); + byte[] hash = Derive(pin, salt, iterations); + return string.Join('$', Scheme, iterations.ToString(CultureInfo.InvariantCulture), Convert.ToBase64String(salt), Convert.ToBase64String(hash)); + } + + /// True when has the shape of a hash made by . + public static bool IsHash(string? stored) => TryParse(stored, out _, out _, out _); + + /// True when is the PIN of . A malformed hash never matches. + public static bool Verify(string? pin, string? stored) + { + if (pin is null || !TryParse(stored, out int iterations, out byte[] salt, out byte[] expected)) return false; + if (!IsValidPin(pin)) return false; + return CryptographicOperations.FixedTimeEquals(Derive(pin, salt, iterations), expected); + } + + private static byte[] Derive(string pin, byte[] salt, int iterations) => + Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(pin.Trim()), salt, iterations, HashAlgorithmName.SHA256, HashBytes); + + private static bool TryParse(string? stored, out int iterations, out byte[] salt, out byte[] hash) + { + iterations = 0; + salt = hash = Array.Empty(); + if (string.IsNullOrWhiteSpace(stored)) return false; + var parts = stored.Trim().Split('$'); + if (parts.Length != 4 || !string.Equals(parts[0], Scheme, StringComparison.Ordinal)) return false; + if (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out iterations) || iterations is < MinIterations or > MaxIterations) return false; + try + { + salt = Convert.FromBase64String(parts[2]); + hash = Convert.FromBase64String(parts[3]); + } + catch (FormatException) + { + return false; + } + return salt.Length >= 8 && hash.Length == HashBytes; + } +} diff --git a/tests/Rotula.Tests/StationConfigTests.cs b/tests/Rotula.Tests/StationConfigTests.cs new file mode 100644 index 0000000..56f5e81 --- /dev/null +++ b/tests/Rotula.Tests/StationConfigTests.cs @@ -0,0 +1,226 @@ +using System.Xml.Linq; +using Rotula.Core.Model; +using Rotula.Core.Validation; +using Rotula.Files; +using Rotula.Files.Station; + +namespace Rotula.Tests; + +/// The print station configuration (station.json): where it is read from, what is checked, and the message keys of every problem. +public class StationConfigTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "rotula-station-" + Guid.NewGuid().ToString("N")); + + public StationConfigTests() => Directory.CreateDirectory(_dir); + + public void Dispose() => Directory.Delete(_dir, true); + + private string Label(string name, string? folder = null) + { + string dir = folder is null ? _dir : Path.Combine(_dir, folder); + Directory.CreateDirectory(dir); + string path = Path.Combine(dir, name + ".rotula"); + RotulaFileSerializer.Save(new LabelDocument(), path); + return path; + } + + private StationLoadResult Parse(string json) => StationConfigLoader.Parse(json, Path.Combine(_dir, StationConfig.FileName)); + + private static void HasError(StationLoadResult r, string key, params string[] args) + { + Assert.False(r.CanOpen); + var issue = Assert.Single(r.Issues, i => i.MessageKey == key); + Assert.Equal(ValidationSeverity.Error, issue.Severity); + if (args.Length > 0) Assert.Equal(args, issue.Args); + } + + [Fact] + public void AFolderWithoutConfiguration_OffersEveryLabelByName_WithTheDesignersPrinter() + { + Label("pallet"); + Label("Box"); + Label("nested", "sub"); + var r = StationConfigLoader.Load(_dir); + Assert.True(r.CanOpen); + var c = r.Config!; + Assert.Null(c.ConfigPath); + Assert.Equal(new[] { "Box", "pallet" }, c.Labels.Select(l => l.Name)); + Assert.All(c.Labels, l => Assert.Null(l.Printer)); + Assert.All(c.Labels, l => Assert.Equal(StationConfig.DefaultMaxCopies, l.MaxCopies)); + Assert.Null(c.ExitPinHash); + Assert.True(c.PrintOnLastScan); + Assert.True(c.ScanToSelect); + Assert.True(c.FullScreen); + } + + [Fact] + public void AFullConfiguration_IsReadWithItsPrintersOverridesAndKeys() + { + Label("pallet", "labels"); + Label("case", "labels"); + string hash = StationPin.Hash("2468", 10_000); + File.WriteAllText(Path.Combine(_dir, StationConfig.FileName), $$""" + // Packing line 2 + { + "formatVersion": 1, + "title": "Packing line 2", + "labelFolder": "labels", + "printers": { + "line": { "host": "192.168.1.50", "dpi": 300, "darkness": 20, "speed": 4 }, + "desk": { "windowsPrinter": "Desk printer", "checkStatus": false }, + }, + "defaultPrinter": "line", + "defaultCopies": 2, + "maxCopies": 50, + "keepValues": false, + "exitPinHash": "{{hash}}", + "printOnLastScan": false, + "scanToSelect": true, + "fullScreen": false, + "labels": [ + { "file": "pallet.rotula", "name": "Pallet", "key": "PAL-01", "maxCopies": 5, "defaultCopies": 1, "keepFields": ["lot", " "] }, + { "file": "case.rotula", "printer": "desk", "allowDataMerge": true, "keepValues": true } + ] + } + """); + var r = StationConfigLoader.Load(_dir); + Assert.True(r.CanOpen, string.Join(", ", r.Issues.Select(i => i.MessageKey))); + var c = r.Config!; + Assert.Equal(Path.Combine(_dir, StationConfig.FileName), c.ConfigPath); + Assert.Equal("Packing line 2", c.Title); + Assert.Equal(hash, c.ExitPinHash); + Assert.False(c.PrintOnLastScan); + Assert.False(c.FullScreen); + + var pallet = c.Labels[0]; + Assert.Equal("Pallet", pallet.Name); + Assert.Equal("PAL-01", pallet.Key); + Assert.Equal(Path.Combine(_dir, "labels", "pallet.rotula"), pallet.Path); + Assert.Equal(5, pallet.MaxCopies); + Assert.Equal(1, pallet.DefaultCopies); + Assert.Equal(new[] { "lot" }, pallet.KeepFields); + Assert.False(pallet.AllowDataMerge); + var line = pallet.Printer!; + Assert.Equal(("192.168.1.50", 9100, 300, 20, 4, true), (line.Host, line.Port, line.Dpi, line.Darkness, line.Speed, line.CheckStatus)); + Assert.Null(line.WindowsPrinter); + + var box = c.Labels[1]; + Assert.Equal("case", box.Name); + Assert.Null(box.Key); + Assert.Equal(50, box.MaxCopies); + Assert.Equal(2, box.DefaultCopies); + Assert.True(box.AllowDataMerge); + Assert.True(box.KeepAllValues); + Assert.Equal("Desk printer", box.Printer!.WindowsPrinter); + Assert.False(box.Printer.CheckStatus); + } + + [Fact] + public void TheOnlyPrinter_IsTheDefault() + { + Label("a"); + var r = Parse("""{ "printers": { "p": { "host": "printer.local", "port": 6101 } } }"""); + Assert.True(r.CanOpen); + Assert.Equal(("printer.local", 6101), (r.Config!.Labels[0].Printer!.Host, r.Config.Labels[0].Printer!.Port)); + } + + [Fact] + public void AMisspeltSetting_IsAnError_WithItsLine() + { + Label("a"); + var r = Parse("{\n \"maxCopy\": 5\n}"); + HasError(r, StationKeys.InvalidJson); + Assert.Equal("2", r.Issues[0].Args[1]); + Assert.Contains("maxCopy", r.Issues[0].Args[2]); + HasError(Parse("{ \"maxCopies\": \"many\" }"), StationKeys.InvalidJson); + HasError(Parse("{ not json"), StationKeys.InvalidJson); + } + + [Fact] + public void PrinterMistakes_AreReportedByName() + { + Label("a"); + HasError(Parse("""{ "printers": { "p": { } } }"""), StationKeys.PrinterTarget, "p"); + HasError(Parse("""{ "printers": { "p": { "host": "h", "windowsPrinter": "w" } } }"""), StationKeys.PrinterTarget, "p"); + HasError(Parse("""{ "printers": { "p": { "host": "h", "port": 0 } } }"""), StationKeys.PrinterPort, "p"); + HasError(Parse("""{ "printers": { "p": { "host": "h", "dpi": 250 } } }"""), StationKeys.PrinterDpi, "p"); + HasError(Parse("""{ "printers": { "p": { "host": "h", "darkness": 31 } } }"""), StationKeys.PrinterProfile, "p"); + HasError(Parse("""{ "printers": { "p": { "host": "h", "speed": 0 } } }"""), StationKeys.PrinterProfile, "p"); + HasError(Parse("""{ "printers": { "p": { "host": "h" } }, "defaultPrinter": "q" }"""), StationKeys.UnknownPrinter, "q"); + HasError(Parse("""{ "labels": [ { "file": "a.rotula", "printer": "nope" } ] }"""), StationKeys.UnknownPrinter, "nope"); + } + + [Fact] + public void LabelMistakes_AreReported_AndAMissingLabelIsOnlyLeftOut() + { + Label("a"); + Label("b"); + HasError(Parse("""{ "labels": [ { "file": "a.rotula", "key": "X" }, { "file": "b.rotula", "key": " x " } ] }"""), StationKeys.DuplicateKey, "x"); + HasError(Parse("""{ "labels": [ { "file": "a.txt" } ] }"""), StationKeys.LabelNotRotula, "a.txt"); + HasError(Parse("""{ "labels": [ { "name": "no file" } ] }"""), StationKeys.LabelNoFile, "1"); + + var missing = Parse("""{ "labels": [ { "file": "a.rotula" }, { "file": "gone.rotula" } ] }"""); + Assert.True(missing.CanOpen); + Assert.Single(missing.Config!.Labels); + var warning = Assert.Single(missing.Warnings); + Assert.Equal(StationKeys.LabelMissing, warning.MessageKey); + Assert.Equal("gone.rotula", warning.Args[0]); + + HasError(Parse("""{ "labels": [ { "file": "gone.rotula" } ] }"""), StationKeys.NoLabels); + HasError(Parse("""{ "labelFolder": "empty-but-missing" }"""), StationKeys.NotFound); + } + + [Fact] + public void CopiesAndPin_AreChecked() + { + Label("a"); + HasError(Parse("""{ "maxCopies": 0 }"""), StationKeys.Copies); + HasError(Parse("""{ "maxCopies": 5, "defaultCopies": 6 }"""), StationKeys.Copies); + HasError(Parse("""{ "labels": [ { "file": "a.rotula", "name": "A", "maxCopies": 2, "defaultCopies": 3 } ] }"""), StationKeys.LabelCopies, "A"); + HasError(Parse("""{ "exitPinHash": "1234" }"""), StationKeys.PinHash); + HasError(Parse("""{ "formatVersion": 99 }"""), StationKeys.NewerVersion, "99"); + } + + [Fact] + public void ALabelFile_OpensThatLabelOnly_WithTheSettingsNextToIt() + { + string a = Label("a"); + Label("b"); + File.WriteAllText(Path.Combine(_dir, StationConfig.FileName), """{ "maxCopies": 7, "labels": [ { "file": "b.rotula" } ] }"""); + var r = StationConfigLoader.Load(a); + Assert.True(r.CanOpen); + var only = Assert.Single(r.Config!.Labels); + Assert.Equal(a, only.Path); + Assert.Equal(7, only.MaxCopies); + + // Without a configuration next to it the label opens with the defaults. + string c = Label("c", "alone"); + Assert.Equal(c, Assert.Single(StationConfigLoader.Load(c).Config!.Labels).Path); + } + + [Fact] + public void AConfigurationFile_CanBeGivenDirectly_AndAMissingPathIsExplained() + { + Label("a"); + string json = Path.Combine(_dir, "line2.json"); + File.WriteAllText(json, """{ "title": "Line 2" }"""); + Assert.Equal("Line 2", StationConfigLoader.Load(json).Config!.Title); + HasError(StationConfigLoader.Load(Path.Combine(_dir, "nothing-here")), StationKeys.NotFound); + } + + [Fact] + public void EveryStationKey_HasAnIssueMessage_InBothLanguages() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Rotula.sln"))) dir = dir.Parent; + Assert.NotNull(dir); + var keys = typeof(StationKeys).GetFields().Where(f => f.IsLiteral).Select(f => (string)f.GetRawConstantValue()!).ToList(); + Assert.True(keys.Count >= 15); + foreach (var lang in new[] { "en", "es" }) + { + var names = XDocument.Load(Path.Combine(dir!.FullName, "src", "Rotula.App", "Resources", $"Strings.{lang}.resx")).Root! + .Elements("data").Select(d => d.Attribute("name")!.Value).ToHashSet(); + Assert.DoesNotContain(keys, k => !names.Contains("Issue." + k)); + } + } +} diff --git a/tests/Rotula.Tests/StationScanTests.cs b/tests/Rotula.Tests/StationScanTests.cs new file mode 100644 index 0000000..5522a5e --- /dev/null +++ b/tests/Rotula.Tests/StationScanTests.cs @@ -0,0 +1,147 @@ +using Rotula.Files.Station; + +namespace Rotula.Tests; + +/// The print station's exit PIN hash and the scanner logic (terminators, print on last scan, scan to select), without any window. +public class StationScanTests +{ + // The smallest iteration count the hash accepts keeps these tests fast; the format and the comparison are the same. + private const int FastIterations = 10_000; + + // ------------------------------------------------------------------------------------ PIN + + [Fact] + public void APin_IsStoredAsASaltedHash_AndOnlyTheRightPinMatches() + { + string hash = StationPin.Hash("4711", FastIterations); + Assert.StartsWith("pbkdf2-sha256$10000$", hash); + Assert.DoesNotContain("4711", hash); + Assert.True(StationPin.IsHash(hash)); + Assert.True(StationPin.Verify("4711", hash)); + Assert.True(StationPin.Verify(" 4711 ", hash)); // surrounding spaces are ignored, as when it was made + Assert.False(StationPin.Verify("4712", hash)); + Assert.False(StationPin.Verify("", hash)); + Assert.False(StationPin.Verify(null, hash)); + // A new salt every time: the same PIN never gives the same line twice. + Assert.NotEqual(hash, StationPin.Hash("4711", FastIterations)); + } + + [Fact] + public void TheDefaultHash_UsesManyIterations() + { + string hash = StationPin.Hash("2468"); + Assert.StartsWith("pbkdf2-sha256$600000$", hash); + Assert.True(StationPin.Verify("2468", hash)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("1234")] + [InlineData("pbkdf2-sha256$10000$abc")] + [InlineData("pbkdf2-sha1$10000$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")] + [InlineData("pbkdf2-sha256$10$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")] // too few iterations + [InlineData("pbkdf2-sha256$99999999$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")] // would freeze the station + [InlineData("pbkdf2-sha256$10000$not base64!$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")] + [InlineData("pbkdf2-sha256$10000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA")] // wrong hash length + public void AMalformedHash_IsRefused_AndNeverMatches(string? stored) + { + Assert.False(StationPin.IsHash(stored)); + Assert.False(StationPin.Verify("1234", stored)); + } + + [Theory] + [InlineData("123", false)] + [InlineData("1234", true)] + [InlineData(" ", false)] + [InlineData("12\t34", false)] + [InlineData("abcdefghijabcdefghijabcdefghij12", true)] + [InlineData("abcdefghijabcdefghijabcdefghij123", false)] + public void APin_Needs4To32Characters(string pin, bool valid) + { + Assert.Equal(valid, StationPin.IsValidPin(pin)); + if (!valid) Assert.Throws(() => StationPin.Hash(pin, FastIterations)); + } + + // ------------------------------------------------------------------------------------ questions + + [Fact] + public void EnterAndTab_MoveToTheNextField_AndShiftTabGoesBack() + { + var s = new ScanSequencer(printOnLastScan: true, scanToSelect: true); + Assert.Equal(new ScanStep(ScanStepKind.MoveTo, 1), s.Next(0, 3, ScanTerminator.Enter, true)); + Assert.Equal(new ScanStep(ScanStepKind.MoveTo, 2), s.Next(1, 3, ScanTerminator.Tab, true)); + Assert.Equal(new ScanStep(ScanStepKind.MoveTo, 0), s.Next(1, 3, ScanTerminator.ShiftTab, true)); + Assert.Equal(new ScanStep(ScanStepKind.MoveTo, 0), s.Next(0, 3, ScanTerminator.ShiftTab, true)); + // Going back is allowed even when the field's answer is refused. + Assert.Equal(new ScanStep(ScanStepKind.MoveTo, 0), s.Next(1, 3, ScanTerminator.ShiftTab, false)); + } + + [Fact] + public void TheLastScan_Prints_WhenPrintOnLastScanIsOn() + { + var on = new ScanSequencer(printOnLastScan: true, scanToSelect: true); + Assert.Equal(ScanStepKind.Print, on.Next(2, 3, ScanTerminator.Enter, true).Kind); + Assert.Equal(ScanStepKind.Print, on.Next(2, 3, ScanTerminator.Tab, true).Kind); + var off = new ScanSequencer(printOnLastScan: false, scanToSelect: true); + Assert.Equal(ScanStepKind.FocusPrint, off.Next(2, 3, ScanTerminator.Enter, true).Kind); + } + + [Fact] + public void ARefusedScan_StaysInItsField_AndNeverPrints() + { + var s = new ScanSequencer(printOnLastScan: true, scanToSelect: true); + Assert.Equal(new ScanStep(ScanStepKind.Stay, 0), s.Next(0, 3, ScanTerminator.Enter, false)); + Assert.Equal(new ScanStep(ScanStepKind.Stay, 2), s.Next(2, 3, ScanTerminator.Enter, false)); + } + + [Fact] + public void ALabelWithoutQuestions_PrintsOnEnter_OrGoesToPrint() + { + var s = new ScanSequencer(printOnLastScan: true, scanToSelect: true); + Assert.Equal(ScanStepKind.Print, s.Next(0, 0, ScanTerminator.Enter, true).Kind); + Assert.Equal(ScanStepKind.FocusPrint, s.Next(0, 0, ScanTerminator.Tab, true).Kind); + } + + // ------------------------------------------------------------------------------------ choosing a label + + private sealed record L(string Name, string? Key); + + private static readonly L[] All = { new("Pallet", "PAL-01"), new("Case", "CASE-07"), new("Carton", null) }; + + [Fact] + public void ScanningALabelsKey_OpensIt_IgnoringCaseAndScannerExtras() + { + var s = new ScanSequencer(true, scanToSelect: true); + Assert.Equal(LabelScanKind.Open, s.SelectLabel(" pal-01\r", All, l => l.Key, Array.Empty(), out var hit)); + Assert.Equal("Pallet", hit!.Name); + Assert.Equal(LabelScanKind.Open, s.SelectLabel("\u001DCASE-07\n", All, l => l.Key, Array.Empty(), out hit)); + Assert.Equal("Case", hit!.Name); + } + + [Fact] + public void ASearchThatLeavesOneLabel_OpensIt_AndAnythingElseIsUnknown() + { + var s = new ScanSequencer(true, scanToSelect: true); + Assert.Equal(LabelScanKind.Open, s.SelectLabel("cart", All, l => l.Key, new[] { All[2] }, out var hit)); + Assert.Equal("Carton", hit!.Name); + Assert.Equal(LabelScanKind.Unknown, s.SelectLabel("ca", All, l => l.Key, new[] { All[1], All[2] }, out hit)); + Assert.Null(hit); + Assert.Equal(LabelScanKind.Unknown, s.SelectLabel("XYZ", All, l => l.Key, Array.Empty(), out _)); + Assert.Equal(LabelScanKind.None, s.SelectLabel(" \r\n", All, l => l.Key, All, out _)); + } + + [Fact] + public void WithScanToSelectOff_AKeyDoesNotOpenALabel() + { + var s = new ScanSequencer(true, scanToSelect: false); + Assert.Equal(LabelScanKind.Unknown, s.SelectLabel("PAL-01", All, l => l.Key, Array.Empty(), out _)); + } + + [Fact] + public void Clean_KeepsInnerSeparators() + { + Assert.Equal("01\u001D10ABC", ScanSequencer.Clean("\u001D01\u001D10ABC\r\n")); + Assert.Equal(string.Empty, ScanSequencer.Clean(null)); + } +} From 7f064ffcbd7132a96bd85b2d5d41ceaff57ed3ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:48:40 +0000 Subject: [PATCH 2/3] feat(app): print station window and PIN tool A locked, print-only station window with label tiles and search, large prompt fields with scanner input, quantity limits, live preview and a big plain-words printer status. Printing goes through IPrintService with the status check on, then the prompts reset keeping the configured values. Exit is PIN-gated with a lockout. Entry points: --station, --station-pin and two File menu items. Strings in en and es. --- src/Rotula.App/App.xaml.cs | 48 + src/Rotula.App/MainWindow.xaml | 3 + src/Rotula.App/Resources/Strings.en.resx | 210 +++++ src/Rotula.App/Resources/Strings.es.resx | 210 +++++ src/Rotula.App/Services/ServiceInterfaces.cs | 6 + src/Rotula.App/Services/StationLaunch.cs | 52 ++ src/Rotula.App/Services/WpfServices.cs | 15 + src/Rotula.App/ViewModels/MainViewModel.cs | 24 + .../ViewModels/StationPinViewModel.cs | 68 ++ src/Rotula.App/ViewModels/StationViewModel.cs | 843 ++++++++++++++++++ src/Rotula.App/Views/StationImageConverter.cs | 24 + src/Rotula.App/Views/StationPinWindow.xaml | 41 + src/Rotula.App/Views/StationPinWindow.xaml.cs | 30 + src/Rotula.App/Views/StationWindow.xaml | 265 ++++++ src/Rotula.App/Views/StationWindow.xaml.cs | 206 +++++ tests/Rotula.Tests/AppLocalizationTests.cs | 2 +- tests/Rotula.Tests/AppStationTests.cs | 434 +++++++++ tests/Rotula.Tests/AppTestSupport.cs | 8 +- 18 files changed, 2487 insertions(+), 2 deletions(-) create mode 100644 src/Rotula.App/Services/StationLaunch.cs create mode 100644 src/Rotula.App/ViewModels/StationPinViewModel.cs create mode 100644 src/Rotula.App/ViewModels/StationViewModel.cs create mode 100644 src/Rotula.App/Views/StationImageConverter.cs create mode 100644 src/Rotula.App/Views/StationPinWindow.xaml create mode 100644 src/Rotula.App/Views/StationPinWindow.xaml.cs create mode 100644 src/Rotula.App/Views/StationWindow.xaml create mode 100644 src/Rotula.App/Views/StationWindow.xaml.cs create mode 100644 tests/Rotula.Tests/AppStationTests.cs diff --git a/src/Rotula.App/App.xaml.cs b/src/Rotula.App/App.xaml.cs index 680cab8..1b61666 100644 --- a/src/Rotula.App/App.xaml.cs +++ b/src/Rotula.App/App.xaml.cs @@ -27,6 +27,13 @@ protected override void OnStartup(StartupEventArgs e) Editing.PreviewCache.RenderFailed += LogCrash; AppDomain.CurrentDomain.UnhandledException += (_, args) => LogCrash(args.ExceptionObject as Exception); + var launch = StationLaunch.Parse(e.Args); + if (launch.Mode != LaunchMode.Designer) + { + StartStation(launch); + return; + } + try { // Add-ons load on a background thread while the window opens; a failure of the loader itself only ends up in the log. @@ -59,6 +66,47 @@ protected override void OnStartup(StartupEventArgs e) } } + /// + /// Starts the print station (or its PIN tool) instead of the designer. The station uses the same printing service, counter memory and saved + /// language as the designer; a configuration that cannot be used is explained and the program ends. + /// + private void StartStation(StationLaunch launch) + { + try + { + var settings = new JsonSettingsStore().Load(); + Loc.Instance.SetLanguage(settings.Language); + if (launch.Mode == LaunchMode.StationPin) + { + var pinWindow = new Views.StationPinWindow(new StationPinViewModel(new WpfClipboardService())) { ShowInTaskbar = true }; + MainWindow = pinWindow; + pinWindow.Show(); + return; + } + var load = Rotula.Files.Station.StationConfigLoader.Load(launch.Path); + if (!load.CanOpen) + { + ShowCrashMessage(Loc.Get("Station.Open.Failed") + Environment.NewLine + Environment.NewLine + StationViewModel.DescribeIssues(load.Issues)); + Shutdown(2); + return; + } + // Labels whose data comes from an add-on database need the add-ons; they load in the background as in the designer. + PackStartup.Begin(new Rotula.Data.Packs.PackLoadOptions(), LogCrash); + var variables = new VariableServices(Rotula.Files.FileCounterStore.OpenDefault(), null, textRasterizer: new Rotula.Rendering.SkiaTextRasterizer()); + var station = new StationViewModel(load.Config!, load.Warnings, + new StationServices(new Rotula.Printing.PrintService(), variables, new WpfMessageService(), settings.Print, new DatabaseServices())); + var window = new Views.StationWindow(station); + MainWindow = window; + window.Show(); + } + catch (Exception ex) + { + LogCrash(ex); + ShowCrashMessage(Loc.Get("Crash.Startup", LogPath)); + Shutdown(1); + } + } + private async Task ShowPackNoticeAsync(Task loading, Rotula.Data.Packs.PackLoadOptions options, MainViewModel vm) { try diff --git a/src/Rotula.App/MainWindow.xaml b/src/Rotula.App/MainWindow.xaml index 02c60f5..b8cdb86 100644 --- a/src/Rotula.App/MainWindow.xaml +++ b/src/Rotula.App/MainWindow.xaml @@ -174,6 +174,9 @@ + + + diff --git a/src/Rotula.App/Resources/Strings.en.resx b/src/Rotula.App/Resources/Strings.en.resx index 720cf15..ce840b1 100644 --- a/src/Rotula.App/Resources/Strings.en.resx +++ b/src/Rotula.App/Resources/Strings.en.resx @@ -6023,4 +6023,214 @@ Brand and product names belong to their owners; Rotula is not affiliated with th Before each job Rotula asks the printer for its status and does not send while it is out of labels, open, paused or out of ribbon. After the job it waits until the printer reports it finished. For ZPL compatible printers; many USB drivers cannot answer, and then printing goes ahead without the check. + + Open print station... + + + Create print station PIN... + + + Print station + + + Choose the folder of the print station (with its station.json, or with the labels to offer) + + + The print station cannot open. + + + Exit + + + All labels + + + Search or scan a label + + + Type a name or scan the label's code, then press Enter. Or tap a label below. + + + No label matches the search. + + + No label has the code "{0}". Scan it again or choose a label from the list. + + + Some labels are not available: + + + The label "{0}" cannot be used: {1} + + + Printer: {0} + + + No printer is set up for this label. Ask the person who set up the station to add one to station.json. + + + Quantity + + + 1 to {0} labels + + + Enter a quantity from 1 to {0}, then press Print again. + + + Print + + + Print all records + + + {0} records from the label's data are ready to print. + + + The label's data could not be read: {0} + + + the data has no records. + + + This label asks no questions. Set the quantity and press Print. + + + Some answers are not accepted. Correct the fields marked in red, then print again. + + + Preview + + + Checking the printer... + + + Printer ready + + + Printer status unknown + + + The printer needs attention + + + Printing... + + + Rotula checks the printer before sending and waits until it reports that it finished. + + + Not printed: the printer needs attention + + + Sent to the printer + + + Printed and verified + + + Sent, but not confirmed + + + Sent, but the printer reports a problem + + + Not printed + + + Enter the PIN to leave the print station + + + PIN + + + Leave the station + + + That PIN is not right. Try again. + + + Too many wrong PINs. Wait {0} seconds, then try again. + + + Create a print station PIN + + + The PIN is needed to leave the print station. Rotula keeps only a protected code made from it, never the PIN itself. Copy the line into the station's station.json. + + + New PIN + + + Type the PIN again + + + Create + + + Line for station.json + + + Copy + + + Copied. Paste it into station.json, next to the other settings. + + + A PIN needs {0} to {1} characters. + + + The two PINs are not the same. Type them again. + + + "{0}" was not found. Check the path given to the print station. + + + "{0}" could not be read. Check that the file is not locked and that this user may read it. + + + "{0}" has a mistake near line {1} ({2}): a setting is misspelt, has a value of the wrong kind, or a comma or quote is missing. + + + The station settings were written for a newer version of Rotula (format {0}). Update Rotula on this PC. + + + There is no label to offer. Put .rotula files in the station's folder or list them under "labels" in station.json. + + + The label "{0}" listed in station.json was not found and is left out. + + + "{0}" is not a Rotula label (.rotula file). Correct it in station.json. + + + Label entry {0} in station.json has no "file". Add the label's file name. + + + Two labels use the code "{0}". Give every label its own code, so a scan opens the right one. + + + The printer "{0}" is not defined under "printers" in station.json. + + + The printer "{0}" needs either "windowsPrinter" (an installed printer) or "host" (a network address), not both. + + + The port of the printer "{0}" must be from 1 to 65535 (usually 9100). + + + The resolution of the printer "{0}" must be 203, 300 or 600. + + + The printer "{0}" has a darkness outside 0 to 30 or a speed outside 1 to 14. + + + The station's copies settings are not valid: "maxCopies" must be from 1 to 99999999 and "defaultCopies" from 1 to "maxCopies". + + + The copies settings of the label "{0}" are not valid: "maxCopies" must be from 1 to 99999999 and "defaultCopies" from 1 to "maxCopies". + + + "exitPinHash" is not a PIN code made by Rotula. Create it with File > Create print station PIN (a PIN is never stored as plain text). + diff --git a/src/Rotula.App/Resources/Strings.es.resx b/src/Rotula.App/Resources/Strings.es.resx index dd6c250..3096487 100644 --- a/src/Rotula.App/Resources/Strings.es.resx +++ b/src/Rotula.App/Resources/Strings.es.resx @@ -6023,4 +6023,214 @@ Las marcas y los nombres de producto pertenecen a sus dueños; Rotula no está a Antes de cada trabajo Rotula consulta el estado de la impresora y no envía nada si no tiene etiquetas, está abierta, en pausa o sin cinta. Después del trabajo espera a que la impresora indique que terminó. Para impresoras compatibles con ZPL; muchos controladores USB no pueden responder y entonces se imprime sin la comprobación. + + Abrir puesto de impresión... + + + Crear PIN del puesto de impresión... + + + Puesto de impresión + + + Elija la carpeta del puesto de impresión (con su station.json, o con las etiquetas que se ofrecerán) + + + No se puede abrir el puesto de impresión. + + + Salir + + + Todas las etiquetas + + + Busque o escanee una etiqueta + + + Escriba un nombre o escanee el código de la etiqueta y pulse Intro. O toque una etiqueta de la lista. + + + Ninguna etiqueta coincide con la búsqueda. + + + Ninguna etiqueta tiene el código "{0}". Escanéelo de nuevo o elija una etiqueta de la lista. + + + Algunas etiquetas no están disponibles: + + + No se puede usar la etiqueta "{0}": {1} + + + Impresora: {0} + + + No hay ninguna impresora configurada para esta etiqueta. Pida a quien configuró el puesto que añada una en station.json. + + + Cantidad + + + De 1 a {0} etiquetas + + + Escriba una cantidad de 1 a {0} y pulse Imprimir de nuevo. + + + Imprimir + + + Imprimir todos los registros + + + Hay {0} registros de los datos de la etiqueta listos para imprimir. + + + No se pudieron leer los datos de la etiqueta: {0} + + + los datos no tienen registros. + + + Esta etiqueta no hace preguntas. Indique la cantidad y pulse Imprimir. + + + Algunas respuestas no se aceptan. Corrija los campos marcados en rojo y vuelva a imprimir. + + + Vista previa + + + Comprobando la impresora... + + + Impresora lista + + + Estado de la impresora desconocido + + + La impresora necesita atención + + + Imprimiendo... + + + Rotula comprueba la impresora antes de enviar y espera hasta que indique que terminó. + + + No se imprimió: la impresora necesita atención + + + Enviado a la impresora + + + Impreso y verificado + + + Enviado, pero sin confirmar + + + Enviado, pero la impresora indica un problema + + + No se imprimió + + + Escriba el PIN para salir del puesto de impresión + + + PIN + + + Salir del puesto + + + Ese PIN no es correcto. Inténtelo de nuevo. + + + Demasiados PIN incorrectos. Espere {0} segundos y vuelva a intentarlo. + + + Crear un PIN para el puesto de impresión + + + El PIN se necesita para salir del puesto de impresión. Rotula solo guarda un código protegido creado a partir de él, nunca el PIN. Copie la línea en el station.json del puesto. + + + PIN nuevo + + + Escriba el PIN otra vez + + + Crear + + + Línea para station.json + + + Copiar + + + Copiado. Péguelo en station.json, junto a los demás ajustes. + + + Un PIN necesita de {0} a {1} caracteres. + + + Los dos PIN no coinciden. Escríbalos de nuevo. + + + No se encontró "{0}". Revise la ruta indicada al puesto de impresión. + + + No se pudo leer "{0}". Compruebe que el archivo no esté bloqueado y que este usuario pueda leerlo. + + + "{0}" tiene un error cerca de la línea {1} ({2}): un ajuste está mal escrito, tiene un valor de otro tipo, o falta una coma o unas comillas. + + + Los ajustes del puesto se escribieron para una versión más reciente de Rotula (formato {0}). Actualice Rotula en este equipo. + + + No hay ninguna etiqueta que ofrecer. Ponga archivos .rotula en la carpeta del puesto o enumérelos en "labels" de station.json. + + + No se encontró la etiqueta "{0}" indicada en station.json y se omite. + + + "{0}" no es una etiqueta de Rotula (archivo .rotula). Corríjalo en station.json. + + + La entrada de etiqueta {0} de station.json no tiene "file". Añada el nombre del archivo de la etiqueta. + + + Dos etiquetas usan el código "{0}". Dé a cada etiqueta su propio código para que un escaneo abra la correcta. + + + La impresora "{0}" no está definida en "printers" de station.json. + + + La impresora "{0}" necesita "windowsPrinter" (una impresora instalada) o "host" (una dirección de red), pero no ambos. + + + El puerto de la impresora "{0}" debe estar entre 1 y 65535 (normalmente 9100). + + + La resolución de la impresora "{0}" debe ser 203, 300 o 600. + + + La impresora "{0}" tiene una oscuridad fuera de 0 a 30 o una velocidad fuera de 1 a 14. + + + Los ajustes de copias del puesto no son válidos: "maxCopies" debe estar entre 1 y 99999999 y "defaultCopies" entre 1 y "maxCopies". + + + Los ajustes de copias de la etiqueta "{0}" no son válidos: "maxCopies" debe estar entre 1 y 99999999 y "defaultCopies" entre 1 y "maxCopies". + + + "exitPinHash" no es un código de PIN creado por Rotula. Créelo con Archivo > Crear PIN del puesto de impresión (el PIN nunca se guarda como texto). + diff --git a/src/Rotula.App/Services/ServiceInterfaces.cs b/src/Rotula.App/Services/ServiceInterfaces.cs index 5492c79..5aad106 100644 --- a/src/Rotula.App/Services/ServiceInterfaces.cs +++ b/src/Rotula.App/Services/ServiceInterfaces.cs @@ -164,4 +164,10 @@ public interface IToolDialogService /// returns false, which means "cancelled": a label that asks questions is never printed with guessed answers. /// bool ShowPromptDialog(Rotula.App.ViewModels.PromptDialogViewModel viewModel) => false; + + /// Shows the print station full screen and returns when the operator has left it. The default shows nothing. + void ShowPrintStation(Rotula.App.ViewModels.StationViewModel viewModel) { } + + /// Shows the tool that makes the exit PIN line for station.json. The default shows nothing. + void ShowStationPinTool(Rotula.App.ViewModels.StationPinViewModel viewModel) { } } diff --git a/src/Rotula.App/Services/StationLaunch.cs b/src/Rotula.App/Services/StationLaunch.cs new file mode 100644 index 0000000..7db43aa --- /dev/null +++ b/src/Rotula.App/Services/StationLaunch.cs @@ -0,0 +1,52 @@ +namespace Rotula.App.Services; + +/// What the program was started for. +public enum LaunchMode +{ + /// The designer (optionally opening a label given as the only argument). + Designer, + /// The print station (--station [folder-or-file]). + Station, + /// The tool that makes a station exit PIN (--station-pin). + StationPin, +} + +/// The command line, read once at start-up. +/// What to open. +/// The label (designer) or the station folder, configuration or label (station); null when none was given. +public sealed record StationLaunch(LaunchMode Mode, string? Path) +{ + /// The option that starts the print station. + public const string StationOption = "--station"; + + /// The option that starts the PIN tool. + public const string PinOption = "--station-pin"; + + /// + /// Reads the arguments. --station may be followed by a folder, a station.json or a .rotula file (without one, the shared policy file is used); + /// --station-pin opens the PIN tool; anything else is the designer with the first argument as the label to open. Options ignore case and may + /// also be written /station, as Windows shortcuts often do, or --station=C:\Labels. + /// + public static StationLaunch Parse(IReadOnlyList args) + { + for (int i = 0; i < args.Count; i++) + { + string a = args[i].Trim(); + if (IsOption(a, PinOption)) return new StationLaunch(LaunchMode.StationPin, null); + if (IsOption(a, StationOption)) + { + string? path = i + 1 < args.Count && !args[i + 1].StartsWith("--", StringComparison.Ordinal) ? args[i + 1] : null; + return new StationLaunch(LaunchMode.Station, string.IsNullOrWhiteSpace(path) ? null : path.Trim()); + } + if (a.StartsWith(StationOption + "=", StringComparison.OrdinalIgnoreCase) || a.StartsWith("/station=", StringComparison.OrdinalIgnoreCase)) + { + string path = a[(a.IndexOf('=') + 1)..].Trim().Trim('"'); + return new StationLaunch(LaunchMode.Station, path.Length == 0 ? null : path); + } + } + return new StationLaunch(LaunchMode.Designer, args.Count > 0 ? args[0] : null); + } + + private static bool IsOption(string arg, string option) => + string.Equals(arg, option, StringComparison.OrdinalIgnoreCase) || string.Equals(arg, "/" + option[2..], StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Rotula.App/Services/WpfServices.cs b/src/Rotula.App/Services/WpfServices.cs index c3da1ef..9047993 100644 --- a/src/Rotula.App/Services/WpfServices.cs +++ b/src/Rotula.App/Services/WpfServices.cs @@ -228,6 +228,21 @@ public bool ShowPromptDialog(Rotula.App.ViewModels.PromptDialogViewModel viewMod return viewModel.Accepted; } + /// + public void ShowPrintStation(Rotula.App.ViewModels.StationViewModel viewModel) + { + // Modal over the designer: the operator works only in the station until it is left (with the PIN when one is set). + var window = new StationWindow(viewModel) { Owner = Application.Current?.MainWindow, ShowInTaskbar = false }; + window.ShowDialog(); + } + + /// + public void ShowStationPinTool(Rotula.App.ViewModels.StationPinViewModel viewModel) + { + var window = new StationPinWindow(viewModel) { Owner = Application.Current?.MainWindow }; + window.ShowDialog(); + } + /// public void ShowDataWindow(Rotula.App.ViewModels.DataMergeViewModel viewModel) { diff --git a/src/Rotula.App/ViewModels/MainViewModel.cs b/src/Rotula.App/ViewModels/MainViewModel.cs index 250b697..506f1d5 100644 --- a/src/Rotula.App/ViewModels/MainViewModel.cs +++ b/src/Rotula.App/ViewModels/MainViewModel.cs @@ -989,6 +989,30 @@ private void ImportZpl() _svc.Messages.ShowInfo(Loc.Get("Dialog.ImportNotes"), string.Join(Environment.NewLine, result.Warnings)); } + /// + /// Opens the print station for a folder of labels (its station.json, or every label in it with the printer used here). The station replaces the + /// designer on the screen until the operator leaves it; the open label is not touched. + /// + [RelayCommand] + private void OpenPrintStation() + { + string? folder = _svc.Files.PickFolder(Loc.Get("Station.PickFolder")); + if (folder is null) return; + var load = Rotula.Files.Station.StationConfigLoader.Load(folder); + if (!load.CanOpen) + { + _svc.Messages.ShowError(Loc.Get("Station.Open.Failed"), StationViewModel.DescribeIssues(load.Issues)); + return; + } + var station = new StationViewModel(load.Config!, load.Warnings, + new StationServices(_svc.Printing, Variables, _svc.Messages, _settings.Print, _svc.Database)); + _svc.Dialogs.ShowPrintStation(station); + } + + /// Opens the tool that makes the exit PIN line for a print station's station.json. + [RelayCommand] + private void CreateStationPin() => _svc.Dialogs.ShowStationPinTool(new StationPinViewModel(_svc.Clipboard)); + /// Opens the Print dialog (printer, printer profile, copies, test label, save as ZPL). [RelayCommand] private void Print() diff --git a/src/Rotula.App/ViewModels/StationPinViewModel.cs b/src/Rotula.App/ViewModels/StationPinViewModel.cs new file mode 100644 index 0000000..231dbdb --- /dev/null +++ b/src/Rotula.App/ViewModels/StationPinViewModel.cs @@ -0,0 +1,68 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Rotula.App.Localization; +using Rotula.App.Services; +using Rotula.Files.Station; + +namespace Rotula.App.ViewModels; + +/// +/// The small tool that turns a new exit PIN into the line for station.json. The PIN itself is never shown or stored: only its salted hash is +/// put on the screen for the person setting up the station to copy. +/// +public sealed partial class StationPinViewModel : ObservableObject +{ + private readonly IClipboardService? _clipboard; + private readonly int _iterations; + + /// Creates the tool. + /// Puts the line on the clipboard; null copies nothing (tests). + /// PBKDF2 iterations; tests pass a smaller number. + public StationPinViewModel(IClipboardService? clipboard, int iterations = StationPin.DefaultIterations) + { + _clipboard = clipboard; + _iterations = iterations; + } + + /// The new PIN (copied from the password box; never shown). + public string Pin { get; set; } = string.Empty; + + /// The PIN typed a second time. + public string Confirm { get; set; } = string.Empty; + + /// The line to paste into station.json, once made. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasResult))] + private string _resultLine = string.Empty; + + /// True when is ready. + public bool HasResult => ResultLine.Length > 0; + + /// Why no line was made, or "Copied."; empty otherwise. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasMessage))] + private string _message = string.Empty; + + /// True when has text. + public bool HasMessage => Message.Length > 0; + + /// Makes the hash line from the two PIN boxes. + [RelayCommand] + private void Create() + { + ResultLine = string.Empty; + if (!StationPin.IsValidPin(Pin)) { Message = Loc.Get("Station.NewPin.Invalid", StationPin.MinLength, StationPin.MaxLength); return; } + if (!string.Equals(Pin.Trim(), Confirm.Trim(), StringComparison.Ordinal)) { Message = Loc.Get("Station.NewPin.Mismatch"); return; } + Message = string.Empty; + ResultLine = "\"exitPinHash\": \"" + StationPin.Hash(Pin, _iterations) + "\""; + } + + /// Copies the line to the clipboard. + [RelayCommand] + private void Copy() + { + if (!HasResult) return; + _clipboard?.SetText(ResultLine); + Message = Loc.Get("Station.NewPin.Copied"); + } +} diff --git a/src/Rotula.App/ViewModels/StationViewModel.cs b/src/Rotula.App/ViewModels/StationViewModel.cs new file mode 100644 index 0000000..ad50539 --- /dev/null +++ b/src/Rotula.App/ViewModels/StationViewModel.cs @@ -0,0 +1,843 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Rotula.App.Editing; +using Rotula.App.Localization; +using Rotula.App.Services; +using Rotula.Core.Model; +using Rotula.Core.Validation; +using Rotula.Core.Variables; +using Rotula.Core.Variables.Prompts; +using Rotula.Core.Zpl; +using Rotula.Files; +using Rotula.Files.Station; +using Rotula.Printing; + +namespace Rotula.App.ViewModels; + +/// What the big status panel of the print station says. +public enum StationStatusKind +{ + /// Nothing to report yet. + None, + /// The printer is being asked for its status. + Checking, + /// The printer answered and can print. + Ready, + /// The printer status could not be read; printing is still possible. + Unknown, + /// A job is being sent (and checked). + Printing, + /// The printer reported a fault (head open, out of labels...), so nothing was sent. + HeldBack, + /// The labels were sent; the printer was not asked afterwards. + Printed, + /// The labels were sent and the printer says it finished without a fault. + Verified, + /// The labels were sent but the printer could not confirm them. + NotVerified, + /// Nothing (or not everything) was printed: a wrong answer, a label problem, a printer fault after the job, a connection failure. + Problem, +} + +/// A picture of a label drawn by the preview renderer, as premultiplied BGRA pixels (the view turns it into a bitmap). +/// Width in pixels. +/// Height in pixels. +/// The pixels, row after row. +/// Bytes per row. +public sealed record StationImage(int Width, int Height, byte[] Pixels, int Stride) +{ + /// Draws the label of as it prints, at most on its longer side. Null when it cannot be drawn. + public static StationImage? Render(PreviewCache preview, LabelDocument doc, int maxPixels) + { + var label = preview.GetLabel(); + int longest = Math.Max(label.WidthDots, label.HeightDots); + if (longest <= 0) return null; + double scale = Math.Min(1.0, maxPixels / (double)longest); + using var result = preview.RenderPrinted(scale, out _); + if (result is null) return null; + var bmp = result.Bitmap; + return new StationImage(bmp.Width, bmp.Height, bmp.Bytes, bmp.RowBytes); + } +} + +/// What the print station needs from the application, behind interfaces so the view-model runs in tests with fakes. +/// The print service (the only way anything is printed). +/// Counter memory, remembered answers and the text rasterizer, shared with the designer. +/// Message boxes (the "Print N labels?" question of a data print). +/// The printer last used in the designer; labels without a printer of their own use it. +/// Saved database connections, for labels whose data comes from a database; null uses the real ones. +/// Reads a .rotula file; null uses . +/// Clock for the PIN lock-out; null uses the computer's. +public sealed record StationServices(IPrintService Printing, IVariableServices Variables, IMessageService Messages, PrintSettings? DesignerPrinter = null, + IDatabaseServices? Database = null, Func? LoadLabel = null, TimeProvider? Time = null); + +/// One label tile of the print station: its name, scan key, thumbnail, and why it cannot be used when it cannot. +public sealed partial class StationLabelViewModel : ObservableObject +{ + internal StationLabelViewModel(StationLabel entry) => Entry = entry; + + /// The label as the configuration describes it. + public StationLabel Entry { get; } + + /// The name on the tile. + public string Name => Entry.Name; + + /// The scan key, or empty. + public string Key => Entry.Key ?? string.Empty; + + /// True when the label has a scan key (shown on the tile). + public bool HasKey => Key.Length > 0; + + /// The design, or null when the file could not be read. + public LabelDocument? Document { get; internal set; } + + /// The data settings saved with the label, or null. + public Rotula.Files.DataMergeSettings? Merge { get; internal set; } + + /// The thumbnail, or null when the label could not be drawn. + [ObservableProperty] + private StationImage? _thumbnail; + + /// Why the label cannot be opened, in plain language; empty when it can. + public string LoadError { get; internal set; } = string.Empty; + + /// True when the label was read and can be opened. + public bool IsAvailable => Document is not null; + + /// True when the search text finds this label (by name, key or file name). + internal bool Matches(string search) => + search.Length == 0 || Name.Contains(search, StringComparison.OrdinalIgnoreCase) || Key.Contains(search, StringComparison.OrdinalIgnoreCase) + || Path.GetFileName(Entry.Path).Contains(search, StringComparison.OrdinalIgnoreCase); +} + +/// +/// The print station: a locked, print-only screen for the warehouse floor. The operator picks a label (or scans its code), answers its questions +/// (typed or scanned; Enter or Tab moves on), sets the quantity and prints. Printing goes through exactly like the Print +/// dialog, with the printer status check, and the result is shown in large plain words. Leaving the station needs the PIN when one is set. +/// +public sealed partial class StationViewModel : OperationViewModel +{ + /// Wrong PINs in a row before the PIN box locks. + public const int MaxPinAttempts = 5; + + /// How long the PIN box stays locked after too many wrong PINs. + public static readonly TimeSpan PinLockout = TimeSpan.FromSeconds(30); + + private const int ThumbnailPixels = 240; + private const int PreviewPixels = 900; + + private readonly StationConfig _config; + private readonly StationServices _services; + private readonly IPrintService _service; + private readonly StationAnswers _answers; + private readonly TimeProvider _time; + private PromptSet? _set; + private EditorSession? _session; + private PreviewCache? _previewCache; + private int _statusVersion; + private int _pinFailures; + private DateTimeOffset _lockedUntil = DateTimeOffset.MinValue; + + /// Creates the station and reads every label of the configuration (thumbnails included). + /// The checked configuration. + /// Warnings of the configuration (labels left out), shown on the label list. + /// Printing and the rest. + public StationViewModel(StationConfig config, IReadOnlyList warnings, StationServices services) + { + _config = config; + _services = services; + _service = services.Printing; + _time = services.Time ?? TimeProvider.System; + _answers = new StationAnswers(this, services.Variables); + Scanner = new ScanSequencer(config.PrintOnLastScan, config.ScanToSelect); + var notes = warnings.Select(Loc.FormatIssue).ToList(); + foreach (var entry in config.Labels) + { + var label = new StationLabelViewModel(entry); + Read(label); + if (!label.IsAvailable) notes.Add(Loc.Get("Station.Label.LoadFailed", label.Name, label.LoadError)); + Labels.Add(label); + } + WarningsText = string.Join(Environment.NewLine, notes); + RefreshVisible(); + PropertyChanged += (_, e) => { if (e.PropertyName == nameof(IsBusy)) { OnPropertyChanged(nameof(CanPrint)); OnPropertyChanged(nameof(CanPrintRecords)); } }; + } + + /// Lists configuration problems in plain language, one per line (errors first). + public static string DescribeIssues(IEnumerable issues) => + string.Join(Environment.NewLine, issues.OrderBy(i => i.Severity == ValidationSeverity.Error ? 0 : 1).Select(Loc.FormatIssue)); + + /// Opens the only label at once when the station offers exactly one; call once the window is shown. + public Task StartAsync() => Labels.Count(l => l.IsAvailable) == 1 && Labels.Count == 1 ? OpenAsync(Labels[0]) : Task.CompletedTask; + + // ------------------------------------------------------------------------------------ label list + + /// The terminator logic for the scanner (used by the window for every Enter and Tab). + public ScanSequencer Scanner { get; } + + /// Title at the top of the screen. + public string Title => _config.Title.Length > 0 ? _config.Title : Loc.Get("Station.Title"); + + /// True when the station fills the screen without a title bar. + public bool FullScreen => _config.FullScreen; + + /// Every label of the configuration. + public ObservableCollection Labels { get; } = new(); + + /// The labels the search finds. + public ObservableCollection VisibleLabels { get; } = new(); + + /// Labels left out or unreadable, one per line; empty when everything is fine. + public string WarningsText { get; } + + /// True when has something to say. + public bool HasWarnings => WarningsText.Length > 0; + + /// What is typed (or scanned) into the search box. + [ObservableProperty] + private string _searchText = string.Empty; + + /// Why the last scan on the label list opened nothing; empty otherwise. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasSearchMessage))] + private string _searchMessage = string.Empty; + + /// True when has text. + public bool HasSearchMessage => SearchMessage.Length > 0; + + /// True when the search finds nothing. + public bool NoLabelsFound => VisibleLabels.Count == 0; + + partial void OnSearchTextChanged(string value) + { + SearchMessage = string.Empty; + RefreshVisible(); + } + + private void RefreshVisible() + { + string search = ScanSequencer.Clean(SearchText); + VisibleLabels.Clear(); + foreach (var l in Labels.Where(l => l.Matches(search))) VisibleLabels.Add(l); + OnPropertyChanged(nameof(NoLabelsFound)); + } + + /// + /// Enter in the search box: a scanned key opens its label, a search that leaves one label opens it, anything else says that no label has that code. + /// + public async Task SubmitSearchAsync() + { + var hits = VisibleLabels.ToList(); + var kind = Scanner.SelectLabel(SearchText, Labels, l => l.Entry.Key, hits, out var match); + if (kind == LabelScanKind.Open && match is not null) + { + await OpenAsync(match); + return; + } + if (kind == LabelScanKind.Unknown) SearchMessage = Loc.Get("Station.UnknownCode", ScanSequencer.Clean(SearchText)); + // The code is still in the box: the next scan replaces it. + FocusSearchRequested?.Invoke(this, EventArgs.Empty); + } + + /// Opens a label tile (a click or a tap). + [RelayCommand] + private Task OpenLabel(StationLabelViewModel? label) => label is null ? Task.CompletedTask : OpenAsync(label); + + /// True when the label list can be shown again (the station offers more than one label). + public bool CanGoBack => Labels.Count > 1; + + /// Goes back to the label list. + [RelayCommand] + private void BackToList() + { + if (IsBusy || !CanGoBack) return; + CloseLabel(); + SearchText = string.Empty; + FocusSearchRequested?.Invoke(this, EventArgs.Empty); + } + + private void Read(StationLabelViewModel label) + { + try + { + var content = (_services.LoadLabel ?? RotulaFileSerializer.LoadFile)(label.Entry.Path); + label.Document = content.Document; + label.Merge = content.Merge; + var cache = new PreviewCache(new EditorSession(content.Document)); + label.Thumbnail = StationImage.Render(cache, content.Document, ThumbnailPixels); + cache.Dispose(); + } + catch (RotulaFileException ex) + { + label.LoadError = Loc.Get(ex.MessageKey, ex.Args.Cast().Prepend(Path.GetFileName(label.Entry.Path)).ToArray()); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + label.LoadError = Loc.Get("File.Error.Read", Path.GetFileName(label.Entry.Path)); + } + } + + // ------------------------------------------------------------------------------------ the open label + + /// The open label, or null while the list is shown. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsLabelOpen), nameof(IsChoosing), nameof(CanPrint))] + private StationLabelViewModel? _current; + + /// True while a label is open. + public bool IsLabelOpen => Current is not null; + + /// True while the label list is shown. + public bool IsChoosing => Current is null; + + /// The questions of the open label, in the order the label declares them. + public ObservableCollection Fields { get; } = new(); + + /// True when the open label asks no questions. + public bool HasNoFields => Fields.Count == 0; + + /// The printer of the open label (target and profile), or null when none is set up. + public PrinterSettingsViewModel? Printer { get; private set; } + + /// "Printer: ..." for the open label. + public string PrinterText => Printer is null ? Loc.Get("Station.Printer.None") : Loc.Get("Station.Printer", Printer.Summary); + + /// The quantity as typed. + [ObservableProperty] + private string _copiesText = "1"; + + /// The most copies one print of the open label may have. + public int MaxCopies => Current?.Entry.MaxCopies ?? 1; + + /// "1 to N labels" under the quantity box. + public string CopiesHint => Loc.Get("Station.Copies.Hint", MaxCopies); + + /// The live preview of the open label with the answers typed so far. + [ObservableProperty] + private StationImage? _preview; + + /// True when Print may run. + public bool CanPrint => IsIdle && Current is not null && Printer is not null; + + /// The data the label was saved with, when the station allows printing its records; null otherwise. + public DataMergeViewModel? Records { get; private set; } + + /// True when the open label's records can be printed. + public bool CanPrintRecords => IsIdle && Records is { HasRecords: true } && Printer is not null; + + /// True when the open label may print its records (whether or not they could be loaded). + public bool ShowRecords => Records is not null; + + /// "12 records from the label's data", or why they could not be read. + public string RecordsText => Records is null ? string.Empty + : Records.HasRecords ? Loc.Get("Station.Records", Records.RecordCount) + : Loc.Get("Station.Records.NotLoaded", Records.ResultText.Length > 0 ? Records.ResultText : Loc.Get("Station.Records.None")); + + /// Raised when the keyboard should go to the question at the given index. + public event EventHandler? FocusFieldRequested; + + /// Raised when the keyboard should go to the quantity and the Print button. + public event EventHandler? FocusPrintRequested; + + /// Raised when the keyboard should go to the search box. + public event EventHandler? FocusSearchRequested; + + /// Raised when an answer changed and the preview should be drawn again (the window waits a moment, then calls ). + public event EventHandler? PreviewRefreshRequested; + + /// Opens a label: its questions, quantity, printer, preview, and the printer status check. + public async Task OpenAsync(StationLabelViewModel label) + { + if (IsBusy) return; + if (!label.IsAvailable) + { + SearchMessage = Loc.Get("Station.Label.LoadFailed", label.Name, label.LoadError); + return; + } + CloseLabel(); + var doc = label.Document!; + _session = new EditorSession(doc); + _previewCache = new PreviewCache(_session); + Printer = BuildPrinter(label, doc); + _set = PromptSet.Build(doc, null, _services.Variables.PromptMemory); + foreach (var item in _set.Items) + { + var field = new PromptFieldViewModel(item); + field.PropertyChanged += OnFieldChanged; + Fields.Add(field); + } + int copies = label.Entry.DefaultCopies ?? Math.Max(1, doc.Print.Quantity); + CopiesText = Math.Clamp(copies, 1, label.Entry.MaxCopies).ToString(CultureInfo.InvariantCulture); + Current = label; + ResultText = string.Empty; + SetStatus(StationStatusKind.None, string.Empty, string.Empty); + OnPropertyChanged(nameof(HasNoFields)); + OnPropertyChanged(nameof(PrinterText)); + OnPropertyChanged(nameof(MaxCopies)); + OnPropertyChanged(nameof(CopiesHint)); + OnPropertyChanged(nameof(CanPrint)); + RenderPreview(); + RequestFirstFocus(); + if (Printer is null) SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), Loc.Get("Station.Printer.None")); + + if (label.Entry.AllowDataMerge && label.Merge is not null && Printer is not null) + { + Records = new DataMergeViewModel(_service, Printer, () => doc, () => label.Name, NoFileDialogs.Instance, _services.Messages, _services.Database, + null, _answers); + Records.Apply(label.Merge); + OnPropertyChanged(nameof(ShowRecords)); + await Records.ReloadRememberedDataAsync(); + if (!ReferenceEquals(Current, label)) return; + OnPropertyChanged(nameof(RecordsText)); + OnPropertyChanged(nameof(CanPrintRecords)); + } + await CheckPrinterAsync(); + } + + private void CloseLabel() + { + _statusVersion++; + foreach (var f in Fields) f.PropertyChanged -= OnFieldChanged; + Fields.Clear(); + _previewCache?.Dispose(); + _previewCache = null; + _session = null; + _set = null; + Records = null; + Printer = null; + Preview = null; + Current = null; + OnPropertyChanged(nameof(ShowRecords)); + OnPropertyChanged(nameof(RecordsText)); + OnPropertyChanged(nameof(CanPrintRecords)); + OnPropertyChanged(nameof(HasNoFields)); + SetStatus(StationStatusKind.None, string.Empty, string.Empty); + } + + private PrinterSettingsViewModel? BuildPrinter(StationLabelViewModel label, LabelDocument doc) + { + PrintSettings settings; + if (label.Entry.Printer is { } p) + { + settings = new PrintSettings + { + Configured = true, + Transport = p.Host is null ? "Spooler" : "Tcp", + PrinterName = p.WindowsPrinter ?? string.Empty, + Host = p.Host ?? string.Empty, + Port = p.Port, + Dpi = (p.Dpi is { } dpi ? (PrinterDpi)dpi : doc.Dpi).ToString(), + MediaTracking = doc.MediaTracking.ToString(), + Darkness = p.Darkness, + SpeedIps = p.Speed, + StatusCheckNetwork = p.CheckStatus, + StatusCheckWindows = p.CheckStatus, + }; + } + else if (_services.DesignerPrinter is { Configured: true } d) + { + // A copy: the station never changes the designer's own settings. + settings = new PrintSettings + { + Configured = true, Transport = d.Transport, PrinterName = d.PrinterName, Host = d.Host, Port = d.Port, Dpi = d.Dpi, WidthMm = d.WidthMm, + HeightMm = d.HeightMm, MediaTracking = d.MediaTracking, Darkness = d.Darkness, SpeedIps = d.SpeedIps, + StatusCheckNetwork = d.StatusCheckNetwork, StatusCheckWindows = d.StatusCheckWindows, + }; + } + else return null; + // No driver detection: the station prints at the resolution it was set up with, the same on every PC. + var vm = new PrinterSettingsViewModel(_service, settings, () => { }, () => doc, (_, _) => Task.FromResult(PrinterDetection.None)); + vm.UseDesignSize(); + return vm; + } + + private void OnFieldChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(PromptFieldViewModel.Value)) return; + PreviewRefreshRequested?.Invoke(this, EventArgs.Empty); + } + + /// Draws the preview again with the current answers (and the shown data record, when the label's data is loaded). + public void RenderPreview() + { + if (_previewCache is null || _session is null || Current?.Document is not { } doc) return; + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (Records?.CurrentData is { } record) foreach (var kv in record) data[kv.Key] = kv.Value; + // Typed answers show as the record's values; an empty one keeps the label's default in the picture. + foreach (var f in Fields) if (f.Value.Length > 0) data[f.Name] = f.Item.Uppercase ? f.Value.ToUpperInvariant() : f.Value; + _previewCache.Data = data; + _session.Touch(); + Preview = StationImage.Render(_previewCache, doc, PreviewPixels); + } + + private void RequestFirstFocus() + { + if (Fields.Count > 0) FocusFieldRequested?.Invoke(this, 0); + else FocusPrintRequested?.Invoke(this, EventArgs.Empty); + } + + // ------------------------------------------------------------------------------------ scanner + + /// + /// Enter, Tab or Shift+Tab in a question field (typed, or sent by the scanner after its code). Checks that field's answer, moves the keyboard on, + /// and prints after the last field when "print on last scan" is on. + /// + public async Task FieldTerminatedAsync(PromptFieldViewModel field, ScanTerminator terminator) + { + int index = Fields.IndexOf(field); + if (index < 0 || _set is null) return; + bool accepted = true; + if (terminator != ScanTerminator.ShiftTab) + { + var check = _set.Validate(Typed()); + string error = ErrorsFor(field.Name, check); + field.Error = error; + accepted = error.Length == 0; + } + var step = Scanner.Next(index, Fields.Count, terminator, accepted); + switch (step.Kind) + { + case ScanStepKind.Stay: + case ScanStepKind.MoveTo: + FocusFieldRequested?.Invoke(this, step.FieldIndex); + break; + case ScanStepKind.FocusPrint: + FocusPrintRequested?.Invoke(this, EventArgs.Empty); + break; + case ScanStepKind.Print: + await PrintAsync(); + break; + } + } + + private Dictionary Typed() => Fields.ToDictionary(f => f.Name, f => f.Value, StringComparer.OrdinalIgnoreCase); + + private static string ErrorsFor(string name, PromptValidation check) => + string.Join(" ", check.Issues.Where(i => i.Args.Count > 0 && string.Equals(i.Args[0], name, StringComparison.OrdinalIgnoreCase)).Select(Loc.FormatIssue)); + + /// + /// Checks every answer against the label's own rules (required, length, pattern, type) and marks the refused ones. Returns the accepted answers, + /// or null when one is refused (the keyboard then goes to the first refused field). + /// + private IReadOnlyDictionary? CheckAnswers(PromptSet set) + { + var typed = Typed(); + var check = set.Validate(typed); + foreach (var f in Fields) f.Error = ErrorsFor(f.Name, check); + if (check.IsValid) return check.Values; + int first = Fields.ToList().FindIndex(f => f.HasError); + if (first >= 0) FocusFieldRequested?.Invoke(this, first); + SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), Loc.Get("Station.Fields.Fix")); + return null; + } + + // ------------------------------------------------------------------------------------ printing + + /// Prints the open label with the answers and the quantity, through the print service with the printer status check. + [RelayCommand] + public async Task PrintAsync() + { + if (IsBusy || Current?.Document is not { } source || _set is null) return; + if (Printer is null) + { + SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), Loc.Get("Station.Printer.None")); + return; + } + var answers = CheckAnswers(_set); + if (answers is null) return; + if (!int.TryParse(CopiesText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int copies) || copies < 1 || copies > MaxCopies) + { + SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), Loc.Get("Station.Copies.Invalid", MaxCopies)); + FocusPrintRequested?.Invoke(this, EventArgs.Empty); + return; + } + string? error = Printer.TryBuildTarget(out var target); + PrinterProfile? profile = null; + error ??= Printer.TryBuildProfile(out profile, source.Encoding); + if (error is not null) { SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), error); return; } + + // A private copy: the quantity and the resolution conversion never touch the label the station keeps. + var doc = ElementCloner.CloneDocument(source); + doc.Print.Quantity = copies; + var converted = Printer.ConvertForPrinter(doc, out _, out string? convertError); + if (converted is null) { SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), convertError!); return; } + doc = converted; + // The design is checked with the answers before anything is reserved or sent, so the operator sees the real reason. + var variables = _services.Variables; + var gen = VariableEngine.HasDefinitions(doc) + ? ZplGenerator.GenerateWithVariables(doc, new VariableContext { PromptAnswers = answers, CounterStore = variables.CounterStore }, null, variables.TextRasterizer) + : ZplGenerator.Generate(doc, null, variables.TextRasterizer); + if (gen.HasErrors) + { + SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), Loc.Get("Print.Error.InvalidLabel") + Environment.NewLine + PrintMessages.DescribeIssues(gen.Issues)); + return; + } + + var options = VariableServices.Options(variables, answers); + _statusVersion++; + SetStatus(StationStatusKind.Printing, Loc.Get("Station.Status.Printing"), target!.Status.Enabled ? Loc.Get("Station.Status.Printing.Checked") : string.Empty); + PrintResult? result = null; + bool finished = await RunAsync(Loc.Get("Station.Status.Printing"), async ct => result = await _service.PrintAsync(target, doc, null, profile, options, ct)); + if (!finished || result is null) + { + // Stopped or crashed: RunAsync wrote the reason; nothing is reset so the operator can try again. + SetStatus(ResultIsError ? StationStatusKind.Problem : StationStatusKind.None, ResultIsError ? Loc.Get("Station.Status.Problem") : string.Empty, ResultText); + return; + } + if (ShowResult(result, Loc.Get("Print.Done.Label", copies))) + { + _set.Remember(answers, variables.PromptMemory); + ResetFields(); + } + } + + /// Prints every ticked record of the label's data (through the Data merge batch print), with the answers typed here. + [RelayCommand] + private async Task PrintRecordsAsync() + { + if (IsBusy || Records is not { HasRecords: true } records || _set is null) return; + if (!int.TryParse(CopiesText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int copies) || copies < 1 || copies > MaxCopies) + { + SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), Loc.Get("Station.Copies.Invalid", MaxCopies)); + return; + } + SetStatus(StationStatusKind.Printing, Loc.Get("Station.Status.Printing"), string.Empty); + var outcome = await records.PrintBatchAsync(RunAsync, p => Progress = p, copies); + if (outcome is not { } o) + { + SetStatus(ResultIsError ? StationStatusKind.Problem : StationStatusKind.None, ResultIsError ? Loc.Get("Station.Status.Problem") : string.Empty, ResultText); + return; + } + SetStatus(o.IsError ? StationStatusKind.Problem : StationStatusKind.Printed, Loc.Get(o.IsError ? "Station.Status.Problem" : "Station.Status.Printed"), o.Text); + if (!o.IsError) ResetFields(); + } + + /// Shows a print result in the status panel. Returns true when the labels went out (the questions are then reset for the next item). + private bool ShowResult(PrintResult result, string doneText) + { + string notes = PrintMessages.DescribeNotices(result); + if (!result.Succeeded) + { + var error = result.Error!; + string text = PrintMessages.Describe(error) + (notes.Length > 0 ? Environment.NewLine + notes : string.Empty); + bool heldBack = error.Kind is PrintErrorKind.HeadOpen or PrintErrorKind.PaperOut or PrintErrorKind.RibbonOut or PrintErrorKind.HeadTooHot + or PrintErrorKind.DiagnosticMode or PrintErrorKind.BufferFull or PrintErrorKind.Paused; + SetStatus(heldBack ? StationStatusKind.HeldBack : StationStatusKind.Problem, Loc.Get(heldBack ? "Station.Status.HeldBack" : "Station.Status.Problem"), text); + return false; + } + var keys = result.Notices.Select(n => n.MessageKey).ToHashSet(StringComparer.Ordinal); + var kind = keys.Contains(PrinterStatusKeys.FaultAfterJob) ? StationStatusKind.Problem + : keys.Contains(PrinterStatusKeys.Verified) ? StationStatusKind.Verified + : keys.Contains(PrinterStatusKeys.NotVerified) || keys.Contains(PrinterStatusKeys.NotChecked) ? StationStatusKind.NotVerified + : StationStatusKind.Printed; + string title = Loc.Get(kind switch + { + StationStatusKind.Problem => "Station.Status.Fault", + StationStatusKind.Verified => "Station.Status.Verified", + StationStatusKind.NotVerified => "Station.Status.NotVerified", + _ => "Station.Status.Printed", + }); + SetStatus(kind, title, notes.Length == 0 ? doneText : doneText + Environment.NewLine + notes); + return true; + } + + /// + /// Gets the questions ready for the next item: answers go back to their defaults (or the remembered last answer), except those the station keeps. + /// + private void ResetFields() + { + if (Current?.Document is not { } doc) return; + var entry = Current.Entry; + var keep = new HashSet(entry.KeepFields, StringComparer.OrdinalIgnoreCase); + _set = PromptSet.Build(doc, null, _services.Variables.PromptMemory); + var fresh = _set.Items.ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase); + foreach (var f in Fields) + { + if (!entry.KeepAllValues && !keep.Contains(f.Name) && fresh.TryGetValue(f.Name, out var item)) f.Value = item.InitialValue; + f.Error = string.Empty; + } + RenderPreview(); + RequestFirstFocus(); + } + + /// Asks the printer of the open label whether it can print, and shows the answer. Does nothing when the status check is off. + [RelayCommand] + public async Task CheckPrinterAsync() + { + if (Printer is null || Printer.TryBuildTarget(out var target) is not null || target is null || !target.Status.Enabled) return; + int version = ++_statusVersion; + SetStatus(StationStatusKind.Checking, Loc.Get("Station.Status.Checking"), string.Empty); + PrinterStatus status; + try { status = await _service.GetStatusAsync(target); } + catch (OperationCanceledException) { return; } + // A print that started meanwhile has its own, newer answer. + if (version != _statusVersion) return; + (string text, bool problem) = PrintMessages.DescribeStatus(status); + var kind = !status.IsAvailable ? StationStatusKind.Unknown : problem ? StationStatusKind.HeldBack : StationStatusKind.Ready; + SetStatus(kind, Loc.Get(kind switch + { + StationStatusKind.Unknown => "Station.Status.Unknown", + StationStatusKind.HeldBack => "Station.Status.NeedsAttention", + _ => "Station.Status.Ready", + }), text); + } + + // ------------------------------------------------------------------------------------ status panel + + /// What the status panel shows. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasStatus), nameof(StatusIsGood), nameof(StatusIsBad), nameof(StatusIsNeutral))] + private StationStatusKind _statusKind; + + /// The short, large line of the status panel ("Printed and verified"). + [ObservableProperty] + private string _statusTitle = string.Empty; + + /// The explanation under it: what happened and what to do. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasStatusDetail))] + private string _statusDetail = string.Empty; + + /// True when the status panel shows something. + public bool HasStatus => StatusKind != StationStatusKind.None; + + /// True when has text. + public bool HasStatusDetail => StatusDetail.Length > 0; + + /// Green panel: ready, printed, verified. + public bool StatusIsGood => StatusKind is StationStatusKind.Ready or StationStatusKind.Printed or StationStatusKind.Verified; + + /// Red panel: held back or not printed. + public bool StatusIsBad => StatusKind is StationStatusKind.HeldBack or StationStatusKind.Problem; + + /// Amber panel: checking, printing, status unknown, not confirmed. + public bool StatusIsNeutral => StatusKind is StationStatusKind.Checking or StationStatusKind.Printing or StationStatusKind.Unknown or StationStatusKind.NotVerified; + + private void SetStatus(StationStatusKind kind, string title, string detail) + { + StatusKind = kind; + StatusTitle = title; + StatusDetail = detail; + } + + // ------------------------------------------------------------------------------------ leaving the station + + /// True when leaving the station needs the PIN. + public bool HasPin => _config.ExitPinHash is not null; + + /// True once leaving was allowed (the PIN was right, or there is none); the window may then close. + public bool ExitAuthorized { get; private set; } + + /// True while the PIN box is shown. + [ObservableProperty] + private bool _isPinPromptOpen; + + /// Why the PIN was refused; empty otherwise. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPinError))] + private string _pinError = string.Empty; + + /// True when has text. + public bool HasPinError => PinError.Length > 0; + + /// Raised when the station may close. + public event EventHandler? ExitRequested; + + /// The Exit button (and closing the window): leaves at once without a PIN, otherwise shows the PIN box. + [RelayCommand] + public void RequestExit() + { + if (!HasPin) + { + Leave(); + return; + } + PinError = string.Empty; + IsPinPromptOpen = true; + } + + /// Closes the PIN box and stays in the station. + [RelayCommand] + public void CancelExit() + { + IsPinPromptOpen = false; + PinError = string.Empty; + } + + /// + /// Checks a typed PIN. A right PIN leaves the station; after wrong ones in a row the box is locked for + /// , so trying every PIN by hand is not practical. Returns true when the station may close. + /// + public async Task SubmitPinAsync(string pin) + { + if (!HasPin) { Leave(); return true; } + var now = _time.GetUtcNow(); + if (now < _lockedUntil) + { + PinError = Loc.Get("Station.Pin.Locked", (int)Math.Ceiling((_lockedUntil - now).TotalSeconds)); + return false; + } + string hash = _config.ExitPinHash!; + // PBKDF2 takes a moment on purpose; it runs off the UI thread so the screen stays responsive. + bool ok = await Task.Run(() => StationPin.Verify(pin, hash)); + if (ok) + { + _pinFailures = 0; + IsPinPromptOpen = false; + Leave(); + return true; + } + if (++_pinFailures >= MaxPinAttempts) + { + _pinFailures = 0; + _lockedUntil = _time.GetUtcNow() + PinLockout; + PinError = Loc.Get("Station.Pin.Locked", (int)PinLockout.TotalSeconds); + } + else PinError = Loc.Get("Station.Pin.Wrong"); + return false; + } + + private void Leave() + { + ExitAuthorized = true; + CancelOperation(); + ExitRequested?.Invoke(this, EventArgs.Empty); + } + + // ------------------------------------------------------------------------------------ helpers + + /// + /// Answers the label's questions for the Data merge batch print from the station's own fields, so a data print never opens the prompt dialog. + /// + private sealed class StationAnswers(StationViewModel station, IVariableServices inner) : IVariableServices + { + public Rotula.Core.Variables.Counters.ICounterStore? CounterStore => inner.CounterStore; + public IPromptMemory PromptMemory => inner.PromptMemory; + public Rotula.Core.Text.ITextRasterizer? TextRasterizer => inner.TextRasterizer; + + public IReadOnlyDictionary? Ask(PromptSet set) + { + if (set.IsEmpty) return new Dictionary(); + var answers = station.CheckAnswers(set); + if (answers is null) return null; + set.Remember(answers, inner.PromptMemory); + return answers; + } + } + + /// The station opens no file dialogs: everything it prints comes from its configured folder. + private sealed class NoFileDialogs : IFileDialogService + { + public static readonly NoFileDialogs Instance = new(); + public string? PickOpenLabel() => null; + public string? PickSaveLabel(string suggestedFileName) => null; + public string? PickExportFile(ExportKind kind, string suggestedFileName) => null; + public string? PickOpenZpl() => null; + public string? PickOpenImage() => null; + public string? PickOpenData() => null; + public string? PickOpenDatabaseFile() => null; + } +} diff --git a/src/Rotula.App/Views/StationImageConverter.cs b/src/Rotula.App/Views/StationImageConverter.cs new file mode 100644 index 0000000..e9f774a --- /dev/null +++ b/src/Rotula.App/Views/StationImageConverter.cs @@ -0,0 +1,24 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Rotula.App.ViewModels; + +namespace Rotula.App.Views; + +/// Turns a (premultiplied BGRA pixels from the renderer) into a frozen WPF bitmap. +public sealed class StationImageConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not StationImage image || image.Width <= 0 || image.Height <= 0) return null; + var bitmap = BitmapSource.Create(image.Width, image.Height, 96, 96, PixelFormats.Pbgra32, null, image.Pixels, image.Stride); + bitmap.Freeze(); + return bitmap; + } + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => DependencyProperty.UnsetValue; +} diff --git a/src/Rotula.App/Views/StationPinWindow.xaml b/src/Rotula.App/Views/StationPinWindow.xaml new file mode 100644 index 0000000..148bd2e --- /dev/null +++ b/src/Rotula.App/Views/StationPinWindow.xaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +