Initial commit: LAAPC Print Service - Windows service with multi-tray printing, IPC queue management, PDF testing support, and self-installing CLI
This commit is contained in:
+82
@@ -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
|
||||||
Vendored
+392
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 <filepath> -t <doctype> -o <ordernumber>` 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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
namespace PrintService.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Application settings loaded from appsettings.json
|
||||||
|
/// </summary>
|
||||||
|
public class AppSettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Path to monitor for incoming capture files
|
||||||
|
/// </summary>
|
||||||
|
public string CapturesPath { get; set; } = "Captures";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path for queued files (after moving from Captures)
|
||||||
|
/// </summary>
|
||||||
|
public string QueuePath { get; set; } = "Queue";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path for failed jobs
|
||||||
|
/// </summary>
|
||||||
|
public string ErrorPath { get; set; } = "Errors";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default archive path
|
||||||
|
/// </summary>
|
||||||
|
public string ArchivePath { get; set; } = "Archive";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum retry attempts before moving to error folder
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRetryAttempts { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Debounce delay in milliseconds for file system watcher
|
||||||
|
/// </summary>
|
||||||
|
public int DebounceDelayMs { get; set; } = 200;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Named pipe name for IPC communication
|
||||||
|
/// </summary>
|
||||||
|
public string PipeName { get; set; } = "PrintServicePipe";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Document type configurations
|
||||||
|
/// </summary>
|
||||||
|
public List<DocumentConfig> DocumentTypes { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queue state file path
|
||||||
|
/// </summary>
|
||||||
|
public string QueueStateFile { get; set; } = "queue_state.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable detailed logging
|
||||||
|
/// </summary>
|
||||||
|
public bool VerboseLogging { get; set; } = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
namespace PrintService.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for document types
|
||||||
|
/// </summary>
|
||||||
|
public class DocumentConfig
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Name/identifier of the document type
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Printer name to send to
|
||||||
|
/// </summary>
|
||||||
|
public string PrinterName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sequence of tray numbers to use (1-based, e.g., [3, 4, 1, 2])
|
||||||
|
/// These will be mapped to physical PaperSource indexes
|
||||||
|
/// </summary>
|
||||||
|
public int[] TraySequence { get; set; } = Array.Empty<int>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Font name to use for rendering
|
||||||
|
/// </summary>
|
||||||
|
public string FontName { get; set; } = "Courier New";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Font size in points
|
||||||
|
/// </summary>
|
||||||
|
public float FontSize { get; set; } = 10f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vertical offset adjustment (in pixels)
|
||||||
|
/// </summary>
|
||||||
|
public int VerticalOffset { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Horizontal offset adjustment (in pixels)
|
||||||
|
/// </summary>
|
||||||
|
public int HorizontalOffset { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Text transformation rules (regex patterns to remove/replace)
|
||||||
|
/// </summary>
|
||||||
|
public List<TextTransform> Transformations { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to archive files after printing (true) or delete them (false)
|
||||||
|
/// </summary>
|
||||||
|
public bool ArchiveAfterPrint { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Archive folder path (if ArchiveAfterPrint is true)
|
||||||
|
/// </summary>
|
||||||
|
public string? ArchivePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Output folder for PDF files (when using "Microsoft Print to PDF")
|
||||||
|
/// If specified, PDFs will be saved to this folder automatically
|
||||||
|
/// </summary>
|
||||||
|
public string? OutputPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Skip post-processing (archive/delete) to leave files in queue for repeated testing
|
||||||
|
/// Useful for development and testing scenarios
|
||||||
|
/// </summary>
|
||||||
|
public bool SkipPostProcessing { get; set; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Text transformation rule
|
||||||
|
/// </summary>
|
||||||
|
public class TextTransform
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Regex pattern to match
|
||||||
|
/// </summary>
|
||||||
|
public string Pattern { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replacement text (empty string to remove)
|
||||||
|
/// </summary>
|
||||||
|
public string Replacement { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Description of what this transformation does
|
||||||
|
/// </summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
namespace PrintService.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a print job in the queue
|
||||||
|
/// </summary>
|
||||||
|
public class PrintJob
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier for the job
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the original capture file
|
||||||
|
/// </summary>
|
||||||
|
public string SourceFilePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the queued file (after being moved with GUID name)
|
||||||
|
/// </summary>
|
||||||
|
public string QueuedFilePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Document type (invoice, order, delivery, etc.)
|
||||||
|
/// </summary>
|
||||||
|
public string DocumentType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional order number
|
||||||
|
/// </summary>
|
||||||
|
public string? OrderNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the job was created
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the job was last updated
|
||||||
|
/// </summary>
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.Now;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current status of the job
|
||||||
|
/// </summary>
|
||||||
|
public PrintJobStatus Status { get; set; } = PrintJobStatus.Pending;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of times this job has been attempted
|
||||||
|
/// </summary>
|
||||||
|
public int RetryCount { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last error message if failed
|
||||||
|
/// </summary>
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Original filename before moving to queue
|
||||||
|
/// </summary>
|
||||||
|
public string OriginalFilename { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Status of a print job
|
||||||
|
/// </summary>
|
||||||
|
public enum PrintJobStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Processing,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
RetryScheduled
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>dotnet-PrintService-3e06b535-3a68-4806-9c5b-3a32bde82bf9</UserSecretsId>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Use x86 only for Release builds (for 32-bit target machines) -->
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||||
|
<PlatformTarget>x86</PlatformTarget>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="7.0.0" />
|
||||||
|
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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<AppSettings>(hostContext.Configuration.GetSection("AppSettings"));
|
||||||
|
|
||||||
|
// Register services
|
||||||
|
services.AddSingleton<PrintQueueService>();
|
||||||
|
services.AddSingleton<FileMonitorService>();
|
||||||
|
services.AddSingleton<PrinterService>();
|
||||||
|
services.AddSingleton<DocumentProcessor>();
|
||||||
|
services.AddSingleton<IpcService>();
|
||||||
|
|
||||||
|
// Register the main worker
|
||||||
|
services.AddHostedService<Worker>();
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Ensure directories exist
|
||||||
|
var config = host.Services.GetRequiredService<IOptions<AppSettings>>().Value;
|
||||||
|
Directory.CreateDirectory(config.QueuePath);
|
||||||
|
Directory.CreateDirectory(config.ErrorPath);
|
||||||
|
Directory.CreateDirectory(config.ArchivePath);
|
||||||
|
|
||||||
|
host.Run();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"PrintService": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes documents and renders them for printing
|
||||||
|
/// </summary>
|
||||||
|
public class DocumentProcessor
|
||||||
|
{
|
||||||
|
private readonly AppSettings _settings;
|
||||||
|
private readonly ILogger<DocumentProcessor> _logger;
|
||||||
|
|
||||||
|
public DocumentProcessor(IOptions<AppSettings> settings, ILogger<DocumentProcessor> logger)
|
||||||
|
{
|
||||||
|
_settings = settings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process and transform document content
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get document configuration by type
|
||||||
|
/// </summary>
|
||||||
|
public DocumentConfig? GetDocumentConfig(string documentType)
|
||||||
|
{
|
||||||
|
return _settings.DocumentTypes.FirstOrDefault(dt =>
|
||||||
|
dt.Name.Equals(documentType, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read file content
|
||||||
|
/// </summary>
|
||||||
|
public string ReadFile(string filePath)
|
||||||
|
{
|
||||||
|
return File.ReadAllText(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Archive or delete file after processing
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using PrintService.Models;
|
||||||
|
|
||||||
|
namespace PrintService.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Monitors the Captures folder for new files and moves them to queue
|
||||||
|
/// </summary>
|
||||||
|
public class FileMonitorService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly AppSettings _settings;
|
||||||
|
private readonly PrintQueueService _queueService;
|
||||||
|
private readonly ILogger<FileMonitorService> _logger;
|
||||||
|
private FileSystemWatcher? _watcher;
|
||||||
|
private readonly Dictionary<string, DateTime> _pendingFiles = new();
|
||||||
|
private readonly Timer _debounceTimer;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public FileMonitorService(
|
||||||
|
IOptions<AppSettings> settings,
|
||||||
|
PrintQueueService queueService,
|
||||||
|
ILogger<FileMonitorService> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Start monitoring the Captures folder
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stop monitoring
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process files after debounce delay
|
||||||
|
/// </summary>
|
||||||
|
private void ProcessPendingFiles(object? state)
|
||||||
|
{
|
||||||
|
List<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process a single file: move to queue with GUID name
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manually process a file (called from IPC when CLI provides metadata)
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Named pipe server for IPC communication with CLI
|
||||||
|
/// </summary>
|
||||||
|
public class IpcService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly AppSettings _settings;
|
||||||
|
private readonly PrintQueueService _queueService;
|
||||||
|
private readonly FileMonitorService _fileMonitorService;
|
||||||
|
private readonly ILogger<IpcService> _logger;
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private Task? _listenerTask;
|
||||||
|
|
||||||
|
public IpcService(
|
||||||
|
IOptions<AppSettings> settings,
|
||||||
|
PrintQueueService queueService,
|
||||||
|
FileMonitorService fileMonitorService,
|
||||||
|
ILogger<IpcService> logger)
|
||||||
|
{
|
||||||
|
_settings = settings.Value;
|
||||||
|
_queueService = queueService;
|
||||||
|
_fileMonitorService = fileMonitorService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Start listening for IPC commands
|
||||||
|
/// </summary>
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
_listenerTask = Task.Run(() => ListenForConnections(_cancellationTokenSource.Token));
|
||||||
|
_logger.LogInformation("IPC service started on pipe: {PipeName}", _settings.PipeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stop listening
|
||||||
|
/// </summary>
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_listenerTask?.Wait(TimeSpan.FromSeconds(5));
|
||||||
|
_logger.LogInformation("IPC service stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Listen for incoming connections
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handle a client connection
|
||||||
|
/// </summary>
|
||||||
|
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<PrintCommand>(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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process a print command
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send response back to client
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command sent from CLI to service
|
||||||
|
/// </summary>
|
||||||
|
public class PrintCommand
|
||||||
|
{
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public string DocumentType { get; set; } = string.Empty;
|
||||||
|
public string? OrderNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response from service to CLI
|
||||||
|
/// </summary>
|
||||||
|
public class PrintResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using PrintService.Models;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PrintService.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages the print job queue with persistence
|
||||||
|
/// </summary>
|
||||||
|
public class PrintQueueService
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<PrintJob> _queue = new();
|
||||||
|
private readonly AppSettings _settings;
|
||||||
|
private readonly ILogger<PrintQueueService> _logger;
|
||||||
|
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||||
|
|
||||||
|
public PrintQueueService(IOptions<AppSettings> settings, ILogger<PrintQueueService> logger)
|
||||||
|
{
|
||||||
|
_settings = settings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
LoadQueueState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueue a new print job
|
||||||
|
/// </summary>
|
||||||
|
public void Enqueue(PrintJob job)
|
||||||
|
{
|
||||||
|
_queue.Enqueue(job);
|
||||||
|
_logger.LogInformation("Job {JobId} enqueued for document type: {DocType}", job.Id, job.DocumentType);
|
||||||
|
SaveQueueState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Try to dequeue the next pending job
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Peek at the next job without removing it
|
||||||
|
/// </summary>
|
||||||
|
public bool TryPeek(out PrintJob? job)
|
||||||
|
{
|
||||||
|
return _queue.TryPeek(out job);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get count of jobs in queue
|
||||||
|
/// </summary>
|
||||||
|
public int Count => _queue.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save queue state to disk
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load queue state from disk
|
||||||
|
/// </summary>
|
||||||
|
private void LoadQueueState()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_settings.QueueStateFile))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(_settings.QueueStateFile);
|
||||||
|
var jobs = JsonSerializer.Deserialize<PrintJob[]>(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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requeue a job (for retry)
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Move a failed job to error folder
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using PrintService.Models;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Printing;
|
||||||
|
|
||||||
|
namespace PrintService.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles printing to Windows print queue with tray control
|
||||||
|
/// </summary>
|
||||||
|
public class PrinterService
|
||||||
|
{
|
||||||
|
private readonly AppSettings _settings;
|
||||||
|
private readonly ILogger<PrinterService> _logger;
|
||||||
|
|
||||||
|
public PrinterService(IOptions<AppSettings> settings, ILogger<PrinterService> logger)
|
||||||
|
{
|
||||||
|
_settings = settings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Print document to specified printer with tray sequence
|
||||||
|
/// </summary>
|
||||||
|
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<string>().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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render page content using Graphics API
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculate lines per page based on font and page size
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get PaperSource by logical tray number
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List available paper sources for a printer (for debugging/configuration)
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using PrintService.Models;
|
||||||
|
using PrintService.Services;
|
||||||
|
|
||||||
|
namespace PrintService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Main worker service that coordinates all components
|
||||||
|
/// </summary>
|
||||||
|
public class Worker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<Worker> _logger;
|
||||||
|
private readonly PrintQueueService _queueService;
|
||||||
|
private readonly FileMonitorService _fileMonitorService;
|
||||||
|
private readonly PrinterService _printerService;
|
||||||
|
private readonly DocumentProcessor _documentProcessor;
|
||||||
|
private readonly IpcService _ipcService;
|
||||||
|
|
||||||
|
public Worker(
|
||||||
|
ILogger<Worker> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process a single print job
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Use x86 only for Release builds (for 32-bit target machines) -->
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||||
|
<PlatformTarget>x86</PlatformTarget>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.IO.Pipes" Version="4.3.0" />
|
||||||
|
<PackageReference Include="System.ServiceProcess.ServiceController" Version="7.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse command line arguments
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send command to service via named pipe
|
||||||
|
/// </summary>
|
||||||
|
static async Task<PrintResponse> 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<PrintResponse>(responseJson);
|
||||||
|
return response ?? new PrintResponse { Success = false, Message = "Invalid response from service" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Show usage information
|
||||||
|
/// </summary>
|
||||||
|
static void ShowUsage()
|
||||||
|
{
|
||||||
|
Console.WriteLine("LAAPC Print Service CLI");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Usage: PrintServiceCLI -f <filepath> -t <doctype> [-o <ordernumber>]");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Options:");
|
||||||
|
Console.WriteLine(" -f, --file <path> Path to the capture file to print (required)");
|
||||||
|
Console.WriteLine(" -t, --type <type> Document type (required)");
|
||||||
|
Console.WriteLine(" Examples: invoice, order, delivery");
|
||||||
|
Console.WriteLine(" -o, --order <number> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to send to service
|
||||||
|
/// </summary>
|
||||||
|
class PrintCommand
|
||||||
|
{
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public string DocumentType { get; set; } = string.Empty;
|
||||||
|
public string? OrderNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response from service
|
||||||
|
/// </summary>
|
||||||
|
class PrintResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.ServiceProcess;
|
||||||
|
using System.Security.Principal;
|
||||||
|
|
||||||
|
namespace PrintServiceCLI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages Windows Service installation and verification
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if service is installed and running, with auto-install option
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prompt user and install service if they agree
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Install and start the service with UAC elevation
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restart process with admin elevation
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copy service files from source to destination
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if running with administrator privileges
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsAdministrator()
|
||||||
|
{
|
||||||
|
using var identity = WindowsIdentity.GetCurrent();
|
||||||
|
var principal = new WindowsPrincipal(identity);
|
||||||
|
return principal.IsInRole(WindowsBuiltInRole.Administrator);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <filepath> -t <doctype> [-o <ordernumber>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**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>`: Path to capture file (required)
|
||||||
|
- `-t, --type <type>`: Document type: invoice, order, delivery, etc. (required)
|
||||||
|
- `-o, --order <num>`: 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]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
Reference in New Issue
Block a user