Add quote bridge and quote handoff flow
This commit is contained in:
Vendored
+127
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Update Data Files from CSV",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "${workspaceFolder}/.venv/bin/python",
|
||||||
|
"args": [
|
||||||
|
"parse_csv_to_json.py"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/app"
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared",
|
||||||
|
"clear": true
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": false
|
||||||
|
},
|
||||||
|
"detail": "Generate JSON files from products.csv (Step 1/2)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Generate Bitwise Data",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "${workspaceFolder}/.venv/bin/python",
|
||||||
|
"args": [
|
||||||
|
"generate_bitwise_helper.py"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/app"
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared",
|
||||||
|
"clear": false
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": false
|
||||||
|
},
|
||||||
|
"detail": "Generate bitwise filtering data (Step 2/2)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Process All Data",
|
||||||
|
"dependsOn": [
|
||||||
|
"Update Data Files from CSV",
|
||||||
|
"Generate Bitwise Data"
|
||||||
|
],
|
||||||
|
"dependsOrder": "sequence",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"detail": "Run both scripts to update all JSON files from products.csv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Start Flask Server",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "${workspaceFolder}/.venv/bin/python",
|
||||||
|
"args": [
|
||||||
|
"app.py"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/app"
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated",
|
||||||
|
"clear": true
|
||||||
|
},
|
||||||
|
"problemMatcher": {
|
||||||
|
"pattern": {
|
||||||
|
"regexp": "^\\s*\\*\\s+Running on (https?://\\S+)",
|
||||||
|
"message": 1
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": "^\\s*\\*\\s+Serving Flask app",
|
||||||
|
"endsPattern": "^\\s*\\*\\s+Running on"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"group": "none",
|
||||||
|
"detail": "Start the Flask development server on port 8080"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Start Quote Bridge Listener",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "${workspaceFolder}/.venv/bin/python",
|
||||||
|
"args": [
|
||||||
|
"quote-bridge/listener.py"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated",
|
||||||
|
"clear": true
|
||||||
|
},
|
||||||
|
"problemMatcher": {
|
||||||
|
"pattern": {
|
||||||
|
"regexp": "^\\[.*\\] Quote bridge listener started$",
|
||||||
|
"message": 0
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": "^\\[.*\\] Quote bridge listener started$",
|
||||||
|
"endsPattern": "^\\[.*\\] Poll interval:"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"group": "none",
|
||||||
|
"detail": "Start the quote bridge helper app (shared file listener)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+123
-124
@@ -1,147 +1,146 @@
|
|||||||
# Product App - Modular Flask Application
|
# Product Finder Application
|
||||||
|
|
||||||
## 🏗️ Structure
|
## Overview
|
||||||
|
|
||||||
|
This folder contains the active Flask application for the Product Finder project.
|
||||||
|
The app is structured around blueprints and serves a guided product-selection flow,
|
||||||
|
admin tooling, and image preview APIs from the same codebase.
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
product_app/
|
app/
|
||||||
├── app.py # Main application file
|
├── app.py # Flask app entry point and blueprint registration
|
||||||
├── passenger_wsgi.py # Passenger WSGI entry point for production
|
├── config.py # Backend/config toggles
|
||||||
├── requirements.txt # Python dependencies
|
├── data_access.py # Chooses JSON or SQLite backend
|
||||||
├── blueprints/ # Modular routes organized by feature
|
├── data_access_json.py # JSON-backed data operations
|
||||||
│ ├── __init__.py
|
├── data_access_sqlite.py # SQLite-backed data operations
|
||||||
│ ├── auth.py # Login/logout/session management
|
├── models.py # SQLAlchemy models for SQLite mode
|
||||||
│ └── users.py # User CRUD operations
|
├── blueprints/
|
||||||
├── templates/ # HTML templates
|
│ ├── auth.py # Login, logout, session, location selection
|
||||||
│ ├── login.html
|
│ ├── users.py # User CRUD and password management
|
||||||
│ └── user_manager.html
|
│ ├── products.py # Quiz pages, product APIs, admin pages
|
||||||
└── data/ # Data storage
|
│ └── canvas.py # Layered image fallback API
|
||||||
└── users.json # User accounts with hashed passwords
|
├── static/
|
||||||
|
│ ├── css/styles.css # Main application styles
|
||||||
|
│ ├── js/script.js # Quiz flow, URL state, search, previews
|
||||||
|
│ └── images/ # Product and layered image assets
|
||||||
|
├── templates/ # Flask templates
|
||||||
|
└── data/ # JSON data files used by the app
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎯 Features Implemented
|
## Implemented Features
|
||||||
|
|
||||||
### ✅ Authentication System (blueprints/auth.py)
|
### Authentication and Session Flow
|
||||||
- Login with username/password
|
- Username/password login
|
||||||
- Session management
|
- Session-backed authentication
|
||||||
- Password hashing (PBKDF2-SHA256, 1M iterations)
|
- Active/inactive user checks
|
||||||
- Login required decorator
|
- Multi-location session support with location selection
|
||||||
- Permission checking: `can_user('manage_users')`
|
- Permission checks via `can_user(...)`
|
||||||
- User status checking (active/inactive)
|
|
||||||
|
|
||||||
### ✅ User Management (blueprints/users.py)
|
### User Management
|
||||||
- View all users
|
- List users
|
||||||
- Add new users
|
- Create users
|
||||||
- Delete users
|
- Delete users
|
||||||
- Toggle active/inactive status
|
- Toggle active status
|
||||||
- Change passwords (requires admin verification)
|
- Change passwords
|
||||||
- Location-based access control
|
- Download user data JSON
|
||||||
- Permission system (manage_users, create_quotes, etc.)
|
|
||||||
- Download users as JSON
|
|
||||||
|
|
||||||
## 🔐 Default Login
|
### Product Finder
|
||||||
|
- Quiz flow served at `/quiz/`
|
||||||
|
- URL-based state restoration with query params:
|
||||||
|
- `b` for bitwise state
|
||||||
|
- `q` for current question/results state
|
||||||
|
- `p` for direct product links
|
||||||
|
- Product results grid with clickable product cards
|
||||||
|
- Product detail page with configuration controls
|
||||||
|
- Share buttons for results and product-detail URLs
|
||||||
|
- Browser back/forward support via `popstate`
|
||||||
|
|
||||||
- **Username:** `Master`
|
### Product Management and Search
|
||||||
- **Password:** `Master`
|
- Advanced search page
|
||||||
- **Permissions:** Full access (manage_users)
|
- Product listing page with search and pagination API
|
||||||
|
- Product manager page
|
||||||
|
- Product CRUD API endpoints
|
||||||
|
- Product attributes API endpoint
|
||||||
|
|
||||||
## 🚀 Local Testing
|
### Image Systems
|
||||||
|
- Flat-image preview support
|
||||||
|
- Static layered-image support
|
||||||
|
- Canvas API fallback system for hierarchical image lookup
|
||||||
|
- Layer transforms for flipped/mirrored previews where configured
|
||||||
|
|
||||||
|
### Data Backends
|
||||||
|
- JSON backend is the current default
|
||||||
|
- SQLite backend is supported behind `USE_DATABASE = True` in `config.py`
|
||||||
|
- Data-access calls are routed through `data_access.py`
|
||||||
|
|
||||||
|
## Important Routes
|
||||||
|
|
||||||
|
### App-Level Routes
|
||||||
|
- `/` - Authenticated home page
|
||||||
|
- `/test` - Basic app health page
|
||||||
|
- `/init-db` - Create SQLite tables when database mode is enabled
|
||||||
|
|
||||||
|
### Auth Routes
|
||||||
|
- `/login`
|
||||||
|
- `/select-location`
|
||||||
|
- `/api/login`
|
||||||
|
- `/api/select-location`
|
||||||
|
- `/api/session`
|
||||||
|
- `/logout`
|
||||||
|
|
||||||
|
### Product Routes
|
||||||
|
- `/quiz/`
|
||||||
|
- `/quiz/index`
|
||||||
|
- `/quiz/advanced-search`
|
||||||
|
- `/quiz/list`
|
||||||
|
- `/quiz/manage`
|
||||||
|
- `/quiz/product/<product_code>`
|
||||||
|
- `/quiz/api/products`
|
||||||
|
- `/quiz/api/products/search`
|
||||||
|
- `/quiz/api/products/<product_code>`
|
||||||
|
- `/quiz/api/product-attributes`
|
||||||
|
|
||||||
|
### Canvas API Routes
|
||||||
|
- `/api/canvas/<product_code>/<layer>`
|
||||||
|
- `/api/canvas/<product_code>/info`
|
||||||
|
- `/api/canvas/test`
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd product_app
|
cd app
|
||||||
python app.py
|
../.venv/bin/python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit: http://localhost:8080/
|
Open:
|
||||||
|
- `http://127.0.0.1:8080/`
|
||||||
|
- `http://127.0.0.1:8080/quiz/`
|
||||||
|
|
||||||
## Routes
|
## Data Update Workflow
|
||||||
|
|
||||||
### Main Routes
|
If product source data changes, use the VS Code tasks from the repository root:
|
||||||
- `/` - Home (redirects to login if not authenticated)
|
|
||||||
- `/test` - Test page to verify app is running
|
|
||||||
|
|
||||||
### Authentication Routes (auth_bp)
|
- `Update Data Files from CSV`
|
||||||
- `/login` - Login page
|
- `Generate Bitwise Data`
|
||||||
- `/logout` - Logout
|
- `Process All Data`
|
||||||
- `/api/login` - POST: Login API
|
|
||||||
- `/api/session` - GET: Current session info
|
|
||||||
|
|
||||||
### User Management Routes (users_bp)
|
These regenerate the JSON files consumed by the quiz and filtering logic.
|
||||||
- `/users/` - User management page
|
|
||||||
- `/users/api` - GET: List all users, POST: Add user
|
|
||||||
- `/users/api/<index>` - DELETE: Delete user
|
|
||||||
- `/users/api/<index>/active` - PATCH: Toggle active status
|
|
||||||
- `/users/api/<index>/change-password` - POST: Change password
|
|
||||||
- `/users/api/download` - GET: Download users.json
|
|
||||||
|
|
||||||
## 📦 Production Deployment
|
## Current Limitations
|
||||||
|
|
||||||
### Upload to Server: `/home/bmdwtjuw/product-finder/`
|
- Generic conditional-question evaluation is not fully implemented in the frontend.
|
||||||
|
The current flow works with the generated navigation structure and a small amount
|
||||||
|
of hardcoded branching, but it does not yet use a reusable `resolveNextQuestion`
|
||||||
|
style engine for all conditional prompts.
|
||||||
|
- SQLite support exists, but the project currently runs in JSON mode by default.
|
||||||
|
- There is no automated test suite in this repository yet; validation is currently manual.
|
||||||
|
|
||||||
Files to upload:
|
## Recommended Next Work
|
||||||
- `app.py`
|
|
||||||
- `passenger_wsgi.py` (or just use existing)
|
|
||||||
- `blueprints/` (entire folder)
|
|
||||||
- `__init__.py`
|
|
||||||
- `auth.py`
|
|
||||||
- `users.py`
|
|
||||||
- `templates/` (entire folder)
|
|
||||||
- `login.html`
|
|
||||||
- `user_manager.html`
|
|
||||||
- `data/users.json`
|
|
||||||
|
|
||||||
### Control Panel Settings
|
1. Finish generic conditional navigation so `conditional` metadata in `navigation.json` is evaluated uniformly.
|
||||||
|
2. Add smoke tests for login, quiz state restoration, and product detail deep links.
|
||||||
- **Application startup file:** `passenger_wsgi.py` (or `app.py`)
|
3. Decide whether SQLite should remain optional or become the default backend.
|
||||||
- **Application Entry point:** `application`
|
|
||||||
- **Python version:** 3.13.11
|
|
||||||
|
|
||||||
### Test After Deployment:
|
|
||||||
1. https://columbiawindows.com/product-finder/test
|
|
||||||
2. https://columbiawindows.com/product-finder/login
|
|
||||||
3. Login with Master/Master
|
|
||||||
4. Test user management
|
|
||||||
|
|
||||||
## 🔧 Adding New Features
|
|
||||||
|
|
||||||
### Create a New Blueprint
|
|
||||||
|
|
||||||
1. Create `blueprints/your_feature.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from flask import Blueprint, render_template
|
|
||||||
from blueprints.auth import login_required, can_user
|
|
||||||
|
|
||||||
your_feature_bp = Blueprint('your_feature', __name__, url_prefix='/your-feature')
|
|
||||||
|
|
||||||
@your_feature_bp.route('/')
|
|
||||||
@login_required
|
|
||||||
def index():
|
|
||||||
return render_template('your_feature.html')
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Register in `app.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from blueprints.your_feature import your_feature_bp
|
|
||||||
app.register_blueprint(your_feature_bp)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Create `templates/your_feature.html`
|
|
||||||
|
|
||||||
4. Test locally, then upload to server
|
|
||||||
|
|
||||||
## 📝 Benefits of Blueprint Structure
|
|
||||||
|
|
||||||
✅ **Separation of Concerns:** Each feature in its own file
|
|
||||||
✅ **Easy to Maintain:** Find and edit specific features quickly
|
|
||||||
✅ **Scalable:** Add new features without touching existing code
|
|
||||||
✅ **Testable:** Each blueprint can be tested independently
|
|
||||||
✅ **Reusable:** Share decorators (login_required, permission_required) across blueprints
|
|
||||||
|
|
||||||
## 🛠️ Next Steps
|
|
||||||
|
|
||||||
- Add more blueprints for other features (quotes, products, etc.)
|
|
||||||
- Add more templates as needed
|
|
||||||
- Extend permission system
|
|
||||||
- Add location selection page
|
|
||||||
- Add the main product finder quiz
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ from flask import Blueprint, render_template, send_from_directory, session, json
|
|||||||
from blueprints.auth import login_required, get_current_user, can_user
|
from blueprints.auth import login_required, get_current_user, can_user
|
||||||
import data_access as da
|
import data_access as da
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import config
|
||||||
|
|
||||||
products_bp = Blueprint('products', __name__, url_prefix='/quiz')
|
products_bp = Blueprint('products', __name__, url_prefix='/quiz')
|
||||||
|
|
||||||
@@ -226,6 +230,106 @@ def get_product_attributes():
|
|||||||
'attributes': attributes
|
'attributes': attributes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@products_bp.route('/api/submit-quote', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def submit_quote():
|
||||||
|
"""Submit current order items as a handoff file for downstream processing"""
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not logged in'
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
# Allow explicit quote permission or super admin
|
||||||
|
if not can_user('create_quotes') and not user.get('superAdmin', False):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Permission denied'
|
||||||
|
}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
items = payload.get('items', [])
|
||||||
|
|
||||||
|
if not isinstance(items, list) or len(items) == 0:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No order items to submit'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Normalize each item to a safe subset for handoff
|
||||||
|
normalized_items = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
normalized_items.append({
|
||||||
|
'productCode': str(item.get('productCode', '')).strip(),
|
||||||
|
'description': str(item.get('description', '')).strip(),
|
||||||
|
'material': str(item.get('material', '')).strip(),
|
||||||
|
'color': str(item.get('color', '')).strip(),
|
||||||
|
'size': str(item.get('size', '')).strip(),
|
||||||
|
'hingeLocation': str(item.get('hingeLocation', '')).strip(),
|
||||||
|
'quantity': int(item.get('quantity', 1) or 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
normalized_items = [i for i in normalized_items if i['productCode']]
|
||||||
|
if not normalized_items:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Order items are invalid'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
submission_id = f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:8]}"
|
||||||
|
created_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
quote_data = {
|
||||||
|
'schemaVersion': 1,
|
||||||
|
'submissionId': submission_id,
|
||||||
|
'createdAt': created_at,
|
||||||
|
'createdBy': {
|
||||||
|
'username': session.get('username'),
|
||||||
|
'location': session.get('currentLocation'),
|
||||||
|
'accessibleLocations': session.get('accessibleLocations', [])
|
||||||
|
},
|
||||||
|
'source': {
|
||||||
|
'app': 'product-finder',
|
||||||
|
'route': payload.get('route', ''),
|
||||||
|
'url': payload.get('url', '')
|
||||||
|
},
|
||||||
|
'context': {
|
||||||
|
'answers': payload.get('answers', {}),
|
||||||
|
'bitValue': payload.get('bitValue', 0)
|
||||||
|
},
|
||||||
|
'items': normalized_items
|
||||||
|
}
|
||||||
|
|
||||||
|
outgoing_dir = config.QUOTE_OUTGOING_DIR
|
||||||
|
os.makedirs(outgoing_dir, exist_ok=True)
|
||||||
|
|
||||||
|
final_filename = f"{submission_id}.json"
|
||||||
|
final_path = os.path.join(outgoing_dir, final_filename)
|
||||||
|
tmp_path = final_path + '.tmp'
|
||||||
|
|
||||||
|
# Atomic handoff write
|
||||||
|
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(quote_data, f, indent=2)
|
||||||
|
os.replace(tmp_path, final_path)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Quote submitted successfully',
|
||||||
|
'submissionId': submission_id,
|
||||||
|
'fileName': final_filename,
|
||||||
|
'filePath': final_path
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
@products_bp.route('/css/<path:filename>')
|
@products_bp.route('/css/<path:filename>')
|
||||||
def serve_css(filename):
|
def serve_css(filename):
|
||||||
"""Serve CSS files"""
|
"""Serve CSS files"""
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'data', 'products.db')}"
|
|||||||
# JSON data directory
|
# JSON data directory
|
||||||
DATA_DIR = os.path.join(basedir, 'data')
|
DATA_DIR = os.path.join(basedir, 'data')
|
||||||
|
|
||||||
|
# Shared handoff directory used by quote submission flow
|
||||||
|
_default_shared_dir = os.path.abspath(os.path.join(basedir, '..', 'shared'))
|
||||||
|
SHARED_DIR = os.environ.get('SHARED_DIR', _default_shared_dir)
|
||||||
|
QUOTE_OUTGOING_DIR = os.environ.get('QUOTE_OUTGOING_DIR', os.path.join(SHARED_DIR, 'outgoing'))
|
||||||
|
|
||||||
# Session configuration
|
# Session configuration
|
||||||
SESSION_COOKIE_HTTPONLY = True
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ let productData = [];
|
|||||||
let accessoryData = [];
|
let accessoryData = [];
|
||||||
let bitwiseData = {};
|
let bitwiseData = {};
|
||||||
|
|
||||||
|
// In-page order list for the current browser session
|
||||||
|
let orderItems = [];
|
||||||
|
|
||||||
// Track accumulated bit value for URL state
|
// Track accumulated bit value for URL state
|
||||||
let accumulatedBitValue = 0;
|
let accumulatedBitValue = 0;
|
||||||
|
|
||||||
@@ -625,6 +628,8 @@ function startOver() {
|
|||||||
}
|
}
|
||||||
// Clear bit value
|
// Clear bit value
|
||||||
accumulatedBitValue = 0;
|
accumulatedBitValue = 0;
|
||||||
|
// Clear order list
|
||||||
|
orderItems = [];
|
||||||
// Clear URL
|
// Clear URL
|
||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
loadContent('start');
|
loadContent('start');
|
||||||
@@ -916,6 +921,11 @@ function showProductDetail(product) {
|
|||||||
<div class="config-field" style="margin-top: 20px;">
|
<div class="config-field" style="margin-top: 20px;">
|
||||||
<button class="back-button" onclick="addToOrder('${prodCode}')" style="width: 100%; padding: 12px; font-size: 16px;">Add to Order</button>
|
<button class="back-button" onclick="addToOrder('${prodCode}')" style="width: 100%; padding: 12px; font-size: 16px;">Add to Order</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="config-field" style="margin-top: 12px;">
|
||||||
|
<div style="font-weight: 600; margin-bottom: 8px;">Current Order</div>
|
||||||
|
<div id="order-items-list" style="background: #f8f9fb; border: 1px solid #e3e5ea; border-radius: 8px; padding: 10px;"></div>
|
||||||
|
<button class="back-button" onclick="submitQuoteOrder()" style="width: 100%; margin-top: 10px; padding: 12px; font-size: 16px;">Quote</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-details">
|
<div class="result-details">
|
||||||
@@ -964,6 +974,7 @@ function showProductDetail(product) {
|
|||||||
|
|
||||||
contentDiv.innerHTML = html;
|
contentDiv.innerHTML = html;
|
||||||
updateBreadcrumb();
|
updateBreadcrumb();
|
||||||
|
renderOrderList();
|
||||||
|
|
||||||
// Initialize images based on type
|
// Initialize images based on type
|
||||||
if (useCanvasAPI) {
|
if (useCanvasAPI) {
|
||||||
@@ -1328,6 +1339,143 @@ function handleSizeChange(productCode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectedSizeLabel() {
|
||||||
|
const sizeSelect = document.getElementById('config-size');
|
||||||
|
if (!sizeSelect) return '';
|
||||||
|
|
||||||
|
if (sizeSelect.value === 'custom') {
|
||||||
|
const customWidth = document.getElementById('custom-width')?.value || '';
|
||||||
|
const customHeight = document.getElementById('custom-height')?.value || '';
|
||||||
|
if (customWidth && customHeight) {
|
||||||
|
return `Custom ${customWidth}" x ${customHeight}"`;
|
||||||
|
}
|
||||||
|
return 'Custom Size';
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOption = sizeSelect.options[sizeSelect.selectedIndex];
|
||||||
|
return selectedOption ? selectedOption.textContent.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrderItemKey(item) {
|
||||||
|
return [
|
||||||
|
item.productCode,
|
||||||
|
item.material,
|
||||||
|
item.color,
|
||||||
|
item.size,
|
||||||
|
item.hingeLocation
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToOrder(productCode) {
|
||||||
|
const product = productData.find(p => p.productCode === productCode || p.id === productCode);
|
||||||
|
if (!product) {
|
||||||
|
showNotification('Unable to add item: product not found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = document.getElementById('config-material')?.value || 'N/A';
|
||||||
|
const color = document.getElementById('config-color')?.value || 'N/A';
|
||||||
|
const hingeLocation = document.querySelector('[name="hinge-location"]:checked')?.value || 'left';
|
||||||
|
const size = getSelectedSizeLabel() || 'N/A';
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
productCode,
|
||||||
|
description: product.description || product.DESCRIPTION || product.title || productCode,
|
||||||
|
material,
|
||||||
|
color,
|
||||||
|
size,
|
||||||
|
hingeLocation,
|
||||||
|
quantity: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemKey = buildOrderItemKey(item);
|
||||||
|
const existingIndex = orderItems.findIndex(existing => buildOrderItemKey(existing) === itemKey);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
orderItems[existingIndex].quantity += 1;
|
||||||
|
} else {
|
||||||
|
orderItems.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderOrderList();
|
||||||
|
showNotification('Added to order.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitQuoteOrder() {
|
||||||
|
if (!Array.isArray(orderItems) || orderItems.length === 0) {
|
||||||
|
showNotification('Add at least one item before submitting a quote.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/quiz/api/submit-quote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: orderItems,
|
||||||
|
bitValue: accumulatedBitValue,
|
||||||
|
answers: userAnswers,
|
||||||
|
route: window.location.pathname,
|
||||||
|
url: window.location.href
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || data.status !== 'success') {
|
||||||
|
showNotification(data.message || 'Quote submission failed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionId = data.submissionId || 'unknown';
|
||||||
|
orderItems = [];
|
||||||
|
renderOrderList();
|
||||||
|
showNotification(`Quote submitted (${submissionId}).`);
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Quote submission failed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOrderItem(index) {
|
||||||
|
if (index < 0 || index >= orderItems.length) return;
|
||||||
|
orderItems.splice(index, 1);
|
||||||
|
renderOrderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOrderList() {
|
||||||
|
const container = document.getElementById('order-items-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (orderItems.length === 0) {
|
||||||
|
container.innerHTML = '<div style="color: #666; font-size: 0.95em;">No items added yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalQuantity = orderItems.reduce((total, item) => total + item.quantity, 0);
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display: grid; gap: 8px;">
|
||||||
|
${orderItems.map((item, index) => `
|
||||||
|
<div style="background: #fff; border: 1px solid #dfe3ea; border-radius: 6px; padding: 8px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 8px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 600;">${item.productCode} (${item.quantity})</div>
|
||||||
|
<div style="font-size: 0.9em; color: #444;">${item.description}</div>
|
||||||
|
<div style="font-size: 0.85em; color: #666; margin-top: 4px;">
|
||||||
|
Material: ${item.material} | Color: ${item.color}<br>
|
||||||
|
Size: ${item.size} | Hinge: ${item.hingeLocation}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="back-button" onclick="removeOrderItem(${index})" style="padding: 6px 10px; font-size: 0.85em;">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
<div style="font-size: 0.9em; color: #333; font-weight: 600; padding-top: 4px;">
|
||||||
|
Total Items: ${totalQuantity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// Go back to results from product detail
|
// Go back to results from product detail
|
||||||
function goBackToResults() {
|
function goBackToResults() {
|
||||||
// Find results in history or load it directly
|
// Find results in history or load it directly
|
||||||
|
|||||||
@@ -1,29 +1,9 @@
|
|||||||
# Required Code Changes for Conditional Navigation
|
# Conditional Navigation and Filtering Status
|
||||||
|
|
||||||
## Overview
|
## Purpose
|
||||||
To support conditional material/color questions and dynamic navigation, the following changes are needed in your application code.
|
|
||||||
|
|
||||||
---
|
This document records what was originally proposed for conditional question flow,
|
||||||
|
what is already implemented, and what still remains.
|
||||||
## 1. JavaScript Changes (`js/script.js`)
|
|
||||||
|
|
||||||
### Current State
|
|
||||||
- Loads `questions.json` statically
|
|
||||||
- Has hardcoded conditional for `q-material-conditional`
|
|
||||||
- No product data loading
|
|
||||||
|
|
||||||
### Required Changes
|
|
||||||
|
|
||||||
#### A. Load Multiple Data Files
|
|
||||||
```javascript
|
|
||||||
// At the top of script.js - modify loadQuestionData() to load both files
|
|
||||||
let questionData = {};
|
|
||||||
let productData = [];
|
|
||||||
let accessoryData = [];
|
|
||||||
let bitwiseData = {};
|
|
||||||
let accumulatedBitValue = 0;
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
// Load all data files
|
// Load all data files
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch('data/navigation.json').then(r => r.json()),
|
fetch('data/navigation.json').then(r => r.json()),
|
||||||
|
|||||||
+88
-155
@@ -1,184 +1,117 @@
|
|||||||
# 🎉 Flask Web Application Successfully Created!
|
# Product Finder Project
|
||||||
|
|
||||||
Your HTML/JavaScript quiz has been converted to a Python Flask web application.
|
## What This Project Is
|
||||||
|
|
||||||
## ✅ What Was Created
|
This repository contains a Flask-based Product Finder application used to guide users
|
||||||
|
through product selection and provide product detail, accessory, search, and admin
|
||||||
|
workflows.
|
||||||
|
|
||||||
### Core Application Files
|
The current application is no longer a simple Flask conversion of a static quiz.
|
||||||
- **app.py** - Main Flask application with routes
|
It now includes:
|
||||||
- **wsgi.py** - Production WSGI entry point
|
- modular blueprints
|
||||||
- **config.py** - Configuration management
|
- login and session management
|
||||||
- **requirements.txt** - Python dependencies
|
- location-aware access
|
||||||
|
- product management APIs
|
||||||
|
- shareable quiz URLs
|
||||||
|
- layered image preview support
|
||||||
|
- an optional SQLite backend
|
||||||
|
|
||||||
### Templates & Static Files
|
## Fastest Way To Run It
|
||||||
- **templates/** - HTML files (index.html, index2.html, 404.html)
|
|
||||||
- **css/** - Stylesheets (styles.css)
|
|
||||||
- **js/** - JavaScript files (script.js)
|
|
||||||
- **images/** - Product images folder
|
|
||||||
|
|
||||||
### Documentation & Utilities
|
From the repository root:
|
||||||
- **README.md** - Complete documentation
|
|
||||||
- **QUICKSTART.md** - Quick start guide
|
|
||||||
- **run.bat** - Windows startup script
|
|
||||||
- **.gitignore** - Git ignore file
|
|
||||||
- **.env.example** - Environment template
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### Option 1: Using Batch File (Windows)
|
|
||||||
Double-click `run.bat` to install dependencies and start the server.
|
|
||||||
|
|
||||||
### Option 2: Manual Start
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
cd app
|
||||||
pip install -r requirements.txt
|
../.venv/bin/python app.py
|
||||||
|
|
||||||
# Run the application
|
|
||||||
python app.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Access Your Application
|
Then open:
|
||||||
Open in browser: **http://localhost:8080/quiz**
|
- `http://127.0.0.1:8080/`
|
||||||
|
- `http://127.0.0.1:8080/quiz/`
|
||||||
|
|
||||||
## 🌐 Current Status
|
## Recommended VS Code Tasks
|
||||||
|
|
||||||
✅ Flask is currently running on: http://localhost:8080
|
Use the built-in tasks from the repository root:
|
||||||
✅ Quiz page: http://localhost:8080/quiz
|
|
||||||
✅ Debug mode: Enabled (auto-reloads on file changes)
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
- `Start Flask Server`
|
||||||
|
- `Process All Data`
|
||||||
|
- `Update Data Files from CSV`
|
||||||
|
- `Generate Bitwise Data`
|
||||||
|
|
||||||
```
|
`Process All Data` runs the two data-generation steps in sequence.
|
||||||
project/
|
|
||||||
├── app.py ← Main Flask app (START HERE)
|
|
||||||
├── wsgi.py ← For production deployment
|
|
||||||
├── config.py ← Settings & configuration
|
|
||||||
├── requirements.txt ← Python packages needed
|
|
||||||
├── run.bat ← Windows startup script
|
|
||||||
│
|
|
||||||
├── templates/ ← HTML files (Flask requires this folder)
|
|
||||||
│ ├── index.html ← Main landing page
|
|
||||||
│ ├── index2.html ← Quiz page
|
|
||||||
│ └── 404.html ← Error page
|
|
||||||
│
|
|
||||||
├── css/ ← Stylesheets
|
|
||||||
│ └── styles.css ← Main CSS file
|
|
||||||
│
|
|
||||||
├── js/ ← JavaScript files
|
|
||||||
│ └── script.js ← Quiz logic and data
|
|
||||||
│
|
|
||||||
└── images/ ← Product images (add your images here)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Key Features
|
## Main Application Areas
|
||||||
|
|
||||||
✅ **Dynamic Routing** - Flask handles all page requests
|
### Authentication
|
||||||
✅ **Static File Serving** - CSS, JS, and images properly served
|
- login page
|
||||||
✅ **Error Handling** - Custom 404 page
|
- logout
|
||||||
✅ **API Endpoints** - Ready for future backend features
|
- session info endpoint
|
||||||
✅ **Production Ready** - WSGI config included
|
- location selection for multi-location users
|
||||||
✅ **All Original Features** - Quiz, forms, conditional logic, memory storage
|
|
||||||
|
|
||||||
## 🔧 Customization
|
### Product Finder
|
||||||
|
- guided quiz flow at `/quiz/`
|
||||||
|
- advanced search page
|
||||||
|
- product list page
|
||||||
|
- product manager page
|
||||||
|
- direct product links at `/quiz/product/<product_code>`
|
||||||
|
|
||||||
### Change Port
|
### Images and Previews
|
||||||
Edit `app.py`, line with `app.run()`:
|
- flat product images
|
||||||
```python
|
- layered image previews
|
||||||
app.run(debug=True, host='0.0.0.0', port=YOUR_PORT)
|
- Canvas API fallback routes for hierarchical image lookup
|
||||||
```
|
- layer transforms for mirrored door/hardware previews where configured
|
||||||
|
|
||||||
### Add New Routes
|
### Data Layer
|
||||||
In `app.py`:
|
- JSON is the default runtime backend
|
||||||
```python
|
- SQLite support exists behind a config flag
|
||||||
@app.route('/your-page')
|
- frontend quiz data is driven by JSON files in `app/data/`
|
||||||
def your_page():
|
|
||||||
return render_template('your-page.html')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Quiz Questions
|
## Important Files
|
||||||
Edit `js/script.js` - modify the `questionData` object
|
|
||||||
|
|
||||||
### Change Styling
|
- `app/app.py` - application entry point
|
||||||
Edit `css/styles.css`
|
- `app/blueprints/auth.py` - authentication/session logic
|
||||||
|
- `app/blueprints/users.py` - user management routes
|
||||||
|
- `app/blueprints/products.py` - quiz pages, product APIs, admin pages
|
||||||
|
- `app/blueprints/canvas.py` - Canvas API image routes
|
||||||
|
- `app/static/js/script.js` - quiz flow, URL state, search, previews
|
||||||
|
- `app/data/navigation.json` - quiz navigation structure
|
||||||
|
- `app/data/products.json` - product catalog data
|
||||||
|
- `app/data/accessories.json` - accessory data
|
||||||
|
- `app/data/product_bitwise.json` - bitwise lookup data
|
||||||
|
|
||||||
## 📦 Deployment Options
|
## Current Known Status
|
||||||
|
|
||||||
### 1. Web Panels (cPanel, Plesk)
|
Implemented:
|
||||||
- Upload all files
|
- modular Flask app structure
|
||||||
- Install requirements: `pip install -r requirements.txt`
|
- login/session flow
|
||||||
- Point to `wsgi.py`
|
- location-aware permissions
|
||||||
|
- user management
|
||||||
|
- product CRUD/search APIs
|
||||||
|
- quiz result filtering
|
||||||
|
- shareable URL state
|
||||||
|
- product deep links
|
||||||
|
- Canvas API image fallback system
|
||||||
|
|
||||||
### 2. Cloud Platforms
|
Still incomplete:
|
||||||
- **Heroku**: Add `Procfile` and push to Git
|
- fully generic conditional question skipping based on navigation metadata
|
||||||
- **PythonAnywhere**: Upload and configure WSGI
|
- automated tests
|
||||||
- **AWS/Azure**: Use with Gunicorn
|
- decision on whether SQLite should become the default backend
|
||||||
|
|
||||||
### 3. Docker
|
## If You Are Updating Product Data
|
||||||
See README.md for Dockerfile example
|
|
||||||
|
|
||||||
### 4. Production Server
|
1. Update the source CSV used by the data-generation scripts.
|
||||||
```bash
|
2. Run `Process All Data`.
|
||||||
pip install gunicorn
|
3. Restart the Flask server if needed.
|
||||||
gunicorn -w 4 -b 0.0.0.0:8080 wsgi:app
|
4. Verify quiz results and product detail views.
|
||||||
```
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## If You Are Picking Up Development
|
||||||
|
|
||||||
### Port Already in Use
|
Start with these files:
|
||||||
Change port in app.py or kill the process:
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
netstat -ano | findstr :8080
|
|
||||||
taskkill /PID <PID> /F
|
|
||||||
```
|
|
||||||
|
|
||||||
### Templates Not Found
|
1. `app/app.py`
|
||||||
Make sure HTML files are in `templates/` folder
|
2. `app/blueprints/products.py`
|
||||||
|
3. `app/static/js/script.js`
|
||||||
|
4. `app/data/navigation.json`
|
||||||
|
5. `app/config.py`
|
||||||
|
|
||||||
### Static Files Not Loading
|
Those files define the main routing, quiz behavior, and backend mode.
|
||||||
Check that `css/` and `js/` folders exist in root directory
|
|
||||||
|
|
||||||
### Module Not Found
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 Next Steps
|
|
||||||
|
|
||||||
1. **Test the application** - Visit http://localhost:8080/quiz
|
|
||||||
2. **Add your product images** - Place images in `images/` folder
|
|
||||||
3. **Customize the quiz** - Edit `js/script.js`
|
|
||||||
4. **Update styling** - Modify `css/styles.css`
|
|
||||||
5. **Deploy** - Follow README.md deployment guide
|
|
||||||
|
|
||||||
## 🔒 Security Notes for Production
|
|
||||||
|
|
||||||
Before deploying to production:
|
|
||||||
- [ ] Set `DEBUG = False` in config.py
|
|
||||||
- [ ] Change `SECRET_KEY` to a strong random value
|
|
||||||
- [ ] Use environment variables for sensitive data
|
|
||||||
- [ ] Set up HTTPS/SSL
|
|
||||||
- [ ] Use a production WSGI server (Gunicorn, uWSGI)
|
|
||||||
- [ ] Configure proper logging
|
|
||||||
- [ ] Set up database backups (if using a database)
|
|
||||||
|
|
||||||
## 💡 Tips
|
|
||||||
|
|
||||||
- Flask auto-reloads when you edit files (in debug mode)
|
|
||||||
- Press `Ctrl+C` to stop the server
|
|
||||||
- Check terminal for error messages
|
|
||||||
- Use browser DevTools to debug JavaScript
|
|
||||||
- All original quiz functionality is preserved
|
|
||||||
|
|
||||||
## 📞 Need Help?
|
|
||||||
|
|
||||||
- Check **README.md** for detailed documentation
|
|
||||||
- Check **QUICKSTART.md** for simple instructions
|
|
||||||
- Review Flask logs in terminal for errors
|
|
||||||
- Test API endpoints using browser or Postman
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Your Flask app is ready to use! 🎊**
|
|
||||||
|
|
||||||
Visit: http://localhost:8080/quiz
|
|
||||||
|
|||||||
@@ -1,230 +1,94 @@
|
|||||||
# Implementation Summary - URL State Management
|
# URL State Management
|
||||||
|
|
||||||
## 🎯 What You Get
|
## Current Status
|
||||||
|
|
||||||
Your application will support **shareable URLs** that preserve:
|
URL state management is implemented in the current frontend.
|
||||||
- ✅ User's navigation progress (bitwise value)
|
|
||||||
- ✅ Current question position
|
|
||||||
- ✅ Direct product links
|
|
||||||
- ✅ Browser refresh without data loss
|
|
||||||
- ✅ Browser back/forward buttons
|
|
||||||
- ✅ Copy/share functionality
|
|
||||||
|
|
||||||
## 🔗 URL Examples
|
The quiz now supports:
|
||||||
|
- Shareable URLs for in-progress quiz state
|
||||||
|
- Direct links to product detail views
|
||||||
|
- Browser refresh without losing the current route context
|
||||||
|
- Browser back/forward support
|
||||||
|
- Share buttons on results and product-detail views
|
||||||
|
|
||||||
```
|
## Current URL Parameters
|
||||||
# Initial state (no params)
|
|
||||||
https://yoursite.com/
|
|
||||||
|
|
||||||
# After selecting Door + Storm Door + Aluminum
|
- `b` - accumulated bitwise state for the user selections
|
||||||
https://yoursite.com/?b=16401&q=q-dimensions
|
- `q` - current question key or `results`
|
||||||
↑ ↑
|
- `p` - product code for direct product-detail views
|
||||||
| Current question
|
|
||||||
Accumulated bit value (Door + Aluminum + Storm Door)
|
|
||||||
|
|
||||||
# Viewing specific product
|
Examples:
|
||||||
https://yoursite.com/?p=BGIST&b=16401
|
|
||||||
↑
|
|
||||||
Product code
|
|
||||||
|
|
||||||
# Bit value 16401 decodes to:
|
```text
|
||||||
# Bit 0 (1) = Door
|
/quiz/?b=16401&q=results
|
||||||
# Bit 4 (16) = Aluminum
|
/quiz/?p=404&b=16401
|
||||||
# Bit 14 (16384) = Storm Door subtype
|
/quiz/product/404
|
||||||
# Total: 1 + 16 + 16384 = 16401
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📋 Implementation Checklist
|
## What Is Implemented
|
||||||
|
|
||||||
### Phase 1: Core URL Functionality (Essential)
|
### Core State Handling
|
||||||
- [ ] Add bitwise data loading to `init()` function
|
- `accumulatedBitValue` is tracked in the frontend
|
||||||
- [ ] Add `accumulatedBitValue` variable
|
- `BIT_DEFINITIONS` constants are present in `script.js`
|
||||||
- [ ] Add `BIT_DEFINITIONS` constants
|
- `product_bitwise.json` is loaded during startup
|
||||||
- [ ] Implement `updateURL()` function
|
- `updateBitValue()` updates the accumulated bitmask from answer filters
|
||||||
- [ ] Implement `updateBitValue()` function
|
- `updateURL()` writes `b` and `q` params without reloading the page
|
||||||
- [ ] Update `handleAnswer()` to call `updateBitValue()`
|
- `initFromURL()` restores the app from query params or direct product routes
|
||||||
- [ ] Implement `initFromURL()` function
|
- `restoreStateFromBitValue()` rebuilds the key quiz selections from the bitmask
|
||||||
- [ ] Implement `restoreStateFromBitValue()` function
|
- `startOver()` clears the URL state
|
||||||
- [ ] Update `startOver()` to clear URL
|
|
||||||
|
|
||||||
### Phase 2: Product Deep Linking (Recommended)
|
### Product Deep Linking
|
||||||
- [ ] Implement `showProductByCode()` function
|
- Product cards open detail views through `showProductByCode()`
|
||||||
- [ ] Update `showProductDetail()` to update URL
|
- Product detail views push the selected product code into the URL
|
||||||
- [ ] Make product cards clickable
|
- Direct query-string product links are supported with `?p=<code>`
|
||||||
- [ ] Add URL params when viewing products
|
- Direct Flask routes are supported with `/quiz/product/<product_code>`
|
||||||
|
|
||||||
### Phase 3: Share Functionality (Nice to Have)
|
### Share and Navigation Support
|
||||||
- [ ] Implement `shareCurrentPage()` function
|
- `shareCurrentPage()` copies the current URL to the clipboard when supported
|
||||||
- [ ] Add `showNotification()` helper
|
- A notification helper is shown after successful copy
|
||||||
- [ ] Add "Share" buttons to UI
|
- Share buttons exist on results and product detail screens
|
||||||
- [ ] Add CSS for notification animations
|
- A `popstate` listener handles browser back/forward navigation
|
||||||
|
|
||||||
### Phase 4: Browser Navigation (Polish)
|
## Implementation Notes
|
||||||
- [ ] Add `popstate` event listener
|
|
||||||
- [ ] Test browser back button
|
|
||||||
- [ ] Test browser forward button
|
|
||||||
- [ ] Test refresh behavior
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
The current implementation restores the primary quiz state used by filtering:
|
||||||
|
- base type
|
||||||
|
- subtype
|
||||||
|
- material
|
||||||
|
- color
|
||||||
|
|
||||||
### Step 1: Add Global Variables
|
That is enough to reopen result sets and product-detail pages consistently.
|
||||||
Add to top of `js/script.js`:
|
|
||||||
```javascript
|
|
||||||
let accumulatedBitValue = 0;
|
|
||||||
let bitwiseData = {};
|
|
||||||
|
|
||||||
const BIT_DEFINITIONS = {
|
Dimension data is still primarily driven by the current session flow rather than fully
|
||||||
'base_door': 0, 'base_window': 1,
|
reconstructed from the URL alone.
|
||||||
'material_aluminum': 4, 'material_vinyl': 5,
|
|
||||||
'color_black': 6, 'color_white': 7, 'color_bronze': 8,
|
|
||||||
'color_tan': 9, 'color_mill': 10, 'color_sandstone': 11,
|
|
||||||
'subtype_patio_door': 12, 'subtype_primary_window': 13,
|
|
||||||
'subtype_storm_door': 14, 'subtype_storm_window': 15
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Load Bitwise Data
|
## Validation Checklist
|
||||||
Update `init()` to load `product_bitwise.json`:
|
|
||||||
```javascript
|
|
||||||
Promise.all([
|
|
||||||
fetch('data/navigation.json').then(r => r.json()),
|
|
||||||
fetch('data/products.json').then(r => r.json()),
|
|
||||||
fetch('data/accessories.json').then(r => r.json()),
|
|
||||||
fetch('data/product_bitwise.json').then(r => r.json()) // ADD THIS
|
|
||||||
])
|
|
||||||
.then(([questions, products, accessories, bitwise]) => {
|
|
||||||
// ... existing code ...
|
|
||||||
|
|
||||||
// Index bitwise data
|
|
||||||
bitwiseData = {};
|
|
||||||
bitwise.forEach(item => {
|
|
||||||
bitwiseData[item.PROD_CODE] = item.BIT_VALUE;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for URL state
|
|
||||||
initFromURL();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Add URL Functions
|
- [x] Bitwise data loads at startup
|
||||||
Copy these three key functions from [URL_STATE_MANAGEMENT.md](URL_STATE_MANAGEMENT.md):
|
- [x] URLs update as quiz answers are selected
|
||||||
1. `updateURL()` - Updates browser URL
|
- [x] Results pages can be shared with `b` and `q`
|
||||||
2. `updateBitValue()` - Calculates bit value from selection
|
- [x] Product pages can be shared with `p`
|
||||||
3. `initFromURL()` - Restores state from URL on load
|
- [x] Browser refresh restores route context
|
||||||
|
- [x] Browser back button is handled in code
|
||||||
|
- [x] Browser forward button is handled in code
|
||||||
|
- [ ] Full manual regression testing across all quiz paths
|
||||||
|
|
||||||
### Step 4: Update handleAnswer
|
## Known Limits
|
||||||
Add one line to `handleAnswer()`:
|
|
||||||
```javascript
|
|
||||||
function handleAnswer(currentKey, answerValue, answerIndex) {
|
|
||||||
// ... existing code ...
|
|
||||||
|
|
||||||
updateBitValue(answerObject); // ADD THIS LINE
|
|
||||||
|
|
||||||
const nextKey = resolveNextQuestion(currentKey, answerValue, answerObject);
|
|
||||||
history.push(nextKey);
|
|
||||||
|
|
||||||
updateURL(nextKey); // ADD THIS LINE
|
|
||||||
|
|
||||||
loadContent(nextKey);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Test
|
- URL restoration is designed around the current bitwise filter model, not a complete
|
||||||
1. Run your app
|
replay of every form interaction.
|
||||||
2. Navigate through questions
|
- Conditional question metadata exists in the navigation data, but the frontend does
|
||||||
3. Check URL updates after each selection
|
not yet use a fully generic conditional-resolution engine.
|
||||||
4. Copy URL and paste in new tab
|
|
||||||
5. Should restore to same state ✅
|
|
||||||
|
|
||||||
## 📖 Documentation Files
|
## Related Files
|
||||||
|
|
||||||
All details are in these files:
|
- `app/static/js/script.js`
|
||||||
- **[URL_STATE_MANAGEMENT.md](URL_STATE_MANAGEMENT.md)** - Complete implementation guide
|
- `app/data/product_bitwise.json`
|
||||||
- **[REQUIRED_CODE_CHANGES.md](REQUIRED_CODE_CHANGES.md)** - Updated with URL features
|
- `information/BITWISE_USAGE_GUIDE.md`
|
||||||
- **[BITWISE_USAGE_GUIDE.md](BITWISE_USAGE_GUIDE.md)** - How bitwise system works
|
- `information/REQUIRED_CODE_CHANGES.md`
|
||||||
|
|
||||||
## 🔧 Key Functions Reference
|
## Recommended Next Steps
|
||||||
|
|
||||||
| Function | Purpose | When Called |
|
1. Manually test refresh and back/forward behavior on all major quiz branches.
|
||||||
|----------|---------|-------------|
|
2. Extend restoration if dimension-specific deep linking becomes a requirement.
|
||||||
| `initFromURL()` | Read URL params on page load | Once at startup |
|
3. Finish generic conditional navigation so URL restoration and question skipping use the same rules.
|
||||||
| `updateURL()` | Write current state to URL | After each answer |
|
|
||||||
| `updateBitValue()` | Add selection to bit value | After each answer |
|
|
||||||
| `restoreStateFromBitValue()` | Decode bit value to selections | On page load from URL |
|
|
||||||
| `shareCurrentPage()` | Copy URL to clipboard | User clicks "Share" |
|
|
||||||
|
|
||||||
## 🎨 URL Format Design
|
|
||||||
|
|
||||||
### Why Bitwise?
|
|
||||||
- **Compact**: `?b=16401` vs `?door=true&aluminum=true&storm=true`
|
|
||||||
- **Fast**: Single integer comparison
|
|
||||||
- **Flexible**: Easy to add new attributes
|
|
||||||
- **Shareable**: Short URLs
|
|
||||||
- **Reversible**: Can decode back to selections
|
|
||||||
|
|
||||||
### Parameters Chosen
|
|
||||||
- `b` = bit value (short, recognizable)
|
|
||||||
- `q` = question (short, clear purpose)
|
|
||||||
- `p` = product (short, clear purpose)
|
|
||||||
|
|
||||||
### Alternative Considered
|
|
||||||
Could use hash fragments instead:
|
|
||||||
```
|
|
||||||
https://yoursite.com/#/door/storm-door/aluminum
|
|
||||||
```
|
|
||||||
But query params are better for:
|
|
||||||
- Server-side rendering
|
|
||||||
- Analytics tracking
|
|
||||||
- SEO (if products are indexed)
|
|
||||||
|
|
||||||
## ⚠️ Important Notes
|
|
||||||
|
|
||||||
1. **Bit Definitions Must Match**: The `BIT_DEFINITIONS` in JavaScript must match the Python script
|
|
||||||
2. **URL Length Limits**: URLs have practical limits (~2000 chars), but bit values are small
|
|
||||||
3. **No Sensitive Data**: Don't put sensitive info in URL (it's visible and loggable)
|
|
||||||
4. **Test Thoroughly**: Test all navigation paths and browser actions
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
### URL Not Updating
|
|
||||||
- Check `updateURL()` is called after `handleAnswer()`
|
|
||||||
- Check browser console for errors
|
|
||||||
- Verify `accumulatedBitValue` is being set
|
|
||||||
|
|
||||||
### State Not Restoring
|
|
||||||
- Check `initFromURL()` is called in `init()`
|
|
||||||
- Verify URL has `b` and `q` parameters
|
|
||||||
- Check `restoreStateFromBitValue()` logic
|
|
||||||
|
|
||||||
### Wrong Bit Values
|
|
||||||
- Verify `BIT_DEFINITIONS` matches `generate_bitwise_helper.py`
|
|
||||||
- Check `bitwise_legend.json` for correct bit positions
|
|
||||||
- Use browser console: `console.log(accumulatedBitValue)`
|
|
||||||
|
|
||||||
### Share Button Not Working
|
|
||||||
- Check clipboard API support: `navigator.clipboard`
|
|
||||||
- Fallback to `prompt()` for older browsers
|
|
||||||
- Test in HTTPS (clipboard API requires secure context)
|
|
||||||
|
|
||||||
## 📈 Benefits Summary
|
|
||||||
|
|
||||||
| Feature | User Benefit | Business Benefit |
|
|
||||||
|---------|-------------|------------------|
|
|
||||||
| Shareable URLs | Share configurations | Viral marketing |
|
|
||||||
| Bookmarks | Save favorites | Return visitors |
|
|
||||||
| Refresh-safe | No data loss | Better UX |
|
|
||||||
| Deep linking | Direct to product | SEO indexing |
|
|
||||||
| Browser nav | Back/forward works | Expected behavior |
|
|
||||||
| Short URLs | Easy to share | More sharing |
|
|
||||||
|
|
||||||
## 🎯 Next Steps
|
|
||||||
|
|
||||||
1. ✅ Implement Phase 1 (core URL functionality)
|
|
||||||
2. ✅ Test basic URL state restoration
|
|
||||||
3. ✅ Add Phase 2 (product deep linking)
|
|
||||||
4. ✅ Add Phase 3 (share buttons)
|
|
||||||
5. ✅ Add Phase 4 (browser navigation)
|
|
||||||
6. ✅ Test all scenarios thoroughly
|
|
||||||
7. ✅ Add analytics tracking (optional)
|
|
||||||
|
|
||||||
Good luck! 🚀
|
|
||||||
|
|||||||
@@ -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