From cce924808944871146a92ff29ff16dd45ecc66a8 Mon Sep 17 00:00:00 2001 From: Jason <17367223+jsoltys@users.noreply.github.com> Date: Thu, 14 May 2026 16:59:59 -0500 Subject: [PATCH] Initial commit: LAAPC Print Service - Windows service with multi-tray printing, IPC queue management, PDF testing support, and self-installing CLI --- .gitignore | 82 ++++ .vscode/tasks.json | 392 ++++++++++++++++++++ IMPLEMENTATION_PLAN.md | 220 +++++++++++ PrintService.sln | 28 ++ PrintService/Models/AppSettings.cs | 57 +++ PrintService/Models/DocumentConfig.cs | 91 +++++ PrintService/Models/PrintJob.cs | 74 ++++ PrintService/PrintService.csproj | 21 ++ PrintService/Program.cs | 34 ++ PrintService/Properties/launchSettings.json | 11 + PrintService/Services/DocumentProcessor.cs | 108 ++++++ PrintService/Services/FileMonitorService.cs | 205 ++++++++++ PrintService/Services/IpcService.cs | 241 ++++++++++++ PrintService/Services/PrintQueueService.cs | 167 +++++++++ PrintService/Services/PrinterService.cs | 176 +++++++++ PrintService/Worker.cs | 157 ++++++++ PrintService/appsettings.Development.json | 8 + PrintService/appsettings.json | 76 ++++ PrintServiceCLI/PrintServiceCLI.csproj | 20 + PrintServiceCLI/Program.cs | 232 ++++++++++++ PrintServiceCLI/ServiceManager.cs | 290 +++++++++++++++ README.md | 283 ++++++++++++++ queue_state.json | 1 + 23 files changed, 2974 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/tasks.json create mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 PrintService.sln create mode 100644 PrintService/Models/AppSettings.cs create mode 100644 PrintService/Models/DocumentConfig.cs create mode 100644 PrintService/Models/PrintJob.cs create mode 100644 PrintService/PrintService.csproj create mode 100644 PrintService/Program.cs create mode 100644 PrintService/Properties/launchSettings.json create mode 100644 PrintService/Services/DocumentProcessor.cs create mode 100644 PrintService/Services/FileMonitorService.cs create mode 100644 PrintService/Services/IpcService.cs create mode 100644 PrintService/Services/PrintQueueService.cs create mode 100644 PrintService/Services/PrinterService.cs create mode 100644 PrintService/Worker.cs create mode 100644 PrintService/appsettings.Development.json create mode 100644 PrintService/appsettings.json create mode 100644 PrintServiceCLI/PrintServiceCLI.csproj create mode 100644 PrintServiceCLI/Program.cs create mode 100644 PrintServiceCLI/ServiceManager.cs create mode 100644 README.md create mode 100644 queue_state.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b794f3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,82 @@ +# .NET Build Outputs +bin/ +obj/ +publish/ + +# Runtime Data Folders (don't commit capture files or queues) +Captures/ +Queue/ +Archive/ +Error/ +Output/ + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio Code +.vscode/* +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Visual Studio +.vs/ +*.userprefs + +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# NuGet Packages +*.nupkg +*.snupkg +**/packages/* +!**/packages/build/ +*.nuget.props +*.nuget.targets +project.lock.json +project.fragment.lock.json +artifacts/ + +# Test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Files built by Visual Studio +*.pidb +*.svclog +*.scc + +# ReSharper +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Log files +*.log + +# Queue state file (contains runtime job queue) +queue-state.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..643eacc --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,392 @@ +{ + "version": "2.0.0", + "tasks": [ + // ============================================ + // BUILD TASKS - Debug + // ============================================ + { + "label": "build-service-debug-x86", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/PrintService/PrintService.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "-c", + "Debug", + "-r", + "win-x86" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "build-service-debug-x64", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/PrintService/PrintService.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "-c", + "Debug", + "-r", + "win-x64" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "build-cli-debug-x86", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "-c", + "Debug", + "-r", + "win-x86" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "build-cli-debug-x64", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "-c", + "Debug", + "-r", + "win-x64" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + // ============================================ + // BUILD TASKS - Release + // ============================================ + { + "label": "build-service-release-x86", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/PrintService/PrintService.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "-c", + "Release", + "-r", + "win-x86" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "build-service-release-x64", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/PrintService/PrintService.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "-c", + "Release", + "-r", + "win-x64" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "build-cli-release-x86", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "-c", + "Release", + "-r", + "win-x86" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "build-cli-release-x64", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "-c", + "Release", + "-r", + "win-x64" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + // ============================================ + // BUILD ALL TASKS + // ============================================ + { + "label": "build-all-x86", + "dependsOn": [ + "build-service-release-x86", + "build-cli-release-x86" + ], + "dependsOrder": "parallel", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [] + }, + { + "label": "build-all-x64", + "dependsOn": [ + "build-service-release-x64", + "build-cli-release-x64" + ], + "dependsOrder": "parallel", + "group": "build", + "problemMatcher": [] + }, + { + "label": "build-all-platforms", + "dependsOn": [ + "build-all-x86", + "build-all-x64" + ], + "dependsOrder": "sequence", + "group": "build", + "problemMatcher": [] + }, + // ============================================ + // PUBLISH TASKS (Self-Contained) + // ============================================ + { + "label": "publish-service-x86-self-contained", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/PrintService/PrintService.csproj", + "-c", + "Release", + "-r", + "win-x86", + "--self-contained", + "true", + "/p:PublishSingleFile=true", + "/p:PublishTrimmed=false", + "-o", + "${workspaceFolder}/publish/x86" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "publish-service-x64-self-contained", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/PrintService/PrintService.csproj", + "-c", + "Release", + "-r", + "win-x64", + "--self-contained", + "true", + "/p:PublishSingleFile=true", + "/p:PublishTrimmed=false", + "-o", + "${workspaceFolder}/publish/x64" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "publish-cli-x86-self-contained", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj", + "-c", + "Release", + "-r", + "win-x86", + "--self-contained", + "true", + "/p:PublishSingleFile=true", + "/p:PublishTrimmed=false", + "-o", + "${workspaceFolder}/publish/x86" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "publish-cli-x64-self-contained", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj", + "-c", + "Release", + "-r", + "win-x64", + "--self-contained", + "true", + "/p:PublishSingleFile=true", + "/p:PublishTrimmed=false", + "-o", + "${workspaceFolder}/publish/x64" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "publish-all-x86", + "dependsOn": [ + "publish-service-x86-self-contained", + "publish-cli-x86-self-contained" + ], + "dependsOrder": "parallel", + "group": "build", + "problemMatcher": [] + }, + { + "label": "publish-all-x64", + "dependsOn": [ + "publish-service-x64-self-contained", + "publish-cli-x64-self-contained" + ], + "dependsOrder": "parallel", + "group": "build", + "problemMatcher": [] + }, + // ============================================ + // RUN/TEST TASKS + // ============================================ + { + "label": "run-service", + "command": "dotnet", + "type": "process", + "args": [ + "run", + "--project", + "${workspaceFolder}/PrintService/PrintService.csproj" + ], + "problemMatcher": "$msCompile", + "group": "test" + }, + { + "label": "run-cli", + "command": "dotnet", + "type": "process", + "args": [ + "run", + "--project", + "${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj", + "--", + // Add CLI arguments after -- + // Example: "-f", "${workspaceFolder}/Captures/test.txt", "-t", "invoice" + ], + "problemMatcher": "$msCompile", + "group": "test" + }, + { + "label": "test-pdf-output", + "command": "dotnet", + "type": "process", + "args": [ + "run", + "--project", + "${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj", + "--", + "-f", + "${workspaceFolder}/Captures/~DARLENE~20260514@072912.TXT", + "-t", + "test", + "--skip-service-check" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "test", + "isDefault": true + } + }, + // ============================================ + // WATCH TASKS (Auto-rebuild on changes) + // ============================================ + { + "label": "watch-service", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/PrintService/PrintService.csproj" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + // ============================================ + // CLEAN TASKS + // ============================================ + { + "label": "clean", + "command": "dotnet", + "type": "process", + "args": [ + "clean", + "${workspaceFolder}/PrintService.sln" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "clean-publish", + "type": "shell", + "command": "Remove-Item", + "args": [ + "-Path", + "${workspaceFolder}/publish", + "-Recurse", + "-Force", + "-ErrorAction", + "SilentlyContinue" + ], + "windows": { + "options": { + "shell": { + "executable": "powershell.exe" + } + } + }, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..d352f91 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,220 @@ +# Harbor/Clipper Print Service - Implementation Plan + +**TL;DR**: Build a persistent Windows service that monitors for new print capture files, immediately moves them to prevent overwrites, queues them for ordered processing, and sends multi-tray print jobs to laser printers with document-type-specific configurations. + +**Recommended Approach**: C#/.NET 8+ Windows Service with FileSystemWatcher, print queue, and configurable document templates. C# is completely free (.NET SDK), has superior Windows printer APIs, and excellent service infrastructure. + +--- + +## Steps + +### Phase 1: Service Foundation (Parallel work possible after step 1) + +1. **Create Windows Service project structure** using .NET 8+ Worker Service template + + - Use `BackgroundService` base class for hosting + - Configure as Windows Service with `Microsoft.Extensions.Hosting.WindowsServices` + - Set up dependency injection, logging, and configuration (appsettings.json) +2. **Implement file monitoring system** (*parallel with step 3*) + + - `FileSystemWatcher` on Captures folder with `Created`, `Changed`, and `Renamed` events + - Debounce logic to handle rapid file creation (buffer 100-200ms) + - Immediate file move to processing queue folder with GUID-based naming to prevent collisions + - Include original filename, timestamp, and metadata in queue +3. **Design configuration system** (*parallel with step 2*) + + - Document type definitions (invoice, order, delivery, etc.) + - Per-document-type settings: printer name, tray sequence, content transformations + - Global settings: queue folder paths, archive settings, retry policies + - JSON schema for easy editing + +### Phase 2: Command Interface & Queue Management + +4. **Create CLI command handler** (*depends on 1*) + + - Parse `-f -t -o ` arguments + - Check if service is running via named pipe or TCP + - If running: send command to service via IPC + - If not running: log error or auto-start service + - Return exit code immediately +5. **Build print job queue** (*depends on 1*) + + - Thread-safe queue with priority/ordering (FIFO by default, configurable) + - Persistent queue state (survive service restart) + - Job metadata: source file, document type, order number, timestamp, retry count + - Status tracking: pending, processing, completed, failed +6. **Implement IPC mechanism** (*depends on 4, 5*) + + - Named pipe server listening for commands from CLI + - Deserialize command → create job → enqueue + - Return acknowledgment to caller + +### Phase 3: Printer Integration + +7. **Implement queue-based printer control** (*depends on 5*) + + - Use `System.Drawing.Printing.PrintDocument` with Windows print queue + - Multi-page document with dynamic `PageSettings.PaperSource` for tray selection + - Per-printer tray mapping configuration (map logical tray numbers to physical `PaperSource` indexes) + - Fallback handling when tray empty (configurable: fail job, use default tray, alert) + - Optional: Print to PDF for testing/archiving +8. **Build document processor with Graphics rendering** (*depends on 7*) + + - Read queued capture file and parse text content + - Apply document-type-specific transformations: + - Line position adjustments using `Graphics.DrawString()` coordinates + - Text removal/replacement via regex patterns + - Font and formatting configuration per document type + - Future: header/footer injection, graphics overlay, logo placement + - Render each page with `Graphics` API for pixel-perfect control + - Generate multi-page `PrintDocument` with correct tray sequence + - Send to Windows print queue +9. **Add printer routing logic** (*depends on 7, 8*) + + - Map document types to printer(s) and tray sequences + - Support multiple printer profiles per document type + - Configuration: `{ "invoice": { "printer": "LaserJet-1", "trays": [3,4,1,2] } }` + +### Phase 4: Reliability & Operations + +10. **Implement error handling & retry** (*depends on 5, 8*) + + - Printer offline detection → requeue with exponential backoff + - File read errors → log and move to error folder + - Max retry limit → move to dead letter queue + - Detailed logging with correlation IDs +11. **Add file lifecycle management** (*depends on 2*) + + - Post-processing: delete or move to archive folder (configurable per document type) + - Archive folder structure: organized by date, document type, or both + - Retention policy support (future: auto-cleanup old archives) +12. **Create service installer & deployment** (*depends on all previous*) + + - MSI installer or PowerShell install script + - Service registration with `sc.exe` or WiX toolset + - Auto-start configuration + - Uninstall/upgrade support + +### Phase 5: Testing & Validation + +13. **System testing** (*depends on 12*) + + - Test rapid file creation (simulate Harbor/Clipper speed) + - Verify no overwrites or missing files + - Confirm correct print order + - Test printer offline scenarios + - Validate tray sequences for each document type +14. **Integration with Harbor/Clipper** (*depends on 13*) + + - Replace current Rust CLI calls with new CLI + - Monitor production for 24-48 hours + - Compare output quality and reliability + - Performance tuning if needed + +--- + +## Relevant Files + +**To be created**: + +- `PrintService/Program.cs` — Service host and startup configuration +- `PrintService/Worker.cs` — Background service implementing FileSystemWatcher and queue processor +- `PrintService/Models/PrintJob.cs` — Job data model +- `PrintService/Models/DocumentConfig.cs` — Document type configuration schema +- `PrintService/Services/FileMonitorService.cs` — File watching and immediate move logic +- `PrintService/Services/PrintQueueService.cs` — Queue management and processing +- `PrintService/Services/PrinterService.cs` — Queue-based printer control with tray commands +- `PrintService/Services/DocumentProcessor.cs` — Text parsing and Graphics rendering +- `PrintService/Services/IpcService.cs` — Named pipe listener for CLI commands +- `PrintService/appsettings.json` — Configuration file for document types, printers, paths +- `PrintServiceCLI/Program.cs` — Command-line tool for sending commands to service +- `PrintService.sln` — Solution file + +**Existing**: + +- `Captures/` — Source folder for Harbor/Clipper output files (will be monitored) + +--- + +## Verification + +1. **Unit tests**: Queue ordering, configuration parsing, tray sequence generation +2. **Integration test**: Create 50 files in 10 seconds → verify all printed in order with correct trays +3. **Stress test**: 1000+ files → no overwrites, no missing files, proper ordering +4. **Manual test**: Print each document type to each configured printer and verify tray usage +5. **Production validation**: Monitor service for 48 hours, compare output count with Harbor/Clipper expected count +6. **Error scenario testing**: Unplug printer mid-job → verify retry → reconnect → verify job completes + +--- + +## Decisions + +**Language**: C#/.NET 8+ + +- **Rationale**: Free, best Windows service/printer APIs, excellent tooling, easier printer control than Rust +- **Alternative**: Could still use Rust with `windows-rs` crate, but printer control is more complex + +**Architecture**: Service with IPC (Named Pipes) + +- CLI tool sends commands → service processes asynchronously +- Decouples Harbor/Clipper from processing delays + +**File handling**: Immediate move with GUID renaming + +- **Critical**: Prevents timestamp collision overwrites +- Original metadata preserved in queue state + +**Queue persistence**: JSON file or SQLite + +- Survives service restart +- Allows inspection/recovery + +**Configuration**: JSON file (appsettings.json) + +- Editable without recompilation +- Per-document-type printer/tray/transformation settings + +**Printer control**: PrintDocument with Windows print queue + +- Per-page tray control via `PageSettings.PaperSource` +- Content rendered using `Graphics.DrawString()` for positioning control +- Jobs appear in Windows print queue (pausable, survives restarts) +- Can print to PDF for testing + +**Scope included**: + +- Windows service with file monitoring +- Command-line interface for Harbor/Clipper integration +- Multi-tray printer control via Windows print queue +- Configurable document routing +- Archive/delete options +- Error handling and retry logic +- Content transformation with Graphics rendering (line positioning, text removal) + +**Scope excluded** (future enhancements): + +- GUI management console (use config file + logs for now) +- Advanced document parsing/templating (start with regex transformations) +- Web API or remote monitoring +- Database storage (use file-based queue initially) +- Automatic printer discovery +- Print preview or validation UI + +--- + +## Further Considerations + +1. **Printer-specific tray mapping**: Need to identify your laser printer models and test `PaperSource` indexes. Do you know the printer makes/models? +2. **Content transformation complexity**: You mentioned looping line items. For Phase 1, suggest simple regex-based transformations. If parsing becomes complex, consider a template engine (Scriban, Handlebars.NET) in Phase 2. +3. **Testing environment**: Do you have a test laser printer or can we print to PDF initially to verify tray selection and content rendering work correctly? + +--- + +## Benefits of Queue-Based Approach + +- ✅ **Visibility**: Jobs appear in Windows print queue UI +- ✅ **Resilience**: Queue survives service restarts +- ✅ **Control**: Can pause/cancel jobs through Windows +- ✅ **Testing**: Print to PDF for verification without paper waste +- ✅ **Content Control**: `Graphics.DrawString()` gives pixel-perfect positioning for your line adjustments +- ✅ **Debugging**: Easier to troubleshoot than raw printer commands diff --git a/PrintService.sln b/PrintService.sln new file mode 100644 index 0000000..385961e --- /dev/null +++ b/PrintService.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrintService", "PrintService\PrintService.csproj", "{6FE05D84-E68E-4D43-AD80-7A5D8A59D975}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrintServiceCLI", "PrintServiceCLI\PrintServiceCLI.csproj", "{799E32EC-0A62-4CA1-92A6-F7E53A2A7F73}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6FE05D84-E68E-4D43-AD80-7A5D8A59D975}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6FE05D84-E68E-4D43-AD80-7A5D8A59D975}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6FE05D84-E68E-4D43-AD80-7A5D8A59D975}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6FE05D84-E68E-4D43-AD80-7A5D8A59D975}.Release|Any CPU.Build.0 = Release|Any CPU + {799E32EC-0A62-4CA1-92A6-F7E53A2A7F73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {799E32EC-0A62-4CA1-92A6-F7E53A2A7F73}.Debug|Any CPU.Build.0 = Debug|Any CPU + {799E32EC-0A62-4CA1-92A6-F7E53A2A7F73}.Release|Any CPU.ActiveCfg = Release|Any CPU + {799E32EC-0A62-4CA1-92A6-F7E53A2A7F73}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/PrintService/Models/AppSettings.cs b/PrintService/Models/AppSettings.cs new file mode 100644 index 0000000..feb4b9c --- /dev/null +++ b/PrintService/Models/AppSettings.cs @@ -0,0 +1,57 @@ +namespace PrintService.Models; + +/// +/// Application settings loaded from appsettings.json +/// +public class AppSettings +{ + /// + /// Path to monitor for incoming capture files + /// + public string CapturesPath { get; set; } = "Captures"; + + /// + /// Path for queued files (after moving from Captures) + /// + public string QueuePath { get; set; } = "Queue"; + + /// + /// Path for failed jobs + /// + public string ErrorPath { get; set; } = "Errors"; + + /// + /// Default archive path + /// + public string ArchivePath { get; set; } = "Archive"; + + /// + /// Maximum retry attempts before moving to error folder + /// + public int MaxRetryAttempts { get; set; } = 3; + + /// + /// Debounce delay in milliseconds for file system watcher + /// + public int DebounceDelayMs { get; set; } = 200; + + /// + /// Named pipe name for IPC communication + /// + public string PipeName { get; set; } = "PrintServicePipe"; + + /// + /// Document type configurations + /// + public List DocumentTypes { get; set; } = new(); + + /// + /// Queue state file path + /// + public string QueueStateFile { get; set; } = "queue_state.json"; + + /// + /// Enable detailed logging + /// + public bool VerboseLogging { get; set; } = false; +} diff --git a/PrintService/Models/DocumentConfig.cs b/PrintService/Models/DocumentConfig.cs new file mode 100644 index 0000000..48e8544 --- /dev/null +++ b/PrintService/Models/DocumentConfig.cs @@ -0,0 +1,91 @@ +namespace PrintService.Models; + +/// +/// Configuration for document types +/// +public class DocumentConfig +{ + /// + /// Name/identifier of the document type + /// + public string Name { get; set; } = string.Empty; + + /// + /// Printer name to send to + /// + public string PrinterName { get; set; } = string.Empty; + + /// + /// Sequence of tray numbers to use (1-based, e.g., [3, 4, 1, 2]) + /// These will be mapped to physical PaperSource indexes + /// + public int[] TraySequence { get; set; } = Array.Empty(); + + /// + /// Font name to use for rendering + /// + public string FontName { get; set; } = "Courier New"; + + /// + /// Font size in points + /// + public float FontSize { get; set; } = 10f; + + /// + /// Vertical offset adjustment (in pixels) + /// + public int VerticalOffset { get; set; } = 0; + + /// + /// Horizontal offset adjustment (in pixels) + /// + public int HorizontalOffset { get; set; } = 0; + + /// + /// Text transformation rules (regex patterns to remove/replace) + /// + public List Transformations { get; set; } = new(); + + /// + /// Whether to archive files after printing (true) or delete them (false) + /// + public bool ArchiveAfterPrint { get; set; } = true; + + /// + /// Archive folder path (if ArchiveAfterPrint is true) + /// + public string? ArchivePath { get; set; } + + /// + /// Output folder for PDF files (when using "Microsoft Print to PDF") + /// If specified, PDFs will be saved to this folder automatically + /// + public string? OutputPath { get; set; } + + /// + /// Skip post-processing (archive/delete) to leave files in queue for repeated testing + /// Useful for development and testing scenarios + /// + public bool SkipPostProcessing { get; set; } = false; +} + +/// +/// Text transformation rule +/// +public class TextTransform +{ + /// + /// Regex pattern to match + /// + public string Pattern { get; set; } = string.Empty; + + /// + /// Replacement text (empty string to remove) + /// + public string Replacement { get; set; } = string.Empty; + + /// + /// Description of what this transformation does + /// + public string Description { get; set; } = string.Empty; +} diff --git a/PrintService/Models/PrintJob.cs b/PrintService/Models/PrintJob.cs new file mode 100644 index 0000000..d8d847a --- /dev/null +++ b/PrintService/Models/PrintJob.cs @@ -0,0 +1,74 @@ +namespace PrintService.Models; + +/// +/// Represents a print job in the queue +/// +public class PrintJob +{ + /// + /// Unique identifier for the job + /// + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Path to the original capture file + /// + public string SourceFilePath { get; set; } = string.Empty; + + /// + /// Path to the queued file (after being moved with GUID name) + /// + public string QueuedFilePath { get; set; } = string.Empty; + + /// + /// Document type (invoice, order, delivery, etc.) + /// + public string DocumentType { get; set; } = string.Empty; + + /// + /// Optional order number + /// + public string? OrderNumber { get; set; } + + /// + /// When the job was created + /// + public DateTime CreatedAt { get; set; } = DateTime.Now; + + /// + /// When the job was last updated + /// + public DateTime UpdatedAt { get; set; } = DateTime.Now; + + /// + /// Current status of the job + /// + public PrintJobStatus Status { get; set; } = PrintJobStatus.Pending; + + /// + /// Number of times this job has been attempted + /// + public int RetryCount { get; set; } = 0; + + /// + /// Last error message if failed + /// + public string? LastError { get; set; } + + /// + /// Original filename before moving to queue + /// + public string OriginalFilename { get; set; } = string.Empty; +} + +/// +/// Status of a print job +/// +public enum PrintJobStatus +{ + Pending, + Processing, + Completed, + Failed, + RetryScheduled +} diff --git a/PrintService/PrintService.csproj b/PrintService/PrintService.csproj new file mode 100644 index 0000000..4b8b5a6 --- /dev/null +++ b/PrintService/PrintService.csproj @@ -0,0 +1,21 @@ + + + + net7.0 + enable + enable + dotnet-PrintService-3e06b535-3a68-4806-9c5b-3a32bde82bf9 + Exe + + + + + x86 + + + + + + + + diff --git a/PrintService/Program.cs b/PrintService/Program.cs new file mode 100644 index 0000000..c9ac78d --- /dev/null +++ b/PrintService/Program.cs @@ -0,0 +1,34 @@ +using PrintService; +using PrintService.Models; +using PrintService.Services; +using Microsoft.Extensions.Options; + +IHost host = Host.CreateDefaultBuilder(args) + .UseWindowsService(options => + { + options.ServiceName = "LAAPC Print Service"; + }) + .ConfigureServices((hostContext, services) => + { + // Bind configuration + services.Configure(hostContext.Configuration.GetSection("AppSettings")); + + // Register services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register the main worker + services.AddHostedService(); + }) + .Build(); + +// Ensure directories exist +var config = host.Services.GetRequiredService>().Value; +Directory.CreateDirectory(config.QueuePath); +Directory.CreateDirectory(config.ErrorPath); +Directory.CreateDirectory(config.ArchivePath); + +host.Run(); diff --git a/PrintService/Properties/launchSettings.json b/PrintService/Properties/launchSettings.json new file mode 100644 index 0000000..1ff0bdc --- /dev/null +++ b/PrintService/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "PrintService": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/PrintService/Services/DocumentProcessor.cs b/PrintService/Services/DocumentProcessor.cs new file mode 100644 index 0000000..432d7d5 --- /dev/null +++ b/PrintService/Services/DocumentProcessor.cs @@ -0,0 +1,108 @@ +using Microsoft.Extensions.Options; +using PrintService.Models; +using System.Drawing; +using System.Drawing.Printing; +using System.Text.RegularExpressions; + +namespace PrintService.Services; + +/// +/// Processes documents and renders them for printing +/// +public class DocumentProcessor +{ + private readonly AppSettings _settings; + private readonly ILogger _logger; + + public DocumentProcessor(IOptions settings, ILogger logger) + { + _settings = settings.Value; + _logger = logger; + } + + /// + /// Process and transform document content + /// + public string ProcessDocument(string content, DocumentConfig config) + { + var processed = content; + + // Apply text transformations + foreach (var transform in config.Transformations) + { + try + { + processed = Regex.Replace(processed, transform.Pattern, transform.Replacement); + if (_settings.VerboseLogging) + { + _logger.LogDebug("Applied transformation: {Description}", transform.Description); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to apply transformation: {Pattern}", transform.Pattern); + } + } + + return processed; + } + + /// + /// Get document configuration by type + /// + public DocumentConfig? GetDocumentConfig(string documentType) + { + return _settings.DocumentTypes.FirstOrDefault(dt => + dt.Name.Equals(documentType, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Read file content + /// + public string ReadFile(string filePath) + { + return File.ReadAllText(filePath); + } + + /// + /// Archive or delete file after processing + /// + public void PostProcess(PrintJob job, DocumentConfig config) + { + try + { + // Skip post-processing if configured (useful for testing) + if (config.SkipPostProcessing) + { + _logger.LogInformation("Skipping post-processing for job {JobId} (file remains in queue)", job.Id); + return; + } + + if (config.ArchiveAfterPrint && !string.IsNullOrEmpty(config.ArchivePath)) + { + Directory.CreateDirectory(config.ArchivePath); + + var archiveFileName = Path.Combine(config.ArchivePath, + $"{DateTime.Now:yyyyMMdd_HHmmss}_{job.OriginalFilename}"); + + if (File.Exists(job.QueuedFilePath)) + { + File.Move(job.QueuedFilePath, archiveFileName); + _logger.LogInformation("Archived job {JobId} to {Path}", job.Id, archiveFileName); + } + } + else + { + if (File.Exists(job.QueuedFilePath)) + { + File.Delete(job.QueuedFilePath); + _logger.LogInformation("Deleted file for job {JobId}", job.Id); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to post-process job {JobId}", job.Id); + } + } +} diff --git a/PrintService/Services/FileMonitorService.cs b/PrintService/Services/FileMonitorService.cs new file mode 100644 index 0000000..0814b9f --- /dev/null +++ b/PrintService/Services/FileMonitorService.cs @@ -0,0 +1,205 @@ +using Microsoft.Extensions.Options; +using PrintService.Models; + +namespace PrintService.Services; + +/// +/// Monitors the Captures folder for new files and moves them to queue +/// +public class FileMonitorService : IDisposable +{ + private readonly AppSettings _settings; + private readonly PrintQueueService _queueService; + private readonly ILogger _logger; + private FileSystemWatcher? _watcher; + private readonly Dictionary _pendingFiles = new(); + private readonly Timer _debounceTimer; + private readonly object _lock = new(); + + public FileMonitorService( + IOptions settings, + PrintQueueService queueService, + ILogger logger) + { + _settings = settings.Value; + _queueService = queueService; + _logger = logger; + + // Timer for debouncing file system events + _debounceTimer = new Timer(ProcessPendingFiles, null, + TimeSpan.FromMilliseconds(_settings.DebounceDelayMs), + TimeSpan.FromMilliseconds(_settings.DebounceDelayMs)); + } + + /// + /// Start monitoring the Captures folder + /// + public void Start() + { + if (!Directory.Exists(_settings.CapturesPath)) + { + Directory.CreateDirectory(_settings.CapturesPath); + _logger.LogInformation("Created captures directory: {Path}", _settings.CapturesPath); + } + + _watcher = new FileSystemWatcher(_settings.CapturesPath) + { + Filter = "*.TXT", + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime, + EnableRaisingEvents = true + }; + + _watcher.Created += OnFileCreated; + _watcher.Changed += OnFileChanged; + + _logger.LogInformation("Started monitoring: {Path}", _settings.CapturesPath); + } + + /// + /// Stop monitoring + /// + public void Stop() + { + if (_watcher != null) + { + _watcher.EnableRaisingEvents = false; + _watcher.Dispose(); + _watcher = null; + } + _logger.LogInformation("Stopped monitoring"); + } + + private void OnFileCreated(object sender, FileSystemEventArgs e) + { + lock (_lock) + { + _pendingFiles[e.FullPath] = DateTime.Now; + if (_settings.VerboseLogging) + { + _logger.LogDebug("File created: {Path}", e.FullPath); + } + } + } + + private void OnFileChanged(object sender, FileSystemEventArgs e) + { + lock (_lock) + { + _pendingFiles[e.FullPath] = DateTime.Now; + if (_settings.VerboseLogging) + { + _logger.LogDebug("File changed: {Path}", e.FullPath); + } + } + } + + /// + /// Process files after debounce delay + /// + private void ProcessPendingFiles(object? state) + { + List filesToProcess; + + lock (_lock) + { + var cutoff = DateTime.Now.AddMilliseconds(-_settings.DebounceDelayMs); + filesToProcess = _pendingFiles + .Where(kvp => kvp.Value <= cutoff) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var file in filesToProcess) + { + _pendingFiles.Remove(file); + } + } + + foreach (var filePath in filesToProcess) + { + ProcessFile(filePath); + } + } + + /// + /// Process a single file: move to queue with GUID name + /// + private void ProcessFile(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + return; // File may have been moved or deleted + } + + var fileName = Path.GetFileName(filePath); + var jobId = Guid.NewGuid(); + var queuedFileName = $"{jobId}.txt"; + var queuedPath = Path.Combine(_settings.QueuePath, queuedFileName); + + // Move file to queue with GUID name to prevent collisions + File.Move(filePath, queuedPath); + + _logger.LogInformation("Moved {FileName} to queue as {QueuedFile}", fileName, queuedFileName); + + // Note: Job will be created when CLI sends command with document type + // For now, we just have the file safely in the queue folder + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process file: {Path}", filePath); + } + } + + /// + /// Manually process a file (called from IPC when CLI provides metadata) + /// + public PrintJob CreateJobFromFile(string filePath, string documentType, string? orderNumber) + { + var fileName = Path.GetFileName(filePath); + var jobId = Guid.NewGuid(); + var queuedFileName = $"{jobId}.txt"; + var queuedPath = Path.Combine(_settings.QueuePath, queuedFileName); + + // Check if this document type has SkipPostProcessing enabled + var config = _settings.DocumentTypes.FirstOrDefault(dt => + dt.Name.Equals(documentType, StringComparison.OrdinalIgnoreCase)); + + if (config?.SkipPostProcessing == true) + { + // Copy instead of move for testing scenarios + File.Copy(filePath, queuedPath, overwrite: true); + _logger.LogInformation("Copied {FileName} to queue (test mode - source file preserved)", fileName); + } + else + { + // Move file to queue (normal operation) + File.Move(filePath, queuedPath, overwrite: true); + _logger.LogInformation("Moved {FileName} to queue", fileName); + } + + var job = new PrintJob + { + Id = jobId, + SourceFilePath = filePath, + QueuedFilePath = queuedPath, + DocumentType = documentType, + OrderNumber = orderNumber, + OriginalFilename = fileName, + Status = PrintJobStatus.Pending, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + }; + + _logger.LogInformation("Created job {JobId} for {FileName} (type: {DocType})", + job.Id, fileName, documentType); + + return job; + } + + public void Dispose() + { + _debounceTimer?.Dispose(); + Stop(); + } +} diff --git a/PrintService/Services/IpcService.cs b/PrintService/Services/IpcService.cs new file mode 100644 index 0000000..465c2fb --- /dev/null +++ b/PrintService/Services/IpcService.cs @@ -0,0 +1,241 @@ +using Microsoft.Extensions.Options; +using PrintService.Models; +using System.IO.Pipes; +using System.Text; +using System.Text.Json; + +namespace PrintService.Services; + +/// +/// Named pipe server for IPC communication with CLI +/// +public class IpcService : IDisposable +{ + private readonly AppSettings _settings; + private readonly PrintQueueService _queueService; + private readonly FileMonitorService _fileMonitorService; + private readonly ILogger _logger; + private CancellationTokenSource? _cancellationTokenSource; + private Task? _listenerTask; + + public IpcService( + IOptions settings, + PrintQueueService queueService, + FileMonitorService fileMonitorService, + ILogger logger) + { + _settings = settings.Value; + _queueService = queueService; + _fileMonitorService = fileMonitorService; + _logger = logger; + } + + /// + /// Start listening for IPC commands + /// + public void Start() + { + _cancellationTokenSource = new CancellationTokenSource(); + _listenerTask = Task.Run(() => ListenForConnections(_cancellationTokenSource.Token)); + _logger.LogInformation("IPC service started on pipe: {PipeName}", _settings.PipeName); + } + + /// + /// Stop listening + /// + public void Stop() + { + _cancellationTokenSource?.Cancel(); + _listenerTask?.Wait(TimeSpan.FromSeconds(5)); + _logger.LogInformation("IPC service stopped"); + } + + /// + /// Listen for incoming connections + /// + private async Task ListenForConnections(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + NamedPipeServerStream? pipeServer = null; + try + { + pipeServer = new NamedPipeServerStream( + _settings.PipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Message, + PipeOptions.Asynchronous); + + await pipeServer.WaitForConnectionAsync(cancellationToken); + + // Handle client in separate task, passing ownership of pipeServer + var serverToHandle = pipeServer; + pipeServer = null; // Clear reference so we don't dispose it in catch + _ = Task.Run(async () => + { + using (serverToHandle) + { + await HandleClient(serverToHandle); + } + }, cancellationToken); + } + catch (OperationCanceledException) + { + pipeServer?.Dispose(); + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in IPC listener"); + pipeServer?.Dispose(); + await Task.Delay(1000, cancellationToken); + } + } + } + + /// + /// Handle a client connection + /// + private async Task HandleClient(NamedPipeServerStream pipeServer) + { + try + { + if (!pipeServer.IsConnected) + { + return; + } + + // Read command + var buffer = new byte[4096]; + var bytesRead = await pipeServer.ReadAsync(buffer, 0, buffer.Length); + + if (bytesRead == 0) + { + return; // Client disconnected + } + + var message = Encoding.UTF8.GetString(buffer, 0, bytesRead); + + if (_settings.VerboseLogging) + { + _logger.LogDebug("Received IPC message: {Message}", message); + } + + var command = JsonSerializer.Deserialize(message); + if (command == null) + { + await SendResponse(pipeServer, false, "Invalid command format"); + return; + } + + // Process command + var result = ProcessCommand(command); + await SendResponse(pipeServer, result.Success, result.Message); + } + catch (ObjectDisposedException) + { + // Client disconnected, this is normal + if (_settings.VerboseLogging) + { + _logger.LogDebug("Client disconnected"); + } + } + catch (IOException ex) + { + // Pipe communication error + if (_settings.VerboseLogging) + { + _logger.LogDebug(ex, "Pipe communication error"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling IPC client"); + try + { + if (pipeServer.IsConnected) + { + await SendResponse(pipeServer, false, $"Error: {ex.Message}"); + } + } + catch { } + } + } + + /// + /// Process a print command + /// + private (bool Success, string Message) ProcessCommand(PrintCommand command) + { + try + { + if (string.IsNullOrEmpty(command.FilePath) || !File.Exists(command.FilePath)) + { + return (false, $"File not found: {command.FilePath}"); + } + + if (string.IsNullOrEmpty(command.DocumentType)) + { + return (false, "Document type is required"); + } + + // Create job and enqueue + var job = _fileMonitorService.CreateJobFromFile( + command.FilePath, + command.DocumentType, + command.OrderNumber); + + _queueService.Enqueue(job); + + return (true, $"Job {job.Id} queued successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process command"); + return (false, ex.Message); + } + } + + /// + /// Send response back to client + /// + private async Task SendResponse(NamedPipeServerStream pipeServer, bool success, string message) + { + var response = new PrintResponse + { + Success = success, + Message = message + }; + + var json = JsonSerializer.Serialize(response); + var bytes = Encoding.UTF8.GetBytes(json); + await pipeServer.WriteAsync(bytes, 0, bytes.Length); + await pipeServer.FlushAsync(); + } + + public void Dispose() + { + Stop(); + _cancellationTokenSource?.Dispose(); + } +} + +/// +/// Command sent from CLI to service +/// +public class PrintCommand +{ + public string FilePath { get; set; } = string.Empty; + public string DocumentType { get; set; } = string.Empty; + public string? OrderNumber { get; set; } +} + +/// +/// Response from service to CLI +/// +public class PrintResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; +} diff --git a/PrintService/Services/PrintQueueService.cs b/PrintService/Services/PrintQueueService.cs new file mode 100644 index 0000000..3a7746e --- /dev/null +++ b/PrintService/Services/PrintQueueService.cs @@ -0,0 +1,167 @@ +using Microsoft.Extensions.Options; +using PrintService.Models; +using System.Collections.Concurrent; +using System.Text.Json; + +namespace PrintService.Services; + +/// +/// Manages the print job queue with persistence +/// +public class PrintQueueService +{ + private readonly ConcurrentQueue _queue = new(); + private readonly AppSettings _settings; + private readonly ILogger _logger; + private readonly SemaphoreSlim _semaphore = new(1, 1); + + public PrintQueueService(IOptions settings, ILogger logger) + { + _settings = settings.Value; + _logger = logger; + LoadQueueState(); + } + + /// + /// Enqueue a new print job + /// + public void Enqueue(PrintJob job) + { + _queue.Enqueue(job); + _logger.LogInformation("Job {JobId} enqueued for document type: {DocType}", job.Id, job.DocumentType); + SaveQueueState(); + } + + /// + /// Try to dequeue the next pending job + /// + public bool TryDequeue(out PrintJob? job) + { + bool result = _queue.TryDequeue(out job); + if (result && job != null) + { + _logger.LogInformation("Job {JobId} dequeued", job.Id); + SaveQueueState(); + } + return result; + } + + /// + /// Peek at the next job without removing it + /// + public bool TryPeek(out PrintJob? job) + { + return _queue.TryPeek(out job); + } + + /// + /// Get count of jobs in queue + /// + public int Count => _queue.Count; + + /// + /// Save queue state to disk + /// + private void SaveQueueState() + { + try + { + _semaphore.Wait(); + var jobs = _queue.ToArray(); + var json = JsonSerializer.Serialize(jobs, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(_settings.QueueStateFile, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save queue state"); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Load queue state from disk + /// + private void LoadQueueState() + { + try + { + if (File.Exists(_settings.QueueStateFile)) + { + var json = File.ReadAllText(_settings.QueueStateFile); + var jobs = JsonSerializer.Deserialize(json); + + if (jobs != null) + { + foreach (var job in jobs) + { + // Only reload pending or retry-scheduled jobs + if (job.Status == PrintJobStatus.Pending || job.Status == PrintJobStatus.RetryScheduled) + { + _queue.Enqueue(job); + } + } + _logger.LogInformation("Loaded {Count} jobs from queue state", _queue.Count); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load queue state"); + } + } + + /// + /// Requeue a job (for retry) + /// + public void Requeue(PrintJob job) + { + job.RetryCount++; + job.Status = PrintJobStatus.RetryScheduled; + job.UpdatedAt = DateTime.Now; + + if (job.RetryCount >= _settings.MaxRetryAttempts) + { + _logger.LogWarning("Job {JobId} exceeded max retries, moving to error folder", job.Id); + MoveToErrorFolder(job); + } + else + { + _logger.LogInformation("Requeueing job {JobId}, attempt {Retry}", job.Id, job.RetryCount); + _queue.Enqueue(job); + SaveQueueState(); + } + } + + /// + /// Move a failed job to error folder + /// + private void MoveToErrorFolder(PrintJob job) + { + try + { + var errorFileName = Path.Combine(_settings.ErrorPath, + $"{Path.GetFileNameWithoutExtension(job.OriginalFilename)}_{job.Id}.txt"); + + if (File.Exists(job.QueuedFilePath)) + { + File.Move(job.QueuedFilePath, errorFileName); + } + + // Save error details + var errorInfo = Path.Combine(_settings.ErrorPath, + $"{Path.GetFileNameWithoutExtension(job.OriginalFilename)}_{job.Id}_error.json"); + var json = JsonSerializer.Serialize(job, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(errorInfo, json); + + job.Status = PrintJobStatus.Failed; + SaveQueueState(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to move job {JobId} to error folder", job.Id); + } + } +} diff --git a/PrintService/Services/PrinterService.cs b/PrintService/Services/PrinterService.cs new file mode 100644 index 0000000..291dc64 --- /dev/null +++ b/PrintService/Services/PrinterService.cs @@ -0,0 +1,176 @@ +using Microsoft.Extensions.Options; +using PrintService.Models; +using System.Drawing; +using System.Drawing.Printing; + +namespace PrintService.Services; + +/// +/// Handles printing to Windows print queue with tray control +/// +public class PrinterService +{ + private readonly AppSettings _settings; + private readonly ILogger _logger; + + public PrinterService(IOptions settings, ILogger logger) + { + _settings = settings.Value; + _logger = logger; + } + + /// + /// Print document to specified printer with tray sequence + /// + public void Print(string content, DocumentConfig config, string originalFileName) + { + if (config.TraySequence.Length == 0) + { + throw new InvalidOperationException("No tray sequence defined for document type"); + } + + var lines = content.Split(new[] { '\r', '\n' }, StringSplitOptions.None); + var linesPerPage = CalculateLinesPerPage(config); + var currentPageIndex = 0; + + var printDoc = new PrintDocument + { + PrinterSettings = { PrinterName = config.PrinterName } + }; + + // Handle PDF output if using Microsoft Print to PDF + if (config.PrinterName.Equals("Microsoft Print to PDF", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(config.OutputPath)) + { + Directory.CreateDirectory(config.OutputPath); + var pdfFileName = Path.GetFileNameWithoutExtension(originalFileName) + ".pdf"; + var pdfPath = Path.Combine(config.OutputPath, pdfFileName); + + printDoc.PrinterSettings.PrintToFile = true; + printDoc.PrinterSettings.PrintFileName = pdfPath; + + _logger.LogInformation("PDF will be saved to: {Path}", pdfPath); + } + + // Verify printer exists + if (!PrinterSettings.InstalledPrinters.Cast().Contains(config.PrinterName)) + { + throw new InvalidOperationException($"Printer '{config.PrinterName}' not found"); + } + + printDoc.PrintPage += (sender, e) => + { + if (e.Graphics == null || e.PageSettings == null) + return; + + // Select tray for current page + var trayIndex = config.TraySequence[currentPageIndex % config.TraySequence.Length]; + + try + { + // Map logical tray number to PaperSource + var paperSource = GetPaperSource(printDoc.PrinterSettings, trayIndex); + if (paperSource != null) + { + e.PageSettings.PaperSource = paperSource; + if (_settings.VerboseLogging) + { + _logger.LogDebug("Page {Page}: Using tray {Tray} ({Source})", + currentPageIndex + 1, trayIndex, paperSource.SourceName); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to set tray {Tray}, using default", trayIndex); + } + + // Render page content + RenderPage(e.Graphics, lines, currentPageIndex * linesPerPage, linesPerPage, config); + + currentPageIndex++; + e.HasMorePages = (currentPageIndex < config.TraySequence.Length); + }; + + _logger.LogInformation("Printing to {Printer} with {Pages} pages", + config.PrinterName, config.TraySequence.Length); + + printDoc.Print(); + } + + /// + /// Render page content using Graphics API + /// + private void RenderPage(Graphics graphics, string[] lines, int startLine, int linesPerPage, DocumentConfig config) + { + var font = new Font(config.FontName, config.FontSize); + var brush = Brushes.Black; + var lineHeight = font.GetHeight(graphics); + + var x = (float)config.HorizontalOffset; + var y = (float)config.VerticalOffset; + + var endLine = Math.Min(startLine + linesPerPage, lines.Length); + + for (int i = startLine; i < endLine; i++) + { + if (i < lines.Length) + { + graphics.DrawString(lines[i], font, brush, x, y); + y += lineHeight; + } + } + } + + /// + /// Calculate lines per page based on font and page size + /// + private int CalculateLinesPerPage(DocumentConfig config) + { + // Estimate: standard letter size is 11 inches, at 10pt font ~66 lines + // This is simplified; in production you'd calculate based on actual page dimensions + var estimatedLineHeight = config.FontSize * 1.2f; // points + var pageHeightInPoints = 11 * 72; // 11 inches * 72 points per inch + return (int)Math.Floor(pageHeightInPoints / estimatedLineHeight); + } + + /// + /// Get PaperSource by logical tray number + /// + private PaperSource? GetPaperSource(PrinterSettings printerSettings, int trayNumber) + { + // Try to find by RawKind (tray number) + foreach (PaperSource source in printerSettings.PaperSources) + { + // Some printers use RawKind directly as tray number + if (source.RawKind == trayNumber || + source.RawKind == (trayNumber + 256)) // Some drivers offset by 256 + { + return source; + } + } + + // Fallback: use by index if available + if (trayNumber > 0 && trayNumber <= printerSettings.PaperSources.Count) + { + return printerSettings.PaperSources[trayNumber - 1]; + } + + _logger.LogWarning("Could not map tray {Tray} to PaperSource", trayNumber); + return null; + } + + /// + /// List available paper sources for a printer (for debugging/configuration) + /// + public void ListPaperSources(string printerName) + { + var printDoc = new PrintDocument { PrinterSettings = { PrinterName = printerName } }; + + _logger.LogInformation("Available paper sources for {Printer}:", printerName); + foreach (PaperSource source in printDoc.PrinterSettings.PaperSources) + { + _logger.LogInformation(" - {Name} (RawKind: {Kind})", source.SourceName, source.RawKind); + } + } +} diff --git a/PrintService/Worker.cs b/PrintService/Worker.cs new file mode 100644 index 0000000..fa4fe2a --- /dev/null +++ b/PrintService/Worker.cs @@ -0,0 +1,157 @@ +using PrintService.Models; +using PrintService.Services; + +namespace PrintService; + +/// +/// Main worker service that coordinates all components +/// +public class Worker : BackgroundService +{ + private readonly ILogger _logger; + private readonly PrintQueueService _queueService; + private readonly FileMonitorService _fileMonitorService; + private readonly PrinterService _printerService; + private readonly DocumentProcessor _documentProcessor; + private readonly IpcService _ipcService; + + public Worker( + ILogger logger, + PrintQueueService queueService, + FileMonitorService fileMonitorService, + PrinterService printerService, + DocumentProcessor documentProcessor, + IpcService ipcService) + { + _logger = logger; + _queueService = queueService; + _fileMonitorService = fileMonitorService; + _printerService = printerService; + _documentProcessor = documentProcessor; + _ipcService = ipcService; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("LAAPC Print Service starting at: {time}", DateTimeOffset.Now); + + try + { + // Start IPC server for CLI communication + _ipcService.Start(); + + // Start file monitoring (optional - mainly using IPC for job creation) + // _fileMonitorService.Start(); + + _logger.LogInformation("Print service started successfully"); + _logger.LogInformation("Queue has {Count} pending jobs", _queueService.Count); + + // Main processing loop + while (!stoppingToken.IsCancellationRequested) + { + try + { + if (_queueService.TryDequeue(out var job) && job != null) + { + await ProcessJob(job, stoppingToken); + } + else + { + // No jobs in queue, wait a bit + await Task.Delay(500, stoppingToken); + } + } + catch (OperationCanceledException) + { + // Expected during shutdown, exit gracefully + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in processing loop"); + try + { + await Task.Delay(1000, stoppingToken); + } + catch (OperationCanceledException) + { + // Shutdown requested during error delay + break; + } + } + } + } + catch (OperationCanceledException) + { + // Expected during shutdown + _logger.LogInformation("Service shutdown requested"); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Fatal error in worker service"); + throw; + } + finally + { + _logger.LogInformation("Print service stopping"); + _ipcService.Stop(); + _fileMonitorService.Stop(); + } + } + + /// + /// Process a single print job + /// + private async Task ProcessJob(PrintJob job, CancellationToken cancellationToken) + { + _logger.LogInformation("Processing job {JobId} (Type: {DocType}, Order: {OrderNum})", + job.Id, job.DocumentType, job.OrderNumber ?? "N/A"); + + job.Status = PrintJobStatus.Processing; + job.UpdatedAt = DateTime.Now; + + try + { + // Get document configuration + var config = _documentProcessor.GetDocumentConfig(job.DocumentType); + if (config == null) + { + throw new InvalidOperationException($"No configuration found for document type: {job.DocumentType}"); + } + + // Read file content + if (!File.Exists(job.QueuedFilePath)) + { + throw new FileNotFoundException($"Queued file not found: {job.QueuedFilePath}"); + } + + var content = _documentProcessor.ReadFile(job.QueuedFilePath); + + // Process/transform content + var processedContent = _documentProcessor.ProcessDocument(content, config); + + // Print to Windows queue + await Task.Run(() => _printerService.Print(processedContent, config, job.OriginalFilename), cancellationToken); + + // Post-process (archive or delete) + _documentProcessor.PostProcess(job, config); + + // Mark as completed + job.Status = PrintJobStatus.Completed; + job.UpdatedAt = DateTime.Now; + + _logger.LogInformation("Job {JobId} completed successfully", job.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process job {JobId}", job.Id); + + job.LastError = ex.Message; + job.UpdatedAt = DateTime.Now; + + // Requeue for retry + _queueService.Requeue(job); + } + } +} + diff --git a/PrintService/appsettings.Development.json b/PrintService/appsettings.Development.json new file mode 100644 index 0000000..6901764 --- /dev/null +++ b/PrintService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/PrintService/appsettings.json b/PrintService/appsettings.json new file mode 100644 index 0000000..c9b3392 --- /dev/null +++ b/PrintService/appsettings.json @@ -0,0 +1,76 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AppSettings": { + "CapturesPath": "C:\\Users\\Work\\Desktop\\LAAPC\\Captures", + "QueuePath": "C:\\Users\\Work\\Desktop\\LAAPC\\Queue", + "ErrorPath": "C:\\Users\\Work\\Desktop\\LAAPC\\Errors", + "ArchivePath": "C:\\Users\\Work\\Desktop\\LAAPC\\Archive", + "MaxRetryAttempts": 3, + "DebounceDelayMs": 200, + "PipeName": "PrintServicePipe", + "QueueStateFile": "C:\\Users\\Work\\Desktop\\LAAPC\\queue_state.json", + "VerboseLogging": true, + "DocumentTypes": [ + { + "Name": "invoice", + "PrinterName": "Microsoft Print to PDF", + "TraySequence": [ 3, 4, 1, 2 ], + "FontName": "Courier New", + "FontSize": 10.0, + "VerticalOffset": 0, + "HorizontalOffset": 0, + "ArchiveAfterPrint": true, + "ArchivePath": "C:\\Users\\Work\\Desktop\\LAAPC\\Archive\\Invoices", + "Transformations": [ + { + "Pattern": "W1DUPLICATE INVW0", + "Replacement": "", + "Description": "Remove duplicate invoice marker" + } + ] + }, + { + "Name": "order", + "PrinterName": "Brother HL-2270DW series Printer", + "TraySequence": [ 4, 2, 1 ], + "FontName": "Courier New", + "FontSize": 10.0, + "VerticalOffset": 0, + "HorizontalOffset": 0, + "ArchiveAfterPrint": true, + "ArchivePath": "C:\\Users\\Work\\Desktop\\LAAPC\\Archive\\Orders", + "Transformations": [] + }, + { + "Name": "delivery", + "PrinterName": "Brother HL-2270DW series Printer", + "TraySequence": [ 3, 1 ], + "FontName": "Courier New", + "FontSize": 10.0, + "VerticalOffset": 0, + "HorizontalOffset": 0, + "ArchiveAfterPrint": true, + "ArchivePath": "C:\\Users\\Work\\Desktop\\LAAPC\\Archive\\Delivery", + "Transformations": [] + }, + { + "Name": "test", + "PrinterName": "Microsoft Print to PDF", + "TraySequence": [ 1 ], + "FontName": "Courier New", + "FontSize": 10.0, + "VerticalOffset": 0, + "HorizontalOffset": 0, + "ArchiveAfterPrint": false, + "OutputPath": "C:\\Users\\Work\\Desktop\\LAAPC\\Output", + "SkipPostProcessing": true, + "Transformations": [] + } + ] + } +} diff --git a/PrintServiceCLI/PrintServiceCLI.csproj b/PrintServiceCLI/PrintServiceCLI.csproj new file mode 100644 index 0000000..1acef02 --- /dev/null +++ b/PrintServiceCLI/PrintServiceCLI.csproj @@ -0,0 +1,20 @@ + + + + Exe + net7.0 + enable + enable + + + + + x86 + + + + + + + + diff --git a/PrintServiceCLI/Program.cs b/PrintServiceCLI/Program.cs new file mode 100644 index 0000000..63ca12c --- /dev/null +++ b/PrintServiceCLI/Program.cs @@ -0,0 +1,232 @@ +using System.IO.Pipes; +using System.Text; +using System.Text.Json; + +namespace PrintServiceCLI; + +class Program +{ + private const string PipeName = "PrintServicePipe"; + private const int TimeoutMs = 5000; + + static async Task Main(string[] args) + { + try + { + // Handle special --install-service flag (called by elevated process) + if (args.Contains("--install-service")) + { + return ServiceManager.EnsureServiceRunning(silent: false) ? 0 : 1; + } + + // Check for skip-service-check flag + bool skipServiceCheck = args.Contains("--skip-service-check"); + + // Parse arguments + var command = ParseArguments(args, out bool showHelp); + if (showHelp || command == null) + { + ShowUsage(); + return command == null ? 1 : 0; + } + + // Ensure service is running (unless testing) + if (!skipServiceCheck) + { + if (!ServiceManager.EnsureServiceRunning()) + { + Console.Error.WriteLine("ERROR: Service is not available. Cannot process print request."); + return 1; + } + } + + var result = await SendCommandToService(command); + + if (result.Success) + { + Console.WriteLine($"SUCCESS: {result.Message}"); + return 0; + } + else + { + Console.Error.WriteLine($"ERROR: {result.Message}"); + return 1; + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"FATAL ERROR: {ex.Message}"); + return 1; + } + } + + /// + /// Parse command line arguments + /// + static PrintCommand? ParseArguments(string[] args, out bool showHelp) + { + showHelp = false; + string? filePath = null; + string? documentType = null; + string? orderNumber = null; + + for (int i = 0; i < args.Length; i++) + { + switch (args[i].ToLower()) + { + case "-f": + case "--file": + if (i + 1 < args.Length) + { + filePath = args[++i]; + } + break; + + case "-t": + case "--type": + if (i + 1 < args.Length) + { + documentType = args[++i]; + } + break; + + case "-o": + case "--order": + if (i + 1 < args.Length) + { + orderNumber = args[++i]; + } + break; + + case "-h": + case "--help": + case "?": + showHelp = true; + return null; + + case "--skip-service-check": + case "--install-service": + // These flags are handled in Main, skip here + break; + } + } + + // Validate required arguments + if (string.IsNullOrEmpty(filePath)) + { + Console.Error.WriteLine("Error: File path is required (-f)"); + return null; + } + + if (string.IsNullOrEmpty(documentType)) + { + Console.Error.WriteLine("Error: Document type is required (-t)"); + return null; + } + + if (!File.Exists(filePath)) + { + Console.Error.WriteLine($"Error: File not found: {filePath}"); + return null; + } + + return new PrintCommand + { + FilePath = Path.GetFullPath(filePath), + DocumentType = documentType, + OrderNumber = orderNumber + }; + } + + /// + /// Send command to service via named pipe + /// + static async Task SendCommandToService(PrintCommand command) + { + using var pipeClient = new NamedPipeClientStream( + ".", + PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous); + + try + { + await pipeClient.ConnectAsync(TimeoutMs); + } + catch (TimeoutException) + { + return new PrintResponse + { + Success = false, + Message = "Service not responding. Is the LAAPC Print Service running?" + }; + } + catch (IOException ex) + { + return new PrintResponse + { + Success = false, + Message = $"Cannot connect to service: {ex.Message}" + }; + } + + // Send command + var json = JsonSerializer.Serialize(command); + var bytes = Encoding.UTF8.GetBytes(json); + await pipeClient.WriteAsync(bytes, 0, bytes.Length); + await pipeClient.FlushAsync(); + + // Read response + var buffer = new byte[4096]; + var bytesRead = await pipeClient.ReadAsync(buffer, 0, buffer.Length); + var responseJson = Encoding.UTF8.GetString(buffer, 0, bytesRead); + + var response = JsonSerializer.Deserialize(responseJson); + return response ?? new PrintResponse { Success = false, Message = "Invalid response from service" }; + } + + /// + /// Show usage information + /// + static void ShowUsage() + { + Console.WriteLine("LAAPC Print Service CLI"); + Console.WriteLine(); + Console.WriteLine("Usage: PrintServiceCLI -f -t [-o ]"); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" -f, --file Path to the capture file to print (required)"); + Console.WriteLine(" -t, --type Document type (required)"); + Console.WriteLine(" Examples: invoice, order, delivery"); + Console.WriteLine(" -o, --order Optional order number"); + Console.WriteLine(" -h, --help Show this help message"); + Console.WriteLine(" --skip-service-check Skip service availability check (for testing)"); + Console.WriteLine(); + Console.WriteLine("Examples:"); + Console.WriteLine(" PrintServiceCLI -f \"C:\\Captures\\file.txt\" -t invoice"); + Console.WriteLine(" PrintServiceCLI -f \"C:\\Captures\\file.txt\" -t order -o 12345"); + Console.WriteLine(); + Console.WriteLine("For Development/Testing:"); + Console.WriteLine(" PrintServiceCLI -f \"file.txt\" -t test --skip-service-check"); + } +} + +/// +/// Command to send to service +/// +class PrintCommand +{ + public string FilePath { get; set; } = string.Empty; + public string DocumentType { get; set; } = string.Empty; + public string? OrderNumber { get; set; } +} + +/// +/// Response from service +/// +class PrintResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; +} + diff --git a/PrintServiceCLI/ServiceManager.cs b/PrintServiceCLI/ServiceManager.cs new file mode 100644 index 0000000..c68710a --- /dev/null +++ b/PrintServiceCLI/ServiceManager.cs @@ -0,0 +1,290 @@ +using System.Diagnostics; +using System.ServiceProcess; +using System.Security.Principal; + +namespace PrintServiceCLI; + +/// +/// Manages Windows Service installation and verification +/// +public static class ServiceManager +{ + private const string ServiceName = "LAAPC Print Service"; + private const string ServiceInstallPath = @"C:\ProgramData\LAAPC"; + private const string ServiceExeName = "PrintService.exe"; + + private static bool? _serviceAvailable = null; // Cache result + + /// + /// Check if service is installed and running, with auto-install option + /// + public static bool EnsureServiceRunning(bool silent = false) + { + // Return cached result if already checked + if (_serviceAvailable.HasValue) + return _serviceAvailable.Value; + + try + { + // Check if service exists and is running + using var service = new ServiceController(ServiceName); + service.Refresh(); + + if (service.Status == ServiceControllerStatus.Running) + { + _serviceAvailable = true; + return true; + } + + // Service exists but not running - try to start it + if (service.Status == ServiceControllerStatus.Stopped) + { + if (!silent) + Console.WriteLine("Service is stopped. Starting..."); + + service.Start(); + service.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(10)); + _serviceAvailable = true; + return true; + } + + _serviceAvailable = false; + return false; + } + catch (InvalidOperationException) + { + // Service doesn't exist - offer to install + if (silent) + { + _serviceAvailable = false; + return false; + } + + return PromptAndInstall(); + } + catch (Exception ex) + { + if (!silent) + Console.Error.WriteLine($"Error checking service: {ex.Message}"); + + _serviceAvailable = false; + return false; + } + } + + /// + /// Prompt user and install service if they agree + /// + private static bool PromptAndInstall() + { + Console.WriteLine(); + Console.WriteLine("===================================================="); + Console.WriteLine("LAAPC Print Service is not installed on this PC."); + Console.WriteLine("===================================================="); + Console.WriteLine(); + Console.WriteLine("The service must be installed locally to process print jobs."); + Console.WriteLine("This is a one-time setup that requires administrator access."); + Console.WriteLine(); + Console.Write("Install the service now? [Y/N]: "); + + var response = Console.ReadLine()?.Trim().ToUpper(); + + if (response != "Y" && response != "YES") + { + Console.WriteLine("Installation cancelled. Service is required for printing."); + _serviceAvailable = false; + return false; + } + + return InstallService(); + } + + /// + /// Install and start the service with UAC elevation + /// + private static bool InstallService() + { + try + { + Console.WriteLine(); + Console.WriteLine("Installing service..."); + + // Check if running as admin + if (!IsAdministrator()) + { + Console.WriteLine("Requesting administrator privileges..."); + return InstallWithElevation(); + } + + // Copy service files to local machine + var cliPath = AppContext.BaseDirectory; + var serviceSourcePath = Path.Combine(Path.GetDirectoryName(cliPath)!, ServiceExeName); + + if (!File.Exists(serviceSourcePath)) + { + // Try relative path + serviceSourcePath = Path.Combine(cliPath, "..", "PrintService", ServiceExeName); + serviceSourcePath = Path.GetFullPath(serviceSourcePath); + } + + if (!File.Exists(serviceSourcePath)) + { + Console.Error.WriteLine($"ERROR: Could not find {ServiceExeName}"); + Console.Error.WriteLine($"Expected location: {serviceSourcePath}"); + _serviceAvailable = false; + return false; + } + + // Create installation directory + Directory.CreateDirectory(ServiceInstallPath); + + // Copy service executable and dependencies + var serviceDestPath = Path.Combine(ServiceInstallPath, ServiceExeName); + CopyServiceFiles(Path.GetDirectoryName(serviceSourcePath)!, ServiceInstallPath); + + Console.WriteLine($"Files copied to: {ServiceInstallPath}"); + + // Install Windows Service using sc.exe + var scInstall = Process.Start(new ProcessStartInfo + { + FileName = "sc.exe", + Arguments = $"create \"{ServiceName}\" binPath=\"{serviceDestPath}\" start=auto", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }); + + scInstall?.WaitForExit(); + + if (scInstall?.ExitCode != 0) + { + Console.Error.WriteLine("ERROR: Failed to install service"); + _serviceAvailable = false; + return false; + } + + Console.WriteLine("Service installed successfully."); + + // Start the service + Console.WriteLine("Starting service..."); + var scStart = Process.Start(new ProcessStartInfo + { + FileName = "sc.exe", + Arguments = $"start \"{ServiceName}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }); + + scStart?.WaitForExit(); + + if (scStart?.ExitCode != 0) + { + Console.Error.WriteLine("WARNING: Service installed but failed to start"); + Console.Error.WriteLine("Try running: sc start \"LAAPC Print Service\""); + _serviceAvailable = false; + return false; + } + + // Wait for service to be ready + Thread.Sleep(2000); + + Console.WriteLine("Service started successfully!"); + Console.WriteLine(); + _serviceAvailable = true; + return true; + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: Installation failed: {ex.Message}"); + _serviceAvailable = false; + return false; + } + } + + /// + /// Restart process with admin elevation + /// + private static bool InstallWithElevation() + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = Process.GetCurrentProcess().MainModule?.FileName ?? "PrintServiceCLI.exe", + Arguments = "--install-service", + UseShellExecute = true, + Verb = "runas" // Trigger UAC prompt + }; + + var process = Process.Start(startInfo); + process?.WaitForExit(); + + if (process?.ExitCode == 0) + { + Console.WriteLine("Service installed successfully."); + _serviceAvailable = true; + return true; + } + + Console.Error.WriteLine("Installation was cancelled or failed."); + _serviceAvailable = false; + return false; + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: Could not elevate privileges: {ex.Message}"); + _serviceAvailable = false; + return false; + } + } + + /// + /// Copy service files from source to destination + /// + private static void CopyServiceFiles(string sourceDir, string destDir) + { + // Copy main executable + var sourceExe = Path.Combine(sourceDir, ServiceExeName); + var destExe = Path.Combine(destDir, ServiceExeName); + File.Copy(sourceExe, destExe, overwrite: true); + + // Copy all DLLs and config files + foreach (var file in Directory.GetFiles(sourceDir)) + { + var fileName = Path.GetFileName(file); + var ext = Path.GetExtension(file).ToLower(); + + if (ext == ".dll" || ext == ".json" || ext == ".config") + { + var destFile = Path.Combine(destDir, fileName); + File.Copy(file, destFile, overwrite: true); + } + } + + // Create necessary folders + var capturesPath = Path.Combine(destDir, "Captures"); + var queuePath = Path.Combine(destDir, "Queue"); + var archivePath = Path.Combine(destDir, "Archive"); + var errorPath = Path.Combine(destDir, "Error"); + var outputPath = Path.Combine(destDir, "Output"); + + Directory.CreateDirectory(capturesPath); + Directory.CreateDirectory(queuePath); + Directory.CreateDirectory(archivePath); + Directory.CreateDirectory(errorPath); + Directory.CreateDirectory(outputPath); + } + + /// + /// Check if running with administrator privileges + /// + private static bool IsAdministrator() + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6650717 --- /dev/null +++ b/README.md @@ -0,0 +1,283 @@ +# LAAPC Print Service + +A Windows service for managing print jobs from Harbor/Clipper legacy applications to modern laser printers with multi-tray support. + +## Overview + +This service solves the problem of migrating from dot-matrix printers with carbon copy paper to laser printers with colored paper trays. It: + +- ✅ **Prevents file overwrites** by immediately moving capture files to a queue with GUID-based names +- ✅ **Maintains print order** with a persistent queue system +- ✅ **Controls printer trays** via Windows print queue API for "carbon copy" simulation +- ✅ **Transforms content** with configurable rules per document type +- ✅ **Archives prints** for record-keeping + +## Architecture + +- **PrintService**: Windows service that runs continuously, monitors for print jobs, and processes them +- **PrintServiceCLI**: Command-line tool for Harbor/Clipper to submit print jobs via IPC (named pipes) + +## Quick Start + +### 1. Configure + +Edit `PrintService/appsettings.json`: + +```json +{ + "AppSettings": { + "CapturesPath": "C:\\Users\\Work\\Desktop\\LAAPC\\Captures", + "DocumentTypes": [ + { + "Name": "invoice", + "PrinterName": "Your_Actual_Printer_Name", + "TraySequence": [ 3, 4, 1, 2 ] + } + ] + } +} +``` + +**Important**: Replace `Your_Actual_Printer_Name` with actual Windows printer name(s). + +### 2. Build + +```powershell +# Build for 32-bit (supports both 32-bit and 64-bit Windows) +dotnet build -c Release -r win-x86 + +# Or publish self-contained (includes .NET runtime) +dotnet publish -c Release -r win-x86 --self-contained true -o publish/x86 +``` + +### 3. Install Service + +```powershell +# Run as Administrator +sc.exe create "LAAPC Print Service" binPath="C:\Path\To\PrintService.exe" start=auto +sc.exe start "LAAPC Print Service" +``` + +### 4. Test CLI + +```powershell +PrintServiceCLI.exe -f "C:\Captures\test.txt" -t invoice -o 12345 +``` + +## Usage + +### From Harbor/Clipper + +Replace your current Rust CLI calls with: + +``` +PrintServiceCLI.exe -f -t [-o ] +``` + +**Examples**: +``` +PrintServiceCLI.exe -f "C:\Captures\inv001.txt" -t invoice +PrintServiceCLI.exe -f "C:\Captures\ord002.txt" -t order -o 600005 +PrintServiceCLI.exe -f "C:\Captures\del003.txt" -t delivery +``` + +### CLI Options + +- `-f, --file `: Path to capture file (required) +- `-t, --type `: Document type: invoice, order, delivery, etc. (required) +- `-o, --order `: Optional order number +- `-h, --help`: Show help + +## Configuration + +### Document Types + +Each document type in `appsettings.json` specifies: + +```json +{ + "Name": "invoice", // Document type identifier + "PrinterName": "HP LaserJet 500", // Windows printer name + "TraySequence": [ 3, 4, 1, 2 ], // Tray order (simulates carbon copy) + "FontName": "Courier New", // Font for rendering + "FontSize": 10.0, // Font size in points + "VerticalOffset": 0, // Adjust vertical positioning (pixels) + "HorizontalOffset": 0, // Adjust horizontal positioning (pixels) + "ArchiveAfterPrint": true, // Archive or delete after printing + "ArchivePath": "C:\\Archive\\Invoices", // Where to archive + "Transformations": [ // Text transformation rules + { + "Pattern": "W1DUPLICATE INVW0", // Regex pattern to find + "Replacement": "", // Replace with (empty = remove) + "Description": "Remove duplicate marker" + } + ] +} +``` + +### Tray Mapping + +The `TraySequence` specifies which physical printer trays to use for each page/copy: + +- **Example**: `[3, 4, 1, 2]` prints: + - Page 1 → Tray 3 (e.g., Pink paper) + - Page 2 → Tray 4 (e.g., Orange paper) + - Page 3 → Tray 1 (e.g., Blue paper) + - Page 4 → Tray 2 (e.g., Green paper) + +**Note**: Tray numbers may need adjustment per printer model. Use the included tray discovery tool (see Troubleshooting). + +## Project Structure + +``` +PrintService/ +├── Models/ +│ ├── PrintJob.cs - Print job data model +│ ├── DocumentConfig.cs - Document type configuration +│ └── AppSettings.cs - Application settings +├── Services/ +│ ├── PrintQueueService.cs - Job queue management +│ ├── FileMonitorService.cs - File system monitoring +│ ├── PrinterService.cs - Windows printer integration +│ ├── DocumentProcessor.cs - Content transformation +│ └── IpcService.cs - Named pipe IPC server +├── Worker.cs - Main service coordinator +├── Program.cs - Service host configuration +└── appsettings.json - Configuration file + +PrintServiceCLI/ +└── Program.cs - Command-line interface +``` + +## Troubleshooting + +### Service won't start + +1. Check Windows Event Viewer → Application logs +2. Verify paths in `appsettings.json` exist +3. Run with elevated privileges + +### Printer not found + +List installed printers: +```powershell +Get-Printer | Select-Object Name +``` + +Update `PrinterName` in config to match exactly. + +### Wrong trays selected + +Tray mapping varies by printer model. To discover available trays, temporarily add logging to `PrinterService.ListPaperSources()` and check service logs. + +### Files being overwritten + +Ensure Harbor/Clipper is calling the CLI (not writing directly to Captures folder). The service immediately moves files to prevent timestamp collisions. + +### Print order issues + +Check `queue_state.json` - jobs are processed FIFO. If order is wrong, check file timestamps. + +## Monitoring + +### Service Status + +```powershell +Get-Service "LAAPC Print Service" +sc.exe query "LAAPC Print Service" +``` + +### Logs + +Check Windows Event Viewer or configure file logging in `appsettings.json`. + +### Queue State + +Inspect `queue_state.json` (location specified in `appsettings.json`) to see pending jobs. + +### Folders + +- `Queue/` - Files being processed (GUID-named) +- `Archive/` - Completed jobs (if archiving enabled) +- `Errors/` - Failed jobs after max retries + +## Uninstall + +```powershell +# Run as Administrator +sc.exe stop "LAAPC Print Service" +sc.exe delete "LAAPC Print Service" +``` + +## Development + +### Build for debugging + +```powershell +dotnet build -c Debug +``` + +### Run service locally (not as Windows Service) + +```powershell +dotnet run --project PrintService/PrintService.csproj +``` + +### Watch mode (auto-rebuild on changes) + +```powershell +dotnet watch run --project PrintService/PrintService.csproj +``` + +### VS Code Tasks + +Use Command Palette (`Ctrl+Shift+P`) → "Tasks: Run Task": +- `build-all-x86` - Build release for 32-bit +- `publish-all-x86` - Create deployment package +- `run-service` - Run service locally for testing + +## Technical Details + +### IPC Protocol + +The CLI communicates with the service via Windows Named Pipes (`\\.\pipe\PrintServicePipe`): + +**Request**: +```json +{ + "FilePath": "C:\\Captures\\file.txt", + "DocumentType": "invoice", + "OrderNumber": "12345" +} +``` + +**Response**: +```json +{ + "Success": true, + "Message": "Job {guid} queued successfully" +} +``` + +### Print Queue Integration + +Uses `System.Drawing.Printing.PrintDocument` with per-page `PageSettings.PaperSource` control: + +- Content rendered with `Graphics.DrawString()` for pixel-perfect positioning +- Jobs go through Windows print queue (visible in Windows printer UI) +- Survives service restarts via persistent queue state + +### File Safety + +1. CLI sends command with file path +2. Service immediately moves file to Queue folder with GUID name +3. Original filename preserved in job metadata +4. Prevents timestamp collision overwrites + +## License + +[Your License Here] + +## Support + +For issues or questions, contact [Your Contact Info] diff --git a/queue_state.json b/queue_state.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/queue_state.json @@ -0,0 +1 @@ +[] \ No newline at end of file