From 81c6e2eff0e55950cfe5e6dcc97779a8d22c92d8 Mon Sep 17 00:00:00 2001 From: deucebucket Date: Mon, 16 Feb 2026 00:20:20 -0600 Subject: [PATCH 1/4] Feat #113: Add user feedback system with session logging - Add library_manager/feedback.py: session action logger (circular buffer), path/data sanitizer, local feedback storage (feedback.json) - Add POST /api/feedback route for submitting bug reports, corrections, feature requests with optional session log and system info - Add floating feedback button + modal to base.html matching existing theme system (works with both default and skaldleita themes) - Add Flask error handlers (500, 404) that log to session buffer - Hook session logging into key routes: scan, process, apply_fix, reject_fix, worker start/stop, apply_all - Feedback stored locally, structured for future Skaldleita API forwarding --- app.py | 68 +++++++++++++++ library_manager/feedback.py | 163 ++++++++++++++++++++++++++++++++++++ templates/base.html | 124 +++++++++++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 library_manager/feedback.py diff --git a/app.py b/app.py index f215e6c..e167c1c 100644 --- a/app.py +++ b/app.py @@ -111,6 +111,10 @@ get_instance_data, save_instance_data, ) +from library_manager.feedback import ( + log_action, get_session_log, log_error as feedback_log_error, + get_system_info, store_feedback, sanitize_string as feedback_sanitize, +) from library_manager.folder_triage import triage_folder, triage_book_path, should_use_path_hints, confidence_modifier from library_manager.hints import get_all_hints @@ -610,6 +614,31 @@ def get_locale(): # Configuration code has been moved to library_manager/config.py # Database code has been moved to library_manager/database.py +# ============== ERROR HANDLERS ============== + +@app.errorhandler(500) +def handle_500(error): + feedback_log_error(error, context="500_error") + logger.error(f"Internal server error: {error}") + if request.path.startswith('/api/'): + return jsonify({ + 'success': False, + 'error': 'Internal server error', + 'suggest_feedback': True, + }), 500 + return render_template('base.html', + config=load_config(), + worker_running=is_worker_running(), + ), 500 + + +@app.errorhandler(404) +def handle_404(error): + if request.path.startswith('/api/'): + return jsonify({'success': False, 'error': 'Not found'}), 404 + return redirect('/') + + # ============== ANONYMOUS ERROR REPORTING ============== ERROR_REPORTS_PATH = DATA_DIR / "error_reports.json" @@ -7263,6 +7292,7 @@ def api_scan(): config = load_config() checked, scanned, queued = scan_library(config) + log_action("scan", detail=f"checked={checked} scanned={scanned} queued={queued}", result="success") return jsonify({ 'success': True, 'checked': checked, # Total book folders examined @@ -7769,6 +7799,8 @@ def api_process(): update_processing_status('active', False) clear_current_book() + log_action("process", detail=f"processed={processed} fixed={fixed} remaining={remaining}", result="success") + # Build helpful status message if remaining == 0: status = 'complete' @@ -7932,6 +7964,7 @@ def api_live_status(): def api_apply_fix(history_id): """Apply a specific fix.""" success, message = apply_fix(history_id) + log_action("apply_fix", detail=f"history_id={history_id}", result="success" if success else "error") return jsonify({'success': success, 'message': message}) @app.route('/api/reject_fix/', methods=['POST']) @@ -7959,6 +7992,7 @@ def api_reject_fix(history_id): conn.close() logger.info(f"Rejected fix {history_id}, book {book_id} marked as verified") + log_action("reject_fix", detail=f"history_id={history_id}", result="success") return jsonify({'success': True}) @app.route('/api/dismiss_error/', methods=['POST']) @@ -8005,6 +8039,7 @@ def api_apply_all_pending(): else: errors += 1 + log_action("apply_all", detail=f"applied={applied} errors={errors}", result="success") return jsonify({ 'success': True, 'applied': applied, @@ -8696,6 +8731,37 @@ def api_send_error_reports(): return jsonify(result) +@app.route('/api/feedback', methods=['POST']) +def api_feedback(): + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': 'No data provided'}), 400 + category = data.get('category', '').strip() + description = data.get('description', '').strip() + if not description: + return jsonify({'success': False, 'error': 'Description is required'}), 400 + valid_categories = ['bug', 'correction', 'feature', 'other'] + if category not in valid_categories: + category = 'other' + feedback_id = f"fb-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{get_instance_id()[-4:]}" + entry = { + "feedback_id": feedback_id, + "timestamp": datetime.now().isoformat(), + "instance_id": get_instance_id(), + "app_version": APP_VERSION, + "category": category, + "description": feedback_sanitize(description[:2000]), + } + if data.get('include_session_log', True): + entry["session_log"] = get_session_log() + if data.get('include_system_info', True): + entry["system_info"] = get_system_info(APP_VERSION) + result = store_feedback(entry) + if result.get('success'): + log_action('feedback_submit', detail=category, result='success') + return jsonify(result) + + @app.route('/api/analyze_path', methods=['POST']) def api_analyze_path(): """ @@ -8748,12 +8814,14 @@ def api_analyze_path(): def api_start_worker(): """Start background worker.""" start_worker() + log_action("worker_start", result="success") return jsonify({'success': True}) @app.route('/api/worker/stop', methods=['POST']) def api_stop_worker(): """Stop background worker.""" stop_worker() + log_action("worker_stop", result="success") return jsonify({'success': True}) diff --git a/library_manager/feedback.py b/library_manager/feedback.py new file mode 100644 index 0000000..985909e --- /dev/null +++ b/library_manager/feedback.py @@ -0,0 +1,163 @@ +"""User feedback and session logging for Library Manager. + +Provides: +- Session action logger (circular buffer of recent user actions) +- Path/data sanitizer (strips sensitive info) +- Feedback storage (local JSON, structured for future Skaldleita API forwarding) +""" +import json +import re +import platform +import sys +import logging +import traceback as tb_module +from collections import deque +from datetime import datetime +from pathlib import Path +from threading import Lock + +from .config import DATA_DIR + +logger = logging.getLogger(__name__) + +FEEDBACK_PATH = DATA_DIR / "feedback.json" +MAX_FEEDBACK_ENTRIES = 200 +MAX_SESSION_LOG_SIZE = 50 + +# Thread-safe session log +_session_log = deque(maxlen=MAX_SESSION_LOG_SIZE) +_session_lock = Lock() + +# Patterns to sanitize +_PATH_PATTERN = re.compile( + r'(/home/[^/\s"\']+|/Users/[^/\s"\']+|/mnt/[^/\s"\']+|' + r'/data|/audiobooks|[A-Z]:\\[^\s"\']+)' +) +_IP_PATTERN = re.compile(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b') +_API_KEY_PATTERN = re.compile( + r'(sk-[a-zA-Z0-9]{20,}|AIza[a-zA-Z0-9_-]{20,}|' + r'xkeysib-[a-zA-Z0-9-]+|Bearer\s+[a-zA-Z0-9._-]{20,})' +) + + +def sanitize_string(s): + """Remove sensitive data from a string (paths, IPs, API keys).""" + if not s: + return s + s = _PATH_PATTERN.sub('[PATH]', str(s)) + s = _IP_PATTERN.sub('[IP]', s) + s = _API_KEY_PATTERN.sub('[REDACTED]', s) + return s + + +def log_action(action_type, detail=None, book=None, path=None, result=None): + """Log a user action to the session buffer. + + Args: + action_type: Short action name (e.g., "scan", "apply_fix", "process") + detail: Optional extra detail string + book: Optional book title/author string + path: Optional affected path (will be sanitized) + result: Optional result string ("success", "error", etc.) + """ + entry = { + "ts": datetime.now().isoformat(), + "action": action_type, + } + if detail: + entry["detail"] = str(detail)[:200] + if book: + entry["book"] = str(book)[:150] + if path: + entry["path"] = sanitize_string(str(path)) + if result: + entry["result"] = str(result)[:100] + + with _session_lock: + _session_log.append(entry) + + +def get_session_log(): + """Return a copy of the current session log.""" + with _session_lock: + return list(_session_log) + + +def clear_session_log(): + """Clear the session log.""" + with _session_lock: + _session_log.clear() + + +def log_error(error, context=None): + """Log an error event to the session buffer with sanitized traceback.""" + tb_str = tb_module.format_exc() + entry = { + "ts": datetime.now().isoformat(), + "action": "error", + "detail": sanitize_string(str(error))[:300], + } + if context: + entry["context"] = str(context)[:100] + if tb_str and tb_str.strip() != "NoneType: None": + lines = tb_str.strip().split('\n')[-5:] + entry["traceback"] = [sanitize_string(line) for line in lines] + + with _session_lock: + _session_log.append(entry) + + +def get_system_info(app_version): + """Collect non-sensitive system info for feedback context.""" + return { + "app_version": app_version, + "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "os": platform.system(), + "os_version": platform.release(), + "arch": platform.machine(), + } + + +def store_feedback(feedback_data): + """Store feedback entry locally in feedback.json. + + Args: + feedback_data: dict with category, description, and optional metadata + + Returns: + dict with success status and feedback_id + """ + try: + entries = [] + if FEEDBACK_PATH.exists(): + try: + with open(FEEDBACK_PATH, 'r') as f: + entries = json.load(f) + except (json.JSONDecodeError, IOError): + entries = [] + + entries.append(feedback_data) + # Keep bounded + entries = entries[-MAX_FEEDBACK_ENTRIES:] + + with open(FEEDBACK_PATH, 'w') as f: + json.dump(entries, f, indent=2) + + feedback_id = feedback_data.get("feedback_id", "unknown") + logger.info(f"Feedback stored locally: {feedback_id}") + return {"success": True, "feedback_id": feedback_id} + + except Exception as e: + logger.error(f"Failed to store feedback: {e}") + return {"success": False, "error": str(e)} + + +def get_stored_feedback(): + """Retrieve all locally stored feedback entries.""" + if FEEDBACK_PATH.exists(): + try: + with open(FEEDBACK_PATH, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + return [] diff --git a/templates/base.html b/templates/base.html index 81de4bf..c17e3af 100644 --- a/templates/base.html +++ b/templates/base.html @@ -892,6 +892,80 @@