diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..8c3e453 --- /dev/null +++ b/.vscode/tasks.json @@ -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)" + } + ] +} diff --git a/app/README.md b/app/README.md index 3793c65..6d8cb0c 100644 --- a/app/README.md +++ b/app/README.md @@ -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.py # Main application file -├── passenger_wsgi.py # Passenger WSGI entry point for production -├── requirements.txt # Python dependencies -├── blueprints/ # Modular routes organized by feature -│ ├── __init__.py -│ ├── auth.py # Login/logout/session management -│ └── users.py # User CRUD operations -├── templates/ # HTML templates -│ ├── login.html -│ └── user_manager.html -└── data/ # Data storage - └── users.json # User accounts with hashed passwords +app/ +├── app.py # Flask app entry point and blueprint registration +├── config.py # Backend/config toggles +├── data_access.py # Chooses JSON or SQLite backend +├── data_access_json.py # JSON-backed data operations +├── data_access_sqlite.py # SQLite-backed data operations +├── models.py # SQLAlchemy models for SQLite mode +├── blueprints/ +│ ├── auth.py # Login, logout, session, location selection +│ ├── users.py # User CRUD and password management +│ ├── products.py # Quiz pages, product APIs, admin pages +│ └── canvas.py # Layered image fallback API +├── 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) -- Login with username/password -- Session management -- Password hashing (PBKDF2-SHA256, 1M iterations) -- Login required decorator -- Permission checking: `can_user('manage_users')` -- User status checking (active/inactive) +### Authentication and Session Flow +- Username/password login +- Session-backed authentication +- Active/inactive user checks +- Multi-location session support with location selection +- Permission checks via `can_user(...)` -### ✅ User Management (blueprints/users.py) -- View all users -- Add new users +### User Management +- List users +- Create users - Delete users -- Toggle active/inactive status -- Change passwords (requires admin verification) -- Location-based access control -- Permission system (manage_users, create_quotes, etc.) -- Download users as JSON +- Toggle active status +- Change passwords +- Download user data 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` -- **Password:** `Master` -- **Permissions:** Full access (manage_users) +### Product Management and Search +- Advanced search page +- 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/` +- `/quiz/api/products` +- `/quiz/api/products/search` +- `/quiz/api/products/` +- `/quiz/api/product-attributes` + +### Canvas API Routes +- `/api/canvas//` +- `/api/canvas//info` +- `/api/canvas/test` + +## Local Development + +From the repository root: ```bash -cd product_app -python app.py +cd app +../.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 -- `/` - Home (redirects to login if not authenticated) -- `/test` - Test page to verify app is running +If product source data changes, use the VS Code tasks from the repository root: -### Authentication Routes (auth_bp) -- `/login` - Login page -- `/logout` - Logout -- `/api/login` - POST: Login API -- `/api/session` - GET: Current session info +- `Update Data Files from CSV` +- `Generate Bitwise Data` +- `Process All Data` -### User Management Routes (users_bp) -- `/users/` - User management page -- `/users/api` - GET: List all users, POST: Add user -- `/users/api/` - DELETE: Delete user -- `/users/api//active` - PATCH: Toggle active status -- `/users/api//change-password` - POST: Change password -- `/users/api/download` - GET: Download users.json +These regenerate the JSON files consumed by the quiz and filtering logic. -## 📦 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: -- `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` +## Recommended Next Work -### Control Panel Settings - -- **Application startup file:** `passenger_wsgi.py` (or `app.py`) -- **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 +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. +3. Decide whether SQLite should remain optional or become the default backend. diff --git a/app/blueprints/products.py b/app/blueprints/products.py index 162d8ef..494e689 100644 --- a/app/blueprints/products.py +++ b/app/blueprints/products.py @@ -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 import data_access as da import os +import json +import uuid +from datetime import datetime, timezone +import config products_bp = Blueprint('products', __name__, url_prefix='/quiz') @@ -226,6 +230,106 @@ def get_product_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/') def serve_css(filename): """Serve CSS files""" diff --git a/app/config.py b/app/config.py index eba5d2f..d01775f 100644 --- a/app/config.py +++ b/app/config.py @@ -15,6 +15,11 @@ DATABASE_URI = f"sqlite:///{os.path.join(basedir, 'data', 'products.db')}" # JSON data directory 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_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' diff --git a/app/static/js/script.js b/app/static/js/script.js index 0e8704a..6094078 100644 --- a/app/static/js/script.js +++ b/app/static/js/script.js @@ -10,6 +10,9 @@ let productData = []; let accessoryData = []; let bitwiseData = {}; +// In-page order list for the current browser session +let orderItems = []; + // Track accumulated bit value for URL state let accumulatedBitValue = 0; @@ -625,6 +628,8 @@ function startOver() { } // Clear bit value accumulatedBitValue = 0; + // Clear order list + orderItems = []; // Clear URL window.history.replaceState({}, '', window.location.pathname); loadContent('start'); @@ -916,6 +921,11 @@ function showProductDetail(product) {
+
+
Current Order
+
+ +
@@ -964,6 +974,7 @@ function showProductDetail(product) { contentDiv.innerHTML = html; updateBreadcrumb(); + renderOrderList(); // Initialize images based on type 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 = '
No items added yet.
'; + return; + } + + const totalQuantity = orderItems.reduce((total, item) => total + item.quantity, 0); + + container.innerHTML = ` +
+ ${orderItems.map((item, index) => ` +
+
+
+
${item.productCode} (${item.quantity})
+
${item.description}
+
+ Material: ${item.material} | Color: ${item.color}
+ Size: ${item.size} | Hinge: ${item.hingeLocation} +
+
+ +
+
+ `).join('')} +
+ Total Items: ${totalQuantity} +
+
+ `; +} + // Go back to results from product detail function goBackToResults() { // Find results in history or load it directly diff --git a/information/REQUIRED_CODE_CHANGES.md b/information/REQUIRED_CODE_CHANGES.md index ecbb33c..1e1b81a 100644 --- a/information/REQUIRED_CODE_CHANGES.md +++ b/information/REQUIRED_CODE_CHANGES.md @@ -1,29 +1,9 @@ -# Required Code Changes for Conditional Navigation +# Conditional Navigation and Filtering Status -## Overview -To support conditional material/color questions and dynamic navigation, the following changes are needed in your application code. +## Purpose ---- - -## 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() { +This document records what was originally proposed for conditional question flow, +what is already implemented, and what still remains. // Load all data files Promise.all([ fetch('data/navigation.json').then(r => r.json()), diff --git a/information/START_HERE.md b/information/START_HERE.md index 814c8ff..4838b82 100644 --- a/information/START_HERE.md +++ b/information/START_HERE.md @@ -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 -- **app.py** - Main Flask application with routes -- **wsgi.py** - Production WSGI entry point -- **config.py** - Configuration management -- **requirements.txt** - Python dependencies +The current application is no longer a simple Flask conversion of a static quiz. +It now includes: +- modular blueprints +- login and session management +- location-aware access +- product management APIs +- shareable quiz URLs +- layered image preview support +- an optional SQLite backend -### Templates & Static Files -- **templates/** - HTML files (index.html, index2.html, 404.html) -- **css/** - Stylesheets (styles.css) -- **js/** - JavaScript files (script.js) -- **images/** - Product images folder +## Fastest Way To Run It -### Documentation & Utilities -- **README.md** - Complete documentation -- **QUICKSTART.md** - Quick start guide -- **run.bat** - Windows startup script -- **.gitignore** - Git ignore file -- **.env.example** - Environment template +From the repository root: -## 🚀 Quick Start - -### Option 1: Using Batch File (Windows) -Double-click `run.bat` to install dependencies and start the server. - -### Option 2: Manual Start ```bash -# Install dependencies -pip install -r requirements.txt - -# Run the application -python app.py +cd app +../.venv/bin/python app.py ``` -### Access Your Application -Open in browser: **http://localhost:8080/quiz** +Then open: +- `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 -✅ Quiz page: http://localhost:8080/quiz -✅ Debug mode: Enabled (auto-reloads on file changes) +Use the built-in tasks from the repository root: -## 📁 Project Structure +- `Start Flask Server` +- `Process All Data` +- `Update Data Files from CSV` +- `Generate Bitwise Data` -``` -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) -``` +`Process All Data` runs the two data-generation steps in sequence. -## 🎯 Key Features +## Main Application Areas -✅ **Dynamic Routing** - Flask handles all page requests -✅ **Static File Serving** - CSS, JS, and images properly served -✅ **Error Handling** - Custom 404 page -✅ **API Endpoints** - Ready for future backend features -✅ **Production Ready** - WSGI config included -✅ **All Original Features** - Quiz, forms, conditional logic, memory storage +### Authentication +- login page +- logout +- session info endpoint +- location selection for multi-location users -## 🔧 Customization +### Product Finder +- guided quiz flow at `/quiz/` +- advanced search page +- product list page +- product manager page +- direct product links at `/quiz/product/` -### Change Port -Edit `app.py`, line with `app.run()`: -```python -app.run(debug=True, host='0.0.0.0', port=YOUR_PORT) -``` +### Images and Previews +- flat product images +- layered image previews +- Canvas API fallback routes for hierarchical image lookup +- layer transforms for mirrored door/hardware previews where configured -### Add New Routes -In `app.py`: -```python -@app.route('/your-page') -def your_page(): - return render_template('your-page.html') -``` +### Data Layer +- JSON is the default runtime backend +- SQLite support exists behind a config flag +- frontend quiz data is driven by JSON files in `app/data/` -### Update Quiz Questions -Edit `js/script.js` - modify the `questionData` object +## Important Files -### Change Styling -Edit `css/styles.css` +- `app/app.py` - application entry point +- `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) -- Upload all files -- Install requirements: `pip install -r requirements.txt` -- Point to `wsgi.py` +Implemented: +- modular Flask app structure +- login/session flow +- 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 -- **Heroku**: Add `Procfile` and push to Git -- **PythonAnywhere**: Upload and configure WSGI -- **AWS/Azure**: Use with Gunicorn +Still incomplete: +- fully generic conditional question skipping based on navigation metadata +- automated tests +- decision on whether SQLite should become the default backend -### 3. Docker -See README.md for Dockerfile example +## If You Are Updating Product Data -### 4. Production Server -```bash -pip install gunicorn -gunicorn -w 4 -b 0.0.0.0:8080 wsgi:app -``` +1. Update the source CSV used by the data-generation scripts. +2. Run `Process All Data`. +3. Restart the Flask server if needed. +4. Verify quiz results and product detail views. -## 🐛 Troubleshooting +## If You Are Picking Up Development -### Port Already in Use -Change port in app.py or kill the process: -```bash -# Windows -netstat -ano | findstr :8080 -taskkill /PID /F -``` +Start with these files: -### Templates Not Found -Make sure HTML files are in `templates/` folder +1. `app/app.py` +2. `app/blueprints/products.py` +3. `app/static/js/script.js` +4. `app/data/navigation.json` +5. `app/config.py` -### Static Files Not Loading -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 +Those files define the main routing, quiz behavior, and backend mode. diff --git a/information/URL_IMPLEMENTATION_SUMMARY.md b/information/URL_IMPLEMENTATION_SUMMARY.md index f9473f8..0e200f8 100644 --- a/information/URL_IMPLEMENTATION_SUMMARY.md +++ b/information/URL_IMPLEMENTATION_SUMMARY.md @@ -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: -- ✅ 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 state management is implemented in the current frontend. -## 🔗 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 -``` -# Initial state (no params) -https://yoursite.com/ +## Current URL Parameters -# After selecting Door + Storm Door + Aluminum -https://yoursite.com/?b=16401&q=q-dimensions - ↑ ↑ - | Current question - Accumulated bit value (Door + Aluminum + Storm Door) +- `b` - accumulated bitwise state for the user selections +- `q` - current question key or `results` +- `p` - product code for direct product-detail views -# Viewing specific product -https://yoursite.com/?p=BGIST&b=16401 - ↑ - Product code +Examples: -# Bit value 16401 decodes to: -# Bit 0 (1) = Door -# Bit 4 (16) = Aluminum -# Bit 14 (16384) = Storm Door subtype -# Total: 1 + 16 + 16384 = 16401 +```text +/quiz/?b=16401&q=results +/quiz/?p=404&b=16401 +/quiz/product/404 ``` -## 📋 Implementation Checklist +## What Is Implemented -### Phase 1: Core URL Functionality (Essential) -- [ ] Add bitwise data loading to `init()` function -- [ ] Add `accumulatedBitValue` variable -- [ ] Add `BIT_DEFINITIONS` constants -- [ ] Implement `updateURL()` function -- [ ] Implement `updateBitValue()` function -- [ ] Update `handleAnswer()` to call `updateBitValue()` -- [ ] Implement `initFromURL()` function -- [ ] Implement `restoreStateFromBitValue()` function -- [ ] Update `startOver()` to clear URL +### Core State Handling +- `accumulatedBitValue` is tracked in the frontend +- `BIT_DEFINITIONS` constants are present in `script.js` +- `product_bitwise.json` is loaded during startup +- `updateBitValue()` updates the accumulated bitmask from answer filters +- `updateURL()` writes `b` and `q` params without reloading the page +- `initFromURL()` restores the app from query params or direct product routes +- `restoreStateFromBitValue()` rebuilds the key quiz selections from the bitmask +- `startOver()` clears the URL state -### Phase 2: Product Deep Linking (Recommended) -- [ ] Implement `showProductByCode()` function -- [ ] Update `showProductDetail()` to update URL -- [ ] Make product cards clickable -- [ ] Add URL params when viewing products +### Product Deep Linking +- Product cards open detail views through `showProductByCode()` +- Product detail views push the selected product code into the URL +- Direct query-string product links are supported with `?p=` +- Direct Flask routes are supported with `/quiz/product/` -### Phase 3: Share Functionality (Nice to Have) -- [ ] Implement `shareCurrentPage()` function -- [ ] Add `showNotification()` helper -- [ ] Add "Share" buttons to UI -- [ ] Add CSS for notification animations +### Share and Navigation Support +- `shareCurrentPage()` copies the current URL to the clipboard when supported +- A notification helper is shown after successful copy +- Share buttons exist on results and product detail screens +- A `popstate` listener handles browser back/forward navigation -### Phase 4: Browser Navigation (Polish) -- [ ] Add `popstate` event listener -- [ ] Test browser back button -- [ ] Test browser forward button -- [ ] Test refresh behavior +## Implementation Notes -## 🚀 Quick Start +The current implementation restores the primary quiz state used by filtering: +- base type +- subtype +- material +- color -### Step 1: Add Global Variables -Add to top of `js/script.js`: -```javascript -let accumulatedBitValue = 0; -let bitwiseData = {}; +That is enough to reopen result sets and product-detail pages consistently. -const BIT_DEFINITIONS = { - 'base_door': 0, 'base_window': 1, - '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 -}; -``` +Dimension data is still primarily driven by the current session flow rather than fully +reconstructed from the URL alone. -### Step 2: Load Bitwise Data -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(); -}); -``` +## Validation Checklist -### Step 3: Add URL Functions -Copy these three key functions from [URL_STATE_MANAGEMENT.md](URL_STATE_MANAGEMENT.md): -1. `updateURL()` - Updates browser URL -2. `updateBitValue()` - Calculates bit value from selection -3. `initFromURL()` - Restores state from URL on load +- [x] Bitwise data loads at startup +- [x] URLs update as quiz answers are selected +- [x] Results pages can be shared with `b` and `q` +- [x] Product pages can be shared with `p` +- [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 -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); -} -``` +## Known Limits -### Step 5: Test -1. Run your app -2. Navigate through questions -3. Check URL updates after each selection -4. Copy URL and paste in new tab -5. Should restore to same state ✅ +- URL restoration is designed around the current bitwise filter model, not a complete + replay of every form interaction. +- Conditional question metadata exists in the navigation data, but the frontend does + not yet use a fully generic conditional-resolution engine. -## 📖 Documentation Files +## Related Files -All details are in these files: -- **[URL_STATE_MANAGEMENT.md](URL_STATE_MANAGEMENT.md)** - Complete implementation guide -- **[REQUIRED_CODE_CHANGES.md](REQUIRED_CODE_CHANGES.md)** - Updated with URL features -- **[BITWISE_USAGE_GUIDE.md](BITWISE_USAGE_GUIDE.md)** - How bitwise system works +- `app/static/js/script.js` +- `app/data/product_bitwise.json` +- `information/BITWISE_USAGE_GUIDE.md` +- `information/REQUIRED_CODE_CHANGES.md` -## 🔧 Key Functions Reference +## Recommended Next Steps -| Function | Purpose | When Called | -|----------|---------|-------------| -| `initFromURL()` | Read URL params on page load | Once at startup | -| `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! 🚀 +1. Manually test refresh and back/forward behavior on all major quiz branches. +2. Extend restoration if dimension-specific deep linking becomes a requirement. +3. Finish generic conditional navigation so URL restoration and question skipping use the same rules. diff --git a/quote-bridge/AUTOMATION_DESIGN.md b/quote-bridge/AUTOMATION_DESIGN.md new file mode 100644 index 0000000..672785d --- /dev/null +++ b/quote-bridge/AUTOMATION_DESIGN.md @@ -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. diff --git a/quote-bridge/README.md b/quote-bridge/README.md new file mode 100644 index 0000000..3281700 --- /dev/null +++ b/quote-bridge/README.md @@ -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/.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. diff --git a/quote-bridge/dosbox_processor.py b/quote-bridge/dosbox_processor.py new file mode 100644 index 0000000..ed3f9af --- /dev/null +++ b/quote-bridge/dosbox_processor.py @@ -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}") diff --git a/quote-bridge/listener.py b/quote-bridge/listener.py new file mode 100644 index 0000000..99256d7 --- /dev/null +++ b/quote-bridge/listener.py @@ -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()) diff --git a/quote-bridge/profiles/IOLA.json b/quote-bridge/profiles/IOLA.json new file mode 100644 index 0000000..97499cc --- /dev/null +++ b/quote-bridge/profiles/IOLA.json @@ -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 +} diff --git a/quote-bridge/profiles/KC.json b/quote-bridge/profiles/KC.json new file mode 100644 index 0000000..ecb0009 --- /dev/null +++ b/quote-bridge/profiles/KC.json @@ -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 +} diff --git a/quote-bridge/profiles/LINDS.json b/quote-bridge/profiles/LINDS.json new file mode 100644 index 0000000..7a359f7 --- /dev/null +++ b/quote-bridge/profiles/LINDS.json @@ -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 +} diff --git a/quote-bridge/profiles/default.json b/quote-bridge/profiles/default.json new file mode 100644 index 0000000..ca9dcfa --- /dev/null +++ b/quote-bridge/profiles/default.json @@ -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." + } +} diff --git a/quote-bridge/requirements.txt b/quote-bridge/requirements.txt new file mode 100644 index 0000000..a255469 --- /dev/null +++ b/quote-bridge/requirements.txt @@ -0,0 +1 @@ +# Standard library only for now