From 7662a01584fe9b7d237580f181f5e340f288af2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 15:57:11 +0000 Subject: [PATCH 1/3] Add the headless print engine: rotula CLI and REST API rotula.exe prints .rotula labels without the designer, for scripts and warehouse systems, through the app's own print path (DPI conversion, darkness/speed, variables, questions, data merge, serial numbers used once). - rotula print / status / printers with JSON output and exit codes - rotula serve: REST API (/api/v1/jobs, printer status and lists, OpenAPI), API keys stored as SHA-256 hashes, label-folder and printer allow-lists, size limits, strict settings and request bodies, Windows service hosting - ~HS status check before every job and verification after the last; one job at a time per printer - PrintEngine policy (ADMX/ADML) to switch it off - 52 new strings in en, es, pt-BR and fr-CA - docs/print-engine.md, README, CHANGELOG, notices, deployment, decisions --- CHANGELOG.md | 1 + CONTRIBUTING.md | 4 +- README.md | 26 + Rotula.sln | 30 ++ THIRD-PARTY-NOTICES.md | 7 + docs/decisions.md | 27 ++ docs/deployment.md | 3 +- docs/print-engine.md | 232 +++++++++ packaging/policies/Rotula.admx | 7 + packaging/policies/en-US/Rotula.adml | 8 + packaging/policies/es-ES/Rotula.adml | 8 + src/Rotula.App/Resources/Strings.en.resx | 186 +++++++ src/Rotula.App/Resources/Strings.es.resx | 186 +++++++ src/Rotula.App/Resources/Strings.fr-CA.resx | 186 +++++++ src/Rotula.App/Resources/Strings.pt-BR.resx | 186 +++++++ src/Rotula.Cli/CliApp.cs | 338 +++++++++++++ src/Rotula.Cli/CommandLine.cs | 108 +++++ src/Rotula.Cli/Program.cs | 28 ++ src/Rotula.Cli/Rotula.Cli.csproj | 41 ++ src/Rotula.Cli/Server/EngineServer.cs | 250 ++++++++++ src/Rotula.Engine/ApiKeys.cs | 44 ++ src/Rotula.Engine/EngineJson.cs | 113 +++++ src/Rotula.Engine/EngineSettings.cs | 160 ++++++ src/Rotula.Engine/EngineText.cs | 163 +++++++ src/Rotula.Engine/LabelFiles.cs | 84 ++++ src/Rotula.Engine/PrintEngine.cs | 469 ++++++++++++++++++ src/Rotula.Engine/PrintJobModels.cs | 129 +++++ src/Rotula.Engine/Rotula.Engine.csproj | 36 ++ src/Rotula.Files/Policy/RotulaPolicy.cs | 9 +- tests/Rotula.Tests/EngineCliTests.cs | 353 ++++++++++++++ tests/Rotula.Tests/EngineTests.cs | 509 ++++++++++++++++++++ tests/Rotula.Tests/Rotula.Tests.csproj | 4 + 32 files changed, 3932 insertions(+), 3 deletions(-) create mode 100644 docs/print-engine.md create mode 100644 src/Rotula.Cli/CliApp.cs create mode 100644 src/Rotula.Cli/CommandLine.cs create mode 100644 src/Rotula.Cli/Program.cs create mode 100644 src/Rotula.Cli/Rotula.Cli.csproj create mode 100644 src/Rotula.Cli/Server/EngineServer.cs create mode 100644 src/Rotula.Engine/ApiKeys.cs create mode 100644 src/Rotula.Engine/EngineJson.cs create mode 100644 src/Rotula.Engine/EngineSettings.cs create mode 100644 src/Rotula.Engine/EngineText.cs create mode 100644 src/Rotula.Engine/LabelFiles.cs create mode 100644 src/Rotula.Engine/PrintEngine.cs create mode 100644 src/Rotula.Engine/PrintJobModels.cs create mode 100644 src/Rotula.Engine/Rotula.Engine.csproj create mode 100644 tests/Rotula.Tests/EngineCliTests.cs create mode 100644 tests/Rotula.Tests/EngineTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c561607..208bc7b 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 engine for scripts and warehouse systems** (`rotula.exe`): print `.rotula` labels with no window. `rotula print` takes a label, field values (`--set`) or records from a CSV, Excel or JSON file, and a Windows printer or network address, and prints through the designer's own print path: resolution conversion, darkness and speed, variables, questions (`--answer`, otherwise the designer default; never a sample value), copies, and serial numbers used once. Before every job it checks the printer status (`~HS`) and holds the job back on a fault; after the last job it waits until the printer reports it finished. `rotula status` and `rotula printers` report printers, `--json` gives a machine-readable result, and exit codes tell a script what happened. `rotula serve` offers the same as a REST API (`/api/v1/jobs`, printer status and lists, OpenAPI at `/openapi/v1.json`) that can run as a Windows service: off until started, on `127.0.0.1` by default, an API key on every call (only its hash is stored), and only the label folders and printers its settings file lists. One job at a time per printer. Messages in English, Spanish, Portuguese (Brazil) and French (Canada). A new `PrintEngine` policy lets an administrator switch it off. See [docs/print-engine.md](docs/print-engine.md). - **Label sheets for office printers** (File > Export > Label sheets (PDF), or Label sheets in the Print dialog): put the label on sheets for a laser or inkjet printer and save them as a PDF at the sheet's real size, each label at its own physical size. Pick a generic template (Letter 30-up, 20-up, 14-up, 10-up, 6-up, 80-up and half sheets; A4 3 x 8, 21-up, 65-up, 16-up, 14-up, 8-up and 2-up) or type your own layout, start at any position of a partly used sheet, and add crop marks or label outlines. With data loaded the ticked records go on the sheets with their copies, and serial numbers are used once, exactly as when printing. The layout is saved with the label; older versions of Rotula open such a file and ignore it. Sending the sheets straight to a Windows printer is not offered: print the PDF at actual size. - **Machine-wide MSI for IT departments**: every release now also has `Rotula-X.Y.Z-win-x64.msi`, signed like the setup program. It installs to Program Files for every user with a Start menu entry and the `.rotula` association, upgrades older versions in place, and is made for silent deployment with Intune, Group Policy or Configuration Manager. The setup program can now also install for all users silently with `/ALLUSERS`. Silent install and uninstall commands, exit codes, Intune detection rules and Group Policy steps are in the new [docs/deployment.md](docs/deployment.md). - **Settings your organization can manage**: administrators can fix the language and switch the update check on or off for everyone, suggest a default printer, keep add-ons in the user's own folder from loading, and switch printing or database access of the MCP server off, through Group Policy or Intune. Administrative templates (English and Spanish) are in `packaging/policies`. A setting fixed this way is greyed out in Rotula with "Managed by your organization". diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb90710..b6da1ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,9 @@ These are product rules; changes that break them will not be merged. address the user entered and the databases the user connects. The one exception is the **opt-in update check**: it sends nothing unless the user (Help menu) or an administrator (policy) explicitly switches it on, and then it is at most one anonymous `GET` of the public GitHub "latest release" API a day, with nothing about the user or the computer in it. It never - downloads or installs anything. Do not add other network calls, and do not add anything to that request. + downloads or installs anything. Do not add other network calls, and do not add anything to that request. The print engine's + REST server (`rotula serve`) is not a call out: it listens only when someone starts it, only on the addresses in its settings + file (`127.0.0.1` by default), and talks only to the printers that file allows. - **Light theme only.** - **English, Spanish, Portuguese (Brazil) and French (Canada)** through `.resx` resources in `src/Rotula.App/Resources` (`Strings.en.resx`, `Strings.es.resx`, `Strings.pt-BR.resx`, `Strings.fr-CA.resx`). Every new user-visible string needs all diff --git a/README.md b/README.md index 61fdaf3..246f217 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Design it, preview it, merge your data, print it. No account, no cloud, no telem - [Printing](#printing) - [Print station](#print-station) - [MCP server](#mcp-server) +- [Print engine (command line and REST API)](#print-engine-command-line-and-rest-api) - [Barcode support](#barcode-support) - [Languages](#languages) - [Privacy](#privacy) @@ -452,6 +453,27 @@ on in `rotula-mcp.settings.json`. Printing needs a two-step confirmation. The in --- +## Print engine (command line and REST API) + +`rotula.exe` prints Rotula labels with no window, for scripts, scheduled tasks and warehouse systems (WMS, ERP, shipping software). +It uses the designer's own print path: resolution conversion, darkness and speed, variables and questions, data merge, and serial +numbers that are reserved before a job and used up only for labels that reached the printer. Before every job it asks the printer +for its status (ZPL `~HS`) and holds the job back while the printer is out of labels, open, paused or out of ribbon; after the last +job it waits until the printer reports it finished. Results come back as exit codes or JSON. + +```bat +rotula print carton.rotula --printer "Dock 1" --data wave-1207.csv --reference WAVE-1207 --json +rotula status --host 10.20.0.31 +``` + +`rotula serve` exposes the same engine as a REST API (`POST /api/v1/jobs`, printer lists and status, an OpenAPI description) that +can run as a Windows service. It is off until you start it, listens on `127.0.0.1` unless you configure otherwise, needs an API +key on every call, and prints only labels from the folders and to the printers its settings file lists. Messages follow the +caller's language (English, Spanish, Portuguese (Brazil) or French (Canada)). Commands, API, settings and exit codes: +[docs/print-engine.md](docs/print-engine.md). The installer does not include `rotula.exe` yet; build it from `src/Rotula.Cli`. + +--- + ## Languages The interface is available in **English**, **Spanish**, **Portuguese (Brazil)** and **French (Canada)**. By default Rotula @@ -471,6 +493,8 @@ one. It never downloads or installs anything. An administrator can switch the ch ([deployment](docs/deployment.md)). Your settings (language, units, recent files, last printer) are a small JSON file in `%AppData%\Rotula`. If the program ever fails, it writes a log to `%AppData%\Rotula\rotula.log` on your PC and sends it nowhere. +The print engine's REST server (`rotula serve`) listens +for calls only when you start it, only on the addresses in its settings file, and makes no outgoing calls except to printers. --- @@ -538,6 +562,8 @@ flowchart LR | `src/Rotula.Printing` | Windows spooler and TCP 9100 printing, printer detection | | `src/Rotula.Data` | Database providers, presets, the read-only query guard and the connection store | | `src/Rotula.Mcp` | The MCP server (a separate executable) | +| `src/Rotula.Engine` | The headless print engine: print requests, printer status, settings and API keys. No UI | +| `src/Rotula.Cli` | `rotula.exe`: the print engine's command line and REST server | | `src/Rotula.App` | The WPF application | | `tests/` | xUnit tests and the golden ZPL fixtures | | `packaging/` | Installer script, MSI project, Group Policy templates and winget manifest templates | diff --git a/Rotula.sln b/Rotula.sln index ae37ced..2f6de85 100644 --- a/Rotula.sln +++ b/Rotula.sln @@ -47,6 +47,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rotula.Data.Pack.ClickHouse EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rotula.Data.Packs.Tests", "tests\Rotula.Data.Packs.Tests\Rotula.Data.Packs.Tests.csproj", "{3DC421B4-0B47-4308-8791-4225E9A0CCAD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rotula.Engine", "src\Rotula.Engine\Rotula.Engine.csproj", "{AEEF329C-5A0F-42F6-BE98-2D444BFA3904}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rotula.Cli", "src\Rotula.Cli\Rotula.Cli.csproj", "{1CB2C052-D923-4083-A606-C1E4B81D36C7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -285,6 +289,30 @@ Global {3DC421B4-0B47-4308-8791-4225E9A0CCAD}.Release|x64.Build.0 = Release|Any CPU {3DC421B4-0B47-4308-8791-4225E9A0CCAD}.Release|x86.ActiveCfg = Release|Any CPU {3DC421B4-0B47-4308-8791-4225E9A0CCAD}.Release|x86.Build.0 = Release|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|x64.ActiveCfg = Debug|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|x64.Build.0 = Debug|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|x86.ActiveCfg = Debug|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|x86.Build.0 = Debug|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|Any CPU.Build.0 = Release|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|x64.ActiveCfg = Release|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|x64.Build.0 = Release|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|x86.ActiveCfg = Release|Any CPU + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|x86.Build.0 = Release|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|x64.ActiveCfg = Debug|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|x64.Build.0 = Debug|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|x86.ActiveCfg = Debug|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|x86.Build.0 = Debug|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|Any CPU.Build.0 = Release|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|x64.ActiveCfg = Release|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|x64.Build.0 = Release|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|x86.ActiveCfg = Release|Any CPU + {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -309,5 +337,7 @@ Global {A93A2F2F-9C16-49E9-B046-5A0CC310AB2F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {12DDAC38-D696-4C2E-A2E4-7DADB7D08D28} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {3DC421B4-0B47-4308-8791-4225E9A0CCAD} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {AEEF329C-5A0F-42F6-BE98-2D444BFA3904} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {1CB2C052-D923-4083-A606-C1E4B81D36C7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 578aee5..35bea7d 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -19,10 +19,17 @@ read from the project files and the license metadata in each package's `.nuspec` | System.Configuration.ConfigurationManager (via the Microsoft.Extensions.* packages) | 9.0.18 | MIT | Rotula.Mcp | https://github.com/dotnet/runtime | | System.Security.Cryptography.Pkcs (pinned to the patched version in Rotula.Data) | 10.0.13 | MIT | Rotula.Mcp | https://github.com/dotnet/runtime | | System.Diagnostics.EventLog (via Microsoft.Extensions.Logging.EventLog) | 10.0.10 | MIT | Rotula.Mcp | https://github.com/dotnet/runtime | +| ASP.NET Core shared framework (Microsoft.AspNetCore.App, part of .NET; copied into a self-contained `rotula.exe`) | 10.0 | MIT | Rotula.Cli | https://github.com/dotnet/aspnetcore | +| Microsoft.AspNetCore.OpenApi (OpenAPI description of the print engine's REST API) | 10.0.13 | MIT | Rotula.Cli | https://github.com/dotnet/aspnetcore | +| Microsoft.OpenApi (via Microsoft.AspNetCore.OpenApi) | 2.12.2 | MIT | Rotula.Cli | https://github.com/microsoft/OpenAPI.NET | +| Microsoft.Extensions.Hosting.WindowsServices (runs `rotula serve` as a Windows service) | 10.0.13 | MIT | Rotula.Cli | https://github.com/dotnet/runtime | +| System.ServiceProcess.ServiceController (via Microsoft.Extensions.Hosting.WindowsServices) | 10.0.13 | MIT | Rotula.Cli | https://github.com/dotnet/runtime | | Liberation Sans Regular / Bold and Liberation Mono Regular (embedded stand-in fonts) | 2.x | SIL Open Font License 1.1 | Rotula.Rendering | https://github.com/liberationfonts/liberation-fonts | The MCP server (`Rotula.Mcp`) is a separate executable from the app. It uses the packages marked `Rotula.Mcp` above; its license expressions were read from each package's `.nuspec` in the restored NuGet cache (the SDK is Apache-2.0, used unmodified). +The print engine (`rotula.exe`, `Rotula.Cli` and `Rotula.Engine`) is another separate executable; it uses the packages marked +`Rotula.Cli` above besides Rotula.Rendering's and Rotula.Core's. The Liberation fonts are embedded unmodified in `Rotula.Rendering`; their license text is in `src/Rotula.Rendering/Fonts/LICENSE-LiberationFonts.txt` and is also embedded in the assembly. Apache-2.0 components are diff --git a/docs/decisions.md b/docs/decisions.md index c68c934..2a9624c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -636,3 +636,30 @@ The design canvas stays upright because editing an upside-down label (handles, r "Prints upside down" / "Prints mirrored" instead. PNG, JPEG and PDF exports and the MCP preview stay as designed: they are artwork for documents, proofs and screens, not a picture of the media leaving the printer, and there is no "as printed" export option to honour. The two flips commute (inverted and mirrored together is a top-to-bottom flip), so their order is not a question. + +## Print engine (Rotula.Engine, Rotula.Cli) + +### A library and a thin executable, on the app's print path +`Rotula.Engine` (net10.0, no UI) turns a request into a print through `IPrintService`, `LabelPrintRun` / `VariableJob`, `PromptSet`, +`LabelDpiConverter` and `PrinterSetup`, the same pieces the Print dialog and the data merge use, so a label prints byte for byte the same from +the designer, the station and the engine. `Rotula.Cli` (`rotula.exe`) is only argument parsing, the REST host and the console. Results are values +(`PrintJobResult` with an outcome, an error code and localized notices), never exceptions, so the command line and the API answer the same thing. +Messages come from the app's own `.resx` files, embedded in the engine, so the engine needs no WPF and every string exists in all four languages. + +### Questions take their default, never the sample value +An unattended job cannot ask. An unanswered question takes the default set in the designer; a required question with no default stops the job +with every question listed. The sample value is design-time data and printing it on a real carton would be silent, wrong output. + +### One job at a time per printer, status before every job +Requests for the same printer (a queue name, or host:port) wait on one `SemaphoreSlim`, so two callers never interleave labels on one roll. The +`~HS` check runs before each job and verification once after the last, as in the app's data merge. Counters are reserved before and confirmed +only for the labels sent, so a held-back or failed job gives its serial numbers back unless part of it may have printed. + +### The REST server is opt-in and allow-listed +Offline-first stays true: the server listens only when started, on `127.0.0.1` unless configured, and calls out only to printers. Every route but +health and the OpenAPI document needs a key; keys are 32 random bytes shown once and stored as SHA-256 hashes, compared with `FixedTimeEquals` +(a slow hash is not needed for 256-bit random keys). Labels are named relative to configured folders (no rooted paths, `..`, drives, UNC or +wildcards), printers must be listed, inline label content is off by default, and request bodies and label files have size limits. The settings +file and request bodies refuse unknown names, so a typo never widens what is allowed. The audit log carries an 8-character key id from the hash, +never the key or label data. `PrintEngine = 0` (policy) switches it all off. Kestrel and ASP.NET Core come with .NET; the only packages added are +Microsoft's OpenAPI and Windows service hosting (MIT). diff --git a/docs/deployment.md b/docs/deployment.md index 03c08b5..70e23da 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -205,7 +205,7 @@ the logon requirement to **Whether or not a user is logged on**, and add the new Rotula reads the policies once at start-up from `HKLM\SOFTWARE\Policies\P4Software\Rotula` (the computer) and `HKCU\SOFTWARE\Policies\P4Software\Rotula` (the user). When both set the same value, **the computer's value wins**. A setting fixed by a policy is shown disabled in Rotula with the note "Managed by your organization", and the user's own -choice is kept in `settings.json` for the day the policy is removed. The MCP server (`Rotula.Mcp.exe`) reads the same key. +choice is kept in `settings.json` for the day the policy is removed. The MCP server (`Rotula.Mcp.exe`) and the print engine (`rotula.exe`) read the same key. | Policy (template name) | Registry value | Type | Values | Effect | |---|---|---|---|---| @@ -215,6 +215,7 @@ choice is kept in `settings.json` for the day the policy is removed. The MCP ser | Allow data source add-ons from the user's folder | `UserAddOns` | REG_DWORD | `0` blocks | Add-ons in `%AppData%\Rotula\packs` are neither loaded nor offered for approval; the installed `packs` folder still works ([data-providers.md](data-providers.md)) | | Allow the MCP server to print | `McpPrinting` | REG_DWORD | `0` blocks | The MCP server refuses every print request, whatever its settings file says | | Allow the MCP server to read databases | `McpDatabase` | REG_DWORD | `0` blocks | The MCP server refuses every database query | +| Allow the print engine to print | `PrintEngine` | REG_DWORD | `0` blocks | `rotula.exe` refuses to print or query printers (exit code 5) and its REST server answers 403, whatever its settings file says ([print-engine.md](print-engine.md)) | A value that Rotula does not understand (a language other than the five values above, a number other than 0 or 1) is ignored, so a typo never forces a wrong setting on users. The MCP policies can only take something away: setting them to `1` does not diff --git a/docs/print-engine.md b/docs/print-engine.md new file mode 100644 index 0000000..9343a8f --- /dev/null +++ b/docs/print-engine.md @@ -0,0 +1,232 @@ +# Print engine: command line and REST API + +`rotula.exe` prints Rotula labels without the designer. A script, a scheduled task or a warehouse system (WMS, ERP, shipping +software) hands it a `.rotula` label, the data and a printer; it prints exactly as the designer's Print dialog would, checks the +printer before every job and says how it went in a form a program can act on. + +It has two faces over one engine: + +- **`rotula print`, `rotula status`, `rotula printers`**: a command line for scripts and schedulers. It runs with the rights of the + person or service that starts it. +- **`rotula serve`**: a REST API for systems on this computer or the network. It prints only what its settings file allows (API + keys, label folders, printers) and can run as a Windows service. + +What it shares with the designer: + +- **The same print path.** Resolution conversion to the printer, darkness and speed, variables (dates, counters, questions, computed + values), data merge with the counters, copies and "skip or stop" settings saved with the label, and Windows fonts drawn as + pictures. +- **Serial numbers are used once.** Counters that remember their value use the same counter file as the designer + (`%AppData%\Rotula\counters.json`, or `--counter-file` / `counterFile`). Numbers are reserved before a run and confirmed only for + labels that reached the printer: a printer that was offline gives the numbers back; a send that broke halfway keeps them used, so a + number is never printed twice. +- **The printer status check (ZPL `~HS`).** Before every job the engine asks the printer whether it can print and holds the job back + 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. After the last job it waits (up to 30 seconds) until the printer reports it finished. A printer that cannot answer + (many USB drivers) never blocks printing; the result says the status was not checked. `--no-status` and `--no-verify` (or + `"checkStatus": false` and `"verify": false`) switch either part off. +- **One job at a time per printer.** Requests for the same printer wait for each other, so two callers never interleave their + labels. Different printers print at the same time. +- **The messages of the app**, in English, Spanish, Portuguese (Brazil) or French (Canada). + +## Command line + +```text +rotula print LABEL.rotula --printer NAME | --host ADDRESS [options] +rotula status --printer NAME | --host ADDRESS [--port N] +rotula printers +rotula serve [--settings FILE] [--urls URL] rotula serve --init [--label-folder FOLDER] +rotula new-key +rotula help | version +``` + +| Option | Meaning | +|---|---| +| `--printer NAME` | An installed Windows printer (USB, shared or network queue). | +| `--host ADDRESS`, `--port N` | A network printer reached directly over TCP (port 9100 when not given). | +| `--copies N` | Copies of the label, or of every record. Without it, the quantity saved in the label (or the data's copies column) is used. | +| `--set NAME=VALUE` | The value of a field (`{{NAME}}` in the label). Repeat for more fields. | +| `--data FILE` | Records from a `.csv` / `.txt` (delimiter detected), `.xlsx` (`--sheet NAME`) or `.json` file; one label per record. A JSON object is one label's values. | +| `--answer NAME=VALUE` | The answer to a question the label asks at print time (a prompt variable). | +| `--dpi 203\|300\|600` | Printhead resolution. Without it, a Windows printer's driver is asked; a network printer prints at the label's own resolution. | +| `--darkness 0-30`, `--speed 1-14` | Sent to the printer before the labels. | +| `--no-status`, `--no-verify` | Skip the status check before every job, or the wait for "finished" after the last one. | +| `--dry-run` | Builds the ZPL and prints it to the output (or to `--output FILE`) without sending anything and without using up serial numbers. | +| `--reference TEXT` | Your own reference (order, wave, shipment), repeated in the result and the server log. | +| `--counter-file FILE` | Where remembering counters keep their position. | +| `--json` | The result as JSON, in the same shape as the REST API's answer. | +| `--lang en\|es\|pt-BR\|fr-CA` | Language of the messages. Without it the Language policy, then the `ROTULA_LANG` environment variable, then Windows decides. | + +Questions a label asks at print time take their **designer default** when no `--answer` is given. A required question without +a default and without an answer stops the job and lists every question; a sample value is never printed in place of an answer. + +Examples: + +```bat +rotula print "C:\Labels\carton.rotula" --printer "Dock 1" --set sku=4006381333931 --set lot=L2410 --copies 2 +rotula print "C:\Labels\carton.rotula" --host 10.20.0.31 --data "C:\Exports\wave-1207.csv" --reference WAVE-1207 +rotula print "C:\Labels\carton.rotula" --printer "Dock 1" --data picks.json --dry-run --output wave.zpl +rotula status --host 10.20.0.31 --json +``` + +### Exit codes + +| Code | Meaning | +|---|---| +| 0 | Printed (or the dry run worked, or the printer is ready). | +| 1 | Sending failed (printer not found, offline, unreachable...), or the status could not be read. | +| 2 | The command line or the request is wrong (unknown option, missing printer, unreadable data or label file). | +| 3 | The label has errors, or the answers to its questions are missing. Nothing was sent. | +| 4 | The printer reported a fault (out of labels, head open, paused...) and nothing was sent; for `status`, the printer cannot print now. | +| 5 | Switched off by the organization's `PrintEngine` policy. | +| 6 | Stopped with Ctrl+C. Labels already sent are counted; serial numbers of labels not sent go back. | + +## REST API + +### Set up + +```bat +rotula serve --init --settings "C:\ProgramData\Rotula\rotula-engine.settings.json" --label-folder "D:\Labels" +``` + +This writes a starter settings file and prints a new API key **once**. Give the key to the calling system; the file keeps only its +SHA-256 hash. Add the printers, then start the server: + +```bat +rotula serve --settings "C:\ProgramData\Rotula\rotula-engine.settings.json" +``` + +```jsonc +{ + // Where the server listens. 127.0.0.1 accepts calls from this computer only. + "urls": ["http://127.0.0.1:9180"], + // SHA-256 hashes of the API keys ("rotula new-key" makes a key and its hash). Several keys can be listed, one per caller. + "apiKeyHashes": ["3f1c...e9"], + // A request names a label relative to one of these folders: "outbound/carton.rotula". + "labelFolders": ["D:\\Labels"], + // Windows printer names, or host / host:port of network printers. Nothing else can be printed to or queried. + "allowedPrinters": ["Dock 1", "10.20.0.31", "10.20.0.32:6101"], + // Accept the content of a .rotula file in the request (labelContent). Off: only labels from the folders print. + "allowInlineLabels": false, + "maxCopies": 1000, + "maxRecords": 10000, + "maxRequestBytes": 10485760, + "maxLabelBytes": 5242880, + // Language of the messages when a request has no Accept-Language header. + "language": "en", + // Optional: the counter file shared with the designer, for example on a server where the designer also runs. + "counterFile": null +} +``` + +Unknown settings are an error (a misspelt `allowedPrinter` must not silently allow nothing or everything), and the server does not +start without at least one valid key. Everything a caller can reach is listed: there is no "allow all". + +**HTTPS.** Listening on another address than `127.0.0.1` over plain `http` works but the server warns, because keys and label data +would cross the network unencrypted. Use an `https://` URL with a certificate configured through the standard ASP.NET Core +settings, for example the environment variables `ASPNETCORE_Kestrel__Certificates__Default__Path` and +`ASPNETCORE_Kestrel__Certificates__Default__Password`, or put the server behind your reverse proxy. + +**As a Windows service** (elevated prompt): + +```bat +sc.exe create RotulaEngine binPath= "\"C:\Program Files\Rotula\rotula.exe\" serve --settings \"C:\ProgramData\Rotula\rotula-engine.settings.json\"" start= auto +sc.exe start RotulaEngine +``` + +Run the service as an account that may use the printers and read the label folders. A service account has its own +`%AppData%`; set `counterFile` when serial numbers must continue from the designer's file. + +### Calls + +Every route except `GET /api/v1/health` and the OpenAPI document needs the key in the `X-Api-Key` header (or +`Authorization: Bearer KEY`). The OpenAPI 3 description is at `/openapi/v1.json`. Messages follow the `Accept-Language` header +(or `?lang=`). + +| Route | What it does | +|---|---| +| `GET /api/v1/health` | `{"status":"ok","version":"0.4.0","printingBlocked":false}`. No key needed. | +| `GET /api/v1/printers` | The Windows printers, each with `allowed` (on `allowedPrinters`). | +| `GET /api/v1/printers/{name}/status` | Status of an allowed Windows printer. | +| `POST /api/v1/printers/status` | Status of an allowed printer: `{"host":"10.20.0.31"}` or `{"printer":"Dock 1"}`. | +| `POST /api/v1/jobs` | Prints. | + +A print request: + +```json +{ + "label": "outbound/carton.rotula", + "printer": "Dock 1", + "reference": "WAVE-1207", + "copies": 1, + "records": [ + { "sku": "4006381333931", "lot": "L2410", "qty": 12 }, + { "sku": "4006381333948", "lot": "L2411", "qty": 6 } + ], + "answers": { "operator": "M. Rivera" } +} +``` + +| Field | Meaning | +|---|---| +| `label` / `labelContent` | A label file relative to a label folder, or the content of a `.rotula` file (only with `allowInlineLabels`). Exactly one. | +| `printer` / `host`, `port` | A Windows printer, or a network printer and its port. Exactly one, and it must be on `allowedPrinters`. | +| `values` | The field values of one label. | +| `records` | Several records; one label (times the copies) each. Not together with `values`. Values may be text, numbers or true/false. | +| `copies`, `answers`, `dpi`, `darkness`, `speed` | As the command-line options. | +| `checkStatus`, `verify` | `true` by default. | +| `dryRun` | `true` returns the ZPL in `zpl` without printing. | +| `reference` | Your own reference, repeated in the answer and the log. | + +Unknown fields are refused with 400, so a misspelt `copeis` never prints the label's own quantity by accident. + +The answer is the same JSON as `rotula print --json`: + +```json +{ + "outcome": "printed", + "message": "Sent 2 record(s), 2 label(s), to Dock 1 in 1 job(s).", + "printer": "Dock 1", + "records": 2, + "labels": 2, + "jobs": 1, + "bytesSent": 1874, + "notices": [ { "code": "PrinterStatus.Verified", "severity": "warning", "message": "The printer reported that it finished the job." } ], + "reference": "WAVE-1207", + "succeeded": true +} +``` + +| `outcome` | HTTP | Meaning | +|---|---|---| +| `printed` | 200 | Every label was handed to the printer. Read `notices` for the verification. | +| `dryRun` | 200 | Nothing was sent; the ZPL is in `zpl`. | +| `invalid` | 400 | The request is wrong (copies out of range, values and records together...). | +| `invalidLabel` | 422 | The label has errors or questions are unanswered; `notices` say which. Nothing was sent. | +| `heldBack` | 409 | The printer reported a fault before a job; `errorCode` is `PaperOut`, `HeadOpen`, `RibbonOut`, `Paused`... Nothing more was sent. | +| `failed` | 502 | Sending failed (`errorCode`: `Offline`, `HostUnreachable`, `Timeout`...). `jobs` says how many went out before. | +| `cancelled` | 503 | The call was cancelled. | + +Refusals before the engine runs (no key 401, printer or label folder not allowed 403, label not found 404, unreadable body 400) +answer `{"errorCode":"Engine.Error.PrinterNotAllowed","message":"..."}`. + +### Log + +The server writes one line per job to its console (and to the Windows event log when it runs as a service): the reference, an +8-character id of the API key (a piece of its hash, useless for calling the server), the caller's address, the outcome, the printer, +the number of labels and jobs, and the error code. It never logs label data. + +## Policy + +An administrator can switch the engine off for a computer with the **Allow the print engine to print** policy (`PrintEngine`, +REG_DWORD `0`, in `packaging/policies`). The command line then refuses to print and to query printers (exit code 5), and the +server answers 403 to jobs and status requests; the health check reports `"printingBlocked": true`. The policy cannot switch +anything on; the server's own settings still decide what it allows. See [deployment.md](deployment.md). + +## Publishing + +`rotula.exe` is built from `src/Rotula.Cli` (the engine itself is `src/Rotula.Engine`, a library with no UI): + +```bat +dotnet publish src\Rotula.Cli\Rotula.Cli.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=none -o publish\win-x64-engine +``` diff --git a/packaging/policies/Rotula.admx b/packaging/policies/Rotula.admx index 7afbe67..59f52e7 100644 --- a/packaging/policies/Rotula.admx +++ b/packaging/policies/Rotula.admx @@ -88,5 +88,12 @@ + + + + + + diff --git a/packaging/policies/en-US/Rotula.adml b/packaging/policies/en-US/Rotula.adml index 592b070..c122d03 100644 --- a/packaging/policies/en-US/Rotula.adml +++ b/packaging/policies/en-US/Rotula.adml @@ -65,6 +65,14 @@ Registry value: McpPrinting (number: 0 blocks). Enabled or not configured: the settings file of the MCP server decides (allowDatabase). This policy cannot switch database access on by itself. Registry value: McpDatabase (number: 0 blocks). + Allow the print engine to print + The print engine (rotula.exe) prints labels without the designer: from a command line, a script or a warehouse system calling its REST API. + +Disabled: the print engine never prints and never asks printers for their status, from the command line or its REST server. + +Enabled or not configured: the command line prints with the rights of whoever runs it, and the REST server prints only what its settings file allows (API keys, label folders, allowedPrinters). This policy cannot switch anything on by itself. + +Registry value: PrintEngine (number: 0 blocks). diff --git a/packaging/policies/es-ES/Rotula.adml b/packaging/policies/es-ES/Rotula.adml index c7af7d2..71fa4d4 100644 --- a/packaging/policies/es-ES/Rotula.adml +++ b/packaging/policies/es-ES/Rotula.adml @@ -65,6 +65,14 @@ Valor del registro: McpPrinting (número: 0 bloquea). Habilitada o sin configurar: decide el archivo de configuración del servidor MCP (allowDatabase). Esta directiva no puede activar el acceso a bases de datos por sí sola. Valor del registro: McpDatabase (número: 0 bloquea). + Permitir que el motor de impresión imprima + El motor de impresión (rotula.exe) imprime etiquetas sin el diseñador: desde la línea de comandos, un script o un sistema de almacén que llama a su API REST. + +Deshabilitada: el motor de impresión nunca imprime ni consulta el estado de las impresoras, ni desde la línea de comandos ni desde su servidor REST. + +Habilitada o no configurada: la línea de comandos imprime con los permisos de quien la ejecuta, y el servidor REST imprime solo lo que permite su archivo de configuración (claves de API, carpetas de etiquetas, allowedPrinters). Esta directiva no puede activar nada por sí sola. + +Valor del registro: PrintEngine (número: 0 bloquea). diff --git a/src/Rotula.App/Resources/Strings.en.resx b/src/Rotula.App/Resources/Strings.en.resx index c23ca80..d7a36e3 100644 --- a/src/Rotula.App/Resources/Strings.en.resx +++ b/src/Rotula.App/Resources/Strings.en.resx @@ -6498,4 +6498,190 @@ Brand and product names belong to their owners; Rotula is not affiliated with th The label design is {0} x {1} mm, smaller than the {2} x {3} mm labels of this sheet. It is printed at its own size, centred on each label. + + {0} needs NAME=VALUE, but got "{1}". + + + "{1}" is not a valid value for {0}. + + + rotula {0}: prints Rotula labels without the designer, for scripts, schedulers and warehouse systems. + +Commands: + rotula print LABEL.rotula --printer NAME | --host ADDRESS [options] + Prints a label. Options: + --port N TCP port of --host (default 9100) + --copies N copies of the label, or of every record + --data FILE records from a .csv, .txt, .xlsx or .json file (one label per record) + --sheet NAME the Excel sheet to read + --set NAME=VALUE a field value (repeat for more fields) + --answer NAME=VALUE an answer to a question the label asks at print time + --dpi 203|300|600 printhead resolution (default: what the Windows driver reports) + --darkness 0-30 darkness sent before the labels + --speed 1-14 print speed sent before the labels + --no-status do not ask the printer for its status before every job + --no-verify do not wait for the printer to report that it finished + --dry-run show the ZPL without printing (--output FILE writes it to a file) + --reference TEXT your own reference, repeated in the result + --counter-file FILE where serial numbers are remembered (default: the same file as the app) + rotula status --printer NAME | --host ADDRESS [--port N] + Asks the printer whether it can print right now. + rotula printers + Lists the Windows printers. + rotula serve [--settings FILE] [--urls URL] + Runs the REST API. "rotula serve --init [--label-folder FOLDER]" writes a starter settings file with a new key. + rotula new-key + Makes a new API key and the hash to put in the settings file. + +Every command takes --json (output for programs) and --lang en|es|pt-BR|fr-CA. +Exit codes: 0 done, 1 printing failed, 2 wrong command line, 3 label errors, 4 printer not ready, 5 blocked by policy, 6 cancelled. + + + Created the settings file {0}. Add your printers to allowedPrinters, then run "rotula serve". + + + The settings file {0} already exists; it was not changed. + + + The option {0} needs a value. + + + New API key (shown only now; give it to the system that calls the server): {0} +Hash to add to apiKeyHashes in the settings file: {1} + + + Name exactly one label file, for example: rotula print shipping.rotula --printer "Dock 1" + + + Record {0}: + + + Run "rotula help" for the commands and options. + + + Unknown command "{0}". + + + Unknown option {0}. + + + Warning: + + + Some answers to the label's questions are missing or not valid. The label asks: {0}. Questions marked * must be answered; the others take their default. + + + A valid API key is required. Send it in the X-Api-Key header. + + + The request could not be read: {0} + + + Copies must be a whole number from 1 to {0}. + + + The data file {0} could not be read: {1} + + + The data file {0} does not exist.{1} + + + The data in {0} must be a list of records (objects of field names and values) or one record. {1} + + + The resolution must be 203, 300 or 600 DPI. + + + This server does not accept labels sent as content (allowInlineLabels is off). Name a label file in one of its label folders. + + + Send either a label file name (label) or the label content (labelContent), not both. + + + The label {0} was not found. + + + The label {0} is not a .rotula file inside one of this server's label folders. Name it relative to a label folder, without a drive letter or "..". + + + Name a label file (label) or send the label content (labelContent). + + + The label {0} is larger than the limit of {1} KB. + + + The data has no records, so nothing was printed. + + + No record produced a label, so nothing was sent. See the problems listed with the result. + + + The file {0} could not be written: {1} + + + Name either a Windows printer or the address of a network printer, not both. + + + The printer {0} is not on this server's list of allowed printers (allowedPrinters). Nothing was sent. + + + Name a printer: a Windows printer (printer) or the address of a network printer (host). + + + Printing with the print engine is switched off by your organization's policy for this computer. Ask your IT department. + + + A request may have at most {0} records. + + + Send either the values of one label or a list of records, not both. + + + [default] + + + No printers are installed in Windows. + + + [offline] + + + Nothing was printed (dry run). This is the ZPL that would be sent to {0}. + + + Sent {0} label(s) to {1}. + + + Sent {0} record(s), {1} label(s), to {2} in {3} job(s). + + + The server could not listen on {0}: {1} + + + Rotula print engine listening on {0}. Settings: {1}. Press Ctrl+C to stop. + + + "{0}" in apiKeyHashes is not a key hash (64 hexadecimal characters). Make one with "rotula new-key". + + + The label folder {0} does not exist. + + + The settings file {0} cannot be used: {1} + + + The settings file {0} does not exist. Create one with "rotula serve --init". + + + No label folders are listed (labelFolders), and labels sent as content are switched off, so no label can be printed. + + + The settings file {0} lists no API key (apiKeyHashes). Make one with "rotula new-key". + + + No printers are allowed yet (allowedPrinters), so every print request will be refused. + + + The server accepts calls from other computers over plain http: API keys and label data cross the network unencrypted. Use https. + diff --git a/src/Rotula.App/Resources/Strings.es.resx b/src/Rotula.App/Resources/Strings.es.resx index 6ae4fc3..ad83dbe 100644 --- a/src/Rotula.App/Resources/Strings.es.resx +++ b/src/Rotula.App/Resources/Strings.es.resx @@ -6498,4 +6498,190 @@ Las marcas y los nombres de producto pertenecen a sus dueños; Rotula no está a El diseño de la etiqueta mide {0} x {1} mm, menos que las etiquetas de {2} x {3} mm de esta hoja. Se imprime a su propio tamaño, centrado en cada etiqueta. + + {0} necesita NOMBRE=VALOR, pero recibió "{1}". + + + "{1}" no es un valor válido para {0}. + + + rotula {0}: imprime etiquetas de Rotula sin el diseñador, para scripts, programadores de tareas y sistemas de almacén. + +Comandos: + rotula print ETIQUETA.rotula --printer NOMBRE | --host DIRECCIÓN [opciones] + Imprime una etiqueta. Opciones: + --port N puerto TCP de --host (predeterminado 9100) + --copies N copias de la etiqueta, o de cada registro + --data ARCHIVO registros de un archivo .csv, .txt, .xlsx o .json (una etiqueta por registro) + --sheet NOMBRE la hoja de Excel que se lee + --set NOMBRE=VALOR el valor de un campo (repítalo para más campos) + --answer NOMBRE=VALOR la respuesta a una pregunta que la etiqueta hace al imprimir + --dpi 203|300|600 resolución del cabezal (predeterminado: la que indica el controlador de Windows) + --darkness 0-30 oscuridad enviada antes de las etiquetas + --speed 1-14 velocidad enviada antes de las etiquetas + --no-status no pregunta el estado a la impresora antes de cada trabajo + --no-verify no espera a que la impresora indique que terminó + --dry-run muestra el ZPL sin imprimir (--output ARCHIVO lo guarda en un archivo) + --reference TEXTO su propia referencia, repetida en el resultado + --counter-file ARCHIVO dónde se recuerdan los números de serie (predeterminado: el mismo archivo que la aplicación) + rotula status --printer NOMBRE | --host DIRECCIÓN [--port N] + Pregunta a la impresora si puede imprimir ahora. + rotula printers + Lista las impresoras de Windows. + rotula serve [--settings ARCHIVO] [--urls URL] + Ejecuta la API REST. "rotula serve --init [--label-folder CARPETA]" crea un archivo de configuración inicial con una clave nueva. + rotula new-key + Crea una clave de API nueva y el hash que va en el archivo de configuración. + +Todos los comandos aceptan --json (salida para programas) y --lang en|es|pt-BR|fr-CA. +Códigos de salida: 0 hecho, 1 falló la impresión, 2 línea de comandos incorrecta, 3 errores en la etiqueta, 4 impresora no lista, 5 bloqueado por directiva, 6 cancelado. + + + Se creó el archivo de configuración {0}. Agregue sus impresoras en allowedPrinters y ejecute "rotula serve". + + + El archivo de configuración {0} ya existe; no se modificó. + + + La opción {0} necesita un valor. + + + Clave de API nueva (solo se muestra ahora; entréguela al sistema que llama al servidor): {0} +Hash que debe agregar a apiKeyHashes en el archivo de configuración: {1} + + + Indique exactamente un archivo de etiqueta, por ejemplo: rotula print envio.rotula --printer "Muelle 1" + + + Registro {0}: + + + Ejecute "rotula help" para ver los comandos y las opciones. + + + Comando desconocido "{0}". + + + Opción desconocida {0}. + + + Advertencia: + + + Faltan respuestas a las preguntas de la etiqueta o alguna no es válida. La etiqueta pregunta: {0}. Las preguntas marcadas con * son obligatorias; las demás toman su valor predeterminado. + + + Se necesita una clave de API válida. Envíela en el encabezado X-Api-Key. + + + No se pudo leer la solicitud: {0} + + + Las copias deben ser un número entero de 1 a {0}. + + + No se pudo leer el archivo de datos {0}: {1} + + + El archivo de datos {0} no existe.{1} + + + Los datos de {0} deben ser una lista de registros (objetos con nombres de campo y valores) o un solo registro. {1} + + + La resolución debe ser 203, 300 o 600 DPI. + + + Este servidor no acepta etiquetas enviadas como contenido (allowInlineLabels está desactivado). Indique un archivo de etiqueta de una de sus carpetas de etiquetas. + + + Envíe el nombre de un archivo de etiqueta (label) o el contenido de la etiqueta (labelContent), no ambos. + + + No se encontró la etiqueta {0}. + + + La etiqueta {0} no es un archivo .rotula dentro de una de las carpetas de etiquetas de este servidor. Indíquela relativa a una carpeta de etiquetas, sin letra de unidad ni "..". + + + Indique un archivo de etiqueta (label) o envíe el contenido de la etiqueta (labelContent). + + + La etiqueta {0} supera el límite de {1} KB. + + + Los datos no tienen registros, así que no se imprimió nada. + + + Ningún registro produjo una etiqueta, así que no se envió nada. Vea los problemas indicados en el resultado. + + + No se pudo escribir el archivo {0}: {1} + + + Indique una impresora de Windows o la dirección de una impresora de red, no ambas. + + + La impresora {0} no está en la lista de impresoras permitidas de este servidor (allowedPrinters). No se envió nada. + + + Indique una impresora: una impresora de Windows (printer) o la dirección de una impresora de red (host). + + + La impresión con el motor de impresión está desactivada por la directiva de su organización para este equipo. Consulte a su departamento de TI. + + + Una solicitud puede tener como máximo {0} registros. + + + Envíe los valores de una etiqueta o una lista de registros, no ambos. + + + [predeterminada] + + + No hay impresoras instaladas en Windows. + + + [sin conexión] + + + No se imprimió nada (prueba). Este es el ZPL que se enviaría a {0}. + + + Se enviaron {0} etiqueta(s) a {1}. + + + Se enviaron {0} registro(s), {1} etiqueta(s), a {2} en {3} trabajo(s). + + + El servidor no pudo escuchar en {0}: {1} + + + Motor de impresión de Rotula escuchando en {0}. Configuración: {1}. Pulse Ctrl+C para detenerlo. + + + "{0}" en apiKeyHashes no es un hash de clave (64 caracteres hexadecimales). Cree uno con "rotula new-key". + + + La carpeta de etiquetas {0} no existe. + + + No se puede usar el archivo de configuración {0}: {1} + + + El archivo de configuración {0} no existe. Cree uno con "rotula serve --init". + + + No hay carpetas de etiquetas (labelFolders) y las etiquetas enviadas como contenido están desactivadas, así que no se puede imprimir ninguna etiqueta. + + + El archivo de configuración {0} no tiene ninguna clave de API (apiKeyHashes). Cree una con "rotula new-key". + + + Todavía no hay impresoras permitidas (allowedPrinters), así que se rechazará toda solicitud de impresión. + + + El servidor acepta llamadas de otros equipos por http sin cifrar: las claves de API y los datos de las etiquetas viajan por la red sin cifrado. Use https. + diff --git a/src/Rotula.App/Resources/Strings.fr-CA.resx b/src/Rotula.App/Resources/Strings.fr-CA.resx index d8c15e0..c724575 100644 --- a/src/Rotula.App/Resources/Strings.fr-CA.resx +++ b/src/Rotula.App/Resources/Strings.fr-CA.resx @@ -6498,4 +6498,190 @@ Les noms de marques et de produits appartiennent à leurs propriétaires ; Rot Le dessin de l’étiquette mesure {0} x {1} mm, soit moins que les étiquettes de {2} x {3} mm de cette feuille. Il est imprimé à sa propre taille, centré sur chaque étiquette. + + {0} attend NOM=VALEUR, mais a reçu « {1} ». + + + « {1} » n’est pas une valeur valide pour {0}. + + + rotula {0} : imprime des étiquettes Rotula sans le concepteur, pour les scripts, les planificateurs de tâches et les systèmes d’entrepôt. + +Commandes : + rotula print ÉTIQUETTE.rotula --printer NOM | --host ADRESSE [options] + Imprime une étiquette. Options : + --port N port TCP de --host (9100 par défaut) + --copies N copies de l’étiquette, ou de chaque enregistrement + --data FICHIER enregistrements d’un fichier .csv, .txt, .xlsx ou .json (une étiquette par enregistrement) + --sheet NOM la feuille Excel à lire + --set NOM=VALEUR la valeur d’un champ (répétez pour d’autres champs) + --answer NOM=VALEUR la réponse à une question que l’étiquette pose à l’impression + --dpi 203|300|600 résolution de la tête d’impression (par défaut : celle indiquée par le pilote Windows) + --darkness 0-30 noirceur envoyée avant les étiquettes + --speed 1-14 vitesse envoyée avant les étiquettes + --no-status ne demande pas l’état de l’imprimante avant chaque travail + --no-verify n’attend pas que l’imprimante indique qu’elle a terminé + --dry-run affiche le ZPL sans imprimer (--output FICHIER l’enregistre dans un fichier) + --reference TEXTE votre propre référence, reprise dans le résultat + --counter-file FICHIER où les numéros de série sont mémorisés (par défaut : le même fichier que l’application) + rotula status --printer NOM | --host ADRESSE [--port N] + Demande à l’imprimante si elle peut imprimer maintenant. + rotula printers + Liste les imprimantes Windows. + rotula serve [--settings FICHIER] [--urls URL] + Lance l’API REST. « rotula serve --init [--label-folder DOSSIER] » crée un fichier de paramètres de départ avec une nouvelle clé. + rotula new-key + Crée une nouvelle clé d’API et le hachage à mettre dans le fichier de paramètres. + +Toutes les commandes acceptent --json (sortie pour les programmes) et --lang en|es|pt-BR|fr-CA. +Codes de sortie : 0 terminé, 1 échec de l’impression, 2 ligne de commande incorrecte, 3 erreurs dans l’étiquette, 4 imprimante pas prête, 5 bloqué par une stratégie, 6 annulé. + + + Le fichier de paramètres {0} a été créé. Ajoutez vos imprimantes dans allowedPrinters, puis lancez « rotula serve ». + + + Le fichier de paramètres {0} existe déjà ; il n’a pas été modifié. + + + L’option {0} doit être suivie d’une valeur. + + + Nouvelle clé d’API (affichée une seule fois ; remettez-la au système qui appelle le serveur) : {0} +Hachage à ajouter dans apiKeyHashes du fichier de paramètres : {1} + + + Indiquez exactement un fichier d’étiquette, par exemple : rotula print expedition.rotula --printer "Quai 1" + + + Enregistrement {0} : + + + Lancez « rotula help » pour voir les commandes et les options. + + + Commande inconnue « {0} ». + + + Option inconnue {0}. + + + Avertissement : + + + Des réponses aux questions de l’étiquette manquent ou ne sont pas valides. L’étiquette demande : {0}. Les questions marquées * sont obligatoires; les autres prennent leur valeur par défaut. + + + Une clé d’API valide est requise. Envoyez-la dans l’en-tête X-Api-Key. + + + La requête n’a pas pu être lue : {0} + + + Le nombre de copies doit être un entier de 1 à {0}. + + + Le fichier de données {0} n’a pas pu être lu : {1} + + + Le fichier de données {0} n’existe pas.{1} + + + Les données de {0} doivent être une liste d’enregistrements (objets de noms de champs et de valeurs) ou un seul enregistrement. {1} + + + La résolution doit être de 203, 300 ou 600 ppp. + + + Ce serveur n’accepte pas les étiquettes envoyées comme contenu (allowInlineLabels est désactivé). Indiquez un fichier d’étiquette situé dans un de ses dossiers d’étiquettes. + + + Envoyez soit le nom d’un fichier d’étiquette (label), soit le contenu de l’étiquette (labelContent), pas les deux. + + + L’étiquette {0} est introuvable. + + + L’étiquette {0} n’est pas un fichier .rotula situé dans un des dossiers d’étiquettes de ce serveur. Indiquez-la par rapport à un dossier d’étiquettes, sans lettre de lecteur ni « .. ». + + + Indiquez un fichier d’étiquette (label) ou envoyez le contenu de l’étiquette (labelContent). + + + L’étiquette {0} dépasse la limite de {1} Ko. + + + Les données ne contiennent aucun enregistrement; rien n’a été imprimé. + + + Aucun enregistrement n’a produit d’étiquette; rien n’a été envoyé. Voyez les problèmes indiqués dans le résultat. + + + Le fichier {0} n’a pas pu être écrit : {1} + + + Indiquez soit une imprimante Windows, soit l’adresse d’une imprimante réseau, pas les deux. + + + L’imprimante {0} ne figure pas dans la liste des imprimantes autorisées de ce serveur (allowedPrinters). Rien n’a été envoyé. + + + Indiquez une imprimante : une imprimante Windows (printer) ou l’adresse d’une imprimante réseau (host). + + + L’impression avec le moteur d’impression est désactivée par la stratégie de votre organisation sur cet ordinateur. Adressez-vous à votre service informatique. + + + Une requête peut contenir au plus {0} enregistrements. + + + Envoyez soit les valeurs d’une étiquette, soit une liste d’enregistrements, pas les deux. + + + [par défaut] + + + Aucune imprimante n’est installée dans Windows. + + + [hors ligne] + + + Rien n’a été imprimé (essai). Voici le ZPL qui serait envoyé à {0}. + + + {0} étiquette(s) envoyée(s) à {1}. + + + {0} enregistrement(s), {1} étiquette(s), envoyé(s) à {2} en {3} travail(aux). + + + Le serveur n’a pas pu écouter sur {0} : {1} + + + Moteur d’impression Rotula à l’écoute sur {0}. Paramètres : {1}. Appuyez sur Ctrl+C pour l’arrêter. + + + « {0} » dans apiKeyHashes n’est pas un hachage de clé (64 caractères hexadécimaux). Créez-en un avec « rotula new-key ». + + + Le dossier d’étiquettes {0} n’existe pas. + + + Le fichier de paramètres {0} ne peut pas être utilisé : {1} + + + Le fichier de paramètres {0} n’existe pas. Créez-en un avec « rotula serve --init ». + + + Aucun dossier d’étiquettes n’est indiqué (labelFolders) et les étiquettes envoyées comme contenu sont désactivées; aucune étiquette ne peut donc être imprimée. + + + Le fichier de paramètres {0} ne contient aucune clé d’API (apiKeyHashes). Créez-en une avec « rotula new-key ». + + + Aucune imprimante n’est encore autorisée (allowedPrinters); toute demande d’impression sera donc refusée. + + + Le serveur accepte les appels d’autres ordinateurs en http non chiffré : les clés d’API et les données des étiquettes circulent sur le réseau sans chiffrement. Utilisez https. + diff --git a/src/Rotula.App/Resources/Strings.pt-BR.resx b/src/Rotula.App/Resources/Strings.pt-BR.resx index c8eeae1..a31bf0d 100644 --- a/src/Rotula.App/Resources/Strings.pt-BR.resx +++ b/src/Rotula.App/Resources/Strings.pt-BR.resx @@ -6498,4 +6498,190 @@ Os nomes de marcas e produtos pertencem aos seus proprietários; o Rotula não t O design da etiqueta tem {0} x {1} mm, menor que as etiquetas de {2} x {3} mm desta folha. Ele é impresso no próprio tamanho, centralizado em cada etiqueta. + + {0} precisa de NOME=VALOR, mas recebeu "{1}". + + + "{1}" não é um valor válido para {0}. + + + rotula {0}: imprime etiquetas do Rotula sem o designer, para scripts, agendadores de tarefas e sistemas de armazém. + +Comandos: + rotula print ETIQUETA.rotula --printer NOME | --host ENDEREÇO [opções] + Imprime uma etiqueta. Opções: + --port N porta TCP de --host (padrão 9100) + --copies N cópias da etiqueta, ou de cada registro + --data ARQUIVO registros de um arquivo .csv, .txt, .xlsx ou .json (uma etiqueta por registro) + --sheet NOME a planilha do Excel a ler + --set NOME=VALOR o valor de um campo (repita para mais campos) + --answer NOME=VALOR a resposta a uma pergunta que a etiqueta faz na impressão + --dpi 203|300|600 resolução da cabeça de impressão (padrão: a informada pelo driver do Windows) + --darkness 0-30 escurecimento enviado antes das etiquetas + --speed 1-14 velocidade enviada antes das etiquetas + --no-status não pergunta o status à impressora antes de cada trabalho + --no-verify não espera a impressora informar que terminou + --dry-run mostra o ZPL sem imprimir (--output ARQUIVO grava em um arquivo) + --reference TEXTO sua própria referência, repetida no resultado + --counter-file ARQUIVO onde os números de série são lembrados (padrão: o mesmo arquivo do aplicativo) + rotula status --printer NOME | --host ENDEREÇO [--port N] + Pergunta à impressora se ela pode imprimir agora. + rotula printers + Lista as impressoras do Windows. + rotula serve [--settings ARQUIVO] [--urls URL] + Executa a API REST. "rotula serve --init [--label-folder PASTA]" cria um arquivo de configuração inicial com uma chave nova. + rotula new-key + Cria uma chave de API nova e o hash que vai no arquivo de configuração. + +Todos os comandos aceitam --json (saída para programas) e --lang en|es|pt-BR|fr-CA. +Códigos de saída: 0 concluído, 1 a impressão falhou, 2 linha de comando incorreta, 3 erros na etiqueta, 4 impressora não está pronta, 5 bloqueado por política, 6 cancelado. + + + O arquivo de configuração {0} foi criado. Adicione suas impressoras em allowedPrinters e execute "rotula serve". + + + O arquivo de configuração {0} já existe; ele não foi alterado. + + + A opção {0} precisa de um valor. + + + Chave de API nova (mostrada só agora; entregue-a ao sistema que chama o servidor): {0} +Hash a adicionar em apiKeyHashes no arquivo de configuração: {1} + + + Indique exatamente um arquivo de etiqueta, por exemplo: rotula print envio.rotula --printer "Doca 1" + + + Registro {0}: + + + Execute "rotula help" para ver os comandos e as opções. + + + Comando desconhecido "{0}". + + + Opção desconhecida {0}. + + + Aviso: + + + Faltam respostas às perguntas da etiqueta ou alguma não é válida. A etiqueta pergunta: {0}. As perguntas marcadas com * são obrigatórias; as demais usam o valor padrão. + + + É necessária uma chave de API válida. Envie-a no cabeçalho X-Api-Key. + + + Não foi possível ler a solicitação: {0} + + + As cópias devem ser um número inteiro de 1 a {0}. + + + Não foi possível ler o arquivo de dados {0}: {1} + + + O arquivo de dados {0} não existe.{1} + + + Os dados de {0} devem ser uma lista de registros (objetos com nomes de campo e valores) ou um único registro. {1} + + + A resolução deve ser 203, 300 ou 600 DPI. + + + Este servidor não aceita etiquetas enviadas como conteúdo (allowInlineLabels está desativado). Indique um arquivo de etiqueta de uma das suas pastas de etiquetas. + + + Envie o nome de um arquivo de etiqueta (label) ou o conteúdo da etiqueta (labelContent), não os dois. + + + A etiqueta {0} não foi encontrada. + + + A etiqueta {0} não é um arquivo .rotula dentro de uma das pastas de etiquetas deste servidor. Indique-a em relação a uma pasta de etiquetas, sem letra de unidade nem "..". + + + Indique um arquivo de etiqueta (label) ou envie o conteúdo da etiqueta (labelContent). + + + A etiqueta {0} é maior que o limite de {1} KB. + + + Os dados não têm registros, então nada foi impresso. + + + Nenhum registro gerou uma etiqueta, então nada foi enviado. Veja os problemas listados no resultado. + + + Não foi possível gravar o arquivo {0}: {1} + + + Indique uma impressora do Windows ou o endereço de uma impressora de rede, não as duas. + + + A impressora {0} não está na lista de impressoras permitidas deste servidor (allowedPrinters). Nada foi enviado. + + + Indique uma impressora: uma impressora do Windows (printer) ou o endereço de uma impressora de rede (host). + + + A impressão com o mecanismo de impressão está desativada pela política da sua organização para este computador. Fale com o seu departamento de TI. + + + Uma solicitação pode ter no máximo {0} registros. + + + Envie os valores de uma etiqueta ou uma lista de registros, não os dois. + + + [padrão] + + + Não há impressoras instaladas no Windows. + + + [offline] + + + Nada foi impresso (simulação). Este é o ZPL que seria enviado para {0}. + + + {0} etiqueta(s) enviada(s) para {1}. + + + {0} registro(s), {1} etiqueta(s), enviado(s) para {2} em {3} trabalho(s). + + + O servidor não conseguiu escutar em {0}: {1} + + + Mecanismo de impressão do Rotula escutando em {0}. Configuração: {1}. Pressione Ctrl+C para parar. + + + "{0}" em apiKeyHashes não é um hash de chave (64 caracteres hexadecimais). Crie um com "rotula new-key". + + + A pasta de etiquetas {0} não existe. + + + Não é possível usar o arquivo de configuração {0}: {1} + + + O arquivo de configuração {0} não existe. Crie um com "rotula serve --init". + + + Não há pastas de etiquetas (labelFolders) e as etiquetas enviadas como conteúdo estão desativadas, então nenhuma etiqueta pode ser impressa. + + + O arquivo de configuração {0} não tem nenhuma chave de API (apiKeyHashes). Crie uma com "rotula new-key". + + + Ainda não há impressoras permitidas (allowedPrinters), então toda solicitação de impressão será recusada. + + + O servidor aceita chamadas de outros computadores por http sem criptografia: as chaves de API e os dados das etiquetas trafegam pela rede sem criptografia. Use https. + diff --git a/src/Rotula.Cli/CliApp.cs b/src/Rotula.Cli/CliApp.cs new file mode 100644 index 0000000..1ae8768 --- /dev/null +++ b/src/Rotula.Cli/CliApp.cs @@ -0,0 +1,338 @@ +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using Rotula.Core.Merge; +using Rotula.Core.Variables.Counters; +using Rotula.Engine; +using Rotula.Files.Policy; +using Rotula.Printing; + +namespace Rotula.Cli; + +/// Exit codes of rotula, stable so scripts and schedulers can act on them. +internal static class ExitCodes +{ + /// Printed (or the dry run worked, or the printer is ready). + public const int Ok = 0; + /// Sending failed (printer not found, offline, unreachable...), or the status could not be read. + public const int Failed = 1; + /// The command line or the request is wrong. + public const int Usage = 2; + /// The label has errors or the answers to its questions are missing. + public const int InvalidLabel = 3; + /// The printer reported a fault (paper out, head open, paused...) and nothing was sent, or the status says it cannot print. + public const int PrinterNotReady = 4; + /// A policy of this computer forbids it. + public const int Refused = 5; + /// Stopped with Ctrl+C. + public const int Cancelled = 6; + + /// The exit code of a print outcome. + public static int For(PrintOutcome outcome) => outcome switch + { + PrintOutcome.Printed or PrintOutcome.DryRun => Ok, + PrintOutcome.Invalid => Usage, + PrintOutcome.InvalidLabel => InvalidLabel, + PrintOutcome.HeldBack => PrinterNotReady, + PrintOutcome.Refused => Refused, + PrintOutcome.Cancelled => Cancelled, + _ => Failed, + }; +} + +/// What the commands need from outside, so tests can run them with fakes and without touching the computer. +internal sealed class CliHost +{ + /// The print service. + public required IPrintService Print { get; init; } + + /// The policy of this computer. + public RotulaPolicy Policy { get; init; } = RotulaPolicy.None; + + /// Opens the counter store for serial numbers that remember their value (null path = the app's own file). + public Func Counters { get; init; } = path => new Rotula.Files.FileCounterStore(path ?? Rotula.Files.FileCounterStore.DefaultPath); + + /// Draws text in Windows fonts. + public Rotula.Core.Text.ITextRasterizer? Rasterizer { get; init; } = new Rotula.Rendering.SkiaTextRasterizer(); + + /// Folder of the program (the default place of the settings file). + public string BaseDirectory { get; init; } = AppContext.BaseDirectory; + + /// Normal output. + public TextWriter Out { get; init; } = Console.Out; + + /// Problems. + public TextWriter Error { get; init; } = Console.Error; + + /// Language when neither --lang nor a policy chooses one. + public string? DefaultLanguage { get; init; } = Environment.GetEnvironmentVariable("ROTULA_LANG") ?? CultureInfo.CurrentUICulture.Name; + + /// Starts the REST server and runs it until it stops; replaced in tests. + public Func> Serve { get; init; } = Server.EngineServer.RunAsync; +} + +/// The rotula commands: print, status, printers, new-key, serve, help, version. +internal static class CliApp +{ + /// Runs one command line and returns the exit code. Never throws for a user or printer problem. + public static async Task RunAsync(string[] args, CliHost host, CancellationToken ct = default) + { + var (line, parseError, parseArg) = CommandLine.Parse(args); + // The language is chosen before anything is said: --lang, then the organization's policy, then Windows. + var text = EngineText.For(line.Value("lang") ?? (host.Policy.Language is { } p && p != "auto" ? p : null) ?? host.DefaultLanguage); + if (parseError is not null) + { + host.Error.WriteLine(text.Get(parseError, parseArg ?? string.Empty)); + host.Error.WriteLine(text.Get("Engine.Cli.SeeHelp")); + return ExitCodes.Usage; + } + if (line.Has("version") || line.Command == "version") + { + host.Out.WriteLine("rotula " + Version()); + return ExitCodes.Ok; + } + if (line.Has("help") || line.Command is "" or "help") + { + host.Out.WriteLine(text.Get("Engine.Cli.Help", Version())); + return ExitCodes.Ok; + } + + try + { + return line.Command switch + { + "print" => await PrintAsync(line, host, text, ct).ConfigureAwait(false), + "status" => await StatusAsync(line, host, text, ct).ConfigureAwait(false), + "printers" => await PrintersAsync(line, host, text, ct).ConfigureAwait(false), + "new-key" => NewKey(line, host, text), + "serve" => await ServeAsync(line, host, text, args, ct).ConfigureAwait(false), + _ => Usage(host, text, "Engine.Cli.UnknownCommand", line.Command), + }; + } + catch (OperationCanceledException) + { + host.Error.WriteLine(text.Get("Print.Error.Cancelled")); + return ExitCodes.Cancelled; + } + } + + // ---- print ------------------------------------------------------------------------------------------------------------ + + private static async Task PrintAsync(CommandLine line, CliHost host, EngineText text, CancellationToken ct) + { + if (line.Positionals.Count != 1) return Usage(host, text, "Engine.Cli.PrintNeedsLabel", string.Empty); + if (host.Policy.PrintEngineBlocked) return Refused(line, host, text); + + var request = new PrintJobRequest + { + Label = line.Positionals[0], + Printer = line.Value("printer"), + Host = line.Value("host"), + DryRun = line.Has("dry-run"), + CheckStatus = !line.Has("no-status"), + Verify = !line.Has("no-verify"), + Reference = line.Value("reference"), + }; + foreach (var (name, apply) in new (string, Action)[] + { + ("port", v => request.Port = v), ("copies", v => request.Copies = v), ("dpi", v => request.Dpi = v), + ("darkness", v => request.Darkness = v), ("speed", v => request.Speed = v), + }) + { + if (line.Value(name) is not { } raw) continue; + if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v)) return Usage(host, text, "Engine.Cli.BadValue", "--" + name, raw); + apply(v); + } + var values = CommandLine.Pairs(line.Values("set"), out var badSet); + if (values is null) return Usage(host, text, "Engine.Cli.BadPair", "--set", badSet!); + var answers = CommandLine.Pairs(line.Values("answer"), out var badAnswer); + if (answers is null) return Usage(host, text, "Engine.Cli.BadPair", "--answer", badAnswer!); + if (answers.Count > 0) request.Answers = answers; + + MergeTable? table = null; + if (line.Value("data") is { } dataPath) + { + string? error = ReadData(dataPath, line.Value("sheet"), out table, out var jsonValues, out var detail); + if (error is not null) return Report(line, host, text, ExitCodes.Usage, text.Get(error, dataPath, detail ?? string.Empty)); + if (jsonValues is not null) foreach (var kv in jsonValues) values.TryAdd(kv.Key, kv.Value); + } + if (values.Count > 0) request.Values = values; + + var label = LabelFiles.Load(request.Label); + if (label.Content is null) return Report(line, host, text, ExitCodes.Usage, text.Get(label.ErrorKey!, label.Args)); + + var engine = new PrintEngine(host.Print, host.Counters(line.Value("counter-file")), host.Rasterizer); + var result = await engine.PrintAsync(label.Content, request, table, text, EngineLimits.Default, ct).ConfigureAwait(false); + + if (result.Outcome == PrintOutcome.DryRun && result.Zpl is not null && line.Value("output") is { } output) + { + try { await File.WriteAllTextAsync(output, result.Zpl, new System.Text.UTF8Encoding(false), ct).ConfigureAwait(false); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return Report(line, host, text, ExitCodes.Failed, text.Get("Engine.Error.OutputFile", output, ex.Message)); + } + } + + if (line.Has("json")) + { + host.Out.WriteLine(JsonSerializer.Serialize(result, EngineJson.Indented)); + } + else + { + var writer = result.Succeeded ? host.Out : host.Error; + writer.WriteLine(result.Message); + foreach (var n in result.Notices) + writer.WriteLine(" - " + (n.Record is { } r ? text.Get("Engine.Cli.RecordPrefix", r) + " " : string.Empty) + n.Message); + // Without --output the ZPL of a dry run goes to the output, so it can be piped into a file or another tool. + if (result.Outcome == PrintOutcome.DryRun && result.Zpl is not null && line.Value("output") is null) host.Out.Write(result.Zpl); + } + return ExitCodes.For(result.Outcome); + } + + /// Reads --data: JSON (records or one label's values), Excel (.xlsx) or delimited text (CSV, tab, semicolon; detected). + internal static string? ReadData(string path, string? sheet, out MergeTable? table, out Dictionary? values, out string? detail) + { + table = null; + values = null; + detail = null; + try + { + if (!File.Exists(path)) return "Engine.Error.DataMissing"; + string ext = Path.GetExtension(path).ToLowerInvariant(); + if (ext == ".json") + { + string? key = EngineJson.ReadData(File.ReadAllText(path), out var records, out values, out detail); + if (key is not null) return key; + if (records is not null) table = PrintEngine.RecordsTable(records); + return null; + } + table = ext is ".xlsx" or ".xlsm" + ? XlsxReader.ReadFile(path, new XlsxOptions { SheetName = sheet }) + : CsvReader.ReadFile(path); + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or ArgumentException or FormatException + or InvalidOperationException or NotSupportedException or System.Security.SecurityException) + { + detail = ex.Message; + return "Engine.Error.DataFile"; + } + } + + // ---- status and printers ---------------------------------------------------------------------------------------------- + + private static async Task StatusAsync(CommandLine line, CliHost host, EngineText text, CancellationToken ct) + { + if (host.Policy.PrintEngineBlocked) return Refused(line, host, text); + int? port = null; + if (line.Value("port") is { } rawPort) + { + if (!int.TryParse(rawPort, NumberStyles.Integer, CultureInfo.InvariantCulture, out int p)) return Usage(host, text, "Engine.Cli.BadValue", "--port", rawPort); + port = p; + } + if (PrintEngine.TryBuildTarget(line.Value("printer") ?? line.Positionals.FirstOrDefault(), line.Value("host"), port, out var target) is { } error) + return Usage(host, text, error, string.Empty); + var engine = new PrintEngine(host.Print); + var report = await engine.GetStatusAsync(target!, text, ct).ConfigureAwait(false); + if (line.Has("json")) host.Out.WriteLine(JsonSerializer.Serialize(report, EngineJson.Indented)); + else host.Out.WriteLine(report.Printer + ": " + report.Message); + return !report.Available ? ExitCodes.Failed : report.Ready == true ? ExitCodes.Ok : ExitCodes.PrinterNotReady; + } + + private static async Task PrintersAsync(CommandLine line, CliHost host, EngineText text, CancellationToken ct) + { + var engine = new PrintEngine(host.Print); + var printers = (await engine.GetPrintersAsync(ct).ConfigureAwait(false)) + .Select(p => new PrinterEntry(p.Name, p.PortName, p.DriverName, p.IsDefault, p.IsOffline, true)).ToList(); + if (line.Has("json")) + { + host.Out.WriteLine(JsonSerializer.Serialize(printers, EngineJson.Indented)); + return ExitCodes.Ok; + } + if (printers.Count == 0) host.Out.WriteLine(text.Get("Engine.Printers.None")); + foreach (var p in printers) + { + string flags = (p.IsDefault ? " " + text.Get("Engine.Printers.Default") : string.Empty) + (p.IsOffline ? " " + text.Get("Engine.Printers.Offline") : string.Empty); + host.Out.WriteLine(p.Name + " (" + p.Port + ", " + p.Driver + ")" + flags); + } + return ExitCodes.Ok; + } + + // ---- keys and the server ---------------------------------------------------------------------------------------------- + + private static int NewKey(CommandLine line, CliHost host, EngineText text) + { + var (key, hash) = ApiKeys.Create(); + if (line.Has("json")) host.Out.WriteLine(JsonSerializer.Serialize(new { key, hash }, EngineJson.Indented)); + else host.Out.WriteLine(text.Get("Engine.Cli.NewKey", key, hash)); + return ExitCodes.Ok; + } + + private static async Task ServeAsync(CommandLine line, CliHost host, EngineText text, string[] args, CancellationToken ct) + { + if (host.Policy.PrintEngineBlocked) return Refused(line, host, text); + string path = Path.GetFullPath(line.Value("settings") ?? Path.Combine(host.BaseDirectory, EngineSettings.FileName)); + if (line.Has("init")) + { + if (File.Exists(path)) return Usage(host, text, "Engine.Cli.InitExists", path); + var (key, hash) = ApiKeys.Create(); + string folder = line.Value("label-folder") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Rotula Labels"); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, EngineSettingsLoader.Starter(hash, folder), new System.Text.UTF8Encoding(false), ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + host.Error.WriteLine(text.Get("Engine.Settings.Invalid", path, ex.Message)); + return ExitCodes.Failed; + } + host.Out.WriteLine(text.Get("Engine.Cli.InitDone", path)); + host.Out.WriteLine(text.Get("Engine.Cli.NewKey", key, hash)); + return ExitCodes.Ok; + } + + var loaded = EngineSettingsLoader.Load(path, text); + foreach (var warning in loaded.Warnings) host.Error.WriteLine(text.Get("Engine.Cli.WarningPrefix") + " " + warning); + if (loaded.Settings is null) + { + foreach (var error in loaded.Errors) host.Error.WriteLine(error); + return ExitCodes.Usage; + } + var settings = loaded.Settings; + if (line.Values("urls") is { Count: > 0 } urls) settings = settings with { Urls = urls.SelectMany(u => u.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).ToList() }; + var engine = new PrintEngine(host.Print, host.Counters(line.Value("counter-file") ?? settings.CounterFile), host.Rasterizer); + return await host.Serve(loaded with { Settings = settings }, engine, EngineText.For(settings.Language), host.Policy, args, ct).ConfigureAwait(false); + } + + // ---- helpers ---------------------------------------------------------------------------------------------------------- + + private static int Refused(CommandLine line, CliHost host, EngineText text) => + Report(line, host, text, ExitCodes.Refused, text.Get("Engine.Error.PrintingBlocked"), "Engine.Error.PrintingBlocked"); + + private static int Usage(CliHost host, EngineText text, string key, params object[] args) + { + host.Error.WriteLine(text.Get(key, args)); + host.Error.WriteLine(text.Get("Engine.Cli.SeeHelp")); + return ExitCodes.Usage; + } + + /// A problem found before the engine ran: as JSON with --json (same shape as a print result), otherwise as one line. + private static int Report(CommandLine line, CliHost host, EngineText text, int code, string message, string? errorCode = null) + { + if (line.Has("json")) + { + var outcome = code switch { ExitCodes.Refused => PrintOutcome.Refused, ExitCodes.Usage => PrintOutcome.Invalid, _ => PrintOutcome.Failed }; + host.Out.WriteLine(JsonSerializer.Serialize(new PrintJobResult(outcome, message, errorCode ?? outcome.ToString(), string.Empty, 0, 0, 0, 0, [], null, + line.Value("reference")), EngineJson.Indented)); + } + else + { + host.Error.WriteLine(message); + } + return code; + } + + internal static string Version() => + typeof(CliApp).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "0.0.0"; +} diff --git a/src/Rotula.Cli/CommandLine.cs b/src/Rotula.Cli/CommandLine.cs new file mode 100644 index 0000000..a966eea --- /dev/null +++ b/src/Rotula.Cli/CommandLine.cs @@ -0,0 +1,108 @@ +namespace Rotula.Cli; + +/// +/// The parsed command line: the command, its positional arguments, options with values (--printer NAME or --printer=NAME, +/// repeatable ones such as --set keep every value) and switches (--json). Hand-written so the program needs no parser package. +/// +internal sealed class CommandLine +{ + /// Options that take a value, for every command. + internal static readonly IReadOnlySet ValueOptions = new HashSet(StringComparer.Ordinal) + { + "printer", "host", "port", "copies", "data", "sheet", "set", "answer", "dpi", "darkness", "speed", "output", "reference", "lang", + "settings", "counter-file", "urls", "label-folder", + }; + + /// Switches, for every command. + internal static readonly IReadOnlySet Switches = new HashSet(StringComparer.Ordinal) + { + "json", "dry-run", "no-status", "no-verify", "help", "version", "init", + }; + + private readonly Dictionary> _values = new(StringComparer.Ordinal); + private readonly HashSet _switches = new(StringComparer.Ordinal); + + /// The command (first word), lower case; empty when none was given. + public string Command { get; private set; } = string.Empty; + + /// Words after the command that are not options. + public List Positionals { get; } = new(); + + /// The last value of an option, or null. + public string? Value(string name) => _values.TryGetValue(name, out var list) && list.Count > 0 ? list[^1] : null; + + /// Every value of a repeatable option. + public IReadOnlyList Values(string name) => _values.TryGetValue(name, out var list) ? list : []; + + /// True when the switch was given. + public bool Has(string name) => _switches.Contains(name); + + /// + /// Parses . Returns null on success, or the message key and argument of the problem (an unknown option, an option + /// without its value). + /// + public static (CommandLine Line, string? ErrorKey, string? ErrorArg) Parse(IReadOnlyList args) + { + var line = new CommandLine(); + for (int i = 0; i < args.Count; i++) + { + string a = args[i]; + if (a.StartsWith("--", StringComparison.Ordinal) && a.Length > 2) + { + string name = a[2..], value; + int eq = name.IndexOf('='); + if (eq >= 0) + { + value = name[(eq + 1)..]; + name = name[..eq]; + if (!ValueOptions.Contains(name)) return (line, "Engine.Cli.UnknownOption", "--" + name); + } + else if (Switches.Contains(name)) + { + line._switches.Add(name); + continue; + } + else if (ValueOptions.Contains(name)) + { + if (i + 1 >= args.Count) return (line, "Engine.Cli.MissingValue", a); + value = args[++i]; + } + else + { + return (line, "Engine.Cli.UnknownOption", a); + } + if (!line._values.TryGetValue(name, out var list)) line._values[name] = list = new List(); + list.Add(value); + } + else if (a is "-h" or "/?") + { + line._switches.Add("help"); + } + else if (line.Command.Length == 0) + { + line.Command = a.ToLowerInvariant(); + } + else + { + line.Positionals.Add(a); + } + } + return (line, null, null); + } + + /// + /// Reads repeatable name=value pairs (--set, --answer). Returns null and the bad pair when one has no name. + /// + public static Dictionary? Pairs(IReadOnlyList raw, out string? bad) + { + bad = null; + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in raw) + { + int eq = pair.IndexOf('='); + if (eq <= 0) { bad = pair; return null; } + map[pair[..eq].Trim()] = pair[(eq + 1)..]; + } + return map; + } +} diff --git a/src/Rotula.Cli/Program.cs b/src/Rotula.Cli/Program.cs new file mode 100644 index 0000000..1314c73 --- /dev/null +++ b/src/Rotula.Cli/Program.cs @@ -0,0 +1,28 @@ +using Rotula.Files.Policy; +using Rotula.Printing; + +namespace Rotula.Cli; + +/// Entry point of rotula: the print engine's command line and REST server. +public static class Program +{ + /// Runs the command and returns its exit code (see docs/print-engine.md). + public static async Task Main(string[] args) + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + using var cts = new CancellationTokenSource(); + // Ctrl+C stops the work cleanly: serial numbers of labels that were not sent go back, and the server shuts down. + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + cts.Cancel(); + }; + var host = new CliHost + { + Print = new PrintService(), + // Group Policy / Intune can switch the print engine off for the whole computer (docs/deployment.md). + Policy = RotulaPolicy.Read(RegistryPolicySource.ForThisComputer()), + }; + return await CliApp.RunAsync(args, host, cts.Token).ConfigureAwait(false); + } +} diff --git a/src/Rotula.Cli/Rotula.Cli.csproj b/src/Rotula.Cli/Rotula.Cli.csproj new file mode 100644 index 0000000..54b26bd --- /dev/null +++ b/src/Rotula.Cli/Rotula.Cli.csproj @@ -0,0 +1,41 @@ + + + + + Exe + net10.0 + enable + enable + true + true + Rotula.Cli + rotula + ..\Rotula.Mcp\Assets\rotula-mcp.ico + en + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Rotula.Cli/Server/EngineServer.cs b/src/Rotula.Cli/Server/EngineServer.cs new file mode 100644 index 0000000..e55d269 --- /dev/null +++ b/src/Rotula.Cli/Server/EngineServer.cs @@ -0,0 +1,250 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Rotula.Engine; +using Rotula.Files.Policy; +using Rotula.Printing; + +namespace Rotula.Cli.Server; + +/// An error answer of the REST API: a stable code for programs and a sentence for people. +/// Stable key, for example Engine.Error.PrinterNotAllowed. +/// The sentence in the caller's language. +public sealed record ApiError(string ErrorCode, string Message); + +/// The body of a status request for a network printer (or a Windows printer whose name is awkward in a URL). +/// An installed Windows printer. +/// Host or IP address of a network printer. +/// Its TCP port (9100 when not set). +public sealed record StatusRequest(string? Printer, string? Host, int? Port); + +/// The answer of the health check. +/// Always ok when the server answers. +/// The program version. +/// True when the organization's policy switched printing off. +public sealed record HealthInfo(string Status, string Version, bool PrintingBlocked); + +/// +/// The REST API of the print engine (rotula serve). Every route except the health check and the OpenAPI document needs an API key +/// (X-Api-Key header, or Authorization: Bearer); printers and labels are limited to what the settings file lists. +/// +internal static class EngineServer +{ + /// Starts the server, prints where it listens, and runs until Ctrl+C or the Windows service stops it. + public static async Task RunAsync(EngineSettingsLoad loaded, PrintEngine engine, EngineText text, RotulaPolicy policy, string[] args, CancellationToken ct) + { + var settings = loaded.Settings ?? throw new ArgumentException("The settings are not usable.", nameof(loaded)); + await using var app = Build(settings, engine, policy); + try + { + await app.StartAsync(ct).ConfigureAwait(false); + } + catch (IOException ex) + { + // Typically the port is taken by another program (or another copy of this one). + Console.Error.WriteLine(text.Get("Engine.Server.CannotListen", string.Join(", ", settings.Urls), ex.Message)); + return ExitCodes.Failed; + } + Console.WriteLine(text.Get("Engine.Server.Listening", string.Join(", ", settings.Urls), loaded.Path)); + try { await app.WaitForShutdownAsync(ct).ConfigureAwait(false); } + catch (OperationCanceledException) { } + await app.StopAsync(CancellationToken.None).ConfigureAwait(false); + return ExitCodes.Ok; + } + + /// Builds the web application. lets tests swap Kestrel for the in-memory test server. + public static WebApplication Build(EngineSettings settings, PrintEngine engine, RotulaPolicy policy, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(engine); + ArgumentNullException.ThrowIfNull(policy); + + // No command-line arguments are handed to the host: they are rotula's own, and must not reach the web host's configuration. + var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { Args = [], ContentRootPath = AppContext.BaseDirectory }); + builder.Host.UseWindowsService(o => o.ServiceName = "RotulaEngine"); + builder.WebHost.UseUrls([.. settings.Urls]); + builder.WebHost.ConfigureKestrel(k => + { + k.AddServerHeader = false; + k.Limits.MaxRequestBodySize = settings.MaxRequestBytes; + }); + builder.Services.AddOpenApi("v1", o => o.AddDocumentTransformer((doc, _, _) => + { + doc.Info.Title = "Rotula print engine"; + doc.Info.Version = CliApp.Version(); + return Task.CompletedTask; + })); + configure?.Invoke(builder); + + var app = builder.Build(); + var log = app.Services.GetRequiredService().CreateLogger("Rotula.Engine.Jobs"); + + app.MapOpenApi("/openapi/{documentName}.json"); + var api = app.MapGroup("/api/v1"); + + api.MapGet("/health", () => Results.Json(new HealthInfo("ok", CliApp.Version(), policy.PrintEngineBlocked), EngineJson.Options)) + .WithName("Health").WithSummary("Answers when the server runs. No API key needed.").Produces(); + + var secured = api.MapGroup(string.Empty).AddEndpointFilter(async (context, next) => + { + var http = context.HttpContext; + string? key = http.Request.Headers[ApiKeys.Header].FirstOrDefault(); + string auth = http.Request.Headers.Authorization.FirstOrDefault() ?? string.Empty; + if (string.IsNullOrEmpty(key) && auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) key = auth[7..].Trim(); + if (!ApiKeys.Matches(key, settings.ApiKeyHashes)) + { + log.LogWarning("Refused a call to {Path} from {Remote}: no valid API key.", http.Request.Path, http.Connection.RemoteIpAddress); + return Error(Text(http, settings), StatusCodes.Status401Unauthorized, "Engine.Error.ApiKey"); + } + // A short, non-reversible id of the key for the log: enough to tell callers apart, useless to call the server. + http.Items["keyId"] = ApiKeys.Hash(key!)[..8]; + return await next(context).ConfigureAwait(false); + }); + + secured.MapGet("/printers", async (HttpContext http) => + { + var printers = await engine.GetPrintersAsync(http.RequestAborted).ConfigureAwait(false); + return Results.Json(printers.Select(p => new PrinterEntry(p.Name, p.PortName, p.DriverName, p.IsDefault, p.IsOffline, + settings.IsPrinterAllowed(PrinterTarget.Spooler(p.Name)))).ToList(), EngineJson.Options); + }).WithName("ListPrinters").WithSummary("Lists the Windows printers and which ones this server may print to.").Produces>(); + + secured.MapGet("/printers/{name}/status", (HttpContext http, string name) => StatusAsync(http, name, null, null)) + .WithName("GetPrinterStatus").WithSummary("Asks a Windows printer for its status (~HS).") + .Produces().Produces(StatusCodes.Status403Forbidden); + + secured.MapPost("/printers/status", async (HttpContext http) => + { + var body = await ReadAsync(http, settings).ConfigureAwait(false); + if (body.Error is not null) return body.Error; + return await StatusAsync(http, body.Value!.Printer, body.Value.Host, body.Value.Port).ConfigureAwait(false); + }).WithName("PrinterStatus").WithSummary("Asks a Windows or network printer for its status (~HS).").Accepts("application/json") + .Produces().Produces(StatusCodes.Status400BadRequest).Produces(StatusCodes.Status403Forbidden); + + secured.MapPost("/jobs", async (HttpContext http) => + { + var text = Text(http, settings); + if (policy.PrintEngineBlocked) return Error(text, StatusCodes.Status403Forbidden, "Engine.Error.PrintingBlocked"); + var body = await ReadAsync(http, settings).ConfigureAwait(false); + if (body.Error is not null) return body.Error; + var request = body.Value!; + + if (PrintEngine.TryBuildTarget(request.Printer, request.Host, request.Port, out var target) is { } targetError) + return Error(text, StatusCodes.Status400BadRequest, targetError); + if (!settings.IsPrinterAllowed(target!)) + return Error(text, StatusCodes.Status403Forbidden, "Engine.Error.PrinterNotAllowed", target!.DisplayName); + + var label = LoadLabel(request, settings, text, out var labelError); + if (labelError is not null) return labelError; + + var result = await engine.PrintAsync(label!, request, null, text, settings.Limits, http.RequestAborted).ConfigureAwait(false); + // The audit line: who (key id, address), what (reference, printer, labels) and how it ended. Never the label data. + log.LogInformation("Job {Reference} key {KeyId} from {Remote}: {Outcome} on {Printer}, {Labels} label(s) in {Jobs} job(s){Error}.", + request.Reference ?? "-", http.Items["keyId"], http.Connection.RemoteIpAddress, result.Outcome, result.Printer, result.Labels, result.Jobs, + result.ErrorCode is null ? string.Empty : " (" + result.ErrorCode + ")"); + return Results.Json(result, EngineJson.Options, statusCode: StatusFor(result.Outcome)); + }).WithName("Print").WithSummary("Prints a label, once or for every record, on an allowed printer.") + .Accepts("application/json") + .Produces().Produces(StatusCodes.Status400BadRequest).Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden).Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict).Produces(StatusCodes.Status422UnprocessableEntity) + .Produces(StatusCodes.Status502BadGateway); + + return app; + + async Task StatusAsync(HttpContext http, string? printer, string? host, int? port) + { + var text = Text(http, settings); + if (policy.PrintEngineBlocked) return Error(text, StatusCodes.Status403Forbidden, "Engine.Error.PrintingBlocked"); + if (PrintEngine.TryBuildTarget(printer, host, port, out var target) is { } error) return Error(text, StatusCodes.Status400BadRequest, error); + if (!settings.IsPrinterAllowed(target!)) return Error(text, StatusCodes.Status403Forbidden, "Engine.Error.PrinterNotAllowed", target!.DisplayName); + var report = await engine.GetStatusAsync(target!, text, http.RequestAborted).ConfigureAwait(false); + return Results.Json(report, EngineJson.Options); + } + } + + /// The HTTP status of a print outcome. The body is the full result in every case. + internal static int StatusFor(PrintOutcome outcome) => outcome switch + { + PrintOutcome.Printed or PrintOutcome.DryRun => StatusCodes.Status200OK, + PrintOutcome.Invalid => StatusCodes.Status400BadRequest, + PrintOutcome.InvalidLabel => StatusCodes.Status422UnprocessableEntity, + PrintOutcome.Refused => StatusCodes.Status403Forbidden, + PrintOutcome.HeldBack => StatusCodes.Status409Conflict, + PrintOutcome.Cancelled => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status502BadGateway, + }; + + /// Finds the label of a request within the server's rules: a file in a label folder, or content when the settings allow it. + private static Rotula.Files.RotulaFileContent? LoadLabel(PrintJobRequest request, EngineSettings settings, EngineText text, out IResult? error) + { + error = null; + bool hasPath = !string.IsNullOrWhiteSpace(request.Label), hasContent = !string.IsNullOrWhiteSpace(request.LabelContent); + if (hasPath == hasContent) + { + error = Error(text, StatusCodes.Status400BadRequest, hasPath ? "Engine.Error.LabelBoth" : "Engine.Error.LabelRequired"); + return null; + } + LabelLoad load; + if (hasContent) + { + if (!settings.AllowInlineLabels) + { + error = Error(text, StatusCodes.Status403Forbidden, "Engine.Error.InlineLabelsOff"); + return null; + } + if (request.LabelContent!.Length > settings.MaxLabelBytes) + { + error = Error(text, StatusCodes.Status400BadRequest, "Engine.Error.LabelTooLarge", "labelContent", settings.MaxLabelBytes / 1024); + return null; + } + load = LabelFiles.Parse(request.LabelContent); + } + else + { + string? path = LabelFiles.Resolve(request.Label, settings.LabelFolders, out var key); + if (path is null) + { + error = Error(text, key == "Engine.Error.LabelNotFound" ? StatusCodes.Status404NotFound : StatusCodes.Status403Forbidden, key!, request.Label!); + return null; + } + load = LabelFiles.Load(path, settings.MaxLabelBytes); + } + if (load.Content is null) + { + error = Error(text, StatusCodes.Status422UnprocessableEntity, load.ErrorKey!, load.Args); + return null; + } + return load.Content; + } + + /// Reads a JSON body with the engine's rules (unknown fields refused), or the 400 answer that says what is wrong with it. + private static async Task<(T? Value, IResult? Error)> ReadAsync(HttpContext http, EngineSettings settings) where T : class + { + var text = Text(http, settings); + try + { + var value = await JsonSerializer.DeserializeAsync(http.Request.Body, EngineJson.Options, http.RequestAborted).ConfigureAwait(false); + return value is null ? (null, Error(text, StatusCodes.Status400BadRequest, "Engine.Error.BadRequest", "null")) : (value, null); + } + catch (JsonException ex) + { + return (null, Error(text, StatusCodes.Status400BadRequest, "Engine.Error.BadRequest", ex.Message)); + } + catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex) + { + // Body larger than maxRequestBytes, or cut off. + return (null, Error(text, ex.StatusCode, "Engine.Error.BadRequest", ex.Message)); + } + } + + /// The caller's language: ?lang=, then Accept-Language, then the server's setting. + private static EngineText Text(HttpContext http, EngineSettings settings) => + EngineText.For(http.Request.Query["lang"].FirstOrDefault() ?? http.Request.Headers.AcceptLanguage.FirstOrDefault() ?? settings.Language); + + private static IResult Error(EngineText text, int status, string key, params object[] args) => + Results.Json(new ApiError(key, text.Get(key, args)), EngineJson.Options, statusCode: status); +} diff --git a/src/Rotula.Engine/ApiKeys.cs b/src/Rotula.Engine/ApiKeys.cs new file mode 100644 index 0000000..bcee2d2 --- /dev/null +++ b/src/Rotula.Engine/ApiKeys.cs @@ -0,0 +1,44 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Rotula.Engine; + +/// +/// API keys of the REST server. The settings file keeps only the SHA-256 hash of each key, so reading the file (or a backup of it) does not +/// give anyone a working key. A key is 32 random bytes, written as 64 hexadecimal characters. +/// +public static class ApiKeys +{ + /// The request header that carries the key. + public const string Header = "X-Api-Key"; + + /// Makes a new random key and its hash. + public static (string Key, string Hash) Create() + { + string key = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + return (key, Hash(key)); + } + + /// The hash stored in the settings file: SHA-256 of the key's UTF-8 bytes, lower-case hexadecimal. + public static string Hash(string key) + { + ArgumentNullException.ThrowIfNull(key); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key.Trim()))).ToLowerInvariant(); + } + + /// True when hashes to one of . Compared in constant time. + public static bool Matches(string? presented, IReadOnlyCollection hashes) + { + if (string.IsNullOrWhiteSpace(presented) || presented.Length > 256 || hashes.Count == 0) return false; + byte[] candidate = Encoding.ASCII.GetBytes(Hash(presented)); + bool hit = false; + // Every stored hash is compared, so the time taken does not tell which one (if any) matched. + foreach (var hash in hashes) + hit |= CryptographicOperations.FixedTimeEquals(candidate, Encoding.ASCII.GetBytes(hash.Trim().ToLowerInvariant())); + return hit; + } + + /// True for a value that looks like a stored hash (64 hexadecimal characters). + public static bool IsHash(string? value) => + value is { Length: 64 } && value.All(Uri.IsHexDigit); +} diff --git a/src/Rotula.Engine/EngineJson.cs b/src/Rotula.Engine/EngineJson.cs new file mode 100644 index 0000000..5b1ff83 --- /dev/null +++ b/src/Rotula.Engine/EngineJson.cs @@ -0,0 +1,113 @@ +using System.Buffers; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Rotula.Engine; + +/// The JSON shape shared by the command line (--json, --data file.json) and the REST API. +public static class EngineJson +{ + /// + /// camelCase names, enums as text, comments allowed, unknown names refused (a misspelt field must not be ignored silently: "copeis": 500 + /// would otherwise print the label's own quantity). Field values may be numbers or true/false as well as text. + /// + public static JsonSerializerOptions Options { get; } = Create(indented: false); + + /// As , indented for people. + public static JsonSerializerOptions Indented { get; } = Create(indented: true); + + private static JsonSerializerOptions Create(bool indented) + { + var o = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + MaxDepth = 16, + WriteIndented = indented, + }; + o.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + o.Converters.Add(new LenientStringConverter()); + o.MakeReadOnly(populateMissingResolver: true); + return o; + } + + /// + /// Reads data records from JSON text: an array of objects (one record each) or one object (the values of a single label). Values may be text, + /// numbers, true/false or null (empty). Returns the key of the problem, or null. + /// + public static string? ReadData(string json, out List>? records, out Dictionary? values, out string? detail) + { + records = null; + values = null; + detail = null; + try + { + using var doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, MaxDepth = 8 }); + switch (doc.RootElement.ValueKind) + { + case JsonValueKind.Array: + records = new List>(); + foreach (var item in doc.RootElement.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) { detail = item.ValueKind.ToString(); return "Engine.Error.DataShape"; } + records.Add(Fields(item)); + } + return null; + case JsonValueKind.Object: + values = Fields(doc.RootElement); + return null; + default: + detail = doc.RootElement.ValueKind.ToString(); + return "Engine.Error.DataShape"; + } + } + catch (JsonException ex) + { + detail = ex.Message; + return "Engine.Error.DataShape"; + } + catch (InvalidOperationException ex) + { + detail = ex.Message; + return "Engine.Error.DataShape"; + } + } + + private static Dictionary Fields(JsonElement obj) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in obj.EnumerateObject()) map[p.Name] = Text(p.Value); + return map; + } + + private static string Text(JsonElement e) => e.ValueKind switch + { + JsonValueKind.String => e.GetString() ?? string.Empty, + JsonValueKind.Number => e.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => string.Empty, + // A nested object or list is not a field value; refusing it beats printing its JSON on a label. + _ => throw new InvalidOperationException("A field value must be text, a number, true/false or null."), + }; + + /// Reads a text field that a warehouse system may send as a number ("qty": 5) or true/false. + private sealed class LenientStringConverter : JsonConverter + { + public override bool HandleNull => false; + + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType switch + { + JsonTokenType.String => reader.GetString(), + // As written (1.50 stays 1.50): a number token is plain ASCII, never escaped. + JsonTokenType.Number => System.Text.Encoding.ASCII.GetString(reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan), + JsonTokenType.True => "true", + JsonTokenType.False => "false", + _ => throw new JsonException("Expected text, a number or true/false."), + }; + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) => writer.WriteStringValue(value); + } +} diff --git a/src/Rotula.Engine/EngineSettings.cs b/src/Rotula.Engine/EngineSettings.cs new file mode 100644 index 0000000..65472e4 --- /dev/null +++ b/src/Rotula.Engine/EngineSettings.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Rotula.Printing; + +namespace Rotula.Engine; + +/// +/// The REST server's rules, from rotula-engine.settings.json. Everything a caller can reach is listed explicitly: the addresses the +/// server listens on, the API keys (as hashes), the label folders and the printers. Nothing is allowed by default. +/// +public sealed record EngineSettings +{ + /// The settings file name, looked for next to the program unless --settings names another. + public const string FileName = "rotula-engine.settings.json"; + + /// Default listening address: this computer only. + public const string DefaultUrl = "http://127.0.0.1:9180"; + + /// Addresses to listen on, for example http://127.0.0.1:9180 or https://0.0.0.0:9443. + public IReadOnlyList Urls { get; init; } = [DefaultUrl]; + + /// SHA-256 hashes of the API keys that may call the server (rotula new-key makes a key and its hash). + public IReadOnlyList ApiKeyHashes { get; init; } = []; + + /// Folders labels may be printed from; a request names a label relative to one of them. + public IReadOnlyList LabelFolders { get; init; } = []; + + /// Printers the server may print to and query: Windows printer names, or host / host:port of network printers. + public IReadOnlyList AllowedPrinters { get; init; } = []; + + /// Accept labels sent as content in the request (a caller then can print any design). Off by default. + public bool AllowInlineLabels { get; init; } + + /// Most copies per label or record. + public int MaxCopies { get; init; } = 1000; + + /// Most records per request. + public int MaxRecords { get; init; } = 10_000; + + /// Largest request body, in bytes. + public long MaxRequestBytes { get; init; } = 10 * 1024 * 1024; + + /// Largest label file, in bytes. + public long MaxLabelBytes { get; init; } = 5 * 1024 * 1024; + + /// Language of the messages when the request sends no Accept-Language: en, es, pt-BR or fr-CA. + public string Language { get; init; } = "en"; + + /// Counter file of serial numbers that remember their value; null = the running account's own (%AppData%\Rotula\counters.json). + public string? CounterFile { get; init; } + + /// The request limits as the engine takes them. + public EngineLimits Limits => new(MaxCopies, MaxRecords); + + /// True when the server may print to (or query) . Names and hosts are compared ignoring case. + public bool IsPrinterAllowed(PrinterTarget target) + { + ArgumentNullException.ThrowIfNull(target); + foreach (var entry in AllowedPrinters) + { + string e = entry.Trim(); + if (target.Transport == PrinterTransport.Spooler) + { + if (string.Equals(e, target.PrinterName, StringComparison.OrdinalIgnoreCase)) return true; + continue; + } + // "host" means the default raw port; "host:port" exactly that port. + int colon = e.LastIndexOf(':'); + string host = colon > 0 ? e[..colon] : e; + int port = colon > 0 && int.TryParse(e[(colon + 1)..], out int p) ? p : PrinterTarget.DefaultTcpPort; + if (string.Equals(host, target.Host, StringComparison.OrdinalIgnoreCase) && port == target.Port) return true; + } + return false; + } +} + +/// The outcome of reading the settings: the settings, or the reasons they cannot be used. +/// The settings, or null when is not empty. +/// Problems that stop the server (as sentences, in the requested language). +/// Things worth knowing that do not stop it. +/// The file that was read. +public sealed record EngineSettingsLoad(EngineSettings? Settings, IReadOnlyList Errors, IReadOnlyList Warnings, string Path); + +/// Reads rotula-engine.settings.json. Unknown names are an error, so a typo never silently leaves a rule out. +public static class EngineSettingsLoader +{ + private const long MaxFileBytes = 256 * 1024; + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + MaxDepth = 8, + }; + + /// Loads and checks the settings. Never throws. + public static EngineSettingsLoad Load(string path, EngineText text) + { + ArgumentNullException.ThrowIfNull(text); + var errors = new List(); + var warnings = new List(); + EngineSettings? settings; + try + { + var info = new FileInfo(path); + if (!info.Exists) return new(null, [text.Get("Engine.Settings.Missing", path)], [], path); + if (info.Length > MaxFileBytes) return new(null, [text.Get("Engine.Settings.Invalid", path, "> 256 KB")], [], path); + settings = JsonSerializer.Deserialize(File.ReadAllText(info.FullName), Options); + } + catch (JsonException ex) + { + return new(null, [text.Get("Engine.Settings.Invalid", path, ex.Message)], [], path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return new(null, [text.Get("Engine.Settings.Invalid", path, ex.Message)], [], path); + } + if (settings is null) return new(null, [text.Get("Engine.Settings.Invalid", path, "null")], [], path); + + if (settings.ApiKeyHashes.Count == 0) errors.Add(text.Get("Engine.Settings.NoKeys", path)); + foreach (var hash in settings.ApiKeyHashes.Where(h => !ApiKeys.IsHash(h?.Trim()))) + errors.Add(text.Get("Engine.Settings.BadHash", hash ?? string.Empty)); + if (settings.Urls.Count == 0) errors.Add(text.Get("Engine.Settings.Invalid", path, "urls")); + foreach (var url in settings.Urls) + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) + errors.Add(text.Get("Engine.Settings.Invalid", path, "urls: " + url)); + if (settings.MaxCopies is < 1 or > 99_999_999) errors.Add(text.Get("Engine.Settings.Invalid", path, "maxCopies")); + if (settings.MaxRecords is < 1 or > 1_000_000) errors.Add(text.Get("Engine.Settings.Invalid", path, "maxRecords")); + if (settings.MaxRequestBytes is < 1024 or > 256L * 1024 * 1024) errors.Add(text.Get("Engine.Settings.Invalid", path, "maxRequestBytes")); + if (settings.MaxLabelBytes is < 1024 or > 64L * 1024 * 1024) errors.Add(text.Get("Engine.Settings.Invalid", path, "maxLabelBytes")); + + if (settings.LabelFolders.Count == 0 && !settings.AllowInlineLabels) warnings.Add(text.Get("Engine.Settings.NoFolders")); + foreach (var folder in settings.LabelFolders.Where(f => !Directory.Exists(f))) warnings.Add(text.Get("Engine.Settings.FolderMissing", folder)); + if (settings.AllowedPrinters.Count == 0) warnings.Add(text.Get("Engine.Settings.NoPrinters")); + if (settings.Urls.Any(u => Uri.TryCreate(u, UriKind.Absolute, out var x) && x.Scheme == "http" && !x.IsLoopback)) + warnings.Add(text.Get("Engine.Settings.PlainHttp")); + + settings = settings with { Language = EngineText.Resolve(settings.Language) }; + return errors.Count > 0 ? new(null, errors, warnings, path) : new(settings, [], warnings, path); + } + + /// A commented starter file, written by rotula serve --init. + public static string Starter(string keyHash, string labelFolder) => + "{\n" + + " // Where the server listens. 127.0.0.1 accepts calls from this computer only. Use https with a certificate for other computers.\n" + + " \"urls\": [\"" + EngineSettings.DefaultUrl + "\"],\n" + + " // SHA-256 hashes of the API keys (rotula new-key). Callers send the key itself in the X-Api-Key header.\n" + + " \"apiKeyHashes\": [\"" + keyHash + "\"],\n" + + " // Labels are named relative to one of these folders.\n" + + " \"labelFolders\": [" + JsonSerializer.Serialize(labelFolder) + "],\n" + + " // Windows printer names, or host / host:port of network printers. Nothing else can be printed to.\n" + + " \"allowedPrinters\": [],\n" + + " \"allowInlineLabels\": false,\n" + + " \"maxCopies\": 1000,\n" + + " \"maxRecords\": 10000,\n" + + " \"language\": \"en\"\n" + + "}\n"; +} diff --git a/src/Rotula.Engine/EngineText.cs b/src/Rotula.Engine/EngineText.cs new file mode 100644 index 0000000..294dfba --- /dev/null +++ b/src/Rotula.Engine/EngineText.cs @@ -0,0 +1,163 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Reflection; +using System.Xml.Linq; +using Rotula.Core.Validation; +using Rotula.Printing; + +namespace Rotula.Engine; + +/// +/// The engine's sentences in one of the shipped languages. The texts are the app's own resource files, embedded at build time, so a message +/// from the command line or the REST API reads exactly as the same message in the app. A key missing in a translation falls back to English. +/// +public sealed class EngineText +{ + /// The languages the engine speaks, in the canonical spelling of the app (English first). + public static readonly IReadOnlyList Languages = new[] { "en", "es", "pt-BR", "fr-CA" }; + + private static readonly ConcurrentDictionary> Tables = new(StringComparer.Ordinal); + private static readonly ConcurrentDictionary Instances = new(StringComparer.Ordinal); + + private readonly IReadOnlyDictionary _table; + private readonly IReadOnlyDictionary _english; + + private EngineText(string language) + { + Language = language; + _table = Table(language); + _english = Table("en"); + } + + /// The language of this instance (one of ). + public string Language { get; } + + /// The English texts. + public static EngineText English => For("en"); + + /// + /// The texts for a language name such as es, es-MX, pt or an HTTP Accept-Language value. Anything not shipped + /// gives English, so a caller can never make the engine fail by asking for a language. + /// + public static EngineText For(string? language) => Instances.GetOrAdd(Resolve(language), l => new EngineText(l)); + + /// Maps a requested language to a shipped one: exact match first, then the same base language, then English. + public static string Resolve(string? language) + { + if (string.IsNullOrWhiteSpace(language)) return "en"; + // Accept-Language may list several ("es-MX,es;q=0.9,en;q=0.8"); the first one the engine speaks wins. + foreach (var part in language.Split(',')) + { + string tag = part.Split(';')[0].Trim().Replace('_', '-'); + if (tag.Length == 0) continue; + var exact = Languages.FirstOrDefault(l => string.Equals(l, tag, StringComparison.OrdinalIgnoreCase)); + if (exact is not null) return exact; + string baseTag = tag.Split('-')[0]; + var family = Languages.FirstOrDefault(l => string.Equals(l.Split('-')[0], baseTag, StringComparison.OrdinalIgnoreCase)); + if (family is not null) return family; + } + return "en"; + } + + /// True when the key has a text (in this language or in English). + public bool Has(string key) => _table.ContainsKey(key) || _english.ContainsKey(key); + + /// The text of a key formatted with ; an unknown key comes back as the key itself so nothing is lost. + public string Get(string key, params object[] args) + { + if (!_table.TryGetValue(key, out var text) && !_english.TryGetValue(key, out text)) return key; + if (args.Length == 0) return text; + try { return string.Format(CultureInfo.InvariantCulture, text, args); } + catch (FormatException) { return text; } + } + + /// Describes a validation issue or print notice the way the app does (Issue.<key>, keyword arguments translated). + public string Describe(ValidationIssue issue) + { + ArgumentNullException.ThrowIfNull(issue); + string? arg = issue.MessageKey switch + { + PrinterStatusKeys.Warning or PrinterStatusKeys.FaultAfterJob => Conditions(issue.Args), + PrinterStatusKeys.NotChecked or PrinterStatusKeys.NotVerified => Reason(issue.Args.FirstOrDefault()), + PrinterStatusKeys.Verified => string.Empty, + _ => null, + }; + if (arg is not null) return Get("Issue." + issue.MessageKey, arg); + string key = "Issue." + issue.MessageKey; + // Rotula.Data and the file reader raise some notes under their own key, without the Issue. prefix. + if (!Has(key) && Has(issue.MessageKey)) key = issue.MessageKey; + if (!Has(key)) return Get("Issue.Unknown", issue.MessageKey); + return Get(key, issue.Args.Select(a => (object)(Has("Issue.Arg." + a) ? Get("Issue.Arg." + a) : a)).ToArray()); + } + + /// Describes a print failure: what happened and what to do next (same wording as the app's print dialog). + public string Describe(PrintError error) + { + ArgumentNullException.ThrowIfNull(error); + object[] args = error.Args.Cast().ToArray(); + switch (error.Kind) + { + case PrintErrorKind.InvalidProfile: + return Get(error.Key) + " " + string.Join(" ", error.Args.Where(Has).Select(k => Get(k))); + case PrintErrorKind.Unknown: + args = new object[] { string.Join(", ", error.Args.Where(a => a.Length > 0)) }; + break; + case PrintErrorKind.HeadOpen or PrintErrorKind.PaperOut or PrintErrorKind.RibbonOut or PrintErrorKind.HeadTooHot + or PrintErrorKind.DiagnosticMode or PrintErrorKind.BufferFull or PrintErrorKind.Paused: + { + // Arguments: the printer, then every condition it reported. The message names the worst one; the others follow. + string main = Get(error.Key, error.Args.Count > 0 ? error.Args[0] : string.Empty, string.Empty); + var others = error.Args.Skip(1).Where(c => !string.Equals(c, error.Kind.ToString(), StringComparison.Ordinal)).ToList(); + return others.Count == 0 ? main : main + " " + Get("Print.Status.AlsoReports", Conditions(others)); + } + } + if (args.Length < 2) args = args.Concat(Enumerable.Repeat((object)string.Empty, 2 - args.Length)).ToArray(); + return Has(error.Key) ? Get(error.Key, args) : Get("Print.Error.Unknown", string.Join(", ", error.Args)); + } + + /// Describes a decoded printer status in one or more sentences (ready, or what stops it, plus what it is doing). + public string Describe(PrinterStatus status) + { + ArgumentNullException.ThrowIfNull(status); + if (!status.IsAvailable) return Get("Print.Status.Unavailable", Reason(status.Reason!.Value.ToString())); + var lines = new List + { + status.IsBlocked ? Get("Print.Status.Blocked", Conditions(status.Blocking.Select(c => c.ToString()))) : Get("Print.Status.Ready"), + }; + if (status.Warnings.Count > 0) lines.Add(Get("Print.Status.Warnings", Conditions(status.Warnings.Select(c => c.ToString())))); + if (status.LabelsRemaining is > 0 || status.FormatsInBuffer is > 0) + lines.Add(Get("Print.Status.Busy", status.LabelsRemaining ?? 0, status.FormatsInBuffer ?? 0)); + if (!status.IsComplete) lines.Add(Get("Print.Status.Partial")); + return string.Join(" ", lines); + } + + /// Joins condition names as a phrase ("out of labels, paused"); a name this version does not know is shown as it is. + public string Conditions(IEnumerable names) => + string.Join(", ", names.Select(n => Has("Enum.PrinterCondition." + n) ? Get("Enum.PrinterCondition." + n) : n)); + + private string Reason(string? name) => + name is not null && Has("Print.Status.Reason." + name) ? Get("Print.Status.Reason." + name) : Get("Print.Status.Reason.Unknown"); + + private static IReadOnlyDictionary Table(string language) => Tables.GetOrAdd(language, Load); + + private static IReadOnlyDictionary Load(string language) + { + var map = new Dictionary(StringComparer.Ordinal); + try + { + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("Rotula.Engine.Strings." + language + ".xml"); + if (stream is null) return map; + foreach (var data in XDocument.Load(stream).Descendants("data")) + { + string? name = (string?)data.Attribute("name"); + string? value = (string?)data.Element("value"); + if (name is not null && value is not null) map[name] = value; + } + } + catch (Exception ex) when (ex is System.Xml.XmlException or IOException) + { + // Without texts the engine still prints; messages then show their keys. + } + return map; + } +} diff --git a/src/Rotula.Engine/LabelFiles.cs b/src/Rotula.Engine/LabelFiles.cs new file mode 100644 index 0000000..d382534 --- /dev/null +++ b/src/Rotula.Engine/LabelFiles.cs @@ -0,0 +1,84 @@ +using Rotula.Files; + +namespace Rotula.Engine; + +/// The outcome of finding and reading a label: the content, or a message key with its arguments. +/// The loaded label, or null. +/// Why it could not be loaded (an Engine.Error.* or File.Error.* key), or null. +/// Arguments of . +public sealed record LabelLoad(RotulaFileContent? Content, string? ErrorKey, object[] Args) +{ + /// A loaded label. + public static LabelLoad Ok(RotulaFileContent content) => new(content, null, []); + + /// A label that could not be loaded. + public static LabelLoad Error(string key, params object[] args) => new(null, key, args); +} + +/// Finds and reads .rotula labels for the engine, with the path rules of the REST server. +public static class LabelFiles +{ + /// The label file extension. + public const string Extension = ".rotula"; + + /// Reads a label from any path (the command line runs with the rights of the person or service that starts it). + /// The file. + /// Largest file accepted. + public static LabelLoad Load(string path, long maxBytes = long.MaxValue) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + try + { + var info = new FileInfo(path); + if (!info.Exists) return LabelLoad.Error("Engine.Error.LabelNotFound", path); + if (info.Length > maxBytes) return LabelLoad.Error("Engine.Error.LabelTooLarge", path, maxBytes / 1024); + return Parse(File.ReadAllText(info.FullName), Path.GetFileName(info.FullName)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException or ArgumentException or NotSupportedException) + { + return LabelLoad.Error("File.Error.Read", Path.GetFileName(path)); + } + } + + /// Reads a label sent as text (the content of a .rotula file). + /// The JSON of the file. + /// How messages name it (the file name, or "labelContent"). + public static LabelLoad Parse(string content, string name = "labelContent") + { + try { return LabelLoad.Ok(RotulaFileSerializer.DeserializeFile(content)); } + // The File.Error texts name the file first, then their own arguments (the format version of a newer file). + catch (RotulaFileException ex) { return LabelLoad.Error(ex.MessageKey, [name, .. ex.Args]); } + } + + /// + /// Finds a label named relative to one of , for the REST server. Only a plain relative path to a .rotula file is + /// accepted: no drive, no UNC or device path, no "..", no stream name; and the resolved file must lie inside the folder. Folders are tried in + /// order and the first that holds the file wins. Returns the full path, or null with the key of the problem in . + /// + public static string? Resolve(string? requested, IReadOnlyList folders, out string? errorKey) + { + errorKey = "Engine.Error.LabelOutsideFolders"; + if (string.IsNullOrWhiteSpace(requested) || requested.Length > 512) return null; + string rel = requested.Trim().Replace('\\', '/'); + if (rel.StartsWith('/') || rel.Contains(':') || rel.IndexOfAny(['?', '*', '<', '>', '|', '"', '\0']) >= 0) return null; + var parts = rel.Split('/'); + if (parts.Any(p => p.Length == 0 || p == "." || p == "..")) return null; + if (!rel.EndsWith(Extension, StringComparison.OrdinalIgnoreCase)) return null; + + foreach (var folder in folders) + { + string root; + try { root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(folder)) + Path.DirectorySeparatorChar; } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) { continue; } + string full = Path.GetFullPath(Path.Combine(root, Path.Combine(parts))); + if (!full.StartsWith(root, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) continue; + if (File.Exists(full)) + { + errorKey = null; + return full; + } + } + errorKey = "Engine.Error.LabelNotFound"; + return null; + } +} diff --git a/src/Rotula.Engine/PrintEngine.cs b/src/Rotula.Engine/PrintEngine.cs new file mode 100644 index 0000000..e93f3de --- /dev/null +++ b/src/Rotula.Engine/PrintEngine.cs @@ -0,0 +1,469 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using Rotula.Core.Merge; +using Rotula.Core.Model; +using Rotula.Core.Validation; +using Rotula.Core.Variables; +using Rotula.Core.Variables.Counters; +using Rotula.Core.Variables.Prompts; +using Rotula.Files; +using Rotula.Printing; + +namespace Rotula.Engine; + +/// Limits a host puts on requests. The command line uses ; the REST server reads its own from the settings file. +/// Most copies per label or record. +/// Most records per request. +public sealed record EngineLimits(int MaxCopies, int MaxRecords) +{ + /// The Print dialog's copies limit and a generous record limit. + public static EngineLimits Default { get; } = new(99_999_999, 1_000_000); +} + +/// +/// The headless print engine: one label file, optional data, one printer, no UI. It does what the app's Print dialog and data merge do +/// (resolution conversion, printer set-up, variables, serial numbers reserved first and confirmed only for labels that reached the printer, +/// the printer status check before every job and the verification after the last) and reports the outcome as a value, never an exception. +/// +/// +/// Jobs for the same printer run one after the other (a per-printer queue), so two callers never interleave their labels; jobs for different +/// printers run at the same time. Serial numbers that remember their value use the same counter file as the app, which locks it per write. +/// +public sealed class PrintEngine +{ + private readonly IPrintService _print; + private readonly ICounterStore? _counters; + private readonly Rotula.Core.Text.ITextRasterizer? _rasterizer; + private readonly TimeProvider? _time; + private readonly ConcurrentDictionary _queues = new(StringComparer.OrdinalIgnoreCase); + + /// Creates the engine. + /// The print service (the real one, or a fake in tests). + /// Where remembering counters keep their position; null makes such a label refuse to print instead of repeating numbers. + /// Draws text in Windows fonts; null makes such a text an error. + /// Clock for date variables; null uses the computer's. + public PrintEngine(IPrintService print, ICounterStore? counters = null, Rotula.Core.Text.ITextRasterizer? rasterizer = null, TimeProvider? time = null) + { + _print = print ?? throw new ArgumentNullException(nameof(print)); + _counters = counters; + _rasterizer = rasterizer; + _time = time; + } + + /// Lists the installed Windows printers. Never throws for a spooler problem; an unreadable list is empty. + public async Task> GetPrintersAsync(CancellationToken ct = default) + { + try { return await _print.GetPrintersAsync(ct).ConfigureAwait(false); } + catch (Exception ex) when (ex is not OperationCanceledException) { return Array.Empty(); } + } + + /// + /// Builds the printer target of a request: exactly one of a Windows printer and a host, a port from 1 to 65535. Returns the key of the + /// problem, or null. + /// + public static string? TryBuildTarget(string? printer, string? host, int? port, out PrinterTarget? target) + { + target = null; + bool hasPrinter = !string.IsNullOrWhiteSpace(printer), hasHost = !string.IsNullOrWhiteSpace(host); + if (!hasPrinter && !hasHost) return "Engine.Error.PrinterRequired"; + if (hasPrinter && hasHost) return "Engine.Error.PrinterBoth"; + if (hasPrinter) + { + target = PrinterTarget.Spooler(printer!.Trim()); + return null; + } + if (port is { } p && (p < 1 || p > 65535)) return "Print.Field.Port"; + target = PrinterTarget.TcpIp(host!.Trim(), port ?? PrinterTarget.DefaultTcpPort); + return null; + } + + /// Asks a printer for its status (~HS) and describes it. Waits for a job running on the same printer to finish first. + public async Task GetStatusAsync(PrinterTarget target, EngineText text, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(text); + var queue = Queue(target); + await queue.WaitAsync(ct).ConfigureAwait(false); + PrinterStatus status; + try { status = await _print.GetStatusAsync(target.WithStatus(PrinterStatusOptions.Default), ct).ConfigureAwait(false); } + finally { queue.Release(); } + return Report(target.DisplayName, status, text); + } + + /// Turns a decoded status into a report with condition codes and a sentence in the caller's language. + public static PrinterStatusReport Report(string printer, PrinterStatus status, EngineText text) + { + ArgumentNullException.ThrowIfNull(status); + ArgumentNullException.ThrowIfNull(text); + if (!status.IsAvailable) + return new PrinterStatusReport(printer, false, null, [], [], null, null, false, status.Reason.ToString(), text.Describe(status)); + return new PrinterStatusReport(printer, true, !status.IsBlocked, status.Blocking.Select(c => c.ToString()).ToList(), + status.Warnings.Select(c => c.ToString()).ToList(), status.LabelsRemaining, status.FormatsInBuffer, status.IsComplete, null, text.Describe(status)); + } + + /// + /// Prints one request. is the loaded .rotula file; carries records read from a CSV or Excel + /// file (the command line), otherwise or are used. + /// + public async Task PrintAsync(RotulaFileContent label, PrintJobRequest request, MergeTable? table, EngineText text, + EngineLimits? limits = null, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(label); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(text); + limits ??= EngineLimits.Default; + var job = new JobState(request, text); + + // ---- the request ------------------------------------------------------------------------------------------------ + if (TryBuildTarget(request.Printer, request.Host, request.Port, out var baseTarget) is { } targetError) + return job.Fail(PrintOutcome.Invalid, targetError); + job.Printer = baseTarget!.DisplayName; + if (request.Copies is { } c && (c < 1 || c > limits.MaxCopies)) + return job.Fail(PrintOutcome.Invalid, "Engine.Error.Copies", limits.MaxCopies); + if (request.Values is not null && (request.Records is not null || table is not null)) + return job.Fail(PrintOutcome.Invalid, "Engine.Error.ValuesAndRecords"); + PrinterDpi? dpi = request.Dpi switch + { + null => null, + 203 => PrinterDpi.Dpi203, + 300 => PrinterDpi.Dpi300, + 600 => PrinterDpi.Dpi600, + _ => (PrinterDpi)(-1), + }; + if (dpi is (PrinterDpi)(-1)) return job.Fail(PrintOutcome.Invalid, "Engine.Error.Dpi"); + table ??= request.Records is { } records ? RecordsTable(records) : null; + if (table is not null) + { + if (table.RowCount == 0) return job.Fail(PrintOutcome.Invalid, "Engine.Error.NoRecords"); + if (table.RowCount > limits.MaxRecords) return job.Fail(PrintOutcome.Invalid, "Engine.Error.Records", limits.MaxRecords); + } + + // ---- the label, matched to the printer -------------------------------------------------------------------------- + var doc = label.Document; + if (dpi is null && baseTarget.Transport == PrinterTransport.Spooler) + { + var caps = await _print.GetPrinterInfoAsync(baseTarget, ct).ConfigureAwait(false); + dpi = caps.Dpi; + } + if (dpi is { } printerDpi && LabelDpiConverter.NeedsConversion(doc, printerDpi)) + { + var converted = LabelDpiConverter.Convert(doc, printerDpi); + foreach (var issue in converted.Issues) + job.Notices.Add(new EngineNotice(issue.MessageKey, "warning", text.Has("Issue." + issue.MessageKey) ? text.Describe(issue) : text.Get("Print.DpiIssue.Generic"))); + doc = converted.Document; + } + + PrinterProfile? profile = null; + if (request.Darkness is not null || request.Speed is not null) + { + profile = new PrinterProfile + { + Dpi = doc.Dpi, Width = doc.Width, Height = doc.Height, MediaTracking = doc.MediaTracking, Encoding = doc.Encoding, + Darkness = request.Darkness, SpeedIps = request.Speed, + }; + var bad = profile.Validate(); + if (bad.Count > 0) return job.Fail(PrintOutcome.Invalid, "Print.Error.InvalidProfile", bad.Select(k => (object)text.Get(k)).ToArray()); + } + + var status = request.CheckStatus || request.Verify ? new PrinterStatusOptions(request.CheckStatus, request.Verify) : PrinterStatusOptions.Off; + var target = baseTarget.WithStatus(status); + + var queue = Queue(target); + await queue.WaitAsync(ct).ConfigureAwait(false); + try + { + return table is null + ? await PrintOneAsync(job, doc, target, profile, ct).ConfigureAwait(false) + : await PrintRecordsAsync(job, doc, label.Merge, table, target, profile, ct).ConfigureAwait(false); + } + finally + { + queue.Release(); + } + } + + // ---- one label ------------------------------------------------------------------------------------------------------ + + private async Task PrintOneAsync(JobState job, LabelDocument doc, PrinterTarget target, PrinterProfile? profile, CancellationToken ct) + { + var request = job.Request; + if (request.Copies is { } copies) doc.Print.Quantity = copies; + IReadOnlyDictionary? data = request.Values is null ? null : new Dictionary(request.Values, StringComparer.OrdinalIgnoreCase); + + IReadOnlyDictionary? answers = null; + if (VariableEngine.HasDefinitions(doc)) + { + var prompts = PromptSet.Build(doc, data); + if (!prompts.IsEmpty && CheckAnswers(job, doc, prompts, out answers) is { } refused) return refused; + } + + var options = new PrintVariableOptions { CounterStore = _counters, PromptAnswers = answers, TextRasterizer = _rasterizer, Time = _time }; + if (request.DryRun) + { + var run = LabelPrintRun.Begin(doc, data, options); + var sb = new StringBuilder(); + try + { + if (run.CanPrint) foreach (var block in run.Blocks()) sb.Append(block.Zpl); + } + finally + { + // A dry run prints nothing, so every reserved serial number goes back. + run.Cancel(); + } + job.AddIssues(run.Issues); + if (!run.CanPrint || run.StoppedEarly) return job.Fail(PrintOutcome.InvalidLabel, "Print.Error.InvalidLabel"); + job.Records = 1; + job.Labels = run.LabelCount; + string setup = profile is null ? string.Empty : PrinterSetup.BuildSetupZpl(profile); + return job.Finish(PrintOutcome.DryRun, job.Text.Get("Engine.Result.DryRun", job.Printer), setup + sb); + } + + PrintResult result; + try { result = await _print.PrintAsync(target, doc, data, profile, options, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { result = PrintResult.Fail(PrintErrorKind.Cancelled); } + job.AddIssues(result.Notices); + job.BytesSent = result.BytesSent; + if (!result.Succeeded) return job.FromError(result.Error!); + job.Records = 1; + job.Labels = Math.Max(1, doc.Print.Quantity); + job.Jobs = 1; + return job.Finish(PrintOutcome.Printed, job.Text.Get("Engine.Result.Printed", job.Labels, job.Printer)); + } + + // ---- several records ------------------------------------------------------------------------------------------------ + + private async Task PrintRecordsAsync(JobState job, LabelDocument doc, DataMergeSettings? merge, MergeTable table, PrinterTarget target, + PrinterProfile? profile, CancellationToken ct) + { + var request = job.Request; + var options = MergeOptionsFor(merge); + if (request.Copies is { } copies) + { + options.CopiesPerRecord = copies; + options.CopiesColumn = null; + } + options.CounterStore = _counters; + options.TextRasterizer = _rasterizer; + options.Time = _time; + + MergeDataSet ds; + try { ds = new MergeDataSet(doc, table, options); } + catch (ArgumentException ex) { return job.Fail(PrintOutcome.Invalid, "Engine.Error.DataFile", "-", ex.Message); } + job.AddIssues(table.Issues); + job.AddIssues(ds.Issues); + if (ds.Issues.Any(i => i.Severity == ValidationSeverity.Error)) return job.Fail(PrintOutcome.InvalidLabel, "Print.Error.InvalidLabel"); + + var prompts = ds.BuildPromptSet(); + if (!prompts.IsEmpty) + { + if (CheckAnswers(job, doc, prompts, out var answers) is { } refused) return refused; + foreach (var kv in answers!) ds.Options.PromptAnswers[kv.Key] = kv.Value; + } + + // The run owns the serial-number reservation, so only labels that really reached the printer are confirmed (as in the app's batch print). + VariableJob? serials = null; + if (VariableEngine.HasDefinitions(doc)) + { + serials = VariableJob.Begin(doc, ds.CreateBatchContext(), ds.BatchCount); + ds.Options.Job = serials; + job.AddIssues(serials.Issues); + if (serials.Issues.Any(i => i.Severity == ValidationSeverity.Error)) + { + job.AddIssues(serials.Cancel()); + return job.Fail(PrintOutcome.InvalidLabel, "Print.Error.InvalidLabel"); + } + } + + var report = new MergeReport(); + string setup = profile is null ? string.Empty : PrinterSetup.BuildSetupZpl(profile); + var rawBlocks = ByteExactBlocks.From(doc); + var jobTarget = target.WithStatus(target.Status with { VerifyAfterPrinting = false }); + int confirmedThrough = 0, inFlight = 0; + PrintError? failure = null; + var zpl = request.DryRun ? new StringBuilder(setup) : null; + try + { + foreach (var block in MergeBatch.JobBlocks(doc, ds, report)) + { + ct.ThrowIfCancellationRequested(); + if (zpl is not null) + { + zpl.Append(block.Zpl); + job.Jobs++; + continue; + } + inFlight = block.ItemsThrough; + var result = await _print.PrintZplAsync(jobTarget, job.Jobs == 0 ? setup + block.Zpl : block.Zpl, doc.Encoding, rawBlocks, ct).ConfigureAwait(false); + job.AddIssues(result.Notices); + job.BytesSent += result.BytesSent; + if (!result.Succeeded) + { + failure = result.Error; + // A send that moved bytes may have printed labels: their numbers stay used. One that moved nothing gives them back. + if (LabelPrintRun.MayHavePrinted(result)) confirmedThrough = inFlight; + break; + } + confirmedThrough = inFlight; + inFlight = 0; + job.Jobs++; + } + } + catch (OperationCanceledException) + { + // Cancelled while a job was on its way: it may have printed, so its numbers are not given back. + if (inFlight > 0) confirmedThrough = inFlight; + failure = PrintError.Create(PrintErrorKind.Cancelled); + } + finally + { + // Always confirms (a dry run confirms nothing), so a reservation is never left hanging. + if (serials is not null) job.AddIssues(serials.Complete(zpl is null ? confirmedThrough : 0)); + } + + foreach (var ri in report.Issues) job.Notices.Add(job.Notice(ri.Issue, ri.RecordNumber)); + job.Records = report.Emitted; + job.Labels = CountLabels(ds, doc, report); + + if (failure is not null) return job.FromError(failure, partial: job.Jobs); + if (report.Emitted == 0) return job.Fail(PrintOutcome.InvalidLabel, "Engine.Error.NothingPrinted"); + if (zpl is not null) return job.Finish(PrintOutcome.DryRun, job.Text.Get("Engine.Result.DryRun", job.Printer), zpl.ToString()); + + if (job.Jobs > 0 && target.Status.VerifyAfterPrinting) + job.AddIssues(await _print.VerifyPrintedAsync(target, ct).ConfigureAwait(false)); + string message = job.Text.Get("Engine.Result.PrintedRecords", report.Emitted, job.Labels, job.Printer, job.Jobs); + if (report.SkippedRecords.Count > 0) message += " " + job.Text.Get("Data.Batch.Skipped", report.SkippedRecords.Count); + if (report.Stopped) message += " " + job.Text.Get("Data.Batch.Stopped", report.SkippedRecords[^1]); + return job.Finish(PrintOutcome.Printed, message); + } + + /// Labels the batch produced, copies included: the copies column or setting per record, or the label's own quantity. + private static long CountLabels(MergeDataSet ds, LabelDocument doc, MergeReport report) + { + if (ds.Options.CopiesColumn is null) return (long)report.Emitted * Math.Max(1, ds.Options.CopiesPerRecord ?? doc.Print.Quantity); + // With a copies column every record can differ; the batch already worked them out per label. + long total = 0; + int column = ds.Table.IndexOfColumn(ds.Options.CopiesColumn); + for (int i = 0; i < ds.BatchCount && column >= 0; i++) + { + if (report.SkippedRecords.Contains(ds.RecordAt(i) + 1)) continue; + total += int.TryParse(ds.Table.Rows[ds.RecordAt(i)][column], NumberStyles.None, CultureInfo.InvariantCulture, out int n) && n > 0 ? n : 1; + } + return Math.Max(total, report.Emitted); + } + + // ---- helpers -------------------------------------------------------------------------------------------------------- + + /// + /// Checks the answers to the label's questions. A question nobody answered takes the default the designer gave it (never the sample value); + /// a required question without answer or default refuses the job and lists every question. + /// + private static PrintJobResult? CheckAnswers(JobState job, LabelDocument doc, PromptSet prompts, out IReadOnlyDictionary? answers) + { + var given = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in prompts.Items) + { + var defined = doc.Variables.FirstOrDefault(v => string.Equals(v.Name, item.Name, StringComparison.OrdinalIgnoreCase)); + if (defined?.Prompt?.Default is { Length: > 0 } fallback) given[item.Name] = fallback; + } + if (job.Request.Answers is { } supplied) + foreach (var kv in supplied) given[kv.Key] = kv.Value; + var validation = prompts.Validate(given); + answers = validation.Values; + if (validation.IsValid) return null; + job.AddIssues(validation.Issues); + string list = string.Join("; ", prompts.Items.Select(i => i.Required ? i.Name + " (" + i.Label + ", *)" : i.Name + " (" + i.Label + ")")); + return job.Fail(PrintOutcome.InvalidLabel, "Engine.Error.Answers", list); + } + + /// The merge settings saved with the label (counters, column choices, copies, invalid-record handling, job size). + internal static MergeOptions MergeOptionsFor(DataMergeSettings? merge) + { + var o = new MergeOptions(); + if (merge is null) return o; + foreach (var c in merge.Counters) + { + o.Counters.Add(new SerialCounter + { + Name = c.Name, Kind = c.Kind, Start = c.Start, Step = c.Step, Padding = c.Padding, Prefix = c.Prefix, Suffix = c.Suffix, Overflow = c.Overflow, + }); + } + foreach (var kv in merge.ColumnOverrides) o.ColumnOverrides[kv.Key] = kv.Value; + o.CopiesPerRecord = merge.CopiesPerRecord; + o.CopiesColumn = merge.CopiesColumn; + o.CopiesMode = merge.CopiesMode; + o.OnInvalidRecord = merge.OnInvalidRecord; + o.MaxRecordsPerJob = merge.MaxRecordsPerJob; + o.DateEvaluation = merge.DateEvaluation; + return o; + } + + /// Turns JSON records into a table: the columns are every field name in the order first seen; a missing field is empty. + public static MergeTable RecordsTable(IReadOnlyList> records) + { + ArgumentNullException.ThrowIfNull(records); + var columns = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var record in records) + foreach (var key in record.Keys) + if (seen.Add(key)) columns.Add(key); + var table = new MergeTable(columns); + foreach (var record in records) + { + var lookup = new Dictionary(record, StringComparer.OrdinalIgnoreCase); + table.AddRow(columns.Select(c => lookup.TryGetValue(c, out var v) ? v ?? string.Empty : string.Empty).ToArray()); + } + return table; + } + + private SemaphoreSlim Queue(PrinterTarget target) => _queues.GetOrAdd(target.DisplayName, _ => new SemaphoreSlim(1, 1)); + + /// What a request collected on its way, turned into the result at the end. + private sealed class JobState(PrintJobRequest request, EngineText text) + { + public PrintJobRequest Request { get; } = request; + public EngineText Text { get; } = text; + public string Printer { get; set; } = string.Empty; + public int Records { get; set; } + public long Labels { get; set; } + public int Jobs { get; set; } + public long BytesSent { get; set; } + public List Notices { get; } = new(); + + public EngineNotice Notice(ValidationIssue issue, int? record = null) => + new(issue.MessageKey, issue.Severity == ValidationSeverity.Error ? "error" : "warning", Text.Describe(issue), record); + + public void AddIssues(IEnumerable issues) + { + foreach (var issue in issues) + { + var notice = Notice(issue); + // The same note can come from several places (the status check before every job); it is listed once. + if (!Notices.Any(n => n.Code == notice.Code && n.Message == notice.Message && n.Record is null)) Notices.Add(notice); + } + } + + public PrintJobResult Fail(PrintOutcome outcome, string key, params object[] args) => + new(outcome, Text.Get(key, args), key, Printer, Records, Labels, Jobs, BytesSent, Notices, null, Request.Reference); + + public PrintJobResult FromError(PrintError error, int partial = 0) + { + var outcome = error.Kind switch + { + PrintErrorKind.InvalidLabel => PrintOutcome.InvalidLabel, + PrintErrorKind.InvalidProfile or PrintErrorKind.EncodingFailed => PrintOutcome.Invalid, + PrintErrorKind.Cancelled => PrintOutcome.Cancelled, + PrintErrorKind.HeadOpen or PrintErrorKind.PaperOut or PrintErrorKind.RibbonOut or PrintErrorKind.HeadTooHot + or PrintErrorKind.DiagnosticMode or PrintErrorKind.BufferFull or PrintErrorKind.Paused => PrintOutcome.HeldBack, + _ => PrintOutcome.Failed, + }; + string message = Text.Describe(error); + if (partial > 0) message += " " + Text.Get("Data.Batch.Partial", partial); + return new(outcome, message, error.Kind.ToString(), Printer, Records, Labels, Jobs, BytesSent, Notices, null, Request.Reference); + } + + public PrintJobResult Finish(PrintOutcome outcome, string message, string? zpl = null) => + new(outcome, message, null, Printer, Records, Labels, Jobs, BytesSent, Notices, zpl, Request.Reference); + } +} diff --git a/src/Rotula.Engine/PrintJobModels.cs b/src/Rotula.Engine/PrintJobModels.cs new file mode 100644 index 0000000..3539f6a --- /dev/null +++ b/src/Rotula.Engine/PrintJobModels.cs @@ -0,0 +1,129 @@ +using System.Text.Json.Serialization; + +namespace Rotula.Engine; + +/// +/// One print request, as the command line builds it from its options and as the REST API reads it from JSON. Exactly one of +/// and , and exactly one of and , must be set. +/// +public sealed class PrintJobRequest +{ + /// The .rotula file. The command line accepts any path; the REST server only a path inside one of its label folders. + public string? Label { get; set; } + + /// The content of a .rotula file, for a caller that keeps its templates itself. The REST server accepts it only when allowed. + public string? LabelContent { get; set; } + + /// An installed Windows printer (USB, shared or network queue). + public string? Printer { get; set; } + + /// Host name or IP address of a network printer reached directly over TCP. + public string? Host { get; set; } + + /// TCP port of ; 9100 when not set. + public int? Port { get; set; } + + /// Copies of the label (or of every record); null keeps the quantity saved in the label. + public int? Copies { get; set; } + + /// Values for the label's fields when printing one label (field name to value). + public Dictionary? Values { get; set; } + + /// Several records, each a set of field values; one label (times the copies) per record. Not together with . + public List>? Records { get; set; } + + /// Answers to the label's print-time questions (variable name to answer), the same for every record. + public Dictionary? Answers { get; set; } + + /// Printhead resolution (203, 300 or 600). Null uses what the Windows driver reports, or the label's own resolution. + public int? Dpi { get; set; } + + /// Darkness 0-30 sent before the labels; null leaves the printer as it is. + public int? Darkness { get; set; } + + /// Print speed 1-14 inches per second; null leaves the printer as it is. + public int? Speed { get; set; } + + /// Ask the printer for its status (~HS) before every job and do not send while it reports a fault. On by default. + public bool CheckStatus { get; set; } = true; + + /// After the last job, ask the printer until it reports it finished or a fault. On by default. + public bool Verify { get; set; } = true; + + /// Build the ZPL and report it without sending anything and without using up serial numbers. + public bool DryRun { get; set; } + + /// The caller's own reference (an order or shipment number). Echoed in the result and the log, never printed. + public string? Reference { get; set; } +} + +/// How a print request ended. The command line turns it into an exit code and the REST API into an HTTP status. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PrintOutcome +{ + /// Every label was handed to the printer (read the notices for the verification). + Printed, + /// A dry run: nothing was sent; the ZPL is in the result. + DryRun, + /// The request itself is wrong (missing printer, bad copies, unreadable data...). Nothing was sent. + Invalid, + /// The label has errors (or answers are missing). Nothing was sent. + InvalidLabel, + /// A rule of this computer or server forbids the request (policy, allow-lists). Nothing was sent. + Refused, + /// The printer reported a fault (paper out, head open, paused...) before a job, so it was held back. + HeldBack, + /// Sending failed (printer not found, offline, unreachable...). Some jobs may have gone out before; see . + Failed, + /// The caller cancelled; the jobs counted in the result went out. + Cancelled, +} + +/// A note about a request: a warning that did not stop it, or the reasons it was stopped. +/// Stable key (PrinterStatus.Verified, Barcode.Ean13.CheckDigit...), for programs. +/// warning or error. +/// The sentence in the requested language, for people. +/// 1-based record the note is about, or null. +public sealed record EngineNotice(string Code, string Severity, string Message, int? Record = null); + +/// The outcome of one print request. +/// How it ended. +/// One or two sentences for people, in the requested language. +/// For a failure, a stable code: the print error kind (PaperOut, Offline...) or an engine key; null on success. +/// The printer as named in messages (queue name or host:port); empty when none was resolved. +/// Records that produced a label (1 for a single label). +/// Labels sent, copies included (or that would be sent, for a dry run). +/// Print jobs handed to the printer. +/// Bytes handed to the spooler or the socket. +/// Warnings and problems, each with a code and a sentence. +/// The ZPL of a dry run; null otherwise. +/// The caller's reference from the request. +public sealed record PrintJobResult(PrintOutcome Outcome, string Message, string? ErrorCode, string Printer, int Records, long Labels, int Jobs, + long BytesSent, IReadOnlyList Notices, string? Zpl, string? Reference) +{ + /// True when the labels went out (or, for a dry run, would go out). + public bool Succeeded => Outcome is PrintOutcome.Printed or PrintOutcome.DryRun; +} + +/// A printer's answer to the status question (~HS), decoded. +/// The printer as named in messages. +/// False when the status could not be read (see ); that does not mean the printer is broken. +/// True when nothing stops printing; null when unknown. +/// Conditions that stop printing (PaperOut, HeadOpen...). +/// Conditions that do not stop printing. +/// Labels the printer still has to print. +/// Formats waiting in the printer's buffer. +/// False when only part of the reply arrived. +/// Why the status could not be read (NoReply, NotSupported...), or null. +/// The status in the requested language. +public sealed record PrinterStatusReport(string Printer, bool Available, bool? Ready, IReadOnlyList Blocking, IReadOnlyList Warnings, + long? LabelsRemaining, int? FormatsInBuffer, bool Complete, string? UnavailableReason, string Message); + +/// An installed Windows printer as the engine lists it. +/// Queue name to pass as the printer. +/// Windows port (USB001, IP_10.0.0.5...). +/// Driver name. +/// The Windows default printer. +/// Windows reports it offline. +/// The REST server may print to it (always true on the command line). +public sealed record PrinterEntry(string Name, string Port, string Driver, bool IsDefault, bool IsOffline, bool Allowed); diff --git a/src/Rotula.Engine/Rotula.Engine.csproj b/src/Rotula.Engine/Rotula.Engine.csproj new file mode 100644 index 0000000..5fb58ff --- /dev/null +++ b/src/Rotula.Engine/Rotula.Engine.csproj @@ -0,0 +1,36 @@ + + + + + net10.0 + enable + enable + true + true + Rotula.Engine + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Rotula.Files/Policy/RotulaPolicy.cs b/src/Rotula.Files/Policy/RotulaPolicy.cs index 62a2936..d31e15c 100644 --- a/src/Rotula.Files/Policy/RotulaPolicy.cs +++ b/src/Rotula.Files/Policy/RotulaPolicy.cs @@ -27,8 +27,11 @@ public static class PolicyNames /// Number: 0 means the MCP server never reads databases, whatever its settings file says. public const string McpDatabase = "McpDatabase"; + /// Number: 0 means the print engine (the rotula command line and its REST server) never prints and never queries printers. + public const string PrintEngine = "PrintEngine"; + /// Every value name, for the template check. - public static readonly IReadOnlyList All = new[] { Language, UpdateCheck, DefaultPrinter, UserAddOns, McpPrinting, McpDatabase }; + public static readonly IReadOnlyList All = new[] { Language, UpdateCheck, DefaultPrinter, UserAddOns, McpPrinting, McpDatabase, PrintEngine }; } /// @@ -65,6 +68,9 @@ public sealed record RotulaPolicy /// True when the MCP server must refuse database access. public bool McpDatabaseBlocked { get; init; } + /// True when the print engine (command line and REST server) must refuse to print and to query printers. + public bool PrintEngineBlocked { get; init; } + /// True when the language is chosen by the organization. public bool IsLanguageManaged => Language is not null; @@ -90,6 +96,7 @@ public static RotulaPolicy Read(IPolicySource source) AllowUserAddOns = source.GetInt(PolicyNames.UserAddOns) != 0, McpPrintingBlocked = source.GetInt(PolicyNames.McpPrinting) == 0, McpDatabaseBlocked = source.GetInt(PolicyNames.McpDatabase) == 0, + PrintEngineBlocked = source.GetInt(PolicyNames.PrintEngine) == 0, }; } } diff --git a/tests/Rotula.Tests/EngineCliTests.cs b/tests/Rotula.Tests/EngineCliTests.cs new file mode 100644 index 0000000..b61513c --- /dev/null +++ b/tests/Rotula.Tests/EngineCliTests.cs @@ -0,0 +1,353 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Rotula.Cli; +using Rotula.Cli.Server; +using Rotula.Engine; +using Rotula.Files.Policy; +using Rotula.Printing; + +namespace Rotula.Tests; + +/// The rotula command line against a fake printer: exit codes, output, data files, keys and the settings starter. +public class EngineCliTests +{ + private sealed class Run(EngineFixture f, RotulaPolicy? policy = null) + { + public StringWriter Out { get; } = new(); + public StringWriter Err { get; } = new(); + public List Served { get; } = new(); + public List CounterPaths { get; } = new(); + + public Task Exec(params string[] args) => CliApp.RunAsync(args, new CliHost + { + Print = f.Service, + Policy = policy ?? RotulaPolicy.None, + Counters = path => { CounterPaths.Add(path); return f.Counters; }, + Rasterizer = null, + BaseDirectory = f.Folder, + Out = Out, + Error = Err, + DefaultLanguage = "en", + Serve = (loaded, _, _, _, _, _) => { Served.Add(loaded); return Task.FromResult(0); }, + }); + } + + [Fact] + public async Task Print_SendsTheLabel_AndWritesTheResultAsJson() + { + using var f = new EngineFixture(); + string label = f.SaveLabel("box.rotula"); + var run = new Run(f); + int code = await run.Exec("print", label, "--printer", "Dock 1", "--set", "sku=CLI-1", "--copies=2", "--no-verify", "--reference", "PO-5", "--json"); + + Assert.Equal(ExitCodes.Ok, code); + Assert.Contains("^FDCLI-1^FS", Assert.Single(f.Printer.Sent).Zpl); + using var json = JsonDocument.Parse(run.Out.ToString()); + Assert.Equal("printed", json.RootElement.GetProperty("outcome").GetString()); + Assert.Equal(2, json.RootElement.GetProperty("labels").GetInt32()); + Assert.Equal("PO-5", json.RootElement.GetProperty("reference").GetString()); + } + + [Fact] + public async Task Print_FromACsvFile_PrintsEveryRecord() + { + using var f = new EngineFixture(); + string label = f.SaveLabel("box.rotula", null, "sku", "qty"); + string csv = Path.Combine(f.Folder, "picks.csv"); + File.WriteAllText(csv, "sku;qty\nA-1;4\nB-2;6\n"); + var run = new Run(f); + int code = await run.Exec("print", label, "--printer", "Dock 1", "--data", csv, "--no-verify"); + + Assert.Equal(ExitCodes.Ok, code); + string zpl = Assert.Single(f.Printer.Sent).Zpl; + Assert.Contains("^FDB-2^FS", zpl); + Assert.Contains("^FD6^FS", zpl); + Assert.Contains("2 record(s)", run.Out.ToString()); + } + + [Fact] + public async Task DryRun_WritesTheZplToTheOutputFile() + { + using var f = new EngineFixture(); + string label = f.SaveLabel("box.rotula"); + string output = Path.Combine(f.Folder, "out.zpl"); + int code = await new Run(f).Exec("print", label, "--printer", "Dock 1", "--set", "sku=DRY", "--dry-run", "--output", output); + + Assert.Equal(ExitCodes.Ok, code); + Assert.Empty(f.Printer.Sent); + Assert.Contains("^FDDRY^FS", File.ReadAllText(output)); + } + + [Fact] + public async Task ExitCodes_TellScriptsWhatHappened() + { + using var f = new EngineFixture(_ => HostStatusParser.Parse(HsSamples.S1(paperOut: 1) + HsSamples.S2() + HsSamples.S3)); + string label = f.SaveLabel("box.rotula"); + + Assert.Equal(ExitCodes.PrinterNotReady, await new Run(f).Exec("print", label, "--printer", "Dock 1", "--set", "sku=x")); + Assert.Equal(ExitCodes.PrinterNotReady, await new Run(f).Exec("status", "--printer", "Dock 1")); + Assert.Equal(ExitCodes.Usage, await new Run(f).Exec("print", label, "--printr", "Dock 1")); + Assert.Equal(ExitCodes.Usage, await new Run(f).Exec("print", label, "--printer")); + Assert.Equal(ExitCodes.Usage, await new Run(f).Exec("print", label, "--printer", "Dock 1", "--copies", "many")); + Assert.Equal(ExitCodes.Usage, await new Run(f).Exec("print", label, "--printer", "Dock 1", "--set", "novalue")); + Assert.Equal(ExitCodes.Usage, await new Run(f).Exec("print", Path.Combine(f.Folder, "missing.rotula"), "--printer", "Dock 1")); + Assert.Equal(ExitCodes.Usage, await new Run(f).Exec("frobnicate")); + Assert.Equal(ExitCodes.Ok, await new Run(f).Exec("help")); + Assert.Empty(f.Printer.Sent); + } + + [Fact] + public async Task Policy_BlocksPrintingStatusAndTheServer() + { + using var f = new EngineFixture(); + string label = f.SaveLabel("box.rotula"); + var blocked = new RotulaPolicy { PrintEngineBlocked = true }; + var run = new Run(f, blocked); + Assert.Equal(ExitCodes.Refused, await run.Exec("print", label, "--printer", "Dock 1", "--set", "sku=x")); + Assert.Equal(ExitCodes.Refused, await run.Exec("status", "--printer", "Dock 1")); + Assert.Equal(ExitCodes.Refused, await run.Exec("serve")); + Assert.Empty(f.Printer.Sent); + Assert.Contains(EngineText.English.Get("Engine.Error.PrintingBlocked"), run.Err.ToString()); + } + + [Fact] + public async Task Messages_FollowLang_AndThePolicyLanguage() + { + using var f = new EngineFixture(); + var es = new Run(f); + await es.Exec("print", "--lang", "es"); + Assert.Contains(EngineText.For("es").Get("Engine.Cli.SeeHelp"), es.Err.ToString()); + + var managed = new Run(f, new RotulaPolicy { Language = "pt-BR" }); + await managed.Exec("frobnicate"); + Assert.Contains(EngineText.For("pt-BR").Get("Engine.Cli.UnknownCommand", "frobnicate"), managed.Err.ToString()); + } + + [Fact] + public async Task Status_AndPrinters_ReportInJson() + { + using var f = new EngineFixture(); + var status = new Run(f); + Assert.Equal(ExitCodes.Ok, await status.Exec("status", "--host", "10.0.0.5", "--json")); + Assert.True(JsonDocument.Parse(status.Out.ToString()).RootElement.GetProperty("ready").GetBoolean()); + + var printers = new Run(f); + Assert.Equal(ExitCodes.Ok, await printers.Exec("printers", "--json")); + Assert.Equal("Dock 1", JsonDocument.Parse(printers.Out.ToString()).RootElement[0].GetProperty("name").GetString()); + } + + [Fact] + public async Task NewKey_AndServeInit_MakeAWorkingSettingsFile() + { + using var f = new EngineFixture(); + var key = new Run(f); + await key.Exec("new-key", "--json"); + using var made = JsonDocument.Parse(key.Out.ToString()); + Assert.True(ApiKeys.Matches(made.RootElement.GetProperty("key").GetString(), [made.RootElement.GetProperty("hash").GetString()!])); + + string settings = Path.Combine(f.Folder, "conf", EngineSettings.FileName); + var init = new Run(f); + Assert.Equal(ExitCodes.Ok, await init.Exec("serve", "--init", "--settings", settings, "--label-folder", f.Folder)); + Assert.Equal(ExitCodes.Usage, await new Run(f).Exec("serve", "--init", "--settings", settings)); + + var serve = new Run(f); + Assert.Equal(ExitCodes.Ok, await serve.Exec("serve", "--settings", settings, "--urls", "http://127.0.0.1:9999")); + var loaded = Assert.Single(serve.Served); + Assert.Equal(["http://127.0.0.1:9999"], loaded.Settings!.Urls); + Assert.Equal([f.Folder], loaded.Settings.LabelFolders); + // The starter allows no printer yet, and says so. + Assert.Contains(EngineText.English.Get("Engine.Settings.NoPrinters"), serve.Err.ToString()); + Assert.Equal([null], serve.CounterPaths); + + // counterFile shares the designer's serial numbers with a service account; --counter-file still wins. + string counters = Path.Combine(f.Folder, "counters.json"); + File.WriteAllText(settings, File.ReadAllText(settings).Replace("\"language\": \"en\"", "\"language\": \"en\", \"counterFile\": " + JsonSerializer.Serialize(counters))); + var shared = new Run(f); + Assert.Equal(ExitCodes.Ok, await shared.Exec("serve", "--settings", settings)); + Assert.Equal([counters], shared.CounterPaths); + var overridden = new Run(f); + Assert.Equal(ExitCodes.Ok, await overridden.Exec("serve", "--settings", settings, "--counter-file", "other.json")); + Assert.Equal(["other.json"], overridden.CounterPaths); + } +} + +/// The REST API over the in-memory test server: keys, allow-lists, label folders, status codes and languages. +public class EngineServerTests +{ + private static async Task<(WebApplication App, HttpClient Client, string Key)> Start(EngineFixture f, Func? edit = null, + RotulaPolicy? policy = null) + { + var (key, hash) = ApiKeys.Create(); + var settings = new EngineSettings + { + ApiKeyHashes = [hash], + LabelFolders = [f.Folder], + AllowedPrinters = ["Dock 1", "10.0.0.5"], + }; + if (edit is not null) settings = edit(settings); + var app = EngineServer.Build(settings, f.Engine, policy ?? RotulaPolicy.None, b => b.WebHost.UseTestServer()); + await app.StartAsync(); + var client = app.GetTestClient(); + client.DefaultRequestHeaders.Add(ApiKeys.Header, key); + return (app, client, key); + } + + private static StringContent Body(string json) => new(json, Encoding.UTF8, "application/json"); + + [Fact] + public async Task Health_NeedsNoKey_EverythingElseDoes() + { + using var f = new EngineFixture(); + var (app, client, _) = await Start(f); + await using var _a = app; + client.DefaultRequestHeaders.Remove(ApiKeys.Header); + + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/health")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/v1/printers")).StatusCode); + var refused = await client.PostAsync("/api/v1/jobs", Body("{\"label\":\"box.rotula\",\"printer\":\"Dock 1\"}")); + Assert.Equal(HttpStatusCode.Unauthorized, refused.StatusCode); + client.DefaultRequestHeaders.Add(ApiKeys.Header, ApiKeys.Create().Key); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/v1/printers")).StatusCode); + Assert.Empty(f.Printer.Sent); + } + + [Fact] + public async Task Job_PrintsALabelFromAFolder_WithRecords() + { + using var f = new EngineFixture(); + f.SaveLabel(Path.Combine("outbound", "carton.rotula")); + var (app, client, _) = await Start(f); + await using var _a = app; + + var response = await client.PostAsync("/api/v1/jobs", Body( + "{\"label\":\"outbound/carton.rotula\",\"printer\":\"Dock 1\",\"verify\":false,\"reference\":\"WAVE-12\",\"records\":[{\"sku\":\"R1\"},{\"sku\":2}]}")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(EngineJson.Options); + Assert.Equal(PrintOutcome.Printed, result!.Outcome); + Assert.Equal(2, result.Records); + Assert.Equal("WAVE-12", result.Reference); + string zpl = Assert.Single(f.Printer.Sent).Zpl; + Assert.Contains("^FDR1^FS", zpl); + Assert.Contains("^FD2^FS", zpl); + } + + [Fact] + public async Task Job_RefusesPrintersLabelsAndContentOutsideTheRules() + { + using var f = new EngineFixture(); + f.SaveLabel("box.rotula"); + var (app, client, _) = await Start(f); + await using var _a = app; + + async Task<(HttpStatusCode, string)> Post(string json) + { + var r = await client.PostAsync("/api/v1/jobs", Body(json)); + using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync()); + return (r.StatusCode, doc.RootElement.GetProperty("errorCode").GetString()!); + } + + Assert.Equal((HttpStatusCode.Forbidden, "Engine.Error.PrinterNotAllowed"), await Post("{\"label\":\"box.rotula\",\"printer\":\"Office\"}")); + Assert.Equal((HttpStatusCode.Forbidden, "Engine.Error.PrinterNotAllowed"), await Post("{\"label\":\"box.rotula\",\"host\":\"10.0.0.5\",\"port\":6101}")); + Assert.Equal((HttpStatusCode.Forbidden, "Engine.Error.LabelOutsideFolders"), await Post("{\"label\":\"../box.rotula\",\"printer\":\"Dock 1\"}")); + Assert.Equal((HttpStatusCode.NotFound, "Engine.Error.LabelNotFound"), await Post("{\"label\":\"none.rotula\",\"printer\":\"Dock 1\"}")); + Assert.Equal((HttpStatusCode.Forbidden, "Engine.Error.InlineLabelsOff"), await Post("{\"labelContent\":\"{}\",\"printer\":\"Dock 1\"}")); + Assert.Equal((HttpStatusCode.BadRequest, "Engine.Error.LabelRequired"), await Post("{\"printer\":\"Dock 1\"}")); + Assert.Equal((HttpStatusCode.BadRequest, "Engine.Error.BadRequest"), await Post("{\"label\":\"box.rotula\",\"printer\":\"Dock 1\",\"copeis\":3}")); + Assert.Equal((HttpStatusCode.BadRequest, "Engine.Error.Copies"), await Post("{\"label\":\"box.rotula\",\"printer\":\"Dock 1\",\"copies\":5000}")); + Assert.Empty(f.Printer.Sent); + } + + [Fact] + public async Task Job_WithInlineContent_WhenAllowed() + { + using var f = new EngineFixture(); + var (app, client, _) = await Start(f, s => s with { AllowInlineLabels = true }); + await using var _a = app; + string content = Rotula.Files.RotulaFileSerializer.Serialize(EngineFixture.Label()); + var response = await client.PostAsync("/api/v1/jobs", Body(JsonSerializer.Serialize(new { labelContent = content, host = "10.0.0.5", values = new { sku = "INLINE" }, verify = false }))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("^FDINLINE^FS", Assert.Single(f.Printer.Sent).Zpl); + } + + [Fact] + public async Task PaperOut_Is409_InTheCallersLanguage() + { + using var f = new EngineFixture(_ => HostStatusParser.Parse(HsSamples.S1(paperOut: 1) + HsSamples.S2() + HsSamples.S3)); + f.SaveLabel("box.rotula"); + var (app, client, _) = await Start(f); + await using var _a = app; + var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/jobs") { Content = Body("{\"label\":\"box.rotula\",\"printer\":\"Dock 1\",\"values\":{\"sku\":\"x\"}}") }; + request.Headers.AcceptLanguage.ParseAdd("es-MX"); + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(EngineJson.Options); + Assert.Equal(PrintOutcome.HeldBack, result!.Outcome); + Assert.Equal(EngineText.For("es").Describe(PrintError.FromStatus(HostStatusParser.Parse(HsSamples.S1(paperOut: 1) + HsSamples.S2() + HsSamples.S3), "Dock 1")!), result.Message); + Assert.Empty(f.Printer.Sent); + } + + [Fact] + public async Task Status_AndPrinters_FollowTheAllowList() + { + using var f = new EngineFixture(); + f.Printer.Installed.Add(new PrinterInfo("Office", "USB002", "Laser", false, false)); + var (app, client, _) = await Start(f); + await using var _a = app; + + var printers = await client.GetFromJsonAsync>("/api/v1/printers", EngineJson.Options); + Assert.True(printers!.Single(p => p.Name == "Dock 1").Allowed); + Assert.False(printers!.Single(p => p.Name == "Office").Allowed); + + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/printers/Dock%201/status")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/printers/Office/status")).StatusCode); + var tcp = await client.PostAsync("/api/v1/printers/status", Body("{\"host\":\"10.0.0.5\"}")); + Assert.Equal(HttpStatusCode.OK, tcp.StatusCode); + Assert.True((await tcp.Content.ReadFromJsonAsync(EngineJson.Options))!.Ready); + } + + [Fact] + public async Task Policy_BlocksJobsAndStatus_ButNotHealth() + { + using var f = new EngineFixture(); + f.SaveLabel("box.rotula"); + var (app, client, _) = await Start(f, policy: new RotulaPolicy { PrintEngineBlocked = true }); + await using var _a = app; + + Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync("/api/v1/jobs", Body("{\"label\":\"box.rotula\",\"printer\":\"Dock 1\"}"))).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/printers/Dock%201/status")).StatusCode); + var health = await client.GetFromJsonAsync("/api/v1/health", EngineJson.Options); + Assert.True(health!.PrintingBlocked); + Assert.Empty(f.Printer.Sent); + } + + [Fact] + public async Task OpenApiDocument_DescribesTheRoutes() + { + using var f = new EngineFixture(); + var (app, client, _) = await Start(f); + await using var _a = app; + string doc = await client.GetStringAsync("/openapi/v1.json"); + Assert.Contains("/api/v1/jobs", doc); + Assert.Contains("/api/v1/printers/status", doc); + Assert.Contains("Rotula print engine", doc); + } + + [Fact] + public void Outcomes_MapToHttpStatusCodes() + { + Assert.Equal(200, EngineServer.StatusFor(PrintOutcome.Printed)); + Assert.Equal(200, EngineServer.StatusFor(PrintOutcome.DryRun)); + Assert.Equal(400, EngineServer.StatusFor(PrintOutcome.Invalid)); + Assert.Equal(422, EngineServer.StatusFor(PrintOutcome.InvalidLabel)); + Assert.Equal(409, EngineServer.StatusFor(PrintOutcome.HeldBack)); + Assert.Equal(502, EngineServer.StatusFor(PrintOutcome.Failed)); + } +} diff --git a/tests/Rotula.Tests/EngineTests.cs b/tests/Rotula.Tests/EngineTests.cs new file mode 100644 index 0000000..c6f8f2a --- /dev/null +++ b/tests/Rotula.Tests/EngineTests.cs @@ -0,0 +1,509 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Rotula.Core.Elements; +using Rotula.Core.Model; +using Rotula.Core.Variables; +using Rotula.Core.Variables.Counters; +using Rotula.Engine; +using Rotula.Files; +using Rotula.Files.Policy; +using Rotula.Printing; + +namespace Rotula.Tests; + +/// Fixtures of the print engine tests: a label on disk, a fake printer and the engine over the real print service. +internal sealed class EngineFixture : IDisposable +{ + public EngineFixture(Func? status = null) + { + Folder = Path.Combine(Path.GetTempPath(), "rotula-engine-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Folder); + Printer = new EnginePrinter(); + // A ready printer unless the test scripts something else. + Status = new ScriptedStatusReader(status ?? (_ => HostStatusParser.Parse(HsSamples.Ready))); + Counters = new MemoryCounterStore(); + Service = new PrintService(Printer, Printer, Printer, Status); + Engine = new PrintEngine(Service, Counters); + } + + public string Folder { get; } + public EnginePrinter Printer { get; } + public ScriptedStatusReader Status { get; } + public MemoryCounterStore Counters { get; } + public PrintService Service { get; } + public PrintEngine Engine { get; } + + /// A 50 x 25 mm label with one text field per name, saved as in . + public string SaveLabel(string file, Action? edit = null, params string[] fields) + { + var doc = Label(edit, fields); + string path = Path.Combine(Folder, file); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + RotulaFileSerializer.Save(doc, path); + return path; + } + + public static LabelDocument Label(Action? edit = null, params string[] fields) + { + var doc = new LabelDocument { Width = Measure.Millimeters(50), Height = Measure.Millimeters(25) }; + int y = 20; + foreach (var f in fields.DefaultIfEmpty("sku")) + { + doc.Elements.Add(new TextElement { X = 20, Y = y, Text = "{{" + f + "}}", FontHeight = 30, FontWidth = 30 }); + y += 40; + } + edit?.Invoke(doc); + return doc; + } + + public static RotulaFileContent Content(Action? edit = null, params string[] fields) => new(Label(edit, fields), null); + + /// Adds a counter variable called "serial" that remembers its value (which needs a saved label, so an id). + public static void RememberingCounter(LabelDocument doc) + { + doc.Id = "engine-test-label"; + doc.Variables.Add(new LabelVariable("serial", "S") { Kind = VariableKind.Counter, Counter = new CounterVariableOptions { Remember = true } }); + doc.Elements.Add(new TextElement { X = 20, Y = 150, Text = "N{{serial}}", FontHeight = 30, FontWidth = 30 }); + } + + public void Dispose() + { + try { Directory.Delete(Folder, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } +} + +/// A spooler and network fake: records every job, can fail, can be slow, and counts how many jobs ran at once. +internal sealed class EnginePrinter : IRawPrinter, INetworkPrinter, IPrinterDiscovery +{ + private int _running; + + public List<(string Target, string Zpl)> Sent { get; } = new(); + public Func? Fail { get; set; } + public TimeSpan Delay { get; set; } + public int MaxConcurrent { get; private set; } + public List Installed { get; } = new() { new PrinterInfo("Dock 1", "USB001", "ZPL driver", true, false) }; + + public Task SendAsync(string printerName, string documentName, byte[] data, CancellationToken cancellationToken = default) => + Send(printerName, data, cancellationToken); + + public Task SendAsync(string host, int port, byte[] data, NetworkPrinterOptions options, CancellationToken cancellationToken = default) => + Send(host + ":" + port, data, cancellationToken); + + public Task> GetPrintersAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(Installed); + + private async Task Send(string target, byte[] data, CancellationToken ct) + { + int now = Interlocked.Increment(ref _running); + lock (Sent) MaxConcurrent = Math.Max(MaxConcurrent, now); + try + { + if (Delay > TimeSpan.Zero) await Task.Delay(Delay, ct); + int n; + lock (Sent) n = Sent.Count + 1; + if (Fail?.Invoke(n) is { } failure) return failure; + lock (Sent) Sent.Add((target, Encoding.UTF8.GetString(data))); + return PrintResult.Ok(data.Length); + } + finally + { + Interlocked.Decrement(ref _running); + } + } +} + +/// The engine itself: one label, records, status, serial numbers, questions, dry runs and the per-printer queue. +public class PrintEngineTests +{ + private static PrintJobRequest Dock(Action? edit = null) + { + var r = new PrintJobRequest { Printer = "Dock 1", Verify = false }; + edit?.Invoke(r); + return r; + } + + [Fact] + public async Task OneLabel_WithValues_IsSentOnce_WithTheValueAndTheCopies() + { + using var f = new EngineFixture(); + var result = await f.Engine.PrintAsync(EngineFixture.Content(), Dock(r => { r.Values = new() { ["sku"] = "ABC-123" }; r.Copies = 3; r.Reference = "SO-77"; }), + null, EngineText.English); + + Assert.Equal(PrintOutcome.Printed, result.Outcome); + Assert.True(result.Succeeded); + Assert.Equal(3, result.Labels); + Assert.Equal("SO-77", result.Reference); + Assert.Equal("Dock 1", result.Printer); + var (target, zpl) = Assert.Single(f.Printer.Sent); + Assert.Equal("Dock 1", target); + Assert.Contains("^FDABC-123^FS", zpl); + Assert.Contains("^PQ3", zpl); + Assert.Equal("Sent 3 label(s) to Dock 1.", result.Message); + } + + [Fact] + public async Task Records_PrintOneLabelEach_InOneJob_AndReportTheCount() + { + using var f = new EngineFixture(); + var records = new List> { new() { ["sku"] = "A1" }, new() { ["sku"] = "B2" }, new() { ["sku"] = "C3" } }; + var result = await f.Engine.PrintAsync(EngineFixture.Content(), Dock(r => r.Records = records), null, EngineText.English); + + Assert.Equal(PrintOutcome.Printed, result.Outcome); + Assert.Equal(3, result.Records); + Assert.Equal(3, result.Labels); + Assert.Equal(1, result.Jobs); + string zpl = Assert.Single(f.Printer.Sent).Zpl; + Assert.Contains("^FDA1^FS", zpl); + Assert.Contains("^FDC3^FS", zpl); + } + + [Fact] + public async Task PaperOut_HoldsTheJobBack_AndSendsNothing() + { + using var f = new EngineFixture(_ => HostStatusParser.Parse(HsSamples.S1(paperOut: 1) + HsSamples.S2() + HsSamples.S3)); + var result = await f.Engine.PrintAsync(EngineFixture.Content(), Dock(r => r.Values = new() { ["sku"] = "x" }), null, EngineText.English); + + Assert.Equal(PrintOutcome.HeldBack, result.Outcome); + Assert.Equal(nameof(PrintErrorKind.PaperOut), result.ErrorCode); + Assert.Empty(f.Printer.Sent); + Assert.False(result.Succeeded); + } + + [Fact] + public async Task NoStatus_SkipsTheQuestion() + { + using var f = new EngineFixture(_ => HostStatusParser.Parse(HsSamples.S1(paperOut: 1) + HsSamples.S2() + HsSamples.S3)); + var result = await f.Engine.PrintAsync(EngineFixture.Content(), Dock(r => { r.CheckStatus = false; r.Values = new() { ["sku"] = "x" }; }), null, EngineText.English); + + Assert.Equal(PrintOutcome.Printed, result.Outcome); + Assert.Equal(0, f.Status.Calls); + } + + [Fact] + public async Task RememberedSerialNumbers_ContinueAcrossRequests_AndGoBackWhenNothingWasSent() + { + using var f = new EngineFixture(); + var content = new RotulaFileContent(EngineFixture.Label(EngineFixture.RememberingCounter), null); + var records = new List> { new() { ["sku"] = "A" }, new() { ["sku"] = "B" } }; + + var first = await f.Engine.PrintAsync(content, Dock(r => r.Records = records), null, EngineText.English); + Assert.Equal(PrintOutcome.Printed, first.Outcome); + Assert.Contains("^FDN1^FS", f.Printer.Sent[0].Zpl); + Assert.Contains("^FDN2^FS", f.Printer.Sent[0].Zpl); + + // The printer is unreachable: nothing moved, so the numbers 3 and 4 are given back and the next run uses them. + f.Printer.Fail = _ => PrintResult.Fail(PrintErrorKind.Offline, "Dock 1"); + var failed = await f.Engine.PrintAsync(new RotulaFileContent(EngineFixture.Label(EngineFixture.RememberingCounter), null), Dock(r => r.Records = records), null, EngineText.English); + Assert.Equal(PrintOutcome.Failed, failed.Outcome); + Assert.Equal(nameof(PrintErrorKind.Offline), failed.ErrorCode); + + f.Printer.Fail = null; + var again = await f.Engine.PrintAsync(new RotulaFileContent(EngineFixture.Label(EngineFixture.RememberingCounter), null), Dock(r => r.Records = records), null, EngineText.English); + Assert.Equal(PrintOutcome.Printed, again.Outcome); + Assert.Contains("^FDN3^FS", f.Printer.Sent[^1].Zpl); + } + + [Fact] + public async Task DryRun_ReturnsTheZpl_SendsNothing_AndUsesNoSerialNumber() + { + using var f = new EngineFixture(); + var dry = await f.Engine.PrintAsync(new RotulaFileContent(EngineFixture.Label(EngineFixture.RememberingCounter), null), + Dock(r => { r.DryRun = true; r.Values = new() { ["sku"] = "D" }; r.Darkness = 20; }), null, EngineText.English); + + Assert.Equal(PrintOutcome.DryRun, dry.Outcome); + Assert.Empty(f.Printer.Sent); + Assert.Contains("^FDD^FS", dry.Zpl); + Assert.StartsWith("~SD20", dry.Zpl); + + var real = await f.Engine.PrintAsync(new RotulaFileContent(EngineFixture.Label(EngineFixture.RememberingCounter), null), + Dock(r => r.Values = new() { ["sku"] = "D" }), null, EngineText.English); + Assert.Equal(PrintOutcome.Printed, real.Outcome); + Assert.Contains("^FDN1^FS", f.Printer.Sent[0].Zpl); + } + + [Fact] + public async Task RequiredQuestion_WithoutAnswer_IsRefused_AndADefaultAnswersAnOptionalOne() + { + using var f = new EngineFixture(); + void Questions(LabelDocument d) + { + d.Variables.Add(new LabelVariable("lot", "SAMPLE") { Kind = VariableKind.Prompt, Prompt = new PromptVariableOptions { Required = true, Label = "Lot" } }); + d.Variables.Add(new LabelVariable("line", "SAMPLE") { Kind = VariableKind.Prompt, Prompt = new PromptVariableOptions { Default = "L2" } }); + d.Elements.Add(new TextElement { X = 20, Y = 150, Text = "{{lot}}/{{line}}", FontHeight = 30, FontWidth = 30 }); + } + + var refused = await f.Engine.PrintAsync(new RotulaFileContent(EngineFixture.Label(Questions), null), Dock(r => r.Values = new() { ["sku"] = "x" }), null, EngineText.English); + Assert.Equal(PrintOutcome.InvalidLabel, refused.Outcome); + Assert.Equal("Engine.Error.Answers", refused.ErrorCode); + Assert.Contains("lot (Lot, *)", refused.Message); + Assert.Empty(f.Printer.Sent); + + var printed = await f.Engine.PrintAsync(new RotulaFileContent(EngineFixture.Label(Questions), null), + Dock(r => { r.Values = new() { ["sku"] = "x" }; r.Answers = new() { ["lot"] = "LOT9" }; }), null, EngineText.English); + Assert.Equal(PrintOutcome.Printed, printed.Outcome); + // The sample value is never printed in place of a missing answer; the designer's default is. + Assert.Contains("^FDLOT9/L2^FS", Assert.Single(f.Printer.Sent).Zpl); + } + + [Fact] + public async Task ALabelWithErrors_IsNotSent() + { + using var f = new EngineFixture(); + var content = EngineFixture.Content(d => d.Elements.Add(new BarcodeElement { X = 10, Y = 10, Symbology = BarcodeSymbology.Ean13, Data = "12" })); + var result = await f.Engine.PrintAsync(content, Dock(r => r.Values = new() { ["sku"] = "x" }), null, EngineText.English); + + Assert.Equal(PrintOutcome.InvalidLabel, result.Outcome); + Assert.Contains(result.Notices, n => n.Severity == "error"); + Assert.Empty(f.Printer.Sent); + } + + [Theory] + [InlineData(null, null, null, "Engine.Error.PrinterRequired")] + [InlineData("Dock 1", "10.0.0.5", null, "Engine.Error.PrinterBoth")] + [InlineData(null, "10.0.0.5", 70000, "Print.Field.Port")] + public async Task ABadPrinter_IsAnInvalidRequest(string? printer, string? host, int? port, string code) + { + using var f = new EngineFixture(); + var result = await f.Engine.PrintAsync(EngineFixture.Content(), new PrintJobRequest { Printer = printer, Host = host, Port = port }, null, EngineText.English); + Assert.Equal(PrintOutcome.Invalid, result.Outcome); + Assert.Equal(code, result.ErrorCode); + } + + [Fact] + public async Task Limits_CopiesRecordsAndResolution_AreChecked() + { + using var f = new EngineFixture(); + var limits = new EngineLimits(5, 2); + var copies = await f.Engine.PrintAsync(EngineFixture.Content(), Dock(r => r.Copies = 6), null, EngineText.English, limits); + Assert.Equal("Engine.Error.Copies", copies.ErrorCode); + var records = await f.Engine.PrintAsync(EngineFixture.Content(), Dock(r => r.Records = [new(), new(), new()]), null, EngineText.English, limits); + Assert.Equal("Engine.Error.Records", records.ErrorCode); + var dpi = await f.Engine.PrintAsync(EngineFixture.Content(), Dock(r => r.Dpi = 400), null, EngineText.English, limits); + Assert.Equal("Engine.Error.Dpi", dpi.ErrorCode); + var both = await f.Engine.PrintAsync(EngineFixture.Content(), Dock(r => { r.Records = [new()]; r.Values = new(); }), null, EngineText.English, limits); + Assert.Equal("Engine.Error.ValuesAndRecords", both.ErrorCode); + Assert.Empty(f.Printer.Sent); + } + + [Fact] + public async Task NetworkPrinters_GetTheirOwnTarget() + { + using var f = new EngineFixture(); + var result = await f.Engine.PrintAsync(EngineFixture.Content(), new PrintJobRequest { Host = "10.0.0.5", Port = 6101, Verify = false }, null, EngineText.English); + Assert.Equal(PrintOutcome.Printed, result.Outcome); + Assert.Equal("10.0.0.5:6101", Assert.Single(f.Printer.Sent).Target); + } + + [Fact] + public async Task JobsForTheSamePrinter_RunOneAfterTheOther() + { + using var f = new EngineFixture(); + f.Printer.Delay = TimeSpan.FromMilliseconds(60); + var jobs = Enumerable.Range(0, 4).Select(i => + f.Engine.PrintAsync(EngineFixture.Content(), Dock(r => { r.CheckStatus = false; r.Values = new() { ["sku"] = "J" + i }; }), null, EngineText.English)); + var results = await Task.WhenAll(jobs); + + Assert.All(results, r => Assert.Equal(PrintOutcome.Printed, r.Outcome)); + Assert.Equal(4, f.Printer.Sent.Count); + Assert.Equal(1, f.Printer.MaxConcurrent); + } + + [Fact] + public async Task Verification_AddsTheVerifiedNotice() + { + using var f = new EngineFixture(); + var result = await f.Engine.PrintAsync(EngineFixture.Content(), new PrintJobRequest { Printer = "Dock 1", Values = new() { ["sku"] = "v" } }, null, EngineText.English); + Assert.Equal(PrintOutcome.Printed, result.Outcome); + Assert.Contains(result.Notices, n => n.Code == PrinterStatusKeys.Verified); + } + + [Fact] + public async Task Status_IsDescribed_WithConditionCodes_InTheCallersLanguage() + { + using var f = new EngineFixture(_ => HostStatusParser.Parse(HsSamples.S1(paperOut: 1) + HsSamples.S2(headUp: 1) + HsSamples.S3)); + var report = await f.Engine.GetStatusAsync(PrinterTarget.Spooler("Dock 1"), EngineText.For("es")); + + Assert.True(report.Available); + Assert.False(report.Ready); + Assert.Contains("PaperOut", report.Blocking); + Assert.Contains("HeadOpen", report.Blocking); + Assert.Equal(EngineText.For("es").Describe(HostStatusParser.Parse(HsSamples.S1(paperOut: 1) + HsSamples.S2(headUp: 1) + HsSamples.S3)), report.Message); + Assert.NotEqual(EngineText.English.Get("Print.Status.Blocked", "x"), EngineText.For("es").Get("Print.Status.Blocked", "x")); + } + + [Fact] + public void RecordsTable_TakesEveryFieldName_InTheOrderFirstSeen() + { + var table = PrintEngine.RecordsTable([new() { ["sku"] = "A" }, new() { ["qty"] = "2", ["SKU"] = "B" }]); + Assert.Equal(new[] { "sku", "qty" }, table.Columns); + Assert.Equal(new[] { "A", "" }, table.Rows[0]); + Assert.Equal(new[] { "B", "2" }, table.Rows[1]); + } +} + +/// Labels, keys, settings, texts and JSON of the engine. +public class EngineSupportTests +{ + [Theory] + [InlineData("../secret.rotula")] + [InlineData("a/../../secret.rotula")] + [InlineData("C:/labels/x.rotula")] + [InlineData("/etc/x.rotula")] + [InlineData("//server/share/x.rotula")] + [InlineData("x.rotula:stream")] + [InlineData("labels/x.txt")] + [InlineData("")] + public void Resolve_RefusesAnythingButARelativeRotulaPath(string requested) + { + using var f = new EngineFixture(); + Assert.Null(LabelFiles.Resolve(requested, [f.Folder], out var key)); + Assert.Equal("Engine.Error.LabelOutsideFolders", key); + } + + [Fact] + public void Resolve_FindsALabelInTheFirstFolderThatHasIt() + { + using var f = new EngineFixture(); + string path = f.SaveLabel(Path.Combine("shipping", "carton.rotula")); + Assert.Equal(path, LabelFiles.Resolve("shipping/carton.rotula", [Path.Combine(f.Folder, "none"), f.Folder], out var key)); + Assert.Null(key); + Assert.Equal(path, LabelFiles.Resolve(@"shipping\carton.rotula", [f.Folder], out _)); + Assert.Null(LabelFiles.Resolve("shipping/missing.rotula", [f.Folder], out key)); + Assert.Equal("Engine.Error.LabelNotFound", key); + } + + [Fact] + public void Load_ReportsMissingTooLargeAndDamagedFiles_ByKey() + { + using var f = new EngineFixture(); + Assert.Equal("Engine.Error.LabelNotFound", LabelFiles.Load(Path.Combine(f.Folder, "no.rotula")).ErrorKey); + string path = f.SaveLabel("a.rotula"); + Assert.NotNull(LabelFiles.Load(path).Content); + Assert.Equal("Engine.Error.LabelTooLarge", LabelFiles.Load(path, maxBytes: 10).ErrorKey); + var damaged = LabelFiles.Parse("{ nope", "x.rotula"); + Assert.Equal("File.Error.Corrupt", damaged.ErrorKey); + Assert.Equal("x.rotula", damaged.Args[0]); + } + + [Fact] + public void ApiKeys_MatchOnlyTheirOwnHash() + { + var (key, hash) = ApiKeys.Create(); + Assert.Equal(64, key.Length); + Assert.True(ApiKeys.IsHash(hash)); + Assert.True(ApiKeys.Matches(key, [hash])); + Assert.True(ApiKeys.Matches(key, [ApiKeys.Create().Hash, hash.ToUpperInvariant()])); + Assert.False(ApiKeys.Matches(key + "x", [hash])); + Assert.False(ApiKeys.Matches(hash, [hash])); // the hash itself is not a key + Assert.False(ApiKeys.Matches(null, [hash])); + Assert.False(ApiKeys.Matches(key, [])); + } + + [Theory] + [InlineData("es-MX", "es")] + [InlineData("pt", "pt-BR")] + [InlineData("pt-PT", "pt-BR")] + [InlineData("fr-FR,en;q=0.5", "fr-CA")] + [InlineData("de-DE,es;q=0.8", "es")] + [InlineData("de", "en")] + [InlineData(null, "en")] + public void Language_IsTheClosestShippedOne(string? requested, string expected) => Assert.Equal(expected, EngineText.Resolve(requested)); + + [Fact] + public void Languages_AreThePolicyLanguages() + { + Assert.Equal(RotulaPolicy.Languages.Where(l => l != "auto"), EngineText.Languages); + // Every language has its texts embedded. + foreach (var lang in EngineText.Languages) Assert.NotEqual("Engine.Cli.SeeHelp", EngineText.For(lang).Get("Engine.Cli.SeeHelp")); + } + + [Fact] + public void EveryKeyTheEngineUses_HasTextInEveryLanguage() + { + string root = Root(); + var shape = new Regex("\"((?:Engine|Print|Data|File|Issue)\\.[A-Za-z0-9]+(?:\\.[A-Za-z0-9]+)+)\""); + var used = new HashSet(StringComparer.Ordinal); + foreach (var dir in new[] { "Rotula.Engine", "Rotula.Cli" }) + foreach (var cs in Directory.EnumerateFiles(Path.Combine(root, "src", dir), "*.cs", SearchOption.AllDirectories) + .Where(p => !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"))) + foreach (Match m in shape.Matches(File.ReadAllText(cs))) + used.Add(m.Groups[1].Value); + Assert.Contains("Engine.Cli.Help", used); + Assert.True(used.Count > 40, "expected the engine keys, found " + used.Count); + foreach (var lang in EngineText.Languages) + { + var keys = XDocument.Load(Path.Combine(root, "src", "Rotula.App", "Resources", $"Strings.{lang}.resx")).Root!.Elements("data") + .Select(d => (string)d.Attribute("name")!).ToHashSet(StringComparer.Ordinal); + Assert.Empty(used.Where(k => !keys.Contains(k)).Select(k => lang + ": " + k)); + } + } + + [Fact] + public void Settings_LoadWithTheirRules_AndRefuseTyposAndMissingKeys() + { + using var f = new EngineFixture(); + string path = Path.Combine(f.Folder, EngineSettings.FileName); + var (_, hash) = ApiKeys.Create(); + File.WriteAllText(path, EngineSettingsLoader.Starter(hash, f.Folder).Replace("\"allowedPrinters\": []", "\"allowedPrinters\": [\"Dock 1\", \"10.0.0.5\", \"10.0.0.6:6101\"]")); + var loaded = EngineSettingsLoader.Load(path, EngineText.English); + Assert.Empty(loaded.Errors); + var s = loaded.Settings!; + Assert.Equal([hash], s.ApiKeyHashes); + Assert.True(s.IsPrinterAllowed(PrinterTarget.Spooler("dock 1"))); + Assert.True(s.IsPrinterAllowed(PrinterTarget.TcpIp("10.0.0.5"))); + Assert.False(s.IsPrinterAllowed(PrinterTarget.TcpIp("10.0.0.5", 6101))); + Assert.True(s.IsPrinterAllowed(PrinterTarget.TcpIp("10.0.0.6", 6101))); + Assert.False(s.IsPrinterAllowed(PrinterTarget.Spooler("Office"))); + + File.WriteAllText(path, "{ \"apiKeyHashes\": [\"" + hash + "\"], \"allowedPrinter\": [\"Dock 1\"] }"); + Assert.Null(EngineSettingsLoader.Load(path, EngineText.English).Settings); + File.WriteAllText(path, "{ \"labelFolders\": [] }"); + Assert.Contains(EngineSettingsLoader.Load(path, EngineText.English).Errors, e => e.Contains("apiKeyHashes")); + File.WriteAllText(path, "{ \"apiKeyHashes\": [\"secret\"] }"); + Assert.Null(EngineSettingsLoader.Load(path, EngineText.English).Settings); + Assert.NotEmpty(EngineSettingsLoader.Load(Path.Combine(f.Folder, "missing.json"), EngineText.English).Errors); + } + + [Fact] + public void Json_ReadsRecordsWithNumbersAndRefusesUnknownFields() + { + Assert.Null(EngineJson.ReadData("[{\"sku\":\"A\",\"qty\":5,\"price\":1.50,\"ok\":true,\"none\":null}]", out var records, out _, out _)); + var r = Assert.Single(records!); + Assert.Equal("5", r["qty"]); + Assert.Equal("1.50", r["price"]); + Assert.Equal("true", r["ok"]); + Assert.Equal(string.Empty, r["none"]); + Assert.Null(EngineJson.ReadData("{\"sku\":\"A\"}", out _, out var values, out _)); + Assert.Equal("A", values!["SKU"]); + Assert.Equal("Engine.Error.DataShape", EngineJson.ReadData("[1]", out _, out _, out _)); + Assert.Equal("Engine.Error.DataShape", EngineJson.ReadData("[{\"a\":{\"b\":1}}]", out _, out _, out _)); + + var request = System.Text.Json.JsonSerializer.Deserialize("{\"printer\":\"Dock 1\",\"copies\":2,\"values\":{\"qty\":7}}", EngineJson.Options)!; + Assert.Equal("7", request.Values!["qty"]); + Assert.Throws(() => + System.Text.Json.JsonSerializer.Deserialize("{\"printer\":\"Dock 1\",\"copeis\":2}", EngineJson.Options)); + } + + [Fact] + public void Policy_PrintEngineValue_BlocksOnlyWithZero() + { + Assert.False(RotulaPolicy.None.PrintEngineBlocked); + Assert.True(RotulaPolicy.Read(new DictionaryPolicy(new() { [PolicyNames.PrintEngine] = 0 })).PrintEngineBlocked); + Assert.False(RotulaPolicy.Read(new DictionaryPolicy(new() { [PolicyNames.PrintEngine] = 1 })).PrintEngineBlocked); + Assert.Contains(PolicyNames.PrintEngine, PolicyNames.All); + } + + private sealed class DictionaryPolicy(Dictionary values) : IPolicySource + { + public string? GetString(string name) => null; + public int? GetInt(string name) => values.TryGetValue(name, out var v) ? v : null; + } + + internal static string Root() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Rotula.sln"))) dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("Rotula.sln not found above the test folder."); + } +} diff --git a/tests/Rotula.Tests/Rotula.Tests.csproj b/tests/Rotula.Tests/Rotula.Tests.csproj index 18f529a..9c0fca5 100644 --- a/tests/Rotula.Tests/Rotula.Tests.csproj +++ b/tests/Rotula.Tests/Rotula.Tests.csproj @@ -14,6 +14,8 @@ + + @@ -27,6 +29,8 @@ + + From a70c3f365600e5ee82e427d6dbb78ba381c5f34d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:00:50 +0000 Subject: [PATCH 2/3] Keep request values from forging server log lines The request path, reference and printer name are written to the audit log; control characters (CR, LF and others) now become spaces and long values are cut, so a caller cannot start a fake log entry. --- src/Rotula.Cli/Server/EngineServer.cs | 17 +++++++++++++++-- tests/Rotula.Tests/EngineCliTests.cs | 9 +++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Rotula.Cli/Server/EngineServer.cs b/src/Rotula.Cli/Server/EngineServer.cs index e55d269..f9de42c 100644 --- a/src/Rotula.Cli/Server/EngineServer.cs +++ b/src/Rotula.Cli/Server/EngineServer.cs @@ -97,7 +97,7 @@ public static WebApplication Build(EngineSettings settings, PrintEngine engine, if (string.IsNullOrEmpty(key) && auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) key = auth[7..].Trim(); if (!ApiKeys.Matches(key, settings.ApiKeyHashes)) { - log.LogWarning("Refused a call to {Path} from {Remote}: no valid API key.", http.Request.Path, http.Connection.RemoteIpAddress); + log.LogWarning("Refused a call to {Path} from {Remote}: no valid API key.", LogValue(http.Request.Path.Value), http.Connection.RemoteIpAddress); return Error(Text(http, settings), StatusCodes.Status401Unauthorized, "Engine.Error.ApiKey"); } // A short, non-reversible id of the key for the log: enough to tell callers apart, useless to call the server. @@ -143,7 +143,7 @@ public static WebApplication Build(EngineSettings settings, PrintEngine engine, var result = await engine.PrintAsync(label!, request, null, text, settings.Limits, http.RequestAborted).ConfigureAwait(false); // The audit line: who (key id, address), what (reference, printer, labels) and how it ended. Never the label data. log.LogInformation("Job {Reference} key {KeyId} from {Remote}: {Outcome} on {Printer}, {Labels} label(s) in {Jobs} job(s){Error}.", - request.Reference ?? "-", http.Items["keyId"], http.Connection.RemoteIpAddress, result.Outcome, result.Printer, result.Labels, result.Jobs, + LogValue(request.Reference), http.Items["keyId"], http.Connection.RemoteIpAddress, result.Outcome, LogValue(result.Printer), result.Labels, result.Jobs, result.ErrorCode is null ? string.Empty : " (" + result.ErrorCode + ")"); return Results.Json(result, EngineJson.Options, statusCode: StatusFor(result.Outcome)); }).WithName("Print").WithSummary("Prints a label, once or for every record, on an allowed printer.") @@ -242,6 +242,19 @@ async Task StatusAsync(HttpContext http, string? printer, string? host, } /// The caller's language: ?lang=, then Accept-Language, then the server's setting. + /// A caller's text made safe for one log line: control characters (CR, LF...) become spaces, so a request cannot forge + /// log entries, and long values are cut. + internal static string LogValue(string? value) + { + if (string.IsNullOrEmpty(value)) return "-"; + if (value.Length > 200) value = value[..200] + "..."; + value = value.Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal); + return string.Create(value.Length, value, static (span, v) => + { + for (int i = 0; i < v.Length; i++) span[i] = char.IsControl(v[i]) ? ' ' : v[i]; + }); + } + private static EngineText Text(HttpContext http, EngineSettings settings) => EngineText.For(http.Request.Query["lang"].FirstOrDefault() ?? http.Request.Headers.AcceptLanguage.FirstOrDefault() ?? settings.Language); diff --git a/tests/Rotula.Tests/EngineCliTests.cs b/tests/Rotula.Tests/EngineCliTests.cs index b61513c..106e7b5 100644 --- a/tests/Rotula.Tests/EngineCliTests.cs +++ b/tests/Rotula.Tests/EngineCliTests.cs @@ -340,6 +340,15 @@ public async Task OpenApiDocument_DescribesTheRoutes() Assert.Contains("Rotula print engine", doc); } + [Fact] + public void LogValues_CannotForgeLogLines() + { + Assert.Equal("WAVE-1 Job FAKE: printed", EngineServer.LogValue("WAVE-1\nJob FAKE: printed")); + Assert.Equal("a b", EngineServer.LogValue("a\r\nb")); + Assert.Equal("-", EngineServer.LogValue(null)); + Assert.Equal(203, EngineServer.LogValue(new string('x', 500)).Length); + } + [Fact] public void Outcomes_MapToHttpStatusCodes() { From 1a2eba2e3cd0147719b3091994696e30ce34ccaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:06:52 +0000 Subject: [PATCH 3/3] Start the MCP server from its own build output in the protocol tests The tests now also reference rotula.exe, which runs on ASP.NET Core, so the build leaves the Microsoft.Extensions.* files that framework provides out of the test folder. Rotula.Mcp.exe runs on plain .NET and could not start from there; the tests now use src/Rotula.Mcp/bin first. --- tests/Rotula.Tests/McpProtocolTests.cs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/Rotula.Tests/McpProtocolTests.cs b/tests/Rotula.Tests/McpProtocolTests.cs index f235854..e5223f4 100644 --- a/tests/Rotula.Tests/McpProtocolTests.cs +++ b/tests/Rotula.Tests/McpProtocolTests.cs @@ -160,17 +160,21 @@ private static bool IsError(JsonElement response) => private static string FindBuildFolder() { - // 1. Next to the tests (the project reference copies the program there). + // 1. The program project's own output, matching the configuration of the running tests. The copy next to the tests is + // not complete: the tests also reference rotula.exe (ASP.NET Core), so the Microsoft.Extensions.* files the shared + // framework provides are left out of the test folder, and Rotula.Mcp.exe (plain .NET) cannot start there. string here = AppContext.BaseDirectory; - if (File.Exists(Path.Combine(here, "Rotula.Mcp.exe"))) return here; - // 2. The program project's own output, matching the configuration of the running tests. var dir = new DirectoryInfo(here); while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Rotula.sln"))) dir = dir.Parent; - Assert.NotNull(dir); - string config = here.Contains(@"\Release\", StringComparison.OrdinalIgnoreCase) ? "Release" : "Debug"; - string folder = Path.Combine(dir.FullName, "src", "Rotula.Mcp", "bin", config, "net10.0-windows"); - Assert.True(File.Exists(Path.Combine(folder, "Rotula.Mcp.exe")), "Build Rotula.Mcp first: " + folder); - return folder; + if (dir is not null) + { + string config = here.Contains(Path.DirectorySeparatorChar + "Release" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ? "Release" : "Debug"; + string folder = Path.Combine(dir.FullName, "src", "Rotula.Mcp", "bin", config, "net10.0-windows"); + if (File.Exists(Path.Combine(folder, "Rotula.Mcp.exe"))) return folder; + } + // 2. Next to the tests, for a test folder copied elsewhere. + Assert.True(File.Exists(Path.Combine(here, "Rotula.Mcp.exe")), "Build Rotula.Mcp first."); + return here; } private static void CopyFolder(string from, string to)