Add quote bridge and quote handoff flow
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
# Quote Bridge Automation Design
|
||||
|
||||
## Goal
|
||||
|
||||
Extend `quote-bridge` so it can do more than move quote files. The target design is a helper app that:
|
||||
|
||||
1. watches for quote JSON files,
|
||||
2. selects the correct location profile,
|
||||
3. launches the DOS app through DOSBox,
|
||||
4. simulates user input,
|
||||
5. enters quote data into the DOS application,
|
||||
6. records success or failure.
|
||||
|
||||
## Recommended Design
|
||||
|
||||
Keep the automation separated into four layers:
|
||||
|
||||
1. `quote` JSON
|
||||
- Business data coming from the Flask app.
|
||||
2. `profile` JSON
|
||||
- Machine and location configuration.
|
||||
3. `instruction set` JSON
|
||||
- The keystroke workflow for the DOS app.
|
||||
4. `executor`
|
||||
- Python code that reads the quote, profile, and instruction set and sends keys to the DOS app.
|
||||
|
||||
This separation keeps machine config, business data, and workflow logic from getting mixed together.
|
||||
|
||||
## Why Not Put Everything In The Profile
|
||||
|
||||
The location profile should define:
|
||||
- where the remote share is mounted,
|
||||
- which DOS app folder to use,
|
||||
- which DOSBox binary/config to use,
|
||||
- which instruction set to execute,
|
||||
- which location-specific values apply.
|
||||
|
||||
It should not hold the full keystroke workflow. If the whole flow lives in the profile, each location file becomes hard to maintain.
|
||||
|
||||
## Recommended Folder Structure
|
||||
|
||||
```text
|
||||
quote-bridge/
|
||||
├── listener.py
|
||||
├── dosbox_processor.py
|
||||
├── profiles/
|
||||
│ ├── default.json
|
||||
│ ├── IOLA.json
|
||||
│ ├── KC.json
|
||||
│ └── LINDS.json
|
||||
├── instruction-sets/
|
||||
│ ├── create-quote-v1.json
|
||||
│ └── create-quote-iola-v1.json
|
||||
└── value-maps/
|
||||
└── optional-future-files.json
|
||||
```
|
||||
|
||||
## Profile Responsibilities
|
||||
|
||||
Profiles should define environment and per-location values.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"profileName": "IOLA",
|
||||
"dosboxBin": "dosbox-x",
|
||||
"mountPath": "/mnt/iola-cgw",
|
||||
"mountDrive": "C",
|
||||
"workingDirectory": "CGWAPP",
|
||||
"bridgeCommand": "BRIDGE.BAT",
|
||||
"instructionSet": "create-quote-v1",
|
||||
"variables": {
|
||||
"taxCode": "ABC",
|
||||
"locationCode": "IOLA"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the profile for:
|
||||
- DOSBox path/config
|
||||
- remote mount path
|
||||
- DOS working directory
|
||||
- instruction set selection
|
||||
- per-location values like tax code, warehouse, location code, salesperson, or other defaults
|
||||
|
||||
## Instruction Set Responsibilities
|
||||
|
||||
Instruction sets should define the interactive workflow.
|
||||
|
||||
Instead of using only freeform key strings, use a small action DSL.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "create-quote-v1",
|
||||
"version": 1,
|
||||
"steps": [
|
||||
{
|
||||
"action": "text",
|
||||
"value": "${secrets.username}"
|
||||
},
|
||||
{
|
||||
"action": "key",
|
||||
"value": "ENTER"
|
||||
},
|
||||
{
|
||||
"action": "text",
|
||||
"value": "${secrets.password}"
|
||||
},
|
||||
{
|
||||
"action": "key",
|
||||
"value": "ENTER"
|
||||
},
|
||||
{
|
||||
"action": "text",
|
||||
"value": "${runtime.today}"
|
||||
},
|
||||
{
|
||||
"action": "key",
|
||||
"value": "ENTER"
|
||||
},
|
||||
{
|
||||
"action": "loop",
|
||||
"source": "items",
|
||||
"steps": [
|
||||
{
|
||||
"action": "text",
|
||||
"value": "${item.productCode}"
|
||||
},
|
||||
{
|
||||
"action": "key",
|
||||
"value": "ENTER"
|
||||
},
|
||||
{
|
||||
"action": "text",
|
||||
"value": "${item.quantity}"
|
||||
},
|
||||
{
|
||||
"action": "key",
|
||||
"value": "ENTER"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Recommended Action Types
|
||||
|
||||
The executor should support a small set of explicit actions:
|
||||
- `text`
|
||||
- `key`
|
||||
- `combo`
|
||||
- `sleep`
|
||||
- `wait`
|
||||
- `loop`
|
||||
- `conditional`
|
||||
- `set-variable`
|
||||
- optional future `assert`
|
||||
|
||||
This is better than a plain `keys: "abc"` design because real workflows need timing, branching, loops, and variable substitution.
|
||||
|
||||
## Special Keys And Combos
|
||||
|
||||
Use symbolic key names for special keys:
|
||||
- `ENTER`
|
||||
- `ESC`
|
||||
- `UP`
|
||||
- `DOWN`
|
||||
- `LEFT`
|
||||
- `RIGHT`
|
||||
- `TAB`
|
||||
- `BACKSPACE`
|
||||
- `F1` through `F12`
|
||||
|
||||
For combos, prefer an array form.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "combo",
|
||||
"keys": ["SHIFT", "~"]
|
||||
}
|
||||
```
|
||||
|
||||
That is less ambiguous than a single string.
|
||||
|
||||
## Handling Location-Specific Differences
|
||||
|
||||
There are two kinds of per-location differences.
|
||||
|
||||
### 1. Data Differences
|
||||
|
||||
Examples:
|
||||
- tax value
|
||||
- warehouse code
|
||||
- location code
|
||||
- default salesperson
|
||||
|
||||
These should stay in the profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"variables": {
|
||||
"taxCode": "IOLA-TAX",
|
||||
"warehouseCode": "01"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Flow Differences
|
||||
|
||||
Examples:
|
||||
- one location needs two extra keys
|
||||
- one location lands on a different screen
|
||||
- one location skips a field
|
||||
|
||||
If the difference is small, use the same instruction set and substitute different values.
|
||||
|
||||
If the difference is structural, create a separate instruction set.
|
||||
|
||||
Examples:
|
||||
- `create-quote-v1`
|
||||
- `create-quote-iola-v1`
|
||||
|
||||
That is cleaner than putting location branches on every single step.
|
||||
|
||||
## Suggested Runtime Context
|
||||
|
||||
When the executor runs, it should build a context containing:
|
||||
- `quote`
|
||||
- `items`
|
||||
- `profile.variables`
|
||||
- `runtime.today`
|
||||
- `secrets.username`
|
||||
- `secrets.password`
|
||||
|
||||
Then placeholders such as `${item.productCode}` or `${profile.variables.taxCode}` can be resolved during execution.
|
||||
|
||||
## How The Executor Should Work
|
||||
|
||||
At runtime:
|
||||
|
||||
1. Read quote file.
|
||||
2. Read `createdBy.location` from the quote.
|
||||
3. Load matching location profile.
|
||||
4. Load the instruction set named by the profile.
|
||||
5. Build runtime context.
|
||||
6. Launch DOSBox.
|
||||
7. Focus the DOSBox window if needed.
|
||||
8. Execute steps sequentially.
|
||||
9. Log each action.
|
||||
10. On success, move file to `processed`.
|
||||
11. On failure, move file to `failed`.
|
||||
|
||||
## How To Actually Send Keys
|
||||
|
||||
DOSBox runs the DOS app, but a separate automation backend is usually needed to send dynamic interactive keys.
|
||||
|
||||
On Ubuntu, the likely choices are:
|
||||
- `xdotool` for X11
|
||||
- `ydotool` for Wayland
|
||||
|
||||
That means the likely stack is:
|
||||
- DOSBox-X runs the DOS app
|
||||
- Python executor controls the DOSBox window
|
||||
- `xdotool` or `ydotool` sends keystrokes
|
||||
|
||||
## Installation Requirements
|
||||
|
||||
Minimum:
|
||||
- DOSBox or preferably DOSBox-X
|
||||
- access to the DOS application files from Ubuntu
|
||||
- Python
|
||||
|
||||
Useful additions:
|
||||
- `xdotool` for X11-based key injection
|
||||
- `ydotool` for Wayland-based key injection if needed
|
||||
- `cifs-utils` for mounting Windows shares on Ubuntu
|
||||
|
||||
## Security Note
|
||||
|
||||
Do not store real usernames and passwords in committed profile JSON files.
|
||||
|
||||
Better options:
|
||||
- environment variables
|
||||
- a local untracked secrets JSON file
|
||||
- a machine-local config file ignored by git
|
||||
|
||||
Example approach:
|
||||
|
||||
```json
|
||||
{
|
||||
"usernameEnv": "IOLA_DOS_USERNAME",
|
||||
"passwordEnv": "IOLA_DOS_PASSWORD"
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints And Risks
|
||||
|
||||
The hardest part is not the JSON format. The difficult part is making the execution deterministic enough that the DOS app is always on the expected screen.
|
||||
|
||||
Main risks:
|
||||
- timing drift
|
||||
- focus problems
|
||||
- unexpected dialogs
|
||||
- location-specific screen differences
|
||||
- item mapping differences between web app and DOS app
|
||||
|
||||
Because of that, start small and keep logging detailed.
|
||||
|
||||
## Recommended First Implementation Scope
|
||||
|
||||
Start with a narrow slice:
|
||||
|
||||
1. one instruction set for login + single item entry
|
||||
2. one location profile
|
||||
3. one automation backend using `xdotool`
|
||||
4. fixed waits only, no screen-reading yet
|
||||
5. detailed action logging
|
||||
|
||||
After that works, extend to:
|
||||
|
||||
1. multi-item loops
|
||||
2. per-location variables
|
||||
3. per-location alternate flows
|
||||
4. optional checkpoints/assertions
|
||||
5. result files with external quote/order references
|
||||
|
||||
## Next Steps For quote-bridge
|
||||
|
||||
### Phase 1: Structure
|
||||
|
||||
1. Add `instruction-sets/` folder.
|
||||
2. Add one starter instruction set file such as `create-quote-v1.json`.
|
||||
3. Extend location profiles to include `instructionSet` and optional `variables`.
|
||||
4. Add a local secrets mechanism for usernames/passwords.
|
||||
|
||||
### Phase 2: Execution Engine
|
||||
|
||||
1. Create an automation executor module.
|
||||
2. Implement action handlers for:
|
||||
- `text`
|
||||
- `key`
|
||||
- `combo`
|
||||
- `sleep`
|
||||
- `loop`
|
||||
3. Add placeholder resolution for quote/profile/runtime values.
|
||||
4. Add step-by-step logging.
|
||||
|
||||
### Phase 3: Input Backend
|
||||
|
||||
1. Decide whether Ubuntu is running X11 or Wayland.
|
||||
2. If X11, install and integrate `xdotool`.
|
||||
3. If Wayland, evaluate `ydotool`.
|
||||
4. Add DOSBox window targeting/focus handling.
|
||||
|
||||
### Phase 4: DOS App Integration
|
||||
|
||||
1. Build a real `BRIDGE.BAT` startup contract.
|
||||
2. Define the login sequence.
|
||||
3. Define single-item quote entry.
|
||||
4. Test with one known product and one location.
|
||||
|
||||
### Phase 5: Expansion
|
||||
|
||||
1. Add multi-item support.
|
||||
2. Add value/code translation maps if needed.
|
||||
3. Add location-specific instruction set variants only where necessary.
|
||||
4. Add result file generation with success/failure metadata.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Keep workflow logic in instruction sets, keep environment/location settings in profiles, and keep secrets out of tracked JSON files.
|
||||
|
||||
That will give `quote-bridge` the best chance of staying maintainable as the DOS automation grows.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Quote Bridge (Listener App)
|
||||
|
||||
This is a separate Python app that runs independently from the Flask app.
|
||||
|
||||
It watches for quote files in `shared/outgoing` and can run in two modes:
|
||||
|
||||
- `move` (default): move incoming files to `shared/processed`
|
||||
- `dosbox`: run DOSBox command/script per quote file, then move to
|
||||
`shared/processed` on success or `shared/failed` on error
|
||||
|
||||
## Run
|
||||
|
||||
From repository root:
|
||||
|
||||
```bash
|
||||
./.venv/bin/python quote-bridge/listener.py
|
||||
```
|
||||
|
||||
Or from this folder:
|
||||
|
||||
```bash
|
||||
../.venv/bin/python listener.py
|
||||
```
|
||||
|
||||
## Install DOSBox On Ubuntu
|
||||
|
||||
You will need DOSBox installed on the Ubuntu machine if you want to use
|
||||
`QUOTE_PROCESSOR_MODE=dosbox`.
|
||||
|
||||
### Option 1: Standard DOSBox
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install dosbox
|
||||
```
|
||||
|
||||
### Option 2: DOSBox-X
|
||||
|
||||
If available in your environment or package source, DOSBox-X is a better choice
|
||||
for many protected-mode DOS applications.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install dosbox-x
|
||||
```
|
||||
|
||||
If your distro does not provide `dosbox-x`, install it from your preferred
|
||||
package source and then update `dosboxBin` in your profile.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `QUOTE_OUTGOING_DIR`: Source directory to watch
|
||||
- `QUOTE_PROCESSED_DIR`: Destination directory for moved files
|
||||
- `QUOTE_FAILED_DIR`: Destination for failed quote files
|
||||
- `QUOTE_PROCESSOR_MODE`: `move` or `dosbox` (default: `move`)
|
||||
- `QUOTE_PROFILE_DIR`: Directory containing per-location DOSBox profiles
|
||||
- `QUOTE_POLL_INTERVAL_SECONDS`: Poll interval (default: `2`)
|
||||
|
||||
### DOSBox Mode Variables
|
||||
|
||||
When `QUOTE_PROCESSOR_MODE=dosbox`, these variables are used:
|
||||
|
||||
- `DOSBOX_BIN`: DOSBox executable (default: `dosbox`)
|
||||
- `DOSBOX_CONF`: Optional DOSBox config file path
|
||||
- `DOSBOX_MOUNT_PATH`: Host path mounted into DOS (default: shared parent path)
|
||||
- `DOSBOX_MOUNT_DRIVE`: DOS drive letter (default: `C`)
|
||||
- `DOSBOX_BRIDGE_COMMAND`: DOS command/batch to run (default: `BRIDGE.BAT`)
|
||||
- `DOSBOX_EXTRA_COMMANDS`: Extra DOS commands separated by `;`
|
||||
- `DOSBOX_TIMEOUT_SECONDS`: Timeout for one DOSBox run (default: `120`)
|
||||
- `DOSBOX_NOCONSOLE`: `true/false` to add `-noconsole` flag
|
||||
|
||||
## Profiles
|
||||
|
||||
When `QUOTE_PROCESSOR_MODE=dosbox`, the helper looks at the quote file's
|
||||
`createdBy.location` field and tries to load a matching profile from:
|
||||
|
||||
- `quote-bridge/profiles/<LOCATION>.json`
|
||||
- then `quote-bridge/profiles/default.json`
|
||||
|
||||
Included sample profiles:
|
||||
|
||||
- `quote-bridge/profiles/IOLA.json`
|
||||
- `quote-bridge/profiles/KC.json`
|
||||
- `quote-bridge/profiles/LINDS.json`
|
||||
- `quote-bridge/profiles/default.json`
|
||||
|
||||
### Where To Enter The Remote Path
|
||||
|
||||
For each location profile, set:
|
||||
|
||||
- `mountPath`: the Linux path where that remote PC's shared drive/folder is mounted
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mountPath": "/mnt/iola-cgw"
|
||||
}
|
||||
```
|
||||
|
||||
That is the main place to enter the mapped path.
|
||||
|
||||
### Where To Enter The DOS App Folder
|
||||
|
||||
For each location profile, set:
|
||||
|
||||
- `workingDirectory`: the DOS folder under the mounted share that contains the app
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"workingDirectory": "CGWAPP"
|
||||
}
|
||||
```
|
||||
|
||||
If the DOS app lives at:
|
||||
|
||||
```text
|
||||
/mnt/iola-cgw/CGWAPP
|
||||
```
|
||||
|
||||
then use:
|
||||
|
||||
- `mountPath = /mnt/iola-cgw`
|
||||
- `workingDirectory = CGWAPP`
|
||||
|
||||
### Where To Enter The DOS Entry Script
|
||||
|
||||
For each location profile, set:
|
||||
|
||||
- `bridgeCommand`: the DOS-side batch/script/executable to run after mounting and changing directory
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"bridgeCommand": "BRIDGE.BAT"
|
||||
}
|
||||
```
|
||||
|
||||
## Suggested Setup For Multiple PCs
|
||||
|
||||
If each location runs from a different PC, the clean pattern is:
|
||||
|
||||
1. Mount each remote PC's application share on Ubuntu.
|
||||
2. Put that mount path into the matching location profile.
|
||||
3. Keep one profile per location.
|
||||
4. Let the helper auto-select the profile based on the quote's current location.
|
||||
|
||||
Example mapping:
|
||||
|
||||
- `IOLA` -> `/mnt/iola-cgw`
|
||||
- `KC` -> `/mnt/kc-cgw`
|
||||
- `LINDS` -> `/mnt/linds-cgw`
|
||||
|
||||
## Mounting Remote PC Shares On Ubuntu
|
||||
|
||||
If the DOS app is stored on remote Windows PCs, Ubuntu needs those folders
|
||||
mounted locally first.
|
||||
|
||||
Example mount points:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /mnt/iola-cgw
|
||||
sudo mkdir -p /mnt/kc-cgw
|
||||
sudo mkdir -p /mnt/linds-cgw
|
||||
```
|
||||
|
||||
Example CIFS mount command:
|
||||
|
||||
```bash
|
||||
sudo mount -t cifs //REMOTE-PC/SharedFolder /mnt/iola-cgw \
|
||||
-o username=YOUR_USER,password=YOUR_PASSWORD,uid=$(id -u),gid=$(id -g)
|
||||
```
|
||||
|
||||
Replace:
|
||||
|
||||
- `REMOTE-PC` with the Windows machine name or IP
|
||||
- `SharedFolder` with the shared folder name
|
||||
- `YOUR_USER` and `YOUR_PASSWORD` with Windows credentials
|
||||
|
||||
After the mount is working, put that Linux mount path into the matching profile
|
||||
as `mountPath`.
|
||||
|
||||
## Example .env-Style Setup
|
||||
|
||||
You can export variables in the shell before starting the listener.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
export QUOTE_PROCESSOR_MODE=dosbox
|
||||
export QUOTE_PROFILE_DIR=/home/jsalmon/Documents/git/quote-builder/quote-bridge/profiles
|
||||
./.venv/bin/python quote-bridge/listener.py
|
||||
```
|
||||
|
||||
If you prefer, create a small shell script such as `start-quote-bridge.sh` that
|
||||
exports these values and starts the listener.
|
||||
|
||||
## Sample Profile Fields
|
||||
|
||||
Example profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"profileName": "IOLA",
|
||||
"dosboxBin": "dosbox-x",
|
||||
"mountDrive": "C",
|
||||
"mountPath": "/mnt/iola-cgw",
|
||||
"workingDirectory": "CGWAPP",
|
||||
"bridgeCommand": "BRIDGE.BAT",
|
||||
"extraCommands": [
|
||||
"SET CLIPPER=F200"
|
||||
],
|
||||
"timeoutSeconds": 180,
|
||||
"noConsole": false
|
||||
}
|
||||
```
|
||||
|
||||
## DOS-Side BRIDGE.BAT Contract
|
||||
|
||||
The helper currently assumes a DOS-side entry script like:
|
||||
|
||||
```bat
|
||||
BRIDGE.BAT "C:\OUTGOING\20260629-153012-ab12cd34.json"
|
||||
```
|
||||
|
||||
That means `BRIDGE.BAT` should accept the quote file path as `%1`.
|
||||
|
||||
Minimal example:
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
rem %1 is the quote JSON file path inside DOSBox
|
||||
echo Processing quote file: %1
|
||||
rem Start your DOS app here and pass or import the file as needed
|
||||
rem Example only:
|
||||
rem MYAPP.EXE %1
|
||||
```
|
||||
|
||||
If your DOS app requires a different startup sequence, change `bridgeCommand`
|
||||
in the location profile.
|
||||
|
||||
## End-To-End Flow
|
||||
|
||||
1. User clicks `Quote` in the Flask app.
|
||||
2. Flask writes a quote JSON file to `shared/outgoing`.
|
||||
3. Listener sees the new file.
|
||||
4. Listener selects profile based on `createdBy.location` in the quote.
|
||||
5. In `dosbox` mode, listener launches DOSBox using that profile.
|
||||
6. DOSBox runs the configured `bridgeCommand`.
|
||||
7. File moves to:
|
||||
- `shared/processed` on success
|
||||
- `shared/failed` on failure
|
||||
|
||||
## Current Behavior
|
||||
|
||||
- Processes `.json` files only
|
||||
- Ignores temporary files such as `*.tmp`
|
||||
- Appends a timestamp suffix if a destination filename already exists
|
||||
- In `move` mode, files are moved from outgoing to processed
|
||||
- In `dosbox` mode, each file is passed to DOSBox first, then moved to:
|
||||
- `processed` on success
|
||||
- `failed` on failure
|
||||
|
||||
Use `Ctrl+C` to stop the listener.
|
||||
@@ -0,0 +1,183 @@
|
||||
"""DOSBox processing helpers for quote bridge.
|
||||
|
||||
This module provides a lightweight wrapper around DOSBox invocation so the
|
||||
listener can process quote files using a DOS-side script.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
|
||||
Logger = Callable[[str], None]
|
||||
|
||||
|
||||
def _default_path(*parts: str) -> str:
|
||||
base_dir = Path(__file__).resolve().parent
|
||||
return str(base_dir.joinpath(*parts))
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _split_commands(raw: str) -> List[str]:
|
||||
return [cmd.strip() for cmd in raw.split(";") if cmd.strip()]
|
||||
|
||||
|
||||
def _load_quote_payload(quote_file: Path) -> Dict[str, Any]:
|
||||
with open(quote_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _profile_dir() -> Path:
|
||||
return Path(os.environ.get("QUOTE_PROFILE_DIR", _default_path("profiles")))
|
||||
|
||||
|
||||
def _profile_candidates(location: str) -> List[Path]:
|
||||
profile_dir = _profile_dir()
|
||||
candidates: List[Path] = []
|
||||
if location:
|
||||
candidates.append(profile_dir / f"{location.upper()}.json")
|
||||
candidates.append(profile_dir / f"{location.lower()}.json")
|
||||
candidates.append(profile_dir / "default.json")
|
||||
return candidates
|
||||
|
||||
|
||||
def _load_profile_for_quote(quote_file: Path, logger: Logger) -> Dict[str, Any]:
|
||||
payload = _load_quote_payload(quote_file)
|
||||
location = str(payload.get('createdBy', {}).get('location', '') or '').strip()
|
||||
|
||||
for candidate in _profile_candidates(location):
|
||||
if candidate.exists():
|
||||
with open(candidate, 'r', encoding='utf-8') as f:
|
||||
profile = json.load(f)
|
||||
profile['_profileFile'] = str(candidate)
|
||||
profile['_quoteLocation'] = location
|
||||
logger(f"Using DOSBox profile: {candidate}")
|
||||
return profile
|
||||
|
||||
logger("No DOSBox profile file found; using environment defaults")
|
||||
return {
|
||||
'_profileFile': None,
|
||||
'_quoteLocation': location,
|
||||
}
|
||||
|
||||
|
||||
def _profile_bool(profile: Dict[str, Any], key: str, env_name: str, default: bool = False) -> bool:
|
||||
value = profile.get(key)
|
||||
if value is None:
|
||||
return _env_bool(env_name, default=default)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _profile_string(profile: Dict[str, Any], key: str, env_name: str, default: str = "") -> str:
|
||||
value = profile.get(key)
|
||||
if value is None or value == "":
|
||||
return os.environ.get(env_name, default)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _profile_float(profile: Dict[str, Any], key: str, env_name: str, default: float) -> float:
|
||||
value = profile.get(key)
|
||||
if value is None or value == "":
|
||||
return float(os.environ.get(env_name, str(default)))
|
||||
return float(value)
|
||||
|
||||
|
||||
def _profile_commands(profile: Dict[str, Any], key: str, env_name: str) -> List[str]:
|
||||
value = profile.get(key)
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
if isinstance(value, str) and value.strip():
|
||||
return _split_commands(value)
|
||||
return _split_commands(os.environ.get(env_name, ""))
|
||||
|
||||
|
||||
def _quote_path_for_dos(path: Path, mount_path: Path, drive: str) -> str:
|
||||
drive_letter = (drive or "C").strip().upper()[:1]
|
||||
try:
|
||||
rel = path.resolve().relative_to(mount_path.resolve())
|
||||
rel_str = str(rel).replace("/", "\\")
|
||||
return f"{drive_letter}:\\{rel_str}"
|
||||
except Exception:
|
||||
# Fallback to absolute path transformed to backslashes.
|
||||
return str(path.resolve()).replace("/", "\\")
|
||||
|
||||
|
||||
def run_dosbox_for_quote(quote_file: Path, logger: Logger) -> None:
|
||||
"""Run DOSBox and execute bridge command for one quote file.
|
||||
|
||||
Required environment values are provided with sensible defaults, but you'll
|
||||
typically set these in deployment:
|
||||
- DOSBOX_BIN
|
||||
- DOSBOX_MOUNT_PATH
|
||||
- DOSBOX_BRIDGE_COMMAND
|
||||
"""
|
||||
|
||||
profile = _load_profile_for_quote(quote_file, logger)
|
||||
|
||||
dosbox_bin = _profile_string(profile, 'dosboxBin', 'DOSBOX_BIN', 'dosbox')
|
||||
mount_path = Path(_profile_string(profile, 'mountPath', 'DOSBOX_MOUNT_PATH', str(quote_file.parent.parent)))
|
||||
mount_drive = _profile_string(profile, 'mountDrive', 'DOSBOX_MOUNT_DRIVE', 'C')
|
||||
working_directory = _profile_string(profile, 'workingDirectory', 'DOSBOX_WORKING_DIRECTORY', '')
|
||||
bridge_command = _profile_string(profile, 'bridgeCommand', 'DOSBOX_BRIDGE_COMMAND', 'BRIDGE.BAT')
|
||||
dosbox_conf = _profile_string(profile, 'dosboxConf', 'DOSBOX_CONF', '') or None
|
||||
dosbox_timeout = _profile_float(profile, 'timeoutSeconds', 'DOSBOX_TIMEOUT_SECONDS', 120.0)
|
||||
dosbox_no_console = _profile_bool(profile, 'noConsole', 'DOSBOX_NOCONSOLE', default=False)
|
||||
dosbox_extra_commands = _profile_commands(profile, 'extraCommands', 'DOSBOX_EXTRA_COMMANDS')
|
||||
|
||||
quote_path_for_dos = _quote_path_for_dos(quote_file, mount_path, mount_drive)
|
||||
|
||||
# Build DOS command script.
|
||||
dos_commands = [
|
||||
f"mount {mount_drive} \"{mount_path}\"",
|
||||
f"{mount_drive}",
|
||||
]
|
||||
if working_directory:
|
||||
dos_commands.append(f"cd {working_directory}")
|
||||
dos_commands.extend(dosbox_extra_commands)
|
||||
dos_commands.append(f"{bridge_command} \"{quote_path_for_dos}\"")
|
||||
dos_commands.append("exit")
|
||||
|
||||
cmd = [dosbox_bin]
|
||||
if dosbox_conf:
|
||||
cmd.extend(["-conf", dosbox_conf])
|
||||
if dosbox_no_console:
|
||||
cmd.append("-noconsole")
|
||||
for line in dos_commands:
|
||||
cmd.extend(["-c", line])
|
||||
|
||||
logger(f"DOSBox command: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
check=False,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=dosbox_timeout,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(f"DOSBox executable not found: {dosbox_bin}") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(
|
||||
f"DOSBox timed out after {dosbox_timeout} seconds"
|
||||
) from exc
|
||||
|
||||
if result.stdout:
|
||||
logger(f"DOSBox stdout:\n{result.stdout.strip()}")
|
||||
if result.stderr:
|
||||
logger(f"DOSBox stderr:\n{result.stderr.strip()}")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"DOSBox exited with code {result.returncode}")
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quote bridge listener.
|
||||
|
||||
Watches a source directory for quote JSON files and processes them in one of
|
||||
two modes:
|
||||
1) move - move straight to processed folder (default)
|
||||
2) dosbox - invoke DOSBox script, then move to processed/failed
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from dosbox_processor import run_dosbox_for_quote
|
||||
|
||||
|
||||
def _default_path(*parts: str) -> str:
|
||||
base_dir = Path(__file__).resolve().parent.parent
|
||||
return str(base_dir.joinpath(*parts))
|
||||
|
||||
|
||||
QUOTE_OUTGOING_DIR = Path(
|
||||
os.environ.get("QUOTE_OUTGOING_DIR", _default_path("shared", "outgoing"))
|
||||
)
|
||||
QUOTE_PROCESSED_DIR = Path(
|
||||
os.environ.get("QUOTE_PROCESSED_DIR", _default_path("shared", "processed"))
|
||||
)
|
||||
QUOTE_FAILED_DIR = Path(
|
||||
os.environ.get("QUOTE_FAILED_DIR", _default_path("shared", "failed"))
|
||||
)
|
||||
PROCESSOR_MODE = os.environ.get("QUOTE_PROCESSOR_MODE", "move").strip().lower()
|
||||
POLL_INTERVAL_SECONDS = float(os.environ.get("QUOTE_POLL_INTERVAL_SECONDS", "2"))
|
||||
|
||||
RUNNING = True
|
||||
|
||||
|
||||
def _timestamp() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(f"[{_timestamp()}] {message}", flush=True)
|
||||
|
||||
|
||||
def should_process(path: Path) -> bool:
|
||||
if not path.is_file():
|
||||
return False
|
||||
if path.suffix.lower() != ".json":
|
||||
return False
|
||||
if path.name.endswith(".tmp"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def move_quote_file(path: Path, destination_dir: Path) -> Path:
|
||||
target = destination_dir / path.name
|
||||
|
||||
# Avoid filename collisions by appending epoch milliseconds.
|
||||
if target.exists():
|
||||
stem = path.stem
|
||||
suffix = path.suffix
|
||||
target = QUOTE_PROCESSED_DIR / f"{stem}-{int(time.time() * 1000)}{suffix}"
|
||||
|
||||
shutil.move(str(path), str(target))
|
||||
return target
|
||||
|
||||
|
||||
def process_quote_file(path: Path) -> None:
|
||||
if PROCESSOR_MODE == "move":
|
||||
target = move_quote_file(path, QUOTE_PROCESSED_DIR)
|
||||
log(f"Moved {path.name} -> {target.name}")
|
||||
return
|
||||
|
||||
if PROCESSOR_MODE == "dosbox":
|
||||
try:
|
||||
run_dosbox_for_quote(path, log)
|
||||
target = move_quote_file(path, QUOTE_PROCESSED_DIR)
|
||||
log(f"DOSBox processed {path.name} -> {target.name}")
|
||||
except Exception as exc:
|
||||
failed_target = move_quote_file(path, QUOTE_FAILED_DIR)
|
||||
log(f"DOSBox failed for {path.name}: {exc}")
|
||||
log(f"Moved to failed: {failed_target.name}")
|
||||
return
|
||||
|
||||
# Unknown mode: fail fast so operator can fix configuration.
|
||||
raise RuntimeError(
|
||||
f"Unsupported QUOTE_PROCESSOR_MODE '{PROCESSOR_MODE}'. Use 'move' or 'dosbox'."
|
||||
)
|
||||
|
||||
|
||||
def process_once() -> int:
|
||||
moved = 0
|
||||
for entry in sorted(QUOTE_OUTGOING_DIR.iterdir()):
|
||||
if not should_process(entry):
|
||||
continue
|
||||
process_quote_file(entry)
|
||||
moved += 1
|
||||
return moved
|
||||
|
||||
|
||||
def handle_shutdown(signum: int, _frame) -> None:
|
||||
global RUNNING
|
||||
RUNNING = False
|
||||
log(f"Received signal {signum}; shutting down...")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
QUOTE_OUTGOING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
QUOTE_PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
QUOTE_FAILED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
signal.signal(signal.SIGINT, handle_shutdown)
|
||||
signal.signal(signal.SIGTERM, handle_shutdown)
|
||||
|
||||
log("Quote bridge listener started")
|
||||
log(f"Processor mode: {PROCESSOR_MODE}")
|
||||
log(f"Watching: {QUOTE_OUTGOING_DIR}")
|
||||
log(f"Processed dir: {QUOTE_PROCESSED_DIR}")
|
||||
log(f"Failed dir: {QUOTE_FAILED_DIR}")
|
||||
log(f"Poll interval: {POLL_INTERVAL_SECONDS}s")
|
||||
|
||||
while RUNNING:
|
||||
try:
|
||||
moved = process_once()
|
||||
if moved == 0:
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
except FileNotFoundError:
|
||||
# If a folder disappears unexpectedly, recreate it and continue.
|
||||
QUOTE_OUTGOING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
QUOTE_PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
QUOTE_FAILED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
except Exception as exc: # pragma: no cover - defensive runtime logging
|
||||
log(f"Error: {exc}")
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
|
||||
log("Quote bridge listener stopped")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"profileName": "IOLA",
|
||||
"description": "Iola DOSBox profile",
|
||||
"enabled": false,
|
||||
"dosboxBin": "dosbox-x",
|
||||
"mountDrive": "C",
|
||||
"mountPath": "/mnt/iola-cgw",
|
||||
"workingDirectory": "CGWAPP",
|
||||
"bridgeCommand": "BRIDGE.BAT",
|
||||
"extraCommands": [],
|
||||
"timeoutSeconds": 180,
|
||||
"noConsole": false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"profileName": "KC",
|
||||
"description": "Kansas City DOSBox profile",
|
||||
"enabled": false,
|
||||
"dosboxBin": "dosbox-x",
|
||||
"mountDrive": "C",
|
||||
"mountPath": "/mnt/kc-cgw",
|
||||
"workingDirectory": "CGWAPP",
|
||||
"bridgeCommand": "BRIDGE.BAT",
|
||||
"extraCommands": [],
|
||||
"timeoutSeconds": 180,
|
||||
"noConsole": false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"profileName": "LINDS",
|
||||
"description": "Lindsborg DOSBox profile",
|
||||
"enabled": false,
|
||||
"dosboxBin": "dosbox-x",
|
||||
"mountDrive": "C",
|
||||
"mountPath": "/mnt/linds-cgw",
|
||||
"workingDirectory": "CGWAPP",
|
||||
"bridgeCommand": "BRIDGE.BAT",
|
||||
"extraCommands": [],
|
||||
"timeoutSeconds": 180,
|
||||
"noConsole": false
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"profileName": "DEFAULT",
|
||||
"description": "Fallback DOSBox profile. Copy and customize per location.",
|
||||
"enabled": false,
|
||||
"dosboxBin": "dosbox-x",
|
||||
"mountDrive": "C",
|
||||
"mountPath": "/mnt/remote-cgw-share",
|
||||
"workingDirectory": "CGWAPP",
|
||||
"bridgeCommand": "BRIDGE.BAT",
|
||||
"extraCommands": [
|
||||
"SET CLIPPER=F200"
|
||||
],
|
||||
"timeoutSeconds": 180,
|
||||
"noConsole": false,
|
||||
"notes": {
|
||||
"mountPath": "Enter the Linux path where the remote PC share is mounted.",
|
||||
"workingDirectory": "Enter the DOS folder under the mounted share that contains your Harbour/Clipper app and BRIDGE.BAT.",
|
||||
"bridgeCommand": "Change this if your DOS-side entry point is not BRIDGE.BAT."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Standard library only for now
|
||||
Reference in New Issue
Block a user