diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef096e..92008cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] ### Added +- **Print station** for the warehouse floor: a locked, print-only screen started with `Rotula.exe --station ` (without a path it uses `%ProgramData%\Rotula\station.json`) or from **File > Open print station**. Labels show as large tiles with thumbnails and a search box; scanning a label's code opens it. Its questions are large fields that take keyboard-wedge scanner input: Enter or Tab moves to the next one, the last scan can print at once, and the label's own rules (required, length, pattern) are checked before anything is sent. Printing goes through the same print path as the Print dialog with the printer status check, and the result is shown in large plain words (ready, held back with the reason, printed and verified, not confirmed); the questions then reset for the next item, keeping the answers the station is told to keep. A `station.json` sets the labels and their scan codes, the printer of each label or a default, copy limits, whether the label's data may be printed, and an exit PIN stored only as a salted PBKDF2 hash (made with **File > Create print station PIN** or `Rotula.exe --station-pin`); five wrong PINs lock the PIN box for 30 seconds. Every setting is checked and a mistake is explained with the setting and line. See [docs/print-station.md](docs/print-station.md). - **Printer status before and after printing** (ZPL `~HS`). Before each job Rotula asks the printer for its status and does not send while it reports it is out of labels, its printhead is open, the ribbon is out, it is paused, too hot, in diagnostics mode or its buffer is full; the Print dialog says which and what to do. After the job it waits (up to 30 seconds) until the printer reports it finished, then says "printed and verified", names a fault that appeared after the job (the labels still count as possibly printed, so no serial number is reused), or says it could not confirm. A printer that cannot answer never blocks printing. On by default for network printers and off for Windows printers, switchable in the Print dialog; "Check printer status" shows the decoded answer at any time. The MCP server has a matching `get_printer_status` tool. - **A gallery of eight showcase labels** in the README (GS1-128 pallet, parcel, food, wine, lab specimen, asset tag, shelf tags with data merge, print conditions). Their `.rotula` files and the CSV data are in `docs/showcase`, and the pictures are rendered with Rotula's own PNG export by a test that only runs when `ROTULA_RENDER_SHOWCASE=1` is set. - **Text fit for wrapped text** (Properties panel, Text fit): choose what a text block does when its text is longer than the box. "Shrink to fit" uses the largest font size, between a smallest and a largest size you set, at which all the text fits; "Grow height" adds lines, up to a limit you set, so all the text shows. With data merge every record gets the size or the lines its own text needs, the canvas shows the same size that prints, and "Do not warn" hides the does-not-fit warning when cutting the text off is what you want. Labels that do not use it are saved exactly as before; a label that does cannot be opened by older versions of Rotula. diff --git a/README.es.md b/README.es.md index c0abe14..1f40bae 100644 --- a/README.es.md +++ b/README.es.md @@ -41,6 +41,7 @@ Diséñala, previsualízala, combina tus datos e imprímela. Sin cuenta, sin nub - [Etiquetas y rollos de la biblioteca](#etiquetas-y-rollos-de-la-biblioteca) - [Fuentes de bases de datos](#fuentes-de-bases-de-datos) - [Impresión](#impresión) +- [Puesto de impresión](#puesto-de-impresión) - [Servidor MCP](#servidor-mcp) - [Códigos de barras compatibles](#códigos-de-barras-compatibles) - [Idiomas](#idiomas) @@ -170,6 +171,7 @@ salida es determinista y en ASCII puro, así que puedes versionarla, compararla | **Cobertura ZPL** | 222 comandos ZPL revisados uno por uno en una [página de cobertura generada](docs/zpl-coverage.md) (en inglés) | | **Servidor MCP** | Un programa aparte que permite a los clientes compatibles con MCP diseñar y revisar etiquetas ([detalles](#servidor-mcp)) | | **Impresión** | Cola de Windows (RAW, incluido USB) y puerto TCP 9100, detección de DPI y papel, perfiles de impresora, etiqueta de prueba, comprobación del estado de la impresora antes y después de cada trabajo | +| **Puesto de impresión** | Una pantalla bloqueada, solo para imprimir, pensada para el almacén: elige o escanea una etiqueta, responde a sus preguntas con un lector de códigos, imprime y lee la respuesta de la impresora en letra grande y palabras sencillas ([detalles](#puesto-de-impresión)) | | **Resolución** | Convierte una etiqueta entre 203, 300 y 600 dpi conservando su tamaño físico | | **Idiomas** | Español e inglés, con cambio en vivo | @@ -392,6 +394,25 @@ Más información en [docs/printing.md](docs/printing.md) (en inglés). --- +## Puesto de impresión + +Una pantalla solo para imprimir, para operarios, que se abre con `Rotula.exe --station ` (o **Archivo > Abrir puesto de +impresión**). Muestra las etiquetas de una carpeta como mosaicos grandes, sin menús ni herramientas de diseño. + +- **Elegir o escanear.** Toca un mosaico, escribe parte de su nombre o escanea el código que el puesto asigna a la etiqueta. +- **Pensado para lectores.** Las preguntas de la etiqueta son campos grandes; un lector de códigos de tipo teclado rellena uno y su + Intro o Tab pasa al siguiente. Con "imprimir al último escaneo" el último escaneo imprime en el acto. Antes se comprueban las + reglas de la propia etiqueta (obligatorio, longitud, patrón), y un escaneo rechazado se queda en su campo. +- **Resultado claro.** Lista, retenida (por ejemplo "el cabezal está abierto"), impresa y verificada, o sin confirmar, en letra + grande y palabras sencillas. Después las preguntas se vacían para la siguiente pieza; se pueden conservar las respuestas elegidas. +- **Bloqueado.** Para salir hace falta un PIN cuando se ha definido (solo se guarda un hash con sal). Las impresoras, los límites + de copias, la impresión de datos y el PIN vienen de un `station.json` junto a las etiquetas o en `%ProgramData%\Rotula`, así que + el puesto se puede configurar por directiva. + +Más información en [docs/print-station.md](docs/print-station.md) (en inglés). + +--- + ## Códigos de barras compatibles Rotula escribe y lee **28 comandos ZPL de códigos de barras** (lineales, 2D, apilados y postales; la lista completa está en la [página de cobertura](docs/zpl-coverage.md)). diff --git a/README.md b/README.md index 35ddbf5..3235bfc 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Design it, preview it, merge your data, print it. No account, no cloud, no telem - [Label stocks](#label-stocks) - [Database sources](#database-sources) - [Printing](#printing) +- [Print station](#print-station) - [MCP server](#mcp-server) - [Barcode support](#barcode-support) - [Languages](#languages) @@ -167,6 +168,7 @@ deterministic and plain ASCII, so you can version it, diff it or send it with an | **ZPL coverage** | 222 ZPL commands tracked one by one on a [generated coverage page](docs/zpl-coverage.md) | | **MCP server** | A separate program that lets MCP-capable clients design and check labels ([details](#mcp-server)) | | **Printing** | Windows spooler (RAW, including USB) and TCP port 9100, DPI and paper detection, printer profiles, test label, printer status check before and after a job | +| **Print station** | A locked, print-only screen for the warehouse floor: pick or scan a label, answer its questions with a barcode scanner, print, and read the printer's answer in large plain words ([details](#print-station)) | | **Resolution** | Convert a label between 203, 300 and 600 dpi while keeping its physical size | | **Languages** | English and Spanish, switchable live | @@ -383,6 +385,24 @@ More in [docs/printing.md](docs/printing.md). --- +## Print station + +A print-only screen for operators, started with `Rotula.exe --station ` (or **File > Open print station**). It shows +the labels of one folder as large tiles, with no menus and no design tools. + +- **Pick or scan.** Tap a tile, type part of its name, or scan the code the station gives the label. +- **Scanner friendly.** The label's questions are large fields; a keyboard-wedge scanner fills one and its Enter or Tab moves to + the next. With "print on last scan" the last scan prints at once. The label's own rules (required, length, pattern) are + checked first, and a refused scan stays in its field. +- **Clear result.** Ready, held back (for example "the printhead is open"), printed and verified, or could not confirm, in + large plain words. The questions then reset for the next item; chosen answers can be kept. +- **Locked.** Leaving needs a PIN when one is set (stored only as a salted hash). Printers, copy limits, data printing and the + PIN come from a `station.json` next to the labels or in `%ProgramData%\Rotula`, so the station can be set up by policy. + +More in [docs/print-station.md](docs/print-station.md). + +--- + ## Barcode support Rotula writes and reads **28 ZPL barcode commands** (linear, 2D, stacked and postal; the full list is on the [coverage page](docs/zpl-coverage.md)). diff --git a/docs/decisions.md b/docs/decisions.md index 9e3cc39..796f281 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -539,3 +539,21 @@ emitted or validated, so a barcode invalid only on a record where it is hidden d prints and PNG/JPEG/PDF; only the design canvas draws them dimmed (`includeNonPrinting`), and the Preview window shows the printed result. Lock is enforced in `EditorCommands` (move, nudge, resize, rotate, align, distribute, delete, cut) and never touches ZPL; property edits stay allowed. Condition fields are listed as merge placeholders so they can be mapped to columns. ZPL import never creates conditions or locks. + +## Print station (Rotula.Files/Station, StationViewModel) + +### One print path, a locked screen on top +The station is a separate window over the same services as the designer: it builds the printer target and profile through +`PrinterSettingsViewModel`, asks the label's questions through `PromptSet`, and prints through `IPrintService` with the status check on. So a +label prints the same from the station as from the Print dialog, and nothing in the print path knows about stations. + +### The configuration is strict and the PIN is only a hash +`station.json` refuses unknown settings (`UnmappedMemberHandling.Disallow`), so a misspelt `maxCopies` is an error with its line, not a silently +unlimited station. The exit PIN is stored as `pbkdf2-sha256$$$` (600,000 iterations, 16-byte salt) and checked with +`CryptographicOperations.FixedTimeEquals`, from the framework only. Five wrong PINs lock the box for 30 seconds. The PIN keeps an operator from +leaving by accident; it is not a security boundary, because anyone who can edit `station.json` can remove it, and Windows keys cannot be blocked. + +### Scanners are read by their terminator +Keyboard-wedge scanners end a code with Enter or Tab, which `ScanSequencer` turns into "stay", "next field", "focus Print" or "print". Timing +heuristics (telling a scan from typing by speed) were left out: they misfire on slow PCs and remote sessions, and the terminator already works for +both people and scanners. diff --git a/docs/print-station.md b/docs/print-station.md new file mode 100644 index 0000000..6f59c2b --- /dev/null +++ b/docs/print-station.md @@ -0,0 +1,187 @@ +# Print station + +The print station is a locked, print-only screen for operators: on a packing line, at a goods-in desk or next to a +shared printer. It shows a set of labels as large tiles, asks the label's questions in large fields that take scanner +input, prints, and tells the operator in plain words what the printer did. It has no menus, no design tools and no +file dialogs. + +## Starting it + +| How | What opens | +|---|---| +| `Rotula.exe --station C:\Labels\Line2` | The labels of that folder, with the `station.json` in it when there is one | +| `Rotula.exe --station C:\Labels\Line2\station.json` | That configuration file (any file name works) | +| `Rotula.exe --station C:\Labels\pallet.rotula` | Only that label, with the `station.json` next to it when there is one | +| `Rotula.exe --station` | `%ProgramData%\Rotula\station.json`, so the station can be set up by policy | +| **File > Open print station...** in the designer | A folder you pick, as with `--station ` | + +`/station` and `--station=` work too. A shortcut with `--station` in the Startup folder (or a Windows +Assigned Access / kiosk setup) turns a PC into a dedicated station. + +When the configuration has a mistake, Rotula says which setting and line before the station opens; nothing is +guessed. A label that is listed but missing is only left out, with a warning at the top of the station. + +## Using it + +1. **Pick a label.** Tap its tile, or type part of its name in the search box and press Enter. When a label has a + `key`, scanning that code in the search box opens it directly. With a single label, the station opens it at once. +2. **Answer the questions.** Every question of the label (its prompted variables) is a large field, with the label's + own hint and default. A keyboard-wedge scanner types the code and ends it with Enter or Tab: the station checks the + answer and moves to the next field. Shift+Tab goes back. +3. **Print.** With `printOnLastScan` on (the default), the scan into the last field prints straight away. Otherwise + the focus moves to the big Print button, and Enter prints. The quantity box accepts 1 up to the label's + `maxCopies`. +4. **Read the result** in the large status area. The questions then empty for the next item; values the station is + told to keep stay filled in. + +The label's rules for each question (required, minimum and maximum length, pattern) are checked when a scan ends +with Enter or Tab, and again before printing. A refused scan stays in its field with the reason under it, so the operator scans +again instead of printing a wrong label. + +### What the status means + +| Status | Meaning | +|---|---| +| **Printer ready** | The printer answered and reports no problem. | +| **The printer needs attention** | The printer reports a problem (for example the printhead is open or the paper is out). The reason is shown below. | +| **Printer status unknown** | The printer could not be asked (a Windows printer that does not answer, or the check is off). Printing still works. | +| **Not printed: the printer needs attention** | Rotula asked the printer first and held the job back. Fix what it says and press Print again; nothing was sent. | +| **Printed and verified** | The job was sent and the printer then reported that it finished without a problem. | +| **Sent, but not confirmed** | The job was sent, but the printer could not confirm it. Look at the label before moving on. | +| **Sent, but the printer reports a problem** | The job was sent and the printer reported a problem straight after. Check the label that came out. | +| **Sent to the printer** | The job was sent; the printer's answer was not checked. | +| **Not printed** | Something else stopped the job (for example an unreachable printer). The reason is shown below. | + +The status check uses the same print path as the designer's Print dialog (see [printing.md](printing.md)). It is +on for every station printer unless that printer sets `"checkStatus": false`. + +### Printing the label's data + +When a label has data printing set up (a spreadsheet, CSV or database) and the station allows it +(`allowDataMerge`), the label page also shows the records with a **Print all records** button. Files and queries are +the ones saved with the label; the station never opens a file dialog. A label whose data source cannot be reached +says so and still prints single labels. + +### Leaving the station + +With an `exitPinHash`, the Exit button (and closing the window) asks for the PIN. Five wrong PINs lock the PIN box +for 30 seconds. Without a PIN, Exit leaves straight away. + +In full screen (`fullScreen`, the default) the window covers the task bar and has no title bar. Rotula cannot block +keys that Windows keeps for itself, such as the Windows key or Ctrl+Alt+Del; for a fully locked PC, use Windows +Assigned Access (kiosk mode) with `Rotula.exe --station` as the app. + +## station.json + +The file is JSON; comments (`//`) and trailing commas are allowed. Every setting is optional. Setting names are +checked exactly: a misspelt name is an error rather than being ignored, so a typo never silently changes what the +station does. + +```jsonc +{ + "formatVersion": 1, + "title": "Packing line 2", + "labelFolder": "labels", + "printers": { + "line": { "host": "192.168.1.50", "dpi": 300 }, + "desk": { "windowsPrinter": "Desk printer", "checkStatus": false } + }, + "defaultPrinter": "line", + "defaultCopies": 1, + "maxCopies": 50, + "allowDataMerge": false, + "keepValues": false, + "exitPinHash": "pbkdf2-sha256$600000$...$...", + "printOnLastScan": true, + "scanToSelect": true, + "fullScreen": true, + "labels": [ + { "file": "pallet.rotula", "name": "Pallet", "key": "PAL-01", "maxCopies": 5, "keepFields": ["lot"] }, + { "file": "case.rotula", "printer": "desk", "allowDataMerge": true } + ] +} +``` + +### Station settings + +| Setting | Default | Meaning | +|---|---|---| +| `formatVersion` | `1` | The format of the file. A newer number than this Rotula knows is refused. | +| `title` | "Print station" | Shown at the top of the station. | +| `labelFolder` | the file's folder | Where the labels are, relative to `station.json` or absolute. | +| `labels` | every `.rotula` in `labelFolder` | The labels to offer, in this order (see below). Without it, every label of the folder is offered by file name. | +| `printers` | none | Named printers (see below). | +| `defaultPrinter` | the only printer | The printer of labels that do not name one. With no printers at all, the printer last used in the designer on this PC is used. | +| `defaultCopies` | `1` | The quantity a label opens with. | +| `maxCopies` | `100` | The highest quantity an operator may print at once (1 to 99999999). | +| `allowDataMerge` | `false` | Whether labels may print their saved data (all records). | +| `keepValues` | `false` | Keep every answer after printing instead of emptying the fields. | +| `exitPinHash` | none | The protected code of the exit PIN (see below). Without it, leaving needs no PIN. | +| `printOnLastScan` | `true` | The scan into the last field prints. Off: it moves to the Print button instead. | +| `scanToSelect` | `true` | Scanning a label's `key` in the search box opens that label. | +| `fullScreen` | `true` | Open full screen without a title bar. | +| `$schema` | | Ignored; allowed so an editor can point at a schema. | + +### Label settings + +| Setting | Meaning | +|---|---| +| `file` | **Required.** The `.rotula` file, relative to `labelFolder` or absolute. | +| `name` | The name on the tile. Default: the file name. | +| `key` | The code that opens the label when scanned. Codes are compared without case and must be unique. | +| `printer` | One of the names under `printers`. | +| `defaultCopies`, `maxCopies`, `allowDataMerge`, `keepValues` | Override the station setting of the same name for this label. | +| `keepFields` | Answers (by variable name) to keep after printing, for example a lot number that stays the same all shift. | + +### Printer settings + +| Setting | Meaning | +|---|---| +| `windowsPrinter` | The name of a printer installed in Windows (USB, shared or network queue). | +| `host` | A network address; printed to directly over TCP. Give either `windowsPrinter` or `host`, not both. | +| `port` | The TCP port, 1 to 65535. Default `9100`. | +| `dpi` | 203, 300 or 600. Default: the label's own resolution. | +| `darkness` | 0 to 30. Default: the printer's own setting. | +| `speed` | 1 to 14 (inches per second). Default: the printer's own setting. | +| `checkStatus` | Ask the printer before and after each job. Default `true`. | + +### Problems Rotula reports + +Every problem has a stable message key (`StationConfig.*` in `Rotula.Files.Station.StationKeys`) and is shown in plain +words in the UI language: + +| Key | When | +|---|---| +| `NotFound` | The path, the configuration or the `labelFolder` does not exist. | +| `ReadFailed` | The file exists but cannot be read. | +| `InvalidJson` | A JSON mistake, a misspelt setting or a value of the wrong kind; the line and setting are named. | +| `NewerVersion` | `formatVersion` is newer than this Rotula. | +| `NoLabels` | There is no label left to offer. | +| `LabelMissing` | (warning) A listed label file does not exist; it is left out. | +| `LabelNotRotula` | A listed file is not a `.rotula` label. | +| `LabelNoFile` | A label entry has no `file`. | +| `DuplicateKey` | Two labels share a `key`. | +| `UnknownPrinter` | A label or `defaultPrinter` names a printer that is not defined. | +| `PrinterTarget` | A printer has neither or both of `windowsPrinter` and `host`. | +| `PrinterPort`, `PrinterDpi`, `PrinterProfile` | A printer's port, resolution, darkness or speed is out of range. | +| `Copies`, `LabelCopies` | `maxCopies` or `defaultCopies` is out of range, for the station or one label. | +| `PinHash` | `exitPinHash` is not a code made by Rotula. | + +## The exit PIN + +The PIN is never stored. `exitPinHash` holds a salted PBKDF2-SHA256 code made from it +(`pbkdf2-sha256$$$`, 600,000 iterations by default), which Rotula checks in constant time. +To make one, use **File > Create print station PIN...** in the designer, or run `Rotula.exe --station-pin` on any PC. +Type the PIN twice (4 to 32 characters), then copy the line it gives into `station.json`. + +Keep the folder of `station.json` writable only by the people who set up the station: anyone who can edit the file +can remove the PIN. + +## For developers + +- `Rotula.Files.Station.StationConfigLoader` reads and checks the configuration; `StationConfig` is the result. +- `StationPin` makes and checks the PIN codes. +- `ScanSequencer` holds the scanner rules (which terminator moves where, when a scan prints, which scan opens a label) + with no UI, so it is tested on its own. +- `StationViewModel` in the app drives the screen through `IPrintService` and the label's `PromptSet`, so the + station prints exactly what the Print dialog would. 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +