diff --git a/app/__init__.py b/app/__init__.py index d1ef71e..6831522 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -128,12 +128,16 @@ def register_blueprints(app): from app.routes import main_bp from app.modules.quality.routes import quality_bp from app.modules.settings.routes import settings_bp + from app.modules.warehouse.routes import warehouse_bp + from app.modules.warehouse.boxes_routes import boxes_bp app.register_blueprint(main_bp) app.register_blueprint(quality_bp, url_prefix='/quality') app.register_blueprint(settings_bp, url_prefix='/settings') + app.register_blueprint(warehouse_bp, url_prefix='/warehouse') + app.register_blueprint(boxes_bp) - app.logger.info("Blueprints registered: main, quality, settings") + app.logger.info("Blueprints registered: main, quality, settings, warehouse, boxes") def register_error_handlers(app): diff --git a/app/modules/quality/routes.py b/app/modules/quality/routes.py index 681434b..ee4a15f 100644 --- a/app/modules/quality/routes.py +++ b/app/modules/quality/routes.py @@ -26,15 +26,6 @@ def quality_index(): return render_template('modules/quality/index.html') -@quality_bp.route('/inspections', methods=['GET']) -def inspections(): - """View and manage quality inspections""" - if 'user_id' not in session: - return redirect(url_for('main.login')) - - return render_template('modules/quality/inspections.html') - - @quality_bp.route('/reports', methods=['GET']) def quality_reports(): """Quality reports page - displays FG scan reports""" diff --git a/app/modules/settings/logs.py b/app/modules/settings/logs.py new file mode 100644 index 0000000..a6aa13b --- /dev/null +++ b/app/modules/settings/logs.py @@ -0,0 +1,231 @@ +""" +Settings Module - Log Explorer Helper +Provides functions to explore and manage application logs +""" +import os +from datetime import datetime +from pathlib import Path +import logging + +logger = logging.getLogger(__name__) + + +def get_log_files(): + """Get list of all log files in the logs folder""" + try: + log_dir = './data/logs' + if not os.path.exists(log_dir): + return [] + + log_files = [] + for filename in sorted(os.listdir(log_dir)): + filepath = os.path.join(log_dir, filename) + if os.path.isfile(filepath): + try: + stat_info = os.stat(filepath) + log_files.append({ + 'name': filename, + 'size': stat_info.st_size, + 'size_mb': round(stat_info.st_size / 1024 / 1024, 2), + 'modified_at': datetime.fromtimestamp(stat_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S'), + 'path': filepath + }) + except Exception as e: + logger.error(f"Error getting stat info for {filename}: {e}") + continue + + return sorted(log_files, key=lambda x: x['modified_at'], reverse=True) + except Exception as e: + logger.error(f"Error getting log files: {e}") + return [] + + +def get_log_content(filename, lines=100): + """Get content of a log file + + Args: + filename: Name of the log file (without path) + lines: Number of lines to read from the end (None for all) + + Returns: + Dictionary with file info and content + """ + try: + log_dir = './data/logs' + filepath = os.path.join(log_dir, filename) + + # Security check - ensure filepath is within log_dir + if not os.path.abspath(filepath).startswith(os.path.abspath(log_dir)): + logger.error(f"Attempted to access file outside log directory: {filepath}") + return { + 'success': False, + 'error': 'Invalid file path', + 'filename': filename + } + + if not os.path.exists(filepath): + return { + 'success': False, + 'error': f'File not found: {filename}', + 'filename': filename + } + + # Read file content + try: + with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: + if lines: + # Read all lines and get the last N lines + all_lines = f.readlines() + content_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines + content = ''.join(content_lines) + total_lines = len(all_lines) + else: + content = f.read() + total_lines = len(content.splitlines()) + + stat_info = os.stat(filepath) + + return { + 'success': True, + 'filename': filename, + 'size': stat_info.st_size, + 'size_mb': round(stat_info.st_size / 1024 / 1024, 2), + 'modified_at': datetime.fromtimestamp(stat_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S'), + 'content': content, + 'total_lines': total_lines, + 'displayed_lines': len(content.splitlines()), + 'truncated': lines and total_lines > lines + } + except Exception as e: + return { + 'success': False, + 'error': f'Error reading file: {str(e)}', + 'filename': filename + } + + except Exception as e: + logger.error(f"Error getting log content for {filename}: {e}") + return { + 'success': False, + 'error': f'Error: {str(e)}', + 'filename': filename + } + + +def get_log_file_path(filename): + """Get safe file path for download/save + + Args: + filename: Name of the log file (without path) + + Returns: + Full file path if valid, None otherwise + """ + try: + log_dir = './data/logs' + filepath = os.path.join(log_dir, filename) + + # Security check - ensure filepath is within log_dir + if not os.path.abspath(filepath).startswith(os.path.abspath(log_dir)): + logger.error(f"Attempted to access file outside log directory: {filepath}") + return None + + if not os.path.exists(filepath): + logger.error(f"File not found: {filepath}") + return None + + return filepath + except Exception as e: + logger.error(f"Error getting log file path for {filename}: {e}") + return None + + +def get_log_statistics(): + """Get statistics about log files""" + try: + log_files = get_log_files() + + if not log_files: + return { + 'total_files': 0, + 'total_size_mb': 0, + 'oldest_log': None, + 'newest_log': None + } + + total_size = sum(f['size'] for f in log_files) + + return { + 'total_files': len(log_files), + 'total_size_mb': round(total_size / 1024 / 1024, 2), + 'oldest_log': log_files[-1]['modified_at'] if log_files else None, + 'newest_log': log_files[0]['modified_at'] if log_files else None + } + except Exception as e: + logger.error(f"Error getting log statistics: {e}") + return { + 'total_files': 0, + 'total_size_mb': 0, + 'oldest_log': None, + 'newest_log': None + } + + +def search_in_logs(search_term, filename=None, max_results=50): + """Search for a term in log files + + Args: + search_term: Term to search for + filename: Optional specific file to search in + max_results: Maximum number of results to return + + Returns: + List of matching lines with context + """ + try: + log_dir = './data/logs' + results = [] + + if filename: + # Search in specific file + filepath = os.path.join(log_dir, filename) + if not os.path.abspath(filepath).startswith(os.path.abspath(log_dir)): + return [] + + if os.path.exists(filepath): + try: + with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: + for line_num, line in enumerate(f, 1): + if search_term.lower() in line.lower(): + results.append({ + 'file': filename, + 'line_num': line_num, + 'line': line.strip() + }) + if len(results) >= max_results: + break + except Exception as e: + logger.error(f"Error searching in {filename}: {e}") + else: + # Search in all log files + for log_file in get_log_files(): + try: + with open(log_file['path'], 'r', encoding='utf-8', errors='ignore') as f: + for line_num, line in enumerate(f, 1): + if search_term.lower() in line.lower(): + results.append({ + 'file': log_file['name'], + 'line_num': line_num, + 'line': line.strip() + }) + if len(results) >= max_results: + break + if len(results) >= max_results: + break + except Exception as e: + logger.error(f"Error searching in {log_file['name']}: {e}") + + return results + except Exception as e: + logger.error(f"Error searching logs: {e}") + return [] diff --git a/app/modules/settings/routes.py b/app/modules/settings/routes.py index 5419d10..950dffc 100644 --- a/app/modules/settings/routes.py +++ b/app/modules/settings/routes.py @@ -7,6 +7,8 @@ import hashlib import secrets from datetime import datetime, timedelta from app.database import get_db +from app.modules.settings.stats import get_all_stats +from app.modules.settings.logs import get_log_files, get_log_content, get_log_file_path, get_log_statistics, search_in_logs import subprocess import os import json @@ -19,11 +21,30 @@ settings_bp = Blueprint('settings', __name__, url_prefix='/settings') @settings_bp.route('/', methods=['GET']) def settings_index(): - """Settings module main page""" + """Settings module main page with app overview""" if 'user_id' not in session: return redirect(url_for('main.login')) - return render_template('modules/settings/index.html') + # Get all app statistics + try: + stats = get_all_stats() + except Exception as e: + logger.error(f"Error getting stats in settings_index: {e}", exc_info=True) + stats = { + 'user_count': 0, + 'database_size_mb': 0, + 'logs_size_mb': 0, + 'database_count': 0, + 'backup_count': 0, + 'printer_keys_count': 0, + 'app_key_availability': { + 'available': False, + 'count': 0, + 'status': 'Error loading data' + } + } + + return render_template('modules/settings/index.html', stats=stats) @settings_bp.route('/general', methods=['GET', 'POST']) @@ -1254,3 +1275,100 @@ def toggle_backup_schedule(schedule_id): except Exception as e: return jsonify({'error': str(e)}), 500 + +# ============================================================================ +# Log Explorer Routes +# ============================================================================ + +@settings_bp.route('/logs', methods=['GET']) +def logs_explorer(): + """Log explorer main page - list all log files""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + try: + log_files = get_log_files() + log_stats = get_log_statistics() + + return render_template('modules/settings/logs_explorer.html', + log_files=log_files, + log_stats=log_stats) + except Exception as e: + logger.error(f"Error loading logs explorer: {e}") + flash(f"Error loading logs: {str(e)}", 'error') + return render_template('modules/settings/logs_explorer.html', + log_files=[], + log_stats={}) + + +@settings_bp.route('/logs/view/', methods=['GET']) +def view_log(filename): + """View content of a specific log file""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + try: + lines = request.args.get('lines', default=100, type=int) + log_data = get_log_content(filename, lines=lines) + + if not log_data.get('success'): + flash(log_data.get('error', 'Error reading log file'), 'error') + return redirect(url_for('settings.logs_explorer')) + + return render_template('modules/settings/view_log.html', log_data=log_data) + except Exception as e: + logger.error(f"Error viewing log {filename}: {e}") + flash(f"Error viewing log: {str(e)}", 'error') + return redirect(url_for('settings.logs_explorer')) + + +@settings_bp.route('/logs/download/', methods=['GET']) +def download_log(filename): + """Download a log file""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + try: + filepath = get_log_file_path(filename) + + if not filepath: + flash('Invalid file or file not found', 'error') + return redirect(url_for('settings.logs_explorer')) + + return send_file( + filepath, + as_attachment=True, + download_name=f"{filename}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) + except Exception as e: + logger.error(f"Error downloading log {filename}: {e}") + flash(f"Error downloading log: {str(e)}", 'error') + return redirect(url_for('settings.logs_explorer')) + + +@settings_bp.route('/logs/search', methods=['GET']) +def search_logs(): + """Search for terms in log files""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + try: + search_term = request.args.get('q', '').strip() + filename = request.args.get('file', default=None) + + results = [] + if search_term: + results = search_in_logs(search_term, filename=filename) + + log_files = get_log_files() + + return render_template('modules/settings/search_logs.html', + search_term=search_term, + results=results, + log_files=log_files, + selected_file=filename) + except Exception as e: + logger.error(f"Error searching logs: {e}") + flash(f"Error searching logs: {str(e)}", 'error') + return redirect(url_for('settings.logs_explorer')) + diff --git a/app/modules/settings/stats.py b/app/modules/settings/stats.py new file mode 100644 index 0000000..0fc4f03 --- /dev/null +++ b/app/modules/settings/stats.py @@ -0,0 +1,247 @@ +""" +Settings Module - App Statistics Helper +Provides functions to collect various app statistics for the overview +""" +import os +import pymysql +from datetime import datetime +from pathlib import Path +from app.database import get_db +import logging + +logger = logging.getLogger(__name__) + + +def get_user_count(): + """Get total number of existing users""" + try: + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) as count FROM users WHERE is_active = 1") + result = cursor.fetchone() + cursor.close() + return result[0] if result else 0 + except Exception as e: + logger.error(f"Error getting user count: {e}") + return 0 + + +def get_database_size(): + """Get size of the database in MB""" + try: + conn = get_db() + cursor = conn.cursor() + try: + # Get database name from connection + cursor.execute("SELECT DATABASE()") + result = cursor.fetchone() + if not result: + cursor.close() + return 0 + + db_name = result[0] + + # Get database size + query = f""" + SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) + FROM information_schema.tables + WHERE table_schema = %s + """ + cursor.execute(query, (db_name,)) + result = cursor.fetchone() + cursor.close() + return result[0] if result and result[0] else 0 + except Exception as e: + cursor.close() + logger.error(f"Error executing database size query: {e}") + return 0 + except Exception as e: + logger.error(f"Error getting database size: {e}") + return 0 + + +def get_logs_size(): + """Get total size of log files in MB""" + try: + log_dir = './data/logs' + if not os.path.exists(log_dir): + return 0 + + total_size = 0 + for filename in os.listdir(log_dir): + filepath = os.path.join(log_dir, filename) + if os.path.isfile(filepath): + total_size += os.path.getsize(filepath) + + return round(total_size / 1024 / 1024, 2) + except Exception as e: + logger.error(f"Error getting logs size: {e}") + return 0 + + +def get_database_count(): + """Get number of existing databases (user accessible)""" + try: + conn = get_db() + cursor = conn.cursor() + try: + cursor.execute("SHOW DATABASES") + result = cursor.fetchall() + cursor.close() + + # Filter out system databases + if result: + excluded_dbs = {'information_schema', 'mysql', 'performance_schema', 'sys'} + user_dbs = [db for db in result if db[0] not in excluded_dbs] + return len(user_dbs) + return 0 + except Exception as e: + cursor.close() + logger.error(f"Error executing show databases query: {e}") + return 0 + except Exception as e: + logger.error(f"Error getting database count: {e}") + return 0 + + +def get_backup_count(): + """Get number of scheduled backups for the database""" + try: + conn = get_db() + cursor = conn.cursor() + + # Check if backups table exists + cursor.execute(""" + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'backup_schedules' + """) + + if cursor.fetchone()[0] > 0: + cursor.execute("SELECT COUNT(*) FROM backup_schedules WHERE is_active = 1") + result = cursor.fetchone() + cursor.close() + return result[0] if result else 0 + else: + cursor.close() + # Count backup files if no table exists + backup_dir = './data/backups' + if os.path.exists(backup_dir): + return len([f for f in os.listdir(backup_dir) if f.endswith('.sql')]) + return 0 + except Exception as e: + logger.error(f"Error getting backup count: {e}") + # Fallback to counting backup files + try: + backup_dir = './data/backups' + if os.path.exists(backup_dir): + return len([f for f in os.listdir(backup_dir) if f.endswith('.sql')]) + except: + pass + return 0 + + +def get_printer_keys_count(): + """Get number of keys for printers (pairing keys)""" + try: + conn = get_db() + cursor = conn.cursor() + + try: + # Check if qz_pairing_keys table exists + cursor.execute(""" + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'qz_pairing_keys' + """) + + if cursor.fetchone()[0] > 0: + cursor.execute("SELECT COUNT(*) FROM qz_pairing_keys") + result = cursor.fetchone() + cursor.close() + return result[0] if result else 0 + else: + cursor.close() + return 0 + except Exception as e: + cursor.close() + logger.error(f"Error executing printer keys query: {e}") + return 0 + except Exception as e: + logger.error(f"Error getting printer keys count: {e}") + return 0 + + +def check_app_key_availability(): + """Check app key availability""" + try: + conn = get_db() + cursor = conn.cursor() + + try: + # Check if api_keys table exists + cursor.execute(""" + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'api_keys' + """) + + if cursor.fetchone()[0] > 0: + cursor.execute("SELECT COUNT(*) FROM api_keys WHERE is_active = 1") + result = cursor.fetchone() + cursor.close() + count = result[0] if result else 0 + return { + 'available': count > 0, + 'count': count, + 'status': f'{count} active key(s)' if count > 0 else 'No active keys' + } + else: + cursor.close() + return { + 'available': False, + 'count': 0, + 'status': 'API Keys table not found' + } + except Exception as e: + cursor.close() + logger.error(f"Error executing api_keys query: {e}") + return { + 'available': False, + 'count': 0, + 'status': f'Error: {str(e)}' + } + except Exception as e: + logger.error(f"Error checking app key availability: {e}") + return { + 'available': False, + 'count': 0, + 'status': f'Error: {str(e)}' + } + + +def get_all_stats(): + """Get all statistics for the overview""" + try: + return { + 'user_count': get_user_count(), + 'database_size_mb': get_database_size(), + 'logs_size_mb': get_logs_size(), + 'database_count': get_database_count(), + 'backup_count': get_backup_count(), + 'printer_keys_count': get_printer_keys_count(), + 'app_key_availability': check_app_key_availability() + } + except Exception as e: + logger.error(f"Error getting all stats: {e}") + # Return defaults on error + return { + 'user_count': 0, + 'database_size_mb': 0, + 'logs_size_mb': 0, + 'database_count': 0, + 'backup_count': 0, + 'printer_keys_count': 0, + 'app_key_availability': { + 'available': False, + 'count': 0, + 'status': 'Error loading data' + } + } diff --git a/app/modules/warehouse/__init__.py b/app/modules/warehouse/__init__.py new file mode 100644 index 0000000..35b2a78 --- /dev/null +++ b/app/modules/warehouse/__init__.py @@ -0,0 +1,3 @@ +""" +Warehouse Module - Initialization +""" diff --git a/app/modules/warehouse/boxes.py b/app/modules/warehouse/boxes.py new file mode 100644 index 0000000..e9ee6a4 --- /dev/null +++ b/app/modules/warehouse/boxes.py @@ -0,0 +1,254 @@ +""" +Boxes Management Module +Handles CRUD operations for warehouse boxes +Uses boxes_crates table matching the old app structure +""" + +from app.database import get_db +import logging + +logger = logging.getLogger(__name__) + + +def ensure_boxes_table(): + """Create boxes_crates table if it doesn't exist""" + try: + conn = get_db() + cursor = conn.cursor() + + cursor.execute("SHOW TABLES LIKE 'boxes_crates'") + result = cursor.fetchone() + + if not result: + cursor.execute(''' + CREATE TABLE IF NOT EXISTS boxes_crates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + box_number VARCHAR(8) NOT NULL UNIQUE, + status ENUM('open', 'closed') DEFAULT 'open', + location_id BIGINT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(100), + FOREIGN KEY (location_id) REFERENCES warehouse_locations(id) ON DELETE SET NULL, + INDEX idx_box_number (box_number), + INDEX idx_status (status) + ) + ''') + conn.commit() + + cursor.close() + logger.info("boxes_crates table ensured") + return True + except Exception as e: + logger.error(f"Error ensuring boxes_crates table: {e}") + return False + + +def generate_box_number(): + """Generate next box number with 8 digits (00000001, 00000002, etc.)""" + try: + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT MAX(CAST(box_number AS UNSIGNED)) FROM boxes_crates") + result = cursor.fetchone() + cursor.close() + + if result and result[0]: + next_number = int(result[0]) + 1 + else: + next_number = 1 + + return str(next_number).zfill(8) + except Exception as e: + logger.error(f"Error generating box number: {e}") + return "00000001" + + +def add_box(created_by=None): + """Add a new box/crate with auto-generated number""" + try: + ensure_boxes_table() + + box_number = generate_box_number() + conn = get_db() + cursor = conn.cursor() + + cursor.execute( + "INSERT INTO boxes_crates (box_number, status, created_by) VALUES (%s, %s, %s)", + (box_number, 'open', created_by) + ) + conn.commit() + cursor.close() + + logger.info(f"Box {box_number} created successfully") + return True, f"Box {box_number} created successfully" + except Exception as e: + logger.error(f"Error adding box: {e}") + return False, f"Error creating box: {str(e)}" + + +def get_all_boxes(): + """Get all boxes with their location information""" + try: + ensure_boxes_table() + + conn = get_db() + cursor = conn.cursor() + + cursor.execute(''' + SELECT + b.id, + b.box_number, + b.status, + COALESCE(l.location_code, 'Not assigned') as location_code, + b.created_at, + b.updated_at, + b.created_by, + b.location_id + FROM boxes_crates b + LEFT JOIN warehouse_locations l ON b.location_id = l.id + ORDER BY b.created_at DESC + ''') + + boxes = cursor.fetchall() + cursor.close() + + return boxes if boxes else [] + except Exception as e: + logger.error(f"Error getting all boxes: {e}") + return [] + + +def get_box_by_id(box_id): + """Get a single box by ID""" + try: + conn = get_db() + cursor = conn.cursor() + + cursor.execute(''' + SELECT + b.id, + b.box_number, + b.status, + b.location_id, + b.created_by, + b.created_at, + b.updated_at + FROM boxes_crates b + WHERE b.id = %s + ''', (box_id,)) + + box = cursor.fetchone() + cursor.close() + + return box + except Exception as e: + logger.error(f"Error getting box by ID: {e}") + return None + + +def update_box(box_id, status=None, location_id=None): + """Update box status or location""" + try: + conn = get_db() + cursor = conn.cursor() + + if status and location_id is not None: + cursor.execute( + "UPDATE boxes_crates SET status = %s, location_id = %s WHERE id = %s", + (status, location_id if location_id else None, box_id) + ) + elif status: + cursor.execute( + "UPDATE boxes_crates SET status = %s WHERE id = %s", + (status, box_id) + ) + elif location_id is not None: + cursor.execute( + "UPDATE boxes_crates SET location_id = %s WHERE id = %s", + (location_id if location_id else None, box_id) + ) + + conn.commit() + cursor.close() + + logger.info(f"Box {box_id} updated successfully") + return True, "Box updated successfully" + except Exception as e: + logger.error(f"Error updating box: {e}") + return False, f"Error updating box: {str(e)}" + + +def delete_box(box_id): + """Delete a single box""" + try: + conn = get_db() + cursor = conn.cursor() + + cursor.execute("DELETE FROM boxes_crates WHERE id = %s", (box_id,)) + conn.commit() + cursor.close() + + logger.info(f"Box {box_id} deleted successfully") + return True, "Box deleted successfully" + except Exception as e: + logger.error(f"Error deleting box: {e}") + return False, f"Error deleting box: {str(e)}" + + +def delete_multiple_boxes(box_ids_str): + """Delete multiple boxes""" + try: + if not box_ids_str: + return False, "No boxes selected" + + # Parse box IDs + box_ids = [int(x) for x in box_ids_str.split(',') if x.strip()] + + if not box_ids: + return False, "No valid box IDs provided" + + conn = get_db() + cursor = conn.cursor() + + placeholders = ','.join(['%s'] * len(box_ids)) + cursor.execute(f"DELETE FROM boxes_crates WHERE id IN ({placeholders})", box_ids) + conn.commit() + cursor.close() + + logger.info(f"Deleted {len(box_ids)} boxes") + return True, f"Deleted {len(box_ids)} box(es) successfully" + except Exception as e: + logger.error(f"Error deleting multiple boxes: {e}") + return False, f"Error deleting boxes: {str(e)}" + + +def get_box_stats(): + """Get box statistics""" + try: + ensure_boxes_table() + + conn = get_db() + cursor = conn.cursor() + + cursor.execute("SELECT COUNT(*) FROM boxes_crates") + total = cursor.fetchone()[0] if cursor.fetchone() else 0 + + cursor.execute("SELECT COUNT(*) FROM boxes_crates WHERE status = 'open'") + result = cursor.fetchone() + open_count = result[0] if result else 0 + + cursor.execute("SELECT COUNT(*) FROM boxes_crates WHERE status = 'closed'") + result = cursor.fetchone() + closed_count = result[0] if result else 0 + + cursor.close() + + return { + 'total': total, + 'open': open_count, + 'closed': closed_count + } + except Exception as e: + logger.error(f"Error getting box statistics: {e}") + return {'total': 0, 'open': 0, 'closed': 0} diff --git a/app/modules/warehouse/boxes_routes.py b/app/modules/warehouse/boxes_routes.py new file mode 100644 index 0000000..7bae4a3 --- /dev/null +++ b/app/modules/warehouse/boxes_routes.py @@ -0,0 +1,101 @@ +""" +Boxes Management Routes +""" +from flask import Blueprint, render_template, session, redirect, url_for, request, flash +from app.modules.warehouse.boxes import ( + get_all_boxes, add_box, update_box, delete_box, delete_multiple_boxes, + get_box_by_id, get_box_stats, ensure_boxes_table +) +from app.modules.warehouse.warehouse import get_all_locations +import logging + +logger = logging.getLogger(__name__) + +boxes_bp = Blueprint('boxes', __name__, url_prefix='/warehouse/boxes') + + +@boxes_bp.route('/', methods=['GET', 'POST']) +def manage_boxes(): + """Manage warehouse boxes page""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + # Ensure table exists + ensure_boxes_table() + + message = None + message_type = 'info' + + if request.method == 'POST': + action = request.form.get('action', '') + user_id = session.get('user_id', 'System') + + # Add new box (auto-numbered) + if action == 'add_box': + success, msg = add_box(created_by=user_id) + message = msg + message_type = 'success' if success else 'danger' + + # Update box status or location + elif action == 'edit_box': + box_id = request.form.get('box_id', '') + status = request.form.get('status', '') + location_id = request.form.get('location_id', '') + + location_id = int(location_id) if location_id and location_id.isdigit() else None + + success, msg = update_box(box_id, status=status if status else None, location_id=location_id) + message = msg + message_type = 'success' if success else 'danger' + + # Change status + elif action == 'toggle_status': + box_id = request.form.get('box_id', '') + current_status = request.form.get('current_status', 'open') + new_status = 'closed' if current_status == 'open' else 'open' + + success, msg = update_box(box_id, status=new_status) + message = msg + message_type = 'success' if success else 'danger' + + # Delete single box + elif action == 'delete_box': + box_id = request.form.get('box_id', '') + success, msg = delete_box(box_id) + message = msg + message_type = 'success' if success else 'danger' + + # Delete multiple boxes + elif action == 'delete_multiple': + box_ids = request.form.get('delete_ids', '') + success, msg = delete_multiple_boxes(box_ids) + message = msg + message_type = 'success' if success else 'danger' + + # Get data and convert tuples to dictionaries + boxes_data = get_all_boxes() + boxes = [] + for box_tuple in boxes_data: + boxes.append({ + 'id': box_tuple[0], + 'box_number': box_tuple[1], + 'status': box_tuple[2], + 'location_code': box_tuple[3], + 'created_at': box_tuple[4], + 'updated_at': box_tuple[5], + 'created_by': box_tuple[6], + 'location_id': box_tuple[7] + }) + + locations = get_all_locations() + stats = get_box_stats() + + return render_template( + 'modules/warehouse/boxes.html', + boxes=boxes, + locations=locations, + stats=stats, + message=message, + message_type=message_type + ) + diff --git a/app/modules/warehouse/routes.py b/app/modules/warehouse/routes.py new file mode 100644 index 0000000..1c697fc --- /dev/null +++ b/app/modules/warehouse/routes.py @@ -0,0 +1,125 @@ +""" +Warehouse Module Routes +""" +from flask import Blueprint, render_template, session, redirect, url_for, request, flash, jsonify +from app.modules.warehouse.warehouse import ( + get_all_locations, add_location, update_location, delete_location, + delete_multiple_locations, get_location_by_id +) +import logging + +logger = logging.getLogger(__name__) + +warehouse_bp = Blueprint('warehouse', __name__, url_prefix='/warehouse') + + +@warehouse_bp.route('/', methods=['GET']) +def warehouse_index(): + """Warehouse module main page - launcher for all warehouse operations""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + return render_template('modules/warehouse/index.html') + + +@warehouse_bp.route('/set-boxes-locations', methods=['GET', 'POST']) +def set_boxes_locations(): + """Set boxes locations - add or update articles in warehouse inventory""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + return render_template('modules/warehouse/set_boxes_locations.html') + + +@warehouse_bp.route('/locations', methods=['GET', 'POST']) +def locations(): + """Create and manage warehouse locations""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + message = None + message_type = 'info' + + if request.method == 'POST': + # Handle edit location + if request.form.get('edit_location'): + location_id = request.form.get('location_id', '') + size = request.form.get('edit_size', '').strip() + description = request.form.get('edit_description', '').strip() + + try: + location_id = int(location_id) + success, msg = update_location(location_id, None, size if size else None, description if description else None) + message = msg + message_type = 'success' if success else 'error' + except Exception as e: + message = f"Error: {str(e)}" + message_type = 'error' + # Handle delete locations + elif request.form.get('delete_locations'): + delete_ids_str = request.form.get('delete_ids', '') + try: + location_ids = [int(id.strip()) for id in delete_ids_str.split(',') if id.strip().isdigit()] + success, msg = delete_multiple_locations(location_ids) + message = msg + message_type = 'success' if success else 'error' + except Exception as e: + message = f"Error: {str(e)}" + message_type = 'error' + # Handle add location + elif request.form.get('add_location'): + location_code = request.form.get('location_code', '').strip() + size = request.form.get('size', '').strip() + description = request.form.get('description', '').strip() + + if not location_code: + message = "Location code is required" + message_type = 'error' + else: + success, msg = add_location(location_code, size if size else None, description if description else None) + message = msg + message_type = 'success' if success else 'error' + + # Get all locations + locations_list = get_all_locations() + + return render_template('modules/warehouse/locations.html', + locations=locations_list, + message=message, + message_type=message_type) + + +@warehouse_bp.route('/boxes', methods=['GET', 'POST']) +def boxes(): + """Manage boxes and crates in the warehouse""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + return render_template('modules/warehouse/boxes.html') + + +@warehouse_bp.route('/inventory', methods=['GET']) +def inventory(): + """View warehouse inventory - products, boxes, and locations""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + return render_template('modules/warehouse/inventory.html') + + +@warehouse_bp.route('/reports', methods=['GET']) +def reports(): + """Warehouse activity and inventory reports""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + return render_template('modules/warehouse/reports.html') + + +@warehouse_bp.route('/test-barcode', methods=['GET']) +def test_barcode(): + """Test barcode printing functionality""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + return render_template('modules/warehouse/test_barcode.html') diff --git a/app/modules/warehouse/warehouse.py b/app/modules/warehouse/warehouse.py new file mode 100644 index 0000000..b15613c --- /dev/null +++ b/app/modules/warehouse/warehouse.py @@ -0,0 +1,213 @@ +""" +Warehouse Module - Helper Functions +Provides functions for warehouse operations +""" +import logging +from app.database import get_db + +logger = logging.getLogger(__name__) + + +def ensure_warehouse_locations_table(): + """Ensure warehouse_locations table exists""" + try: + conn = get_db() + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS warehouse_locations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + location_code VARCHAR(12) UNIQUE NOT NULL, + size INT, + description VARCHAR(250), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_location_code (location_code) + ) + """) + + conn.commit() + cursor.close() + logger.info("warehouse_locations table ensured") + return True + except Exception as e: + logger.error(f"Error ensuring warehouse_locations table: {e}") + return False + + +def add_location(location_code, size, description): + """Add a new warehouse location""" + try: + ensure_warehouse_locations_table() + + conn = get_db() + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO warehouse_locations (location_code, size, description) + VALUES (%s, %s, %s) + """, (location_code, size if size else None, description)) + + conn.commit() + cursor.close() + + return True, "Location added successfully." + except Exception as e: + if "Duplicate entry" in str(e): + return False, f"Failed: Location code '{location_code}' already exists." + logger.error(f"Error adding location: {e}") + return False, f"Error adding location: {str(e)}" + + +def get_all_locations(): + """Get all warehouse locations""" + try: + ensure_warehouse_locations_table() + + conn = get_db() + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, location_code, size, description, created_at, updated_at + FROM warehouse_locations + ORDER BY id DESC + """) + + locations = cursor.fetchall() + cursor.close() + + result = [] + for loc in locations: + result.append({ + 'id': loc[0], + 'location_code': loc[1], + 'size': loc[2], + 'description': loc[3], + 'created_at': loc[4], + 'updated_at': loc[5] + }) + + return result + except Exception as e: + logger.error(f"Error getting locations: {e}") + return [] + + +def get_location_by_id(location_id): + """Get a specific location by ID""" + try: + conn = get_db() + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, location_code, size, description, created_at, updated_at + FROM warehouse_locations + WHERE id = %s + """, (location_id,)) + + loc = cursor.fetchone() + cursor.close() + + if loc: + return { + 'id': loc[0], + 'location_code': loc[1], + 'size': loc[2], + 'description': loc[3], + 'created_at': loc[4], + 'updated_at': loc[5] + } + return None + except Exception as e: + logger.error(f"Error getting location: {e}") + return None + + +def update_location(location_id, location_code=None, size=None, description=None): + """Update a warehouse location + + Args: + location_id: ID of location to update + location_code: New location code (optional - cannot be changed in form) + size: New size (optional) + description: New description (optional) + """ + try: + conn = get_db() + cursor = conn.cursor() + + # Build update query dynamically + updates = [] + params = [] + + if location_code: + updates.append("location_code = %s") + params.append(location_code) + if size is not None: + updates.append("size = %s") + params.append(size) + if description is not None: + updates.append("description = %s") + params.append(description) + + if not updates: + return False, "No fields to update" + + params.append(location_id) + + query = f"UPDATE warehouse_locations SET {', '.join(updates)} WHERE id = %s" + cursor.execute(query, params) + + conn.commit() + cursor.close() + + return True, "Location updated successfully." + except Exception as e: + if "Duplicate entry" in str(e): + return False, f"Failed: Location code already exists." + logger.error(f"Error updating location: {e}") + return False, f"Error updating location: {str(e)}" + + +def delete_location(location_id): + """Delete a warehouse location""" + try: + conn = get_db() + cursor = conn.cursor() + + cursor.execute("DELETE FROM warehouse_locations WHERE id = %s", (location_id,)) + + conn.commit() + cursor.close() + + return True, "Location deleted successfully." + except Exception as e: + logger.error(f"Error deleting location: {e}") + return False, f"Error deleting location: {str(e)}" + + +def delete_multiple_locations(location_ids): + """Delete multiple warehouse locations""" + try: + if not location_ids: + return False, "No locations to delete." + + conn = get_db() + cursor = conn.cursor() + + deleted_count = 0 + for loc_id in location_ids: + try: + cursor.execute("DELETE FROM warehouse_locations WHERE id = %s", (int(loc_id),)) + if cursor.rowcount > 0: + deleted_count += 1 + except: + pass + + conn.commit() + cursor.close() + + return True, f"Deleted {deleted_count} location(s)." + except Exception as e: + logger.error(f"Error deleting multiple locations: {e}") + return False, f"Error deleting locations: {str(e)}" diff --git a/app/routes.py b/app/routes.py index 6b0d064..0598081 100644 --- a/app/routes.py +++ b/app/routes.py @@ -77,6 +77,13 @@ def dashboard(): 'color': 'primary', 'url': url_for('quality.quality_index') }, + { + 'name': 'Warehouse Module', + 'description': 'Manage warehouse operations and inventory', + 'icon': 'fa-warehouse', + 'color': 'info', + 'url': url_for('warehouse.warehouse_index') + }, { 'name': 'Settings', 'description': 'Configure application settings', diff --git a/app/static/js/qz-printer.js b/app/static/js/qz-printer.js new file mode 100644 index 0000000..8a5c447 --- /dev/null +++ b/app/static/js/qz-printer.js @@ -0,0 +1,295 @@ +/** + * QZ Tray Printer Module + * Shared printer functionality for all pages + * Provides printer detection, selection, and printing capabilities + */ + +(function() { + 'use strict'; + + // Global printer state + window.qzPrinter = { + connected: false, + availablePrinters: [], + selectedPrinter: '', + + /** + * Initialize QZ Tray connection + * @returns {Promise} True if connected, false otherwise + */ + initialize: async function() { + try { + console.log('Initializing QZ Tray...'); + + if (typeof qz === 'undefined') { + console.warn('QZ Tray library not loaded'); + return false; + } + + // Try to connect + await qz.websocket.connect(); + this.connected = true; + console.log('✅ QZ Tray connected'); + + // Load available printers + await this.loadPrinters(); + + return true; + + } catch (error) { + console.warn('QZ Tray not available:', error.message); + this.connected = false; + return false; + } + }, + + /** + * Load available printers from QZ Tray + * @returns {Promise} Array of printer names + */ + loadPrinters: async function() { + try { + if (!this.connected) return []; + + const printers = await qz.printers.find(); + this.availablePrinters = printers; + + console.log('Loaded printers:', printers); + + // Auto-select first thermal printer if available + const thermalPrinter = printers.find(p => + p.toLowerCase().includes('thermal') || + p.toLowerCase().includes('label') || + p.toLowerCase().includes('zebra') + ); + + if (thermalPrinter) { + this.selectedPrinter = thermalPrinter; + console.log('Auto-selected thermal printer:', thermalPrinter); + } + + return printers; + + } catch (error) { + console.error('Error loading printers:', error); + return []; + } + }, + + /** + * Get printer selection dropdown HTML + * @param {string} selectId - ID for the select element + * @returns {string} HTML string for printer select dropdown + */ + getPrinterSelectHTML: function(selectId = 'printer-select') { + let html = `'; + return html; + }, + + /** + * Update printer selection + * @param {string} printerName - Name of printer to select + */ + selectPrinter: function(printerName) { + this.selectedPrinter = printerName || ''; + console.log('Selected printer:', this.selectedPrinter); + }, + + /** + * Test QZ Tray connection + * @returns {boolean} True if connected + */ + test: function() { + if (!this.connected) { + alert('QZ Tray is not connected.\nBrowser print will be used instead.'); + return false; + } + + const printerList = this.availablePrinters.length > 0 + ? this.availablePrinters.join('\n• ') + : 'No printers found'; + + alert('✅ QZ Tray is connected and ready!\n\nAvailable printers:\n• ' + printerList); + return true; + }, + + /** + * Print barcode using QZ Tray + * @param {string} barcodeData - Barcode value + * @param {string} printerName - Printer to use (optional, uses selected) + * @param {Object} options - Print options + * @returns {Promise} + */ + printBarcode: async function(barcodeData, printerName, options = {}) { + try { + if (!this.connected) { + throw new Error('QZ Tray not connected'); + } + + const targetPrinter = printerName || this.selectedPrinter; + console.log('Printing to:', targetPrinter, 'Data:', barcodeData); + + // Default print options + const printConfig = { + printer: targetPrinter, + colorType: options.colorType || 'color', + copies: options.copies || 1 + }; + + // Barcode data with default configuration + const printData = [{ + type: 'barcode', + format: options.format || 'CODE128', + data: barcodeData, + width: options.width || 2, + height: options.height || 100, + displayValue: options.displayValue !== false + }]; + + // Add optional label + if (options.label) { + printData.push({ + type: 'text', + data: options.label, + position: options.labelPosition || {x: 0.5, y: 2.2}, + font: options.font || {family: 'Arial', size: 12, weight: 'bold'} + }); + } + + // Send to printer + await qz.print(printConfig, printData); + console.log('✅ Print job sent to', targetPrinter); + return true; + + } catch (error) { + console.error('QZ Tray printing error:', error); + throw error; + } + }, + + /** + * Print SVG/HTML barcode using QZ Tray + * @param {string} svgElement - SVG element or selector + * @param {string} barcodeText - Text to display with barcode + * @param {string} printerName - Printer to use (optional) + * @returns {Promise} + */ + printSVGBarcode: async function(svgElement, barcodeText, printerName) { + try { + if (!this.connected) { + throw new Error('QZ Tray not connected'); + } + + // If no printer specified, use the selected printer or default + let targetPrinter = printerName || this.selectedPrinter; + + // Get SVG element + let svgEl = typeof svgElement === 'string' + ? document.querySelector(svgElement) + : svgElement; + + if (!svgEl) { + throw new Error('Barcode SVG element not found'); + } + + // Serialize SVG to string and encode as base64 + const svgString = new XMLSerializer().serializeToString(svgEl); + const svgBase64 = btoa(svgString); + + // If still no printer, get the device default + if (!targetPrinter) { + try { + const defaultPrinter = await qz.printers.getDefault(); + targetPrinter = defaultPrinter; + console.log('Using device default printer:', targetPrinter); + } catch (err) { + console.warn('Could not get default printer, using system default'); + targetPrinter = ''; // Empty string uses system default + } + } + + const printConfig = { + printer: targetPrinter, + colorType: 'color', + copies: 1 + }; + + const printData = [{ + type: 'image', + format: 'base64', + data: svgBase64, + width: 3, + height: 1.5, + position: {x: 0.5, y: 0.5} + }]; + + if (barcodeText) { + printData.push({ + type: 'text', + data: barcodeText, + position: {x: 0.5, y: 2.2}, + font: {family: 'Arial', size: 12, weight: 'bold'} + }); + } + + console.log('Printing to thermal printer:', targetPrinter); + await qz.print(printConfig, printData); + console.log('✅ SVG print job sent to', targetPrinter); + return true; + + } catch (error) { + console.error('QZ Tray SVG printing error:', error); + throw error; + } + }, + + /** + * Fallback to browser print + * @param {string} title - Print document title + * @param {string} content - HTML content to print + */ + printBrowser: function(title, content) { + const printWindow = window.open('', '', 'height=400,width=600'); + printWindow.document.write('' + title + ''); + printWindow.document.write(''); + printWindow.document.write('

' + title + '

'); + printWindow.document.write('
' + content + '
'); + printWindow.document.write('

'); + printWindow.document.write('Printed on: ' + new Date().toLocaleString()); + printWindow.document.write('

'); + printWindow.document.close(); + + setTimeout(() => { + printWindow.print(); + }, 250); + } + }; + + // Auto-initialize when document is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + if (typeof qz !== 'undefined') { + window.qzPrinter.initialize(); + } + }); + } else { + // Document already loaded + if (typeof qz !== 'undefined') { + window.qzPrinter.initialize(); + } + } + +})(); diff --git a/app/static/js/qz-tray.js b/app/static/js/qz-tray.js new file mode 100644 index 0000000..71146c8 --- /dev/null +++ b/app/static/js/qz-tray.js @@ -0,0 +1,2871 @@ +'use strict'; + +/** + * @version 2.2.4 + * @overview QZ Tray Connector + * @license LGPL-2.1-only + *

+ * Connects a web client to the QZ Tray software. + * Enables printing and device communication from javascript. + */ +var qz = (function() { + +///// POLYFILLS ///// + + if (!Array.isArray) { + Array.isArray = function(arg) { + return Object.prototype.toString.call(arg) === '[object Array]'; + }; + } + + if (!Number.isInteger) { + Number.isInteger = function(value) { + return typeof value === 'number' && isFinite(value) && Math.floor(value) === value; + }; + } + +///// PRIVATE METHODS ///// + + var _qz = { + VERSION: "2.2.4", //must match @version above + DEBUG: false, + + log: { + /** Debugging messages */ + trace: function() { if (_qz.DEBUG) { console.log.apply(console, arguments); } }, + /** General messages */ + info: function() { console.info.apply(console, arguments); }, + /** General warnings */ + warn: function() { console.warn.apply(console, arguments); }, + /** Debugging errors */ + allay: function() { if (_qz.DEBUG) { console.warn.apply(console, arguments); } }, + /** General errors */ + error: function() { console.error.apply(console, arguments); } + }, + + + //stream types + streams: { + serial: 'SERIAL', usb: 'USB', hid: 'HID', printer: 'PRINTER', file: 'FILE', socket: 'SOCKET' + }, + + + websocket: { + /** The actual websocket object managing the connection. */ + connection: null, + /** Track if a connection attempt is being cancelled. */ + shutdown: false, + + /** Default parameters used on new connections. Override values using options parameter on {@link qz.websocket.connect}. */ + connectConfig: { + host: ["localhost", "localhost.qz.io"], //hosts QZ Tray can be running on + hostIndex: 0, //internal var - index on host array + usingSecure: true, //boolean use of secure protocol + protocol: { + secure: "wss://", //secure websocket + insecure: "ws://" //insecure websocket + }, + port: { + secure: [8181, 8282, 8383, 8484], //list of secure ports QZ Tray could be listening on + insecure: [8182, 8283, 8384, 8485], //list of insecure ports QZ Tray could be listening on + portIndex: 0 //internal var - index on active port array + }, + keepAlive: 60, //time between pings to keep connection alive, in seconds + retries: 0, //number of times to reconnect before failing + delay: 0 //seconds before firing a connection + }, + + setup: { + /** Loop through possible ports to open connection, sets web socket calls that will settle the promise. */ + findConnection: function(config, resolve, reject) { + if (_qz.websocket.shutdown) { + reject(new Error("Connection attempt cancelled by user")); + return; + } + + //force flag if missing ports + if (!config.port.secure.length) { + if (!config.port.insecure.length) { + reject(new Error("No ports have been specified to connect over")); + return; + } else if (config.usingSecure) { + _qz.log.error("No secure ports specified - forcing insecure connection"); + config.usingSecure = false; + } + } else if (!config.port.insecure.length && !config.usingSecure) { + _qz.log.trace("No insecure ports specified - forcing secure connection"); + config.usingSecure = true; + } + + var deeper = function() { + if (_qz.websocket.shutdown) { + //connection attempt was cancelled, bail out + reject(new Error("Connection attempt cancelled by user")); + return; + } + + config.port.portIndex++; + + if ((config.usingSecure && config.port.portIndex >= config.port.secure.length) + || (!config.usingSecure && config.port.portIndex >= config.port.insecure.length)) { + if (config.hostIndex >= config.host.length - 1) { + //give up, all hope is lost + reject(new Error("Unable to establish connection with QZ")); + return; + } else { + config.hostIndex++; + config.port.portIndex = 0; + } + } + + // recursive call until connection established or all ports are exhausted + _qz.websocket.setup.findConnection(config, resolve, reject); + }; + + var address; + if (config.usingSecure) { + address = config.protocol.secure + config.host[config.hostIndex] + ":" + config.port.secure[config.port.portIndex]; + } else { + address = config.protocol.insecure + config.host[config.hostIndex] + ":" + config.port.insecure[config.port.portIndex]; + } + + try { + _qz.log.trace("Attempting connection", address); + _qz.websocket.connection = new _qz.tools.ws(address); + } + catch(err) { + _qz.log.error(err); + deeper(); + return; + } + + if (_qz.websocket.connection != null) { + _qz.websocket.connection.established = false; + + //called on successful connection to qz, begins setup of websocket calls and resolves connect promise after certificate is sent + _qz.websocket.connection.onopen = function(evt) { + if (!_qz.websocket.connection.established) { + _qz.log.trace(evt); + _qz.log.info("Established connection with QZ Tray on " + address); + + _qz.websocket.setup.openConnection({ resolve: resolve, reject: reject }); + + if (config.keepAlive > 0) { + var interval = setInterval(function() { + if (!_qz.tools.isActive() || _qz.websocket.connection.interval !== interval) { + clearInterval(interval); + return; + } + + _qz.websocket.connection.send("ping"); + }, config.keepAlive * 1000); + + _qz.websocket.connection.interval = interval; + } + } + }; + + //called during websocket close during setup + _qz.websocket.connection.onclose = function() { + // Safari compatibility fix to raise error event + if (_qz.websocket.connection && typeof navigator !== 'undefined' && navigator.userAgent.indexOf('Safari') != -1 && navigator.userAgent.indexOf('Chrome') == -1) { + _qz.websocket.connection.onerror(); + } + }; + + //called for errors during setup (such as invalid ports), reject connect promise only if all ports have been tried + _qz.websocket.connection.onerror = function(evt) { + _qz.log.trace(evt); + + _qz.websocket.connection = null; + + deeper(); + }; + } else { + reject(new Error("Unable to create a websocket connection")); + } + }, + + /** Finish setting calls on successful connection, sets web socket calls that won't settle the promise. */ + openConnection: function(openPromise) { + _qz.websocket.connection.established = true; + + //called when an open connection is closed + _qz.websocket.connection.onclose = function(evt) { + _qz.log.trace(evt); + + _qz.websocket.connection = null; + _qz.websocket.callClose(evt); + _qz.log.info("Closed connection with QZ Tray"); + + for(var uid in _qz.websocket.pendingCalls) { + if (_qz.websocket.pendingCalls.hasOwnProperty(uid)) { + _qz.websocket.pendingCalls[uid].reject(new Error("Connection closed before response received")); + } + } + + //if this is set, then an explicit close call was made + if (this.promise != undefined) { + this.promise.resolve(); + } + }; + + //called for any errors with an open connection + _qz.websocket.connection.onerror = function(evt) { + _qz.websocket.callError(evt); + }; + + //send JSON objects to qz + _qz.websocket.connection.sendData = function(obj) { + _qz.log.trace("Preparing object for websocket", obj); + + if (obj.timestamp == undefined) { + obj.timestamp = Date.now(); + if (typeof obj.timestamp !== 'number') { + obj.timestamp = new Date().getTime(); + } + } + if (obj.promise != undefined) { + obj.uid = _qz.websocket.setup.newUID(); + _qz.websocket.pendingCalls[obj.uid] = obj.promise; + } + + // track requesting monitor + obj.position = { + x: typeof screen !== 'undefined' ? ((screen.availWidth || screen.width) / 2) + (screen.left || screen.availLeft || 0) : 0, + y: typeof screen !== 'undefined' ? ((screen.availHeight || screen.height) / 2) + (screen.top || screen.availTop || 0) : 0 + }; + + try { + if (obj.call != undefined && obj.signature == undefined && _qz.security.needsSigned(obj.call)) { + var signObj = { + call: obj.call, + params: obj.params, + timestamp: obj.timestamp + }; + + //make a hashing promise if not already one + var hashing = _qz.tools.hash(_qz.tools.stringify(signObj)); + if (!hashing.then) { + hashing = _qz.tools.promise(function(resolve) { + resolve(hashing); + }); + } + + hashing.then(function(hashed) { + return _qz.security.callSign(hashed); + }).then(function(signature) { + _qz.log.trace("Signature for call", signature); + obj.signature = signature || ""; + obj.signAlgorithm = _qz.security.signAlgorithm; + + _qz.signContent = undefined; + _qz.websocket.connection.send(_qz.tools.stringify(obj)); + }).catch(function(err) { + _qz.log.error("Signing failed", err); + + if (obj.promise != undefined) { + obj.promise.reject(new Error("Failed to sign request")); + delete _qz.websocket.pendingCalls[obj.uid]; + } + }); + } else { + _qz.log.trace("Signature for call", obj.signature); + + //called for pre-signed content and (unsigned) setup calls + _qz.websocket.connection.send(_qz.tools.stringify(obj)); + } + } + catch(err) { + _qz.log.error(err); + + if (obj.promise != undefined) { + obj.promise.reject(err); + delete _qz.websocket.pendingCalls[obj.uid]; + } + } + }; + + //receive message from qz + _qz.websocket.connection.onmessage = function(evt) { + var returned = JSON.parse(evt.data); + + if (returned.uid == null) { + if (returned.type == null) { + //incorrect response format, likely connected to incompatible qz version + _qz.websocket.connection.close(4003, "Connected to incompatible QZ Tray version"); + + } else { + //streams (callbacks only, no promises) + switch(returned.type) { + case _qz.streams.serial: + if (!returned.event) { + returned.event = JSON.stringify({ portName: returned.key, output: returned.data }); + } + + _qz.serial.callSerial(JSON.parse(returned.event)); + break; + case _qz.streams.socket: + _qz.socket.callSocket(JSON.parse(returned.event)); + break; + case _qz.streams.usb: + if (!returned.event) { + returned.event = JSON.stringify({ vendorId: returned.key[0], productId: returned.key[1], output: returned.data }); + } + + _qz.usb.callUsb(JSON.parse(returned.event)); + break; + case _qz.streams.hid: + _qz.hid.callHid(JSON.parse(returned.event)); + break; + case _qz.streams.printer: + _qz.printers.callPrinter(JSON.parse(returned.event)); + break; + case _qz.streams.file: + _qz.file.callFile(JSON.parse(returned.event)); + break; + default: + _qz.log.allay("Cannot determine stream type for callback", returned); + break; + } + } + + return; + } + + _qz.log.trace("Received response from websocket", returned); + + var promise = _qz.websocket.pendingCalls[returned.uid]; + if (promise == undefined) { + _qz.log.allay('No promise found for returned response'); + } else { + if (returned.error != undefined) { + promise.reject(new Error(returned.error)); + } else { + promise.resolve(returned.result); + } + } + + delete _qz.websocket.pendingCalls[returned.uid]; + }; + + + //send up the certificate before making any calls + //also gives the user a chance to deny the connection + // PATCHED: Skip certificate sending for custom QZ Tray with pairing key authentication + function sendCert(cert) { + if (cert === undefined) { cert = null; } + + //websocket setup, query what version is connected + // PATCHED: Don't send certificate - custom server uses pairing keys only + qz.api.getVersion().then(function(version) { + _qz.websocket.connection.version = version; + _qz.websocket.connection.semver = version.toLowerCase().replace(/-rc\./g, "-rc").split(/[\\+\\.-]/g); + for(var i = 0; i < _qz.websocket.connection.semver.length; i++) { + try { + if (i == 3 && _qz.websocket.connection.semver[i].toLowerCase().indexOf("rc") == 0) { + // Handle "rc1" pre-release by negating build info + _qz.websocket.connection.semver[i] = -(_qz.websocket.connection.semver[i].replace(/\D/g, "")); + continue; + } + _qz.websocket.connection.semver[i] = parseInt(_qz.websocket.connection.semver[i]); + } + catch(ignore) {} + + if (_qz.websocket.connection.semver.length < 4) { + _qz.websocket.connection.semver[3] = 0; + } + } + + //algorithm can be declared before a connection, check for incompatibilities now that we have one + _qz.compatible.algorithm(true); + }).then(function() { + // PATCHED: Don't send certificate to server - custom QZ Tray uses pairing keys + // Original: _qz.websocket.connection.sendData({ certificate: cert, promise: openPromise }); + // Just resolve the promise immediately without sending certificate + openPromise.resolve(); + }); + } + + // PATCHED: Skip certificate retrieval for custom QZ Tray with pairing keys + // Just call sendCert with null immediately + sendCert(null); + + // Original code below (commented out): + /* + _qz.security.callCert().then(sendCert).catch(function(error) { + _qz.log.warn("Failed to get certificate:", error); + + if (_qz.security.rejectOnCertFailure) { + openPromise.reject(error); + } else { + sendCert(null); + } + }); + */ + }, + + /** Generate unique ID used to map a response to a call. */ + newUID: function() { + var len = 6; + return (new Array(len + 1).join("0") + (Math.random() * Math.pow(36, len) << 0).toString(36)).slice(-len) + } + }, + + dataPromise: function(callName, params, signature, signingTimestamp) { + return _qz.tools.promise(function(resolve, reject) { + var msg = { + call: callName, + promise: { resolve: resolve, reject: reject }, + params: params, + signature: signature, + timestamp: signingTimestamp + }; + + _qz.websocket.connection.sendData(msg); + }); + }, + + /** Library of promises awaiting a response, uid -> promise */ + pendingCalls: {}, + + /** List of functions to call on error from the websocket. */ + errorCallbacks: [], + /** Calls all functions registered to listen for errors. */ + callError: function(evt) { + if (Array.isArray(_qz.websocket.errorCallbacks)) { + for(var i = 0; i < _qz.websocket.errorCallbacks.length; i++) { + _qz.websocket.errorCallbacks[i](evt); + } + } else { + _qz.websocket.errorCallbacks(evt); + } + }, + + /** List of function to call on closing from the websocket. */ + closedCallbacks: [], + /** Calls all functions registered to listen for closing. */ + callClose: function(evt) { + if (Array.isArray(_qz.websocket.closedCallbacks)) { + for(var i = 0; i < _qz.websocket.closedCallbacks.length; i++) { + _qz.websocket.closedCallbacks[i](evt); + } + } else { + _qz.websocket.closedCallbacks(evt); + } + } + }, + + + printing: { + /** Default options used for new printer configs. Can be overridden using {@link qz.configs.setDefaults}. */ + defaultConfig: { + //value purposes are explained in the qz.configs.setDefaults docs + + bounds: null, + colorType: 'color', + copies: 1, + density: 0, + duplex: false, + fallbackDensity: null, + interpolation: 'bicubic', + jobName: null, + legacy: false, + margins: 0, + orientation: null, + paperThickness: null, + printerTray: null, + rasterize: false, + rotation: 0, + scaleContent: true, + size: null, + units: 'in', + + forceRaw: false, + encoding: null, + spool: null + } + }, + + + serial: { + /** List of functions called when receiving data from serial connection. */ + serialCallbacks: [], + /** Calls all functions registered to listen for serial events. */ + callSerial: function(streamEvent) { + if (Array.isArray(_qz.serial.serialCallbacks)) { + for(var i = 0; i < _qz.serial.serialCallbacks.length; i++) { + _qz.serial.serialCallbacks[i](streamEvent); + } + } else { + _qz.serial.serialCallbacks(streamEvent); + } + } + }, + + + socket: { + /** List of functions called when receiving data from network socket connection. */ + socketCallbacks: [], + /** Calls all functions registered to listen for network socket events. */ + callSocket: function(socketEvent) { + if (Array.isArray(_qz.socket.socketCallbacks)) { + for(var i = 0; i < _qz.socket.socketCallbacks.length; i++) { + _qz.socket.socketCallbacks[i](socketEvent); + } + } else { + _qz.socket.socketCallbacks(socketEvent); + } + } + }, + + + usb: { + /** List of functions called when receiving data from usb connection. */ + usbCallbacks: [], + /** Calls all functions registered to listen for usb events. */ + callUsb: function(streamEvent) { + if (Array.isArray(_qz.usb.usbCallbacks)) { + for(var i = 0; i < _qz.usb.usbCallbacks.length; i++) { + _qz.usb.usbCallbacks[i](streamEvent); + } + } else { + _qz.usb.usbCallbacks(streamEvent); + } + } + }, + + + hid: { + /** List of functions called when receiving data from hid connection. */ + hidCallbacks: [], + /** Calls all functions registered to listen for hid events. */ + callHid: function(streamEvent) { + if (Array.isArray(_qz.hid.hidCallbacks)) { + for(var i = 0; i < _qz.hid.hidCallbacks.length; i++) { + _qz.hid.hidCallbacks[i](streamEvent); + } + } else { + _qz.hid.hidCallbacks(streamEvent); + } + } + }, + + + printers: { + /** List of functions called when receiving data from printer connection. */ + printerCallbacks: [], + /** Calls all functions registered to listen for printer events. */ + callPrinter: function(streamEvent) { + if (Array.isArray(_qz.printers.printerCallbacks)) { + for(var i = 0; i < _qz.printers.printerCallbacks.length; i++) { + _qz.printers.printerCallbacks[i](streamEvent); + } + } else { + _qz.printers.printerCallbacks(streamEvent); + } + } + }, + + + file: { + /** List of functions called when receiving info regarding file changes. */ + fileCallbacks: [], + /** Calls all functions registered to listen for file events. */ + callFile: function(streamEvent) { + if (Array.isArray(_qz.file.fileCallbacks)) { + for(var i = 0; i < _qz.file.fileCallbacks.length; i++) { + _qz.file.fileCallbacks[i](streamEvent); + } + } else { + _qz.file.fileCallbacks(streamEvent); + } + } + }, + + + security: { + /** Function used to resolve promise when acquiring site's public certificate. */ + certHandler: function(resolve, reject) { reject(); }, + /** Called to create new promise (using {@link _qz.security.certHandler}) for certificate retrieval. */ + callCert: function() { + if (typeof _qz.security.certHandler.then === 'function') { + //already a promise + return _qz.security.certHandler; + } else if (_qz.security.certHandler.constructor.name === "AsyncFunction") { + //already callable as a promise + return _qz.security.certHandler(); + } else { + //turn into a promise + return _qz.tools.promise(_qz.security.certHandler); + } + }, + + /** Function used to create promise resolver when requiring signed calls. */ + signatureFactory: function() { return function(resolve) { resolve(); } }, + /** Called to create new promise (using {@link _qz.security.signatureFactory}) for signed calls. */ + callSign: function(toSign) { + if (_qz.security.signatureFactory.constructor.name === "AsyncFunction") { + //use directly + return _qz.security.signatureFactory(toSign); + } else { + //use in a promise + return _qz.tools.promise(_qz.security.signatureFactory(toSign)); + } + }, + + /** Signing algorithm used on signatures */ + signAlgorithm: "SHA1", + + rejectOnCertFailure: false, + + needsSigned: function(callName) { + const undialoged = [ + "printers.getStatus", + "printers.stopListening", + "usb.isClaimed", + "usb.closeStream", + "usb.releaseDevice", + "hid.stopListening", + "hid.isClaimed", + "hid.closeStream", + "hid.releaseDevice", + "file.stopListening", + "getVersion" + ]; + + return callName != null && undialoged.indexOf(callName) === -1; + } + }, + + + tools: { + /** Create a new promise */ + promise: function(resolver) { + //prefer global object for historical purposes + if (typeof RSVP !== 'undefined') { + return new RSVP.Promise(resolver); + } else if (typeof Promise !== 'undefined') { + return new Promise(resolver); + } else { + _qz.log.error("Promise/A+ support is required. See qz.api.setPromiseType(...)"); + } + }, + + /** Stub for rejecting with an Error from withing a Promise */ + reject: function(error) { + return _qz.tools.promise(function(resolve, reject) { + reject(error); + }); + }, + + stringify: function(object) { + //old versions of prototype affect stringify + var pjson = Array.prototype.toJSON; + delete Array.prototype.toJSON; + + function skipKeys(key, value) { + if (key === "promise") { + return undefined; + } + + return value; + } + + var result = JSON.stringify(object, skipKeys); + + if (pjson) { + Array.prototype.toJSON = pjson; + } + + return result; + }, + + hash: function(data) { + //prefer global object for historical purposes + if (typeof Sha256 !== 'undefined') { + return Sha256.hash(data); + } else { + return _qz.SHA.hash(data); + } + }, + + ws: typeof WebSocket !== 'undefined' ? WebSocket : null, + + absolute: function(loc) { + if (typeof window !== 'undefined' && typeof document.createElement === 'function') { + var a = document.createElement("a"); + a.href = loc; + return a.href; + } else if (typeof exports === 'object') { + //node.js + require('path').resolve(loc); + } + return loc; + }, + + relative: function(data) { + for(var i = 0; i < data.length; i++) { + if (data[i].constructor === Object) { + var absolute = false; + + if (data[i].data && data[i].data.search && data[i].data.search(/data:image\/\w+;base64,/) === 0) { + //upgrade from old base64 behavior + data[i].flavor = "base64"; + data[i].data = data[i].data.replace(/^data:image\/\w+;base64,/, ""); + } else if (data[i].flavor) { + //if flavor is known, we can directly check for absolute flavor types + if (["FILE", "XML"].indexOf(data[i].flavor.toUpperCase()) > -1) { + absolute = true; + } + } else if (data[i].format && ["HTML", "IMAGE", "PDF", "FILE", "XML"].indexOf(data[i].format.toUpperCase()) > -1) { + //if flavor is not known, all valid pixel formats default to file flavor + //previous v2.0 data also used format as what is now flavor, so we check for those values here too + absolute = true; + } else if (data[i].type && ((["PIXEL", "IMAGE", "PDF"].indexOf(data[i].type.toUpperCase()) > -1 && !data[i].format) + || (["HTML", "PDF"].indexOf(data[i].type.toUpperCase()) > -1 && (!data[i].format || data[i].format.toUpperCase() === "FILE")))) { + //if all we know is pixel type, then it is image's file flavor + //previous v2.0 data also used type as what is now format, so we check for those value here too + absolute = true; + } + + if (absolute) { + //change relative links to absolute + data[i].data = _qz.tools.absolute(data[i].data); + } + if (data[i].options && typeof data[i].options.overlay === 'string') { + data[i].options.overlay = _qz.tools.absolute(data[i].options.overlay); + } + } + } + }, + + /** Performs deep copy to target from remaining params */ + extend: function(target) { + //special case when reassigning properties as objects in a deep copy + if (typeof target !== 'object') { + target = {}; + } + + for(var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + if (!source) { continue; } + + for(var key in source) { + if (source.hasOwnProperty(key)) { + if (target === source[key]) { continue; } + + if (source[key] && source[key].constructor && source[key].constructor === Object) { + var clone; + if (Array.isArray(source[key])) { + clone = target[key] || []; + } else { + clone = target[key] || {}; + } + + target[key] = _qz.tools.extend(clone, source[key]); + } else if (source[key] !== undefined) { + target[key] = source[key]; + } + } + } + } + + return target; + }, + + versionCompare: function(major, minor, patch, build) { + if (_qz.tools.assertActive()) { + var semver = _qz.websocket.connection.semver; + if (semver[0] != major) { + return semver[0] - major; + } + if (minor != undefined && semver[1] != minor) { + return semver[1] - minor; + } + if (patch != undefined && semver[2] != patch) { + return semver[2] - patch; + } + if (build != undefined && semver.length > 3 && semver[3] != build) { + return Number.isInteger(semver[3]) && Number.isInteger(build) ? semver[3] - build : semver[3].toString().localeCompare(build.toString()); + } + return 0; + } + }, + + isVersion: function(major, minor, patch, build) { + return _qz.tools.versionCompare(major, minor, patch, build) == 0; + }, + + isActive: function() { + return !_qz.websocket.shutdown && _qz.websocket.connection != null + && (_qz.websocket.connection.readyState === _qz.tools.ws.OPEN + || _qz.websocket.connection.readyState === _qz.tools.ws.CONNECTING); + }, + + assertActive: function() { + if (_qz.tools.isActive()) { + return true; + } + // Promise won't reject on throw; yet better than 'undefined' + throw new Error("A connection to QZ has not been established yet"); + }, + + uint8ArrayToHex: function(uint8) { + return Array.from(uint8) + .map(function(i) { return i.toString(16).padStart(2, '0'); }) + .join(''); + }, + + uint8ArrayToBase64: function(uint8) { + /** + * Adapted from Egor Nepomnyaschih's code under MIT Licence (C) 2020 + * see https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 + */ + var map = [ + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", + "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", + "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "+", "/" + ]; + + var result = '', i, l = uint8.length; + for (i = 2; i < l; i += 3) { + result += map[uint8[i - 2] >> 2]; + result += map[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)]; + result += map[((uint8[i - 1] & 0x0F) << 2) | (uint8[i] >> 6)]; + result += map[uint8[i] & 0x3F]; + } + if (i === l + 1) { // 1 octet yet to write + result += map[uint8[i - 2] >> 2]; + result += map[(uint8[i - 2] & 0x03) << 4]; + result += "=="; + } + if (i === l) { // 2 octets yet to write + result += map[uint8[i - 2] >> 2]; + result += map[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)]; + result += map[(uint8[i - 1] & 0x0F) << 2]; + result += "="; + } + return result; + }, + }, + + compatible: { + /** Converts message format to a previous version's */ + data: function(printData) { + // special handling for Uint8Array + for(var i = 0; i < printData.length; i++) { + if (printData[i].constructor === Object && printData[i].data instanceof Uint8Array) { + if (printData[i].flavor) { + var flavor = printData[i].flavor.toString().toUpperCase(); + switch(flavor) { + case 'BASE64': + printData[i].data = _qz.tools.uint8ArrayToBase64(printData[i].data); + break; + case 'HEX': + printData[i].data = _qz.tools.uint8ArrayToHex(printData[i].data); + break; + default: + throw new Error("Uint8Array conversion to '" + flavor + "' is not supported."); + } + } + } + } + + if(_qz.tools.versionCompare(2, 2, 4) < 0) { + for(var i = 0; i < printData.length; i++) { + if (printData[i].constructor === Object) { + // dotDensity: "double-legacy|single-legacy" since 2.2.4. Fallback to "double|single" + if (printData[i].options && typeof printData[i].options.dotDensity === 'string') { + printData[i].options.dotDensity = printData[i].options.dotDensity.toLowerCase().replace("-legacy", ""); + } + } + } + } + + if (_qz.tools.isVersion(2, 0)) { + /* + 2.0.x conversion + ----- + type=pixel -> use format as 2.0 type (unless 'command' format, which forces 2.0 'raw' type) + type=raw -> 2.0 type has to be 'raw' + if format is 'image' -> force 2.0 'image' format, ignore everything else (unsupported in 2.0) + + flavor translates straight to 2.0 format (unless forced to 'raw'/'image') + */ + _qz.log.trace("Converting print data to v2.0 for " + _qz.websocket.connection.version); + for(var i = 0; i < printData.length; i++) { + if (printData[i].constructor === Object) { + if (printData[i].type && printData[i].type.toUpperCase() === "RAW" && printData[i].format && printData[i].format.toUpperCase() === "IMAGE") { + if (printData[i].flavor && printData[i].flavor.toUpperCase() === "BASE64") { + //special case for raw base64 images + printData[i].data = "data:image/compat;base64," + printData[i].data; + } + printData[i].flavor = "IMAGE"; //forces 'image' format when shifting for conversion + } + if ((printData[i].type && printData[i].type.toUpperCase() === "RAW") || (printData[i].format && printData[i].format.toUpperCase() === "COMMAND")) { + printData[i].format = "RAW"; //forces 'raw' type when shifting for conversion + } + + printData[i].type = printData[i].format; + printData[i].format = printData[i].flavor; + delete printData[i].flavor; + } + } + } + }, + + /* Converts config defaults to match previous version */ + config: function(config, dirty) { + if (_qz.tools.isVersion(2, 0)) { + if (!dirty.rasterize) { + config.rasterize = true; + } + } + if(_qz.tools.versionCompare(2, 2) < 0) { + if(config.forceRaw !== 'undefined') { + config.altPrinting = config.forceRaw; + delete config.forceRaw; + } + } + if(_qz.tools.versionCompare(2, 1, 2, 11) < 0) { + if(config.spool) { + if(config.spool.size) { + config.perSpool = config.spool.size; + delete config.spool.size; + } + if(config.spool.end) { + config.endOfDoc = config.spool.end; + delete config.spool.end; + } + delete config.spool; + } + } + return config; + }, + + /** Compat wrapper with previous version **/ + networking: function(hostname, port, signature, signingTimestamp, mappingCallback) { + // Use 2.0 + if (_qz.tools.isVersion(2, 0)) { + return _qz.tools.promise(function(resolve, reject) { + _qz.websocket.dataPromise('websocket.getNetworkInfo', { + hostname: hostname, + port: port + }, signature, signingTimestamp).then(function(data) { + if (typeof mappingCallback !== 'undefined') { + resolve(mappingCallback(data)); + } else { + resolve(data); + } + }, reject); + }); + } + // Wrap 2.1 + return _qz.tools.promise(function(resolve, reject) { + _qz.websocket.dataPromise('networking.device', { + hostname: hostname, + port: port + }, signature, signingTimestamp).then(function(data) { + resolve({ ipAddress: data.ip, macAddress: data.mac }); + }, reject); + }); + }, + + /** Check if QZ version supports chosen algorithm */ + algorithm: function(quiet) { + //if not connected yet we will assume compatibility exists for the time being + if (_qz.tools.isActive()) { + if (_qz.tools.isVersion(2, 0)) { + if (!quiet) { + _qz.log.warn("Connected to an older version of QZ, alternate signature algorithms are not supported"); + } + return false; + } + } + + return true; + } + }, + + /** + * Adapted from Chris Veness's code under MIT Licence (C) 2002 + * see http://www.movable-type.co.uk/scripts/sha256.html + */ + SHA: { + //@formatter:off - keep this block compact + hash: function(msg) { + // add trailing '1' bit (+ 0's padding) to string [§5.1.1] + msg = _qz.SHA._utf8Encode(msg) + String.fromCharCode(0x80); + + // constants [§4.2.2] + var K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + ]; + // initial hash value [§5.3.1] + var H = [ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 ]; + + // convert string msg into 512-bit/16-integer blocks arrays of ints [§5.2.1] + var l = msg.length / 4 + 2; // length (in 32-bit integers) of msg + ‘1’ + appended length + var N = Math.ceil(l / 16); // number of 16-integer-blocks required to hold 'l' ints + var M = new Array(N); + + for(var i = 0; i < N; i++) { + M[i] = new Array(16); + for(var j = 0; j < 16; j++) { // encode 4 chars per integer, big-endian encoding + M[i][j] = (msg.charCodeAt(i * 64 + j * 4) << 24) | (msg.charCodeAt(i * 64 + j * 4 + 1) << 16) | + (msg.charCodeAt(i * 64 + j * 4 + 2) << 8) | (msg.charCodeAt(i * 64 + j * 4 + 3)); + } // note running off the end of msg is ok 'cos bitwise ops on NaN return 0 + } + // add length (in bits) into final pair of 32-bit integers (big-endian) [§5.1.1] + // note: most significant word would be (len-1)*8 >>> 32, but since JS converts + // bitwise-op args to 32 bits, we need to simulate this by arithmetic operators + M[N-1][14] = ((msg.length - 1) * 8) / Math.pow(2, 32); + M[N-1][14] = Math.floor(M[N-1][14]); + M[N-1][15] = ((msg.length - 1) * 8) & 0xffffffff; + + // HASH COMPUTATION [§6.1.2] + var W = new Array(64); var a, b, c, d, e, f, g, h; + for(var i = 0; i < N; i++) { + // 1 - prepare message schedule 'W' + for(var t = 0; t < 16; t++) { W[t] = M[i][t]; } + for(var t = 16; t < 64; t++) { W[t] = (_qz.SHA._dev1(W[t-2]) + W[t-7] + _qz.SHA._dev0(W[t-15]) + W[t-16]) & 0xffffffff; } + // 2 - initialise working variables a, b, c, d, e, f, g, h with previous hash value + a = H[0]; b = H[1]; c = H[2]; d = H[3]; e = H[4]; f = H[5]; g = H[6]; h = H[7]; + // 3 - main loop (note 'addition modulo 2^32') + for(var t = 0; t < 64; t++) { + var T1 = h + _qz.SHA._sig1(e) + _qz.SHA._ch(e, f, g) + K[t] + W[t]; + var T2 = _qz.SHA._sig0(a) + _qz.SHA._maj(a, b, c); + h = g; g = f; f = e; e = (d + T1) & 0xffffffff; + d = c; c = b; b = a; a = (T1 + T2) & 0xffffffff; + } + // 4 - compute the new intermediate hash value (note 'addition modulo 2^32') + H[0] = (H[0]+a) & 0xffffffff; H[1] = (H[1]+b) & 0xffffffff; H[2] = (H[2]+c) & 0xffffffff; H[3] = (H[3]+d) & 0xffffffff; + H[4] = (H[4]+e) & 0xffffffff; H[5] = (H[5]+f) & 0xffffffff; H[6] = (H[6]+g) & 0xffffffff; H[7] = (H[7]+h) & 0xffffffff; + } + + return _qz.SHA._hexStr(H[0]) + _qz.SHA._hexStr(H[1]) + _qz.SHA._hexStr(H[2]) + _qz.SHA._hexStr(H[3]) + + _qz.SHA._hexStr(H[4]) + _qz.SHA._hexStr(H[5]) + _qz.SHA._hexStr(H[6]) + _qz.SHA._hexStr(H[7]); + }, + + // Rotates right (circular right shift) value x by n positions + _rotr: function(n, x) { return (x >>> n) | (x << (32 - n)); }, + // logical functions + _sig0: function(x) { return _qz.SHA._rotr(2, x) ^ _qz.SHA._rotr(13, x) ^ _qz.SHA._rotr(22, x); }, + _sig1: function(x) { return _qz.SHA._rotr(6, x) ^ _qz.SHA._rotr(11, x) ^ _qz.SHA._rotr(25, x); }, + _dev0: function(x) { return _qz.SHA._rotr(7, x) ^ _qz.SHA._rotr(18, x) ^ (x >>> 3); }, + _dev1: function(x) { return _qz.SHA._rotr(17, x) ^ _qz.SHA._rotr(19, x) ^ (x >>> 10); }, + _ch: function(x, y, z) { return (x & y) ^ (~x & z); }, + _maj: function(x, y, z) { return (x & y) ^ (x & z) ^ (y & z); }, + // note can't use toString(16) as it is implementation-dependant, and in IE returns signed numbers when used on full words + _hexStr: function(n) { var s = "", v; for(var i = 7; i >= 0; i--) { v = (n >>> (i * 4)) & 0xf; s += v.toString(16); } return s; }, + // implementation of deprecated unescape() based on https://cwestblog.com/2011/05/23/escape-unescape-deprecated/ (and comments) + _unescape: function(str) { + return str.replace(/%(u[\da-f]{4}|[\da-f]{2})/gi, function(seq) { + if (seq.length - 1) { + return String.fromCharCode(parseInt(seq.substring(seq.length - 3 ? 2 : 1), 16)) + } else { + var code = seq.charCodeAt(0); + return code < 256 ? "%" + (0 + code.toString(16)).slice(-2).toUpperCase() : "%u" + ("000" + code.toString(16)).slice(-4).toUpperCase() + } + }); + }, + _utf8Encode: function(str) { + return _qz.SHA._unescape(encodeURIComponent(str)); + } + //@formatter:on + }, + }; + + +///// CONFIG CLASS //// + + /** Object to handle configured printer options. */ + function Config(printer, opts) { + + this.config = _qz.tools.extend({}, _qz.printing.defaultConfig); //create a copy of the default options + this._dirtyOpts = {}; //track which config options have changed from the defaults + + /** + * Set the printer assigned to this config. + * @param {string|Object} newPrinter Name of printer. Use object type to specify printing to file or host. + * @param {string} [newPrinter.name] Name of printer to send printing. + * @param {string} [newPrinter.file] DEPRECATED: Name of file to send printing. + * @param {string} [newPrinter.host] IP address or host name to send printing. + * @param {string} [newPrinter.port] Port used by <printer.host>. + */ + this.setPrinter = function(newPrinter) { + if (typeof newPrinter === 'string') { + newPrinter = { name: newPrinter }; + } + this.printer = newPrinter; + }; + + /** + * @returns {Object} The printer currently assigned to this config. + */ + this.getPrinter = function() { + return this.printer; + }; + + /** + * Alter any of the printer options currently applied to this config. + * @param newOpts {Object} The options to change. See qz.configs.setDefaults docs for available values. + * + * @see qz.configs.setDefaults + */ + this.reconfigure = function(newOpts) { + for(var key in newOpts) { + if (newOpts[key] !== undefined) { + this._dirtyOpts[key] = true; + } + } + + _qz.tools.extend(this.config, newOpts); + }; + + /** + * @returns {Object} The currently applied options on this config. + */ + this.getOptions = function() { + return _qz.compatible.config(this.config, this._dirtyOpts); + }; + + // init calls for new config object + this.setPrinter(printer); + this.reconfigure(opts); + } + + /** + * Shortcut method for calling qz.print with a particular config. + * @param {Array} data Array of data being sent to the printer. See qz.print docs for available values. + * @param {boolean} [signature] Pre-signed signature of JSON string containing call, params, and timestamp. + * @param {number} [signingTimestamp] Required with signature. Timestamp used with pre-signed content. + * + * @example + * qz.print(myConfig, ...); // OR + * myConfig.print(...); + * + * @see qz.print + */ + Config.prototype.print = function(data, signature, signingTimestamp) { + qz.print(this, data, signature, signingTimestamp); + }; + + +///// PUBLIC METHODS ///// + + /** @namespace qz */ + var qz = { + + /** + * Calls related specifically to the web socket connection. + * @namespace qz.websocket + */ + websocket: { + /** + * Check connection status. Active connection is necessary for other calls to run. + * + * @returns {boolean} If there is an active connection with QZ Tray. + * + * @see connect + * + * @memberof qz.websocket + */ + isActive: function() { + return _qz.tools.isActive(); + }, + + /** + * Call to setup connection with QZ Tray on user's system. + * + * @param {Object} [options] Configuration options for the web socket connection. + * @param {string|Array} [options.host=['localhost', 'localhost.qz.io']] Host running the QZ Tray software. + * @param {Object} [options.port] Config options for ports to cycle. + * @param {Array} [options.port.secure=[8181, 8282, 8383, 8484]] Array of secure (WSS) ports to try + * @param {Array} [options.port.insecure=[8182, 8283, 8384, 8485]] Array of insecure (WS) ports to try + * @param {boolean} [options.usingSecure=true] If the web socket should try to use secure ports for connecting. + * @param {number} [options.keepAlive=60] Seconds between keep-alive pings to keep connection open. Set to 0 to disable. + * @param {number} [options.retries=0] Number of times to reconnect before failing. + * @param {number} [options.delay=0] Seconds before firing a connection. Ignored if options.retries is 0. + * + * @returns {Promise} + * + * @memberof qz.websocket + */ + connect: function(options) { + return _qz.tools.promise(function(resolve, reject) { + if (_qz.websocket.connection) { + const state = _qz.websocket.connection.readyState; + + if (state === _qz.tools.ws.OPEN) { + reject(new Error("An open connection with QZ Tray already exists")); + return; + } else if (state === _qz.tools.ws.CONNECTING) { + reject(new Error("The current connection attempt has not returned yet")); + return; + } else if (state === _qz.tools.ws.CLOSING) { + reject(new Error("Waiting for previous disconnect request to complete")); + return; + } + } + + if (!_qz.tools.ws) { + reject(new Error("WebSocket not supported by this browser")); + return; + } else if (!_qz.tools.ws.CLOSED || _qz.tools.ws.CLOSED == 2) { + reject(new Error("Unsupported WebSocket version detected: HyBi-00/Hixie-76")); + return; + } + + //ensure some form of options exists for value checks + if (options == undefined) { options = {}; } + + //disable secure ports if page is not secure + if (typeof location === 'undefined' || location.protocol !== 'https:') { + //respect forcing secure ports if it is defined, otherwise disable + if (typeof options.usingSecure === 'undefined') { + _qz.log.trace("Disabling secure ports due to insecure page"); + options.usingSecure = false; + } + } + + //ensure any hosts are passed to internals as an array + if (typeof options.host !== 'undefined' && !Array.isArray(options.host)) { + options.host = [options.host]; + } + + _qz.websocket.shutdown = false; //reset state for new connection attempt + var attempt = function(count) { + var tried = false; + var nextAttempt = function() { + if (!tried) { + tried = true; + + if (options && count < options.retries) { + attempt(count + 1); + } else { + _qz.websocket.connection = null; + reject.apply(null, arguments); + } + } + }; + + var delayed = function() { + var config = _qz.tools.extend({}, _qz.websocket.connectConfig, options); + _qz.websocket.setup.findConnection(config, resolve, nextAttempt) + }; + if (count == 0) { + delayed(); // only retries will be called with a delay + } else { + setTimeout(delayed, options.delay * 1000); + } + }; + + attempt(0); + }); + }, + + /** + * Stop any active connection with QZ Tray. + * + * @returns {Promise} + * + * @memberof qz.websocket + */ + disconnect: function() { + return _qz.tools.promise(function(resolve, reject) { + if (_qz.websocket.connection != null) { + if (_qz.tools.isActive()) { + // handles closing both 'connecting' and 'connected' states + _qz.websocket.shutdown = true; + _qz.websocket.connection.promise = { resolve: resolve, reject: reject }; + _qz.websocket.connection.close(); + } else { + reject(new Error("Current connection is still closing")); + } + } else { + reject(new Error("No open connection with QZ Tray")); + } + }); + }, + + /** + * List of functions called for any connections errors outside of an API call.

+ * Also called if {@link websocket#connect} fails to connect. + * + * @param {Function|Array} calls Single or array of Function({Event} event) calls. + * + * @memberof qz.websocket + */ + setErrorCallbacks: function(calls) { + _qz.websocket.errorCallbacks = calls; + }, + + /** + * List of functions called for any connection closing event outside of an API call.

+ * Also called when {@link websocket#disconnect} is called. + * + * @param {Function|Array} calls Single or array of Function({Event} event) calls. + * + * @memberof qz.websocket + */ + setClosedCallbacks: function(calls) { + _qz.websocket.closedCallbacks = calls; + }, + + /** + * @deprecated Since 2.1.0. Please use qz.networking.device() instead + * + * @param {string} [hostname] Hostname to try to connect to when determining network interfaces, defaults to "google.com" + * @param {number} [port] Port to use with custom hostname, defaults to 443 + * @param {string} [signature] Pre-signed signature of hashed JSON string containing call='websocket.getNetworkInfo', params object, and timestamp. + * @param {number} [signingTimestamp] Required with signature. Timestamp used with pre-signed content. + * + * @returns {Promise|Error>} Connected system's network information. + * + * @memberof qz.websocket + */ + getNetworkInfo: _qz.compatible.networking, + + /** + * @returns {Object<{socket: String, host: String, port: Number}>} Details of active websocket connection + * + * @memberof qz.websocket + */ + getConnectionInfo: function() { + if (_qz.tools.assertActive()) { + var url = _qz.websocket.connection.url.split(/[:\/]+/g); + return { socket: url[0], host: url[1], port: +url[2] }; + } + } + }, + + + /** + * Calls related to getting printer information from the connection. + * @namespace qz.printers + */ + printers: { + /** + * @param {string} [signature] Pre-signed signature of hashed JSON string containing call='printers.getDefault, params, and timestamp. + * @param {number} [signingTimestamp] Required with signature. Timestamp used with pre-signed content. + * + * @returns {Promise} Name of the connected system's default printer. + * + * @memberof qz.printers + */ + getDefault: function(signature, signingTimestamp) { + return _qz.websocket.dataPromise('printers.getDefault', null, signature, signingTimestamp); + }, + + /** + * @param {string} [query] Search for a specific printer. All printers are returned if not provided. + * @param {string} [signature] Pre-signed signature of hashed JSON string containing call='printers.find', params, and timestamp. + * @param {number} [signingTimestamp] Required with signature. Timestamp used with pre-signed content. + * + * @returns {Promise|string|Error>} The matched printer name if query is provided. + * Otherwise an array of printer names found on the connected system. + * + * @memberof qz.printers + */ + find: function(query, signature, signingTimestamp) { + return _qz.websocket.dataPromise('printers.find', { query: query }, signature, signingTimestamp); + }, + + /** + * Provides a list, with additional information, for each printer available to QZ. + * + * @returns {Promise|Object|Error>} + * + * @memberof qz.printers + */ + details: function() { + return _qz.websocket.dataPromise('printers.detail'); + }, + + /** + * Start listening for printer status events, such as paper_jam events. + * Reported under the ACTION type in the streamEvent on callbacks. + * + * @returns {Promise} + * @since 2.1.0 + * + * @see qz.printers.setPrinterCallbacks + * + * @param {null|string|Array} printers Printer or list of printers to listen to, null listens to all. + * @param {Object|null} [options] Printer listener options + * @param {null|boolean} [options.jobData=false] Flag indicating if raw spool file content should be return as well as status information (Windows only) + * @param {null|number} [options.maxJobData=-1] Maximum number of bytes to returns for raw spooled file content (Windows only) + * @param {null|string} [options.flavor="plain"] Flavor of data format returned. Valid flavors are [base64 | hex | plain*] (Windows only) + * + * @memberof qz.printers + */ + startListening: function(printers, options) { + if (!Array.isArray(printers)) { + printers = [printers]; + } + var params = { + printerNames: printers + }; + if (options && options.jobData == true) params.jobData = true; + if (options && options.maxJobData) params.maxJobData = options.maxJobData; + if (options && options.flavor) params.flavor = options.flavor; + return _qz.websocket.dataPromise('printers.startListening', params); + }, + + /** + * Clear the queue of a specified printer or printers. Does not delete retained jobs. + * + * @param {string|Object} [options] Name of printer to clear + * @param {string} [options.printerName] Name of printer to clear + * @param {number} [options.jobId] Cancel a job of a specific JobId instead of canceling all. Must include a printerName. + * + * @returns {Promise} + * @since 2.2.4 + * + * @memberof qz.printers + */ + clearQueue: function(options) { + if (typeof options !== 'object') { + options = { + printerName: options + }; + } + return _qz.websocket.dataPromise('printers.clearQueue', options); + }, + + /** + * Stop listening for printer status actions. + * + * @returns {Promise} + * @since 2.1.0 + * + * @see qz.printers.setPrinterCallbacks + * + * @memberof qz.printers + */ + stopListening: function() { + return _qz.websocket.dataPromise('printers.stopListening'); + }, + + /** + * Retrieve current printer status from any active listeners. + * + * @returns {Promise} + * @since 2.1.0 + * + * @see qz.printers.startListening + * + * @memberof qz.printers + */ + getStatus: function() { + return _qz.websocket.dataPromise('printers.getStatus'); + }, + + /** + * List of functions called for any printer status change. + * Event data will contain {string} printerName and {string} status for all types. + * For RECEIVE types, {Array} output (in hexadecimal format). + * For ERROR types, {string} exception. + * For ACTION types, {string} actionType. + * + * @param {Function|Array} calls Single or array of Function({Object} eventData) calls. + * @since 2.1.0 + * + * @memberof qz.printers + */ + setPrinterCallbacks: function(calls) { + _qz.printers.printerCallbacks = calls; + } + }, + + /** + * Calls related to setting up new printer configurations. + * @namespace qz.configs + */ + configs: { + /** + * Default options used by new configs if not overridden. + * Setting a value to NULL will use the printer's default options. + * Updating these will not update the options on any created config. + * + * @param {Object} options Default options used by printer configs if not overridden. + * + * @param {Object} [options.bounds=null] Bounding box rectangle. + * @param {number} [options.bounds.x=0] Distance from left for bounding box starting corner + * @param {number} [options.bounds.y=0] Distance from top for bounding box starting corner + * @param {number} [options.bounds.width=0] Width of bounding box + * @param {number} [options.bounds.height=0] Height of bounding box + * @param {string} [options.colorType='color'] Valid values [color | grayscale | blackwhite | default] + * @param {number} [options.copies=1] Number of copies to be printed. + * @param {number|Array|Object|Array|string} [options.density=0] Pixel density (DPI, DPMM, or DPCM depending on [options.units]). + * If provided as an array, uses the first supported density found (or the first entry if none found). + * If provided as a string, valid values are [best | draft], corresponding to highest or lowest reported density respectively. + * @param {number} [options.density.cross=0] Asymmetric pixel density for the cross feed direction. + * @param {number} [options.density.feed=0] Asymmetric pixel density for the feed direction. + * @param {boolean|string} [options.duplex=false] Double sided printing, Can specify duplex style by passing a string value: [one-sided | duplex | long-edge | tumble | short-edge] + * @param {number} [options.fallbackDensity=null] Value used when default density value cannot be read, or in cases where reported as "Normal" by the driver, (in DPI, DPMM, or DPCM depending on [options.units]). + * @param {string} [options.interpolation='bicubic'] Valid values [bicubic | bilinear | nearest-neighbor]. Controls how images are handled when resized. + * @param {string} [options.jobName=null] Name to display in print queue. + * @param {boolean} [options.legacy=false] If legacy style printing should be used. + * @param {Object|number} [options.margins=0] If just a number is provided, it is used as the margin for all sides. + * @param {number} [options.margins.top=0] + * @param {number} [options.margins.right=0] + * @param {number} [options.margins.bottom=0] + * @param {number} [options.margins.left=0] + * @param {string} [options.orientation=null] Valid values [portrait | landscape | reverse-landscape | null]. + * If set to null, orientation will be determined automatically. + * @param {number} [options.paperThickness=null] + * @param {string|number} [options.printerTray=null] Printer tray to pull from. The number N assumes string equivalent of 'Tray N'. Uses printer default if NULL. + * @param {boolean} [options.rasterize=false] Whether documents should be rasterized before printing. + * Specifying [options.density] for PDF print formats will set this to true. + * @param {number} [options.rotation=0] Image rotation in degrees. + * @param {boolean} [options.scaleContent=true] Scales print content to page size, keeping ratio. + * @param {Object} [options.size=null] Paper size. + * @param {number} [options.size.width=null] Page width. + * @param {number} [options.size.height=null] Page height. + * @param {string} [options.units='in'] Page units, applies to paper size, margins, and density. Valid value [in | cm | mm] + * + * @param {boolean} [options.forceRaw=false] Print the specified raw data using direct method, skipping the driver. Not yet supported on Windows. + * @param {string|Object} [options.encoding=null] Character set for commands. Can be provided as an object for converting encoding types for RAW types. + * @param {string} [options.encoding.from] If this encoding type is provided, RAW type commands will be parsed from this for the purpose of being converted to the encoding.to value. + * @param {string} [options.encoding.to] Encoding RAW type commands will be converted into. If encoding.from is not provided, this will be treated as if a string was passed for encoding. + * @param {string} [options.endOfDoc=null] DEPRECATED Raw only: Character(s) denoting end of a page to control spooling. + * @param {number} [options.perSpool=1] DEPRECATED: Raw only: Number of pages per spool. + * @param {boolean} [options.retainTemp=false] Retain any temporary files used. Ignored unless forceRaw true. + * @param {Object} [options.spool=null] Advanced spooling options. + * @param {number} [options.spool.size=null] Number of pages per spool. Default is no limit. If spool.end is provided, defaults to 1 + * @param {string} [options.spool.end=null] Raw only: Character(s) denoting end of a page to control spooling. + * + * @memberof qz.configs + */ + setDefaults: function(options) { + _qz.tools.extend(_qz.printing.defaultConfig, options); + }, + + /** + * Creates new printer config to be used in printing. + * + * @param {string|object} printer Name of printer. Use object type to specify printing to file or host. + * @param {string} [printer.name] Name of printer to send printing. + * @param {string} [printer.file] Name of file to send printing. + * @param {string} [printer.host] IP address or host name to send printing. + * @param {string} [printer.port] Port used by <printer.host>. + * @param {Object} [options] Override any of the default options for this config only. + * + * @returns {Config} The new config. + * + * @see configs.setDefaults + * + * @memberof qz.configs + */ + create: function(printer, options) { + return new Config(printer, options); + } + }, + + + /** + * Send data to selected config for printing. + * The promise for this method will resolve when the document has been sent to the printer. Actual printing may not be complete. + *

+ * Optionally, print requests can be pre-signed: + * Signed content consists of a JSON object string containing no spacing, + * following the format of the "call" and "params" keys in the API call, with the addition of a "timestamp" key in milliseconds + * ex. '{"call":"","params":{...},"timestamp":1450000000}' + * + * @param {Object|Array>} configs Previously created config object or objects. + * @param {Array|Array>} data Array of data being sent to the printer.
+ * String values are interpreted as {type: 'raw', format: 'command', flavor: 'plain', data: <string>}. + * @param {string} data.data + * @param {string} data.type Printing type. Valid types are [pixel | raw*]. *Default + * @param {string} data.format Format of data type used. *Default per type

+ * For [pixel] types, valid formats are [html | image* | pdf].

+ * For [raw] types, valid formats are [command* | html | image | pdf]. + * @param {string} data.flavor Flavor of data format used. *Default per format

+ * For [command] formats, valid flavors are [base64 | file | hex | plain* | xml].

+ * For [html] formats, valid flavors are [file* | plain].

+ * For [image] formats, valid flavors are [base64 | file*].

+ * For [pdf] formats, valid flavors are [base64 | file*]. + * @param {Object} [data.options] + * @param {string} [data.options.language] Required with [raw] type + [image] format. Printer language. + * @param {number} [data.options.x] Optional with [raw] type + [image] format. The X position of the image. + * @param {number} [data.options.y] Optional with [raw] type + [image] format. The Y position of the image. + * @param {string|number} [data.options.dotDensity] Optional with [raw] type + [image] format. + * @param {number} [data.precision=128] Optional with [raw] type [image] format. Bit precision of the ribbons. + * @param {boolean|string|Array>} [data.options.overlay=false] Optional with [raw] type [image] format. + * Boolean sets entire layer, string sets mask image, Array sets array of rectangles in format [x1,y1,x2,y2]. + * @param {string} [data.options.xmlTag] Required with [xml] flavor. Tag name containing base64 formatted data. + * @param {number} [data.options.pageWidth] Optional with [html | pdf] formats. Width of the rendering. + * Defaults to paper width. + * @param {number} [data.options.pageHeight] Optional with [html | pdf] formats. Height of the rendering. + * Defaults to paper height for [pdf], or auto sized for [html]. + * @param {string} [data.options.pageRanges] Optional with [pdf] formats. Comma-separated list of page ranges to include. + * @param {boolean} [data.options.ignoreTransparency=false] Optional with [pdf] formats. Instructs transparent PDF elements to be ignored. + * Transparent PDF elements are known to degrade performance and quality when printing. + * @param {boolean} [data.options.altFontRendering=false] Optional with [pdf] formats. Instructs PDF to be rendered using PDFBOX 1.8 techniques. + * Drastically improves low-DPI PDF print quality on Windows. + * @param {...*} [arguments] Additionally three more parameters can be specified:

+ * {boolean} [resumeOnError=false] Whether the chain should continue printing if it hits an error on one the the prints.

+ * {string|Array} [signature] Pre-signed signature(s) of the JSON string for containing call, params, and timestamp.

+ * {number|Array} [signingTimestamps] Required to match with signature. Timestamps for each of the passed pre-signed content. + * + * @returns {Promise} + * + * @see qz.configs.create + * + * @memberof qz + */ + print: function(configs, data) { + var resumeOnError = false, + signatures = [], + signaturesTimestamps = []; + + //find optional parameters + if (arguments.length >= 3) { + if (typeof arguments[2] === 'boolean') { + resumeOnError = arguments[2]; + + if (arguments.length >= 5) { + signatures = arguments[3]; + signaturesTimestamps = arguments[4]; + } + } else if (arguments.length >= 4) { + signatures = arguments[2]; + signaturesTimestamps = arguments[3]; + } + + //ensure values are arrays for consistency + if (signatures && !Array.isArray(signatures)) { signatures = [signatures]; } + if (signaturesTimestamps && !Array.isArray(signaturesTimestamps)) { signaturesTimestamps = [signaturesTimestamps]; } + } + + if (!Array.isArray(configs)) { configs = [configs]; } //single config -> array of configs + if (!Array.isArray(data[0])) { data = [data]; } //single data array -> array of data arrays + + //clean up data formatting + for(var d = 0; d < data.length; d++) { + _qz.tools.relative(data[d]); + _qz.compatible.data(data[d]); + } + + var sendToPrint = function(mapping) { + var params = { + printer: mapping.config.getPrinter(), + options: mapping.config.getOptions(), + data: mapping.data + }; + + return _qz.websocket.dataPromise('print', params, mapping.signature, mapping.timestamp); + }; + + //chain instead of Promise.all, so resumeOnError can collect each error + var chain = []; + for(var i = 0; i < configs.length || i < data.length; i++) { + (function(i_) { + var map = { + config: configs[Math.min(i_, configs.length - 1)], + data: data[Math.min(i_, data.length - 1)], + signature: signatures[i_], + timestamp: signaturesTimestamps[i_] + }; + + chain.push(function() { return sendToPrint(map) }); + })(i); + } + + //setup to catch errors if needed + var fallThrough = null; + if (resumeOnError) { + var fallen = []; + fallThrough = function(err) { fallen.push(err); }; + + //final promise to reject any errors as a group + chain.push(function() { + return _qz.tools.promise(function(resolve, reject) { + fallen.length ? reject(fallen) : resolve(); + }); + }); + } + + var last = null; + chain.reduce(function(sequence, link) { + last = sequence.catch(fallThrough).then(link); //catch is ignored if fallThrough is null + return last; + }, _qz.tools.promise(function(r) { r(); })); //an immediately resolved promise to start off the chain + + //return last promise so users can chain off final action or catch when stopping on error + return last; + }, + + + /** + * Calls related to interaction with serial ports. + * @namespace qz.serial + */ + serial: { + /** + * @returns {Promise|Error>} Communication (RS232, COM, TTY) ports available on connected system. + * + * @memberof qz.serial + */ + findPorts: function() { + return _qz.websocket.dataPromise('serial.findPorts'); + }, + + /** + * List of functions called for any response from open serial ports. + * Event data will contain {string} portName for all types. + * For RECEIVE types, {string} output. + * For ERROR types, {string} exception. + * + * @param {Function|Array} calls Single or array of Function({object} streamEvent) calls. + * + * @memberof qz.serial + */ + setSerialCallbacks: function(calls) { + _qz.serial.serialCallbacks = calls; + }, + + /** + * Opens a serial port for sending and receiving data + * + * @param {string} port Name of serial port to open. + * @param {Object} [options] Serial port configurations. + * @param {number} [options.baudRate=9600] Serial port speed. Set to 0 for auto negotiation. + * @param {number} [options.dataBits=8] Serial port data bits. Set to 0 for auto negotiation. + * @param {number} [options.stopBits=1] Serial port stop bits. Set to 0 for auto negotiation. + * @param {string} [options.parity='NONE'] Serial port parity. Set to AUTO for auto negotiation. Valid values [NONE | EVEN | ODD | MARK | SPACE | AUTO] + * @param {string} [options.flowControl='NONE'] Serial port flow control. Set to AUTO for auto negotiation. Valid values [NONE | XONXOFF | XONXOFF_OUT | XONXOFF_IN | RTSCTS | RTSCTS_OUT | RTSCTS_IN | AUTO] + * @param {string} [options.encoding='UTF-8'] Character set for communications. + * @param {string} [options.start=0x0002] DEPRECATED: Legacy character denoting start of serial response. Use options.rx.start instead. + * @param {string} [options.end=0x000D] DEPRECATED: Legacy character denoting end of serial response. Use options.rx.end instead. + * @param {number} [options.width] DEPRECATED: Legacy use for fixed-width response serial communication. Use options.rx.width instead. + * @param {Object} [options.rx] Serial communications response definitions. If an object is passed but no options are defined, all response data will be sent back as it is received unprocessed. + * @param {string|Array} [options.rx.start] Character(s) denoting start of response bytes. Used in conjunction with `end`, `width`, or `lengthbit` property. + * @param {string} [options.rx.end] Character denoting end of response bytes. Used in conjunction with `start` property. + * @param {number} [options.rx.width] Fixed width size of response bytes (not including header if `start` is set). Used alone or in conjunction with `start` property. + * @param {boolean} [options.rx.untilNewline] Returns data between newline characters (`\n` or `\r`) Truncates empty responses. Overrides `start`, `end`, `width`. + * @param {number|Object} [options.rx.lengthBytes] If a number is passed it is treated as the length index. Other values are left as their defaults. + * @param {number} [options.rx.lengthBytes.index=0] Position of the response byte (not including response `start` bytes) used to denote the length of the remaining response data. + * @param {number} [options.rx.lengthBytes.length=1] Length of response length bytes after response header. + * @param {string} [options.rx.lengthBytes.endian='BIG'] Byte endian for multi-byte length values. Valid values [BIG | LITTLE] + * @param {number|Object} [options.rx.crcBytes] If a number is passed it is treated as the crc length. Other values are left as their defaults. + * @param {number} [options.rx.crcBytes.index=0] Position after the response data (not including length or data bytes) used to denote the crc. + * @param {number} [options.rx.crcBytes.length=1] Length of response crc bytes after the response data length. + * @param {boolean} [options.rx.includeHeader=false] Whether any of the header bytes (`start` bytes and any length bytes) should be included in the processed response. + * @param {string} [options.rx.encoding] Override the encoding used for response data. Uses the same value as options.encoding otherwise. + * + * @returns {Promise} + * + * @memberof qz.serial + */ + openPort: function(port, options) { + var params = { + port: port, + options: options + }; + return _qz.websocket.dataPromise('serial.openPort', params); + }, + + /** + * Send commands over a serial port. + * Any responses from the device will be sent to serial callback functions. + * + * @param {string} port An open serial port to send data. + * @param {string|Array|Object} data Data to be sent to the serial device. + * @param {string} [data.type='PLAIN'] Valid values [FILE | PLAIN | HEX | BASE64] + * @param {string|Array} data.data Data to be sent to the serial device. + * @param {Object} options Serial port configuration updates. See qz.serial.openPort `options` docs for available values. + * For best performance, it is recommended to only set these values on the port open call. + * + * @returns {Promise} + * + * @see qz.serial.setSerialCallbacks + * + * @memberof qz.serial + */ + sendData: function(port, data, options) { + if (_qz.tools.versionCompare(2, 1, 0, 12) >= 0) { + if (typeof data !== 'object') { + data = { + data: data, + type: "PLAIN" + } + } + + if (data.type && data.type.toUpperCase() == "FILE") { + data.data = _qz.tools.absolute(data.data); + } + } + + var params = { + port: port, + data: data, + options: options + }; + return _qz.websocket.dataPromise('serial.sendData', params); + }, + + /** + * @param {string} port Name of port to close. + * + * @returns {Promise} + * + * @memberof qz.serial + */ + closePort: function(port) { + return _qz.websocket.dataPromise('serial.closePort', { port: port }); + } + }, + + /** + * Calls related to interaction with communication sockets. + * @namespace qz.socket + */ + socket: { + /** + * Opens a network port for sending and receiving data. + * + * @param {string} host The connection hostname. + * @param {number} port The connection port number. + * @param {Object} [options] Network socket configuration. + * @param {string} [options.encoding='UTF-8'] Character set for communications. + * + * @memberof qz.socket + */ + open: function(host, port, options) { + var params = { + host: host, + port: port, + options: options + }; + return _qz.websocket.dataPromise("socket.open", params); + }, + + /** + * @param {string} host The connection hostname. + * @param {number} port The connection port number. + * + * @memberof qz.socket + */ + close: function(host, port) { + var params = { + host: host, + port: port + }; + return _qz.websocket.dataPromise("socket.close", params); + }, + + /** + * Send data over an open socket. + * + * @param {string} host The connection hostname. + * @param {number} port The connection port number. + * @param {string|Object} data Data to be sent over the port. + * @param {string} [data.type='PLAIN'] Valid values [PLAIN] + * @param {string} data.data Data to be sent over the port. + * + * @memberof qz.socket + */ + sendData: function(host, port, data) { + if (typeof data !== 'object') { + data = { + data: data, + type: "PLAIN" + }; + } + + var params = { + host: host, + port: port, + data: data + }; + return _qz.websocket.dataPromise("socket.sendData", params); + }, + + /** + * List of functions called for any response from open network sockets. + * Event data will contain {string} host and {number} port for all types. + * For RECEIVE types, {string} response. + * For ERROR types, {string} exception. + * + * @param {Function|Array} calls Single or array of Function({Object} eventData) calls. + * + * @memberof qz.socket + */ + setSocketCallbacks: function(calls) { + _qz.socket.socketCallbacks = calls; + } + }, + + /** + * Calls related to interaction with USB devices. + * @namespace qz.usb + */ + usb: { + /** + * List of available USB devices. Includes (hexadecimal) vendor ID, (hexadecimal) product ID, and hub status. + * If supported, also returns manufacturer and product descriptions. + * + * @param includeHubs Whether to include USB hubs. + * @returns {Promise|Error>} Array of JSON objects containing information on connected USB devices. + * + * @memberof qz.usb + */ + listDevices: function(includeHubs) { + return _qz.websocket.dataPromise('usb.listDevices', { includeHubs: includeHubs }); + }, + + /** + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @returns {Promise|Error>} List of available (hexadecimal) interfaces on a USB device. + * + * @memberof qz.usb + */ + listInterfaces: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('usb.listInterfaces', deviceInfo); + }, + + /** + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.iface Hex string of interface on the USB device to search. + * @returns {Promise|Error>} List of available (hexadecimal) endpoints on a USB device's interface. + * + * @memberof qz.usb + */ + listEndpoints: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + interface: arguments[2] + }; + } + + return _qz.websocket.dataPromise('usb.listEndpoints', deviceInfo); + }, + + /** + * List of functions called for any response from open usb devices. + * Event data will contain {string} vendorId and {string} productId for all types. + * For RECEIVE types, {Array} output (in hexadecimal format). + * For ERROR types, {string} exception. + * + * @param {Function|Array} calls Single or array of Function({Object} eventData) calls. + * + * @memberof qz.usb + */ + setUsbCallbacks: function(calls) { + _qz.usb.usbCallbacks = calls; + }, + + /** + * Claim a USB device's interface to enable sending/reading data across an endpoint. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.interface Hex string of interface on the USB device to claim. + * @returns {Promise} + * + * @memberof qz.usb + */ + claimDevice: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + interface: arguments[2] + }; + } + + return _qz.websocket.dataPromise('usb.claimDevice', deviceInfo); + }, + + /** + * Check the current claim state of a USB device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @returns {Promise} + * + * @since 2.0.2 + * @memberOf qz.usb + */ + isClaimed: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('usb.isClaimed', deviceInfo); + }, + + /** + * Send data to a claimed USB device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.endpoint Hex string of endpoint on the claimed interface for the USB device. + * @param deviceInfo.data Bytes to send over specified endpoint. + * @param {string} [deviceInfo.type='PLAIN'] Valid values [FILE | PLAIN | HEX | BASE64] + * @returns {Promise} + * + * @memberof qz.usb + */ + sendData: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + endpoint: arguments[2], + data: arguments[3] + }; + } + + if (_qz.tools.versionCompare(2, 1, 0, 12) >= 0) { + if (typeof deviceInfo.data !== 'object') { + deviceInfo.data = { + data: deviceInfo.data, + type: "PLAIN" + } + } + + if (deviceInfo.data.type && deviceInfo.data.type.toUpperCase() == "FILE") { + deviceInfo.data.data = _qz.tools.absolute(deviceInfo.data.data); + } + } + + return _qz.websocket.dataPromise('usb.sendData', deviceInfo); + }, + + /** + * Read data from a claimed USB device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.endpoint Hex string of endpoint on the claimed interface for the USB device. + * @param deviceInfo.responseSize Size of the byte array to receive a response in. + * @returns {Promise|Error>} List of (hexadecimal) bytes received from the USB device. + * + * @memberof qz.usb + */ + readData: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + endpoint: arguments[2], + responseSize: arguments[3] + }; + } + + return _qz.websocket.dataPromise('usb.readData', deviceInfo); + }, + + /** + * Provides a continuous stream of read data from a claimed USB device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.endpoint Hex string of endpoint on the claimed interface for the USB device. + * @param deviceInfo.responseSize Size of the byte array to receive a response in. + * @param deviceInfo.interval=100 Frequency to send read data back, in milliseconds. + * @returns {Promise} + * + * @see qz.usb.setUsbCallbacks + * + * @memberof qz.usb + */ + openStream: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + endpoint: arguments[2], + responseSize: arguments[3], + interval: arguments[4] + }; + } + + return _qz.websocket.dataPromise('usb.openStream', deviceInfo); + }, + + /** + * Stops the stream of read data from a claimed USB device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @param deviceInfo.endpoint Hex string of endpoint on the claimed interface for the USB device. + * @returns {Promise} + * + * @memberof qz.usb + */ + closeStream: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + endpoint: arguments[2] + }; + } + + return _qz.websocket.dataPromise('usb.closeStream', deviceInfo); + }, + + /** + * Release a claimed USB device to free resources after sending/reading data. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of USB device's vendor ID. + * @param deviceInfo.productId Hex string of USB device's product ID. + * @returns {Promise} + * + * @memberof qz.usb + */ + releaseDevice: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('usb.releaseDevice', deviceInfo); + } + }, + + + /** + * Calls related to interaction with HID USB devices
+ * Many of these calls can be accomplished from the qz.usb namespace, + * but HID allows for simpler interaction + * @namespace qz.hid + * @since 2.0.1 + */ + hid: { + /** + * List of available HID devices. Includes (hexadecimal) vendor ID and (hexadecimal) product ID. + * If available, also returns manufacturer and product descriptions. + * + * @returns {Promise|Error>} Array of JSON objects containing information on connected HID devices. + * @since 2.0.1 + * + * @memberof qz.hid + */ + listDevices: function() { + return _qz.websocket.dataPromise('hid.listDevices'); + }, + + /** + * Start listening for HID device actions, such as attach / detach events. + * Reported under the ACTION type in the streamEvent on callbacks. + * + * @returns {Promise} + * @since 2.0.1 + * + * @see qz.hid.setHidCallbacks + * + * @memberof qz.hid + */ + startListening: function() { + return _qz.websocket.dataPromise('hid.startListening'); + }, + + /** + * Stop listening for HID device actions. + * + * @returns {Promise} + * @since 2.0.1 + * + * @see qz.hid.setHidCallbacks + * + * @memberof qz.hid + */ + stopListening: function() { + return _qz.websocket.dataPromise('hid.stopListening'); + }, + + /** + * List of functions called for any response from open usb devices. + * Event data will contain {string} vendorId and {string} productId for all types. + * For RECEIVE types, {Array} output (in hexadecimal format). + * For ERROR types, {string} exception. + * For ACTION types, {string} actionType. + * + * @param {Function|Array} calls Single or array of Function({Object} eventData) calls. + * @since 2.0.1 + * + * @memberof qz.hid + */ + setHidCallbacks: function(calls) { + _qz.hid.hidCallbacks = calls; + }, + + /** + * Claim a HID device to enable sending/reading data across. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @returns {Promise} + * @since 2.0.1 + * + * @memberof qz.hid + */ + claimDevice: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('hid.claimDevice', deviceInfo); + }, + + /** + * Check the current claim state of a HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @returns {Promise} + * + * @since 2.0.2 + * @memberOf qz.hid + */ + isClaimed: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('hid.isClaimed', deviceInfo); + }, + + /** + * Send data to a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @param deviceInfo.data Bytes to send over specified endpoint. + * @param deviceInfo.endpoint=0x00 First byte of the data packet signifying the HID report ID. + * Must be 0x00 for devices only supporting a single report. + * @param deviceInfo.reportId=0x00 Alias for deviceInfo.endpoint. Not used if endpoint is provided. + * @param {string} [deviceInfo.type='PLAIN'] Valid values [FILE | PLAIN | HEX | BASE64] + * @returns {Promise} + * @since 2.0.1 + * + * @memberof qz.hid + */ + sendData: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + data: arguments[2], + endpoint: arguments[3] + }; + } + + if (_qz.tools.versionCompare(2, 1, 0, 12) >= 0) { + if (typeof deviceInfo.data !== 'object') { + deviceInfo.data = { + data: deviceInfo.data, + type: "PLAIN" + } + } + + if (deviceInfo.data.type && deviceInfo.data.type.toUpperCase() == "FILE") { + deviceInfo.data.data = _qz.tools.absolute(deviceInfo.data.data); + } + } else { + if (typeof deviceInfo.data === 'object') { + if (deviceInfo.data.type.toUpperCase() !== "PLAIN" + || typeof deviceInfo.data.data !== "string") { + return _qz.tools.reject(new Error("Data format is not supported with connected QZ Tray version " + _qz.websocket.connection.version)); + } + + deviceInfo.data = deviceInfo.data.data; + } + } + + return _qz.websocket.dataPromise('hid.sendData', deviceInfo); + }, + + /** + * Read data from a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @param deviceInfo.responseSize Size of the byte array to receive a response in. + * @returns {Promise|Error>} List of (hexadecimal) bytes received from the HID device. + * @since 2.0.1 + * + * @memberof qz.hid + */ + readData: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + responseSize: arguments[2] + }; + } + + return _qz.websocket.dataPromise('hid.readData', deviceInfo); + }, + + /** + * Send a feature report to a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @param deviceInfo.data Bytes to send over specified endpoint. + * @param deviceInfo.endpoint=0x00 First byte of the data packet signifying the HID report ID. + * Must be 0x00 for devices only supporting a single report. + * @param deviceInfo.reportId=0x00 Alias for deviceInfo.endpoint. Not used if endpoint is provided. + * @param {string} [deviceInfo.type='PLAIN'] Valid values [FILE | PLAIN | HEX | BASE64] + * @returns {Promise} + * + * @memberof qz.hid + */ + sendFeatureReport: function(deviceInfo) { + return _qz.websocket.dataPromise('hid.sendFeatureReport', deviceInfo); + }, + + /** + * Get a feature report from a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @param deviceInfo.responseSize Size of the byte array to receive a response in. + * @returns {Promise|Error>} List of (hexadecimal) bytes received from the HID device. + * + * @memberof qz.hid + */ + getFeatureReport: function(deviceInfo) { + return _qz.websocket.dataPromise('hid.getFeatureReport', deviceInfo); + }, + + /** + * Provides a continuous stream of read data from a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @param deviceInfo.responseSize Size of the byte array to receive a response in. + * @param deviceInfo.interval=100 Frequency to send read data back, in milliseconds. + * @returns {Promise} + * @since 2.0.1 + * + * @see qz.hid.setHidCallbacks + * + * @memberof qz.hid + */ + openStream: function(deviceInfo) { + //backwards compatibility + if (typeof deviceInfo !== 'object') { + deviceInfo = { + vendorId: arguments[0], + productId: arguments[1], + responseSize: arguments[2], + interval: arguments[3] + }; + } + + return _qz.websocket.dataPromise('hid.openStream', deviceInfo); + }, + + /** + * Stops the stream of read data from a claimed HID device. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @returns {Promise} + * @since 2.0.1 + * + * @memberof qz.hid + */ + closeStream: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('hid.closeStream', deviceInfo); + }, + + /** + * Release a claimed HID device to free resources after sending/reading data. + * + * @param {object} deviceInfo Config details of the HID device. + * @param deviceInfo.vendorId Hex string of HID device's vendor ID. + * @param deviceInfo.productId Hex string of HID device's product ID. + * @param deviceInfo.usagePage Hex string of HID device's usage page when multiple are present. + * @param deviceInfo.serial Serial ID of HID device. + * @returns {Promise} + * @since 2.0.1 + * + * @memberof qz.hid + */ + releaseDevice: function(deviceInfo) { + if (typeof deviceInfo !== 'object') { deviceInfo = { vendorId: arguments[0], productId: arguments[1] }; } //backwards compatibility + + return _qz.websocket.dataPromise('hid.releaseDevice', deviceInfo); + } + }, + + + /** + * Calls related to interactions with the filesystem + * @namespace qz.file + * @since 2.1 + */ + file: { + /** + * List of files available at the given directory.
+ * Due to security reasons, paths are limited to the qz data directory unless overridden via properties file. + * + * @param {string} path Relative or absolute directory path. Must reside in qz data directory or a white-listed location. + * @param {Object} [params] Object containing file access parameters + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @returns {Promise|Error>} Array of files at the given path + * + * @memberof qz.file + */ + list: function(path, params) { + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.list', param); + }, + + /** + * Reads contents of file at the given path.
+ * Due to security reasons, paths are limited to the qz data directory unless overridden via properties file. + * + * @param {string} path Relative or absolute file path. Must reside in qz data directory or a white-listed location. + * @param {Object} [params] Object containing file access parameters + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @param {string} [params.flavor='plain'] Flavor of data format used, valid flavors are [base64 | hex | plain]. + * @returns {Promise} String containing the file contents + * + * @memberof qz.file + */ + read: function(path, params) { + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.read', param); + }, + + /** + * Writes data to the file at the given path.
+ * Due to security reasons, paths are limited to the qz data directory unless overridden via properties file. + * + * @param {string} path Relative or absolute file path. Must reside in qz data directory or a white-listed location. + * @param {Object} params Object containing file access parameters + * @param {string} params.data File data to be written + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @param {boolean} [params.append=false] Appends to the end of the file if set, otherwise overwrites existing contents + * @param {string} [params.flavor='plain'] Flavor of data format used, valid flavors are [base64 | file | hex | plain]. + * @returns {Promise} + * + * @memberof qz.file + */ + write: function(path, params) { + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.write', param); + }, + + /** + * Deletes a file at given path.
+ * Due to security reasons, paths are limited to the qz data directory unless overridden via properties file. + * + * @param {string} path Relative or absolute file path. Must reside in qz data directory or a white-listed location. + * @param {Object} [params] Object containing file access parameters + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @returns {Promise} + * + * @memberof qz.file + */ + remove: function(path, params) { + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.remove', param); + }, + + /** + * Provides a continuous stream of events (and optionally data) from a local file. + * + * @param {string} path Relative or absolute directory path. Must reside in qz data directory or a white-listed location. + * @param {Object} [params] Object containing file access parameters + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @param {Object} [params.listener] If defined, file data will be returned on events + * @param {number} [params.listener.bytes=-1] Number of bytes to return or -1 for all + * @param {number} [params.listener.lines=-1] Number of lines to return or -1 for all + * @param {boolean} [params.listener.reverse] Controls whether data should be returned from the bottom of the file. Default value is true for line mode and false for byte mode. + * @param {string|Array} [params.include] File patterns to match. Blank values will be ignored. + * @param {string|Array} [params.exclude] File patterns to exclude. Blank values will be ignored. Takes priority over params.include. + * @param {boolean} [params.ignoreCase=true] Whether params.include or params.exclude are case-sensitive. + * @returns {Promise} + * @since 2.1.0 + * + * @see qz.file.setFileCallbacks + * + * @memberof qz.file + */ + startListening: function(path, params) { + if (params && typeof params.include !== 'undefined' && !Array.isArray(params.include)) { + params.include = [params.include]; + } + if (params && typeof params.exclude !== 'undefined' && !Array.isArray(params.exclude)) { + params.exclude = [params.exclude]; + } + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.startListening', param); + }, + + /** + * Closes listeners with the provided settings. Omitting the path parameter will result in all listeners closing. + * + * @param {string} [path] Previously opened directory path of listener to close, or omit to close all. + * @param {Object} [params] Object containing file access parameters + * @param {boolean} [params.sandbox=true] If relative location from root is only available to the certificate's connection, otherwise all connections + * @param {boolean} [params.shared=true] If relative location from root is accessible to all users on the system, otherwise just the current user + * @returns {Promise} + * + * @memberof qz.file + */ + stopListening: function(path, params) { + var param = _qz.tools.extend({ path: path }, params); + return _qz.websocket.dataPromise('file.stopListening', param); + }, + + /** + * List of functions called for any response from a file listener. + * For ERROR types event data will contain, {string} message. + * For ACTION types event data will contain, {string} file {string} eventType {string} [data]. + * + * @param {Function|Array} calls Single or array of Function({Object} eventData) calls. + * @since 2.1.0 + * + * @memberof qz.file + */ + setFileCallbacks: function(calls) { + _qz.file.fileCallbacks = calls; + } + }, + + /** + * Calls related to networking information + * @namespace qz.networking + * @since 2.1.0 + */ + networking: { + /** + * @param {string} [hostname] Hostname to try to connect to when determining network interfaces, defaults to "google.com" + * @param {number} [port] Port to use with custom hostname, defaults to 443 + * @returns {Promise} Connected system's network information. + * + * @memberof qz.networking + * @since 2.1.0 + */ + device: function(hostname, port) { + // Wrap 2.0 + if (_qz.tools.isVersion(2, 0)) { + return _qz.compatible.networking(hostname, port, null, null, function(data) { + return { ip: data.ipAddress, mac: data.macAddress }; + }); + } + // Use 2.1 + return _qz.websocket.dataPromise('networking.device', { + hostname: hostname, + port: port + }); + }, + + /** + * Get computer hostname + * + * @param {string} [hostname] DEPRECATED Hostname to try to connect to when determining network interfaces, defaults to "google.com" + * @param {number} [port] DEPRECATED Port to use with custom hostname, defaults to 443 + * @returns {Promise} Connected system's hostname. + * + * @memberof qz.networking + * @since 2.2.2 + */ + hostname: function(hostname, port) { + // Wrap < 2.2.2 + if (_qz.tools.versionCompare(2, 2, 2) < 0) { + return _qz.tools.promise(function(resolve, reject) { + _qz.websocket.dataPromise('networking.device', { hostname: hostname, port: port }).then(function(device) { + console.log(device); + resolve(device.hostname); + }); + }); + } else { + return _qz.websocket.dataPromise('networking.hostname'); + } + }, + + /** + * @param {string} [hostname] Hostname to try to connect to when determining network interfaces, defaults to "google.com" + * @param {number} [port] Port to use with custom hostname, defaults to 443 + * @returns {Promise|Error>} Connected system's network information. + * + * @memberof qz.networking + * @since 2.1.0 + */ + devices: function(hostname, port) { + // Wrap 2.0 + if (_qz.tools.isVersion(2, 0)) { + return _qz.compatible.networking(hostname, port, null, null, function(data) { + return [{ ip: data.ipAddress, mac: data.macAddress }]; + }); + } + // Use 2.1 + return _qz.websocket.dataPromise('networking.devices', { + hostname: hostname, + port: port + }); + } + }, + + + /** + * Calls related to signing connection requests. + * @namespace qz.security + */ + security: { + /** + * Set promise resolver for calls to acquire the site's certificate. + * + * @param {Function|AsyncFunction|Promise} promiseHandler Either a function that will be used as a promise resolver (of format Function({function} resolve, {function}reject)), + * an async function, or a promise. Any of which should return the public certificate via their respective resolve call. + * @param {Object} [options] Configuration options for the certificate resolver + * @param {boolean} [options.rejectOnFailure=[false]] Overrides default behavior to call resolve with a blank certificate on failure. + * @memberof qz.security + */ + setCertificatePromise: function(promiseHandler, options) { + _qz.security.certHandler = promiseHandler; + _qz.security.rejectOnCertFailure = !!(options && options.rejectOnFailure); + }, + + /** + * Set promise factory for calls to sign API calls. + * + * @param {Function|AsyncFunction} promiseFactory Either a function that accepts a string parameter of the data to be signed + * and returns a function to be used as a promise resolver (of format Function({function} resolve, {function}reject)), + * or an async function that can take a string parameter of the data to be signed. Either of which should return the signed contents of + * the passed string parameter via their respective resolve call. + * + * @example + * qz.security.setSignaturePromise(function(dataToSign) { + * return function(resolve, reject) { + * $.ajax("/signing-url?data=" + dataToSign).then(resolve, reject); + * } + * }) + * + * @memberof qz.security + */ + setSignaturePromise: function(promiseFactory) { + _qz.security.signatureFactory = promiseFactory; + }, + + /** + * Set which signing algorithm QZ will check signatures against. + * + * @param {string} algorithm The algorithm used in signing. Valid values: [SHA1 | SHA256 | SHA512] + * @since 2.1.0 + * + * @memberof qz.security + */ + setSignatureAlgorithm: function(algorithm) { + //warn for incompatibilities if known + if (!_qz.compatible.algorithm()) { + return; + } + + if (["SHA1", "SHA256", "SHA512"].indexOf(algorithm.toUpperCase()) < 0) { + _qz.log.error("Signing algorithm '" + algorithm + "' is not supported."); + } else { + _qz.security.signAlgorithm = algorithm; + } + }, + + /** + * Get the signing algorithm QZ will be checking signatures against. + * + * @returns {string} The algorithm used in signing. + * @since 2.1.0 + * + * @memberof qz.security + */ + getSignatureAlgorithm: function() { + return _qz.security.signAlgorithm; + } + }, + + /** + * Calls related to compatibility adjustments + * @namespace qz.api + */ + api: { + /** + * Show or hide QZ api debugging statements in the browser console. + * + * @param {boolean} show Whether the debugging logs for QZ should be shown. Hidden by default. + * @returns {boolean} Value of debugging flag + * @memberof qz.api + */ + showDebug: function(show) { + return (_qz.DEBUG = show); + }, + + /** + * Get version of connected QZ Tray application. + * + * @returns {Promise} Version number of QZ Tray. + * + * @memberof qz.api + */ + getVersion: function() { + return _qz.websocket.dataPromise('getVersion'); + }, + + /** + * Checks for the specified version of connected QZ Tray application. + * + * @param {string|number} [major] Major version to check + * @param {string|number} [minor] Minor version to check + * @param {string|number} [patch] Patch version to check + * + * @memberof qz.api + */ + isVersion: _qz.tools.isVersion, + + /** + * Checks if the connected QZ Tray application is greater than the specified version. + * + * @param {string|number} major Major version to check + * @param {string|number} [minor] Minor version to check + * @param {string|number} [patch] Patch version to check + * @param {string|number} [build] Build version to check + * @returns {boolean} True if connected version is greater than the version specified. + * + * @memberof qz.api + * @since 2.1.0-4 + */ + isVersionGreater: function(major, minor, patch, build) { + return _qz.tools.versionCompare(major, minor, patch, build) > 0; + }, + + /** + * Checks if the connected QZ Tray application is less than the specified version. + * + * @param {string|number} major Major version to check + * @param {string|number} [minor] Minor version to check + * @param {string|number} [patch] Patch version to check + * @param {string|number} [build] Build version to check + * @returns {boolean} True if connected version is less than the version specified. + * + * @memberof qz.api + * @since 2.1.0-4 + */ + isVersionLess: function(major, minor, patch, build) { + return _qz.tools.versionCompare(major, minor, patch, build) < 0; + }, + + /** + * Change the promise library used by QZ API. + * Should be called before any initialization to avoid possible errors. + * + * @param {Function} promiser Function({function} resolver) called to create new promises. + * + * @memberof qz.api + */ + setPromiseType: function(promiser) { + _qz.tools.promise = promiser; + }, + + /** + * Change the SHA-256 hashing function used by QZ API. + * Should be called before any initialization to avoid possible errors. + * + * @param {Function} hasher Function({function} message) called to create hash of passed string. + * + * @memberof qz.api + */ + setSha256Type: function(hasher) { + _qz.tools.hash = hasher; + }, + + /** + * Change the WebSocket handler. + * Should be called before any initialization to avoid possible errors. + * + * @param {Function} ws Function({function} WebSocket) called to override the internal WebSocket handler. + * + * @memberof qz.api + */ + setWebSocketType: function(ws) { + _qz.tools.ws = ws; + } + }, + + /** + * Version of this JavaScript library + * + * @constant {string} + * + * @memberof qz + */ + version: _qz.VERSION + }; + + return qz; +})(); + + +(function() { + if (typeof define === 'function' && define.amd) { + define(qz); + } else if (typeof exports === 'object') { + module.exports = qz; + } else { + window.qz = qz; + } +})(); diff --git a/app/templates/base.html b/app/templates/base.html index 6032cfc..62503f5 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -39,6 +39,11 @@ Quality +