""" User Preferences and Settings Persistence Manager This module provides user preferences management, settings persistence, and customization options for the AniWorld web interface. """ import json import os from typing import Dict, Any, Optional from datetime import datetime from flask import Blueprint, request, jsonify, session class UserPreferencesManager: """Manages user preferences and settings persistence.""" def __init__(self, app=None): self.app = app self.preferences_file = 'data/user_preferences.json' self.preferences = {} # Initialize preferences attribute self.default_preferences = { 'ui': { 'theme': 'auto', # 'light', 'dark', 'auto' 'density': 'comfortable', # 'compact', 'comfortable', 'spacious' 'language': 'en', 'animations_enabled': True, 'sidebar_collapsed': False, 'grid_view': True, 'items_per_page': 20 }, 'downloads': { 'auto_download': False, 'download_quality': 'best', 'concurrent_downloads': 3, 'retry_failed': True, 'notification_sound': True, 'auto_organize': True }, 'notifications': { 'browser_notifications': True, 'email_notifications': False, 'webhook_notifications': False, 'notification_types': { 'download_complete': True, 'download_error': True, 'series_updated': False, 'system_alerts': True } }, 'keyboard_shortcuts': { 'enabled': True, 'shortcuts': { 'search': 'ctrl+f', 'download': 'ctrl+d', 'refresh': 'f5', 'select_all': 'ctrl+a', 'help': 'f1', 'settings': 'ctrl+comma' } }, 'advanced': { 'debug_mode': False, 'performance_mode': False, 'cache_enabled': True, 'auto_backup': True, 'log_level': 'info' } } # Initialize with defaults if no app provided if app is None: self.preferences = self.default_preferences.copy() else: self.init_app(app) def init_app(self, app): """Initialize with Flask app.""" self.app = app self.preferences_file = os.path.join(app.instance_path, 'data/user_preferences.json') # Ensure instance path exists os.makedirs(app.instance_path, exist_ok=True) # Load or create preferences file self.load_preferences() def load_preferences(self) -> Dict[str, Any]: """Load preferences from file.""" try: if os.path.exists(self.preferences_file): with open(self.preferences_file, 'r', encoding='utf-8') as f: loaded_prefs = json.load(f) # Merge with defaults to ensure all keys exist self.preferences = self.merge_preferences(self.default_preferences, loaded_prefs) else: self.preferences = self.default_preferences.copy() self.save_preferences() except Exception as e: print(f"Error loading preferences: {e}") self.preferences = self.default_preferences.copy() return self.preferences def save_preferences(self) -> bool: """Save preferences to file.""" try: with open(self.preferences_file, 'w', encoding='utf-8') as f: json.dump(self.preferences, f, indent=2, ensure_ascii=False) return True except Exception as e: print(f"Error saving preferences: {e}") return False def merge_preferences(self, defaults: Dict, user_prefs: Dict) -> Dict: """Recursively merge user preferences with defaults.""" result = defaults.copy() for key, value in user_prefs.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = self.merge_preferences(result[key], value) else: result[key] = value return result def get_preference(self, key: str, default: Any = None) -> Any: """Get a specific preference using dot notation (e.g., 'ui.theme').""" keys = key.split('.') value = self.preferences try: for k in keys: value = value[k] return value except (KeyError, TypeError): return default def set_preference(self, key: str, value: Any) -> bool: """Set a specific preference using dot notation.""" keys = key.split('.') pref_dict = self.preferences try: # Navigate to parent dictionary for k in keys[:-1]: if k not in pref_dict: pref_dict[k] = {} pref_dict = pref_dict[k] # Set the value pref_dict[keys[-1]] = value # Save to file return self.save_preferences() except Exception as e: print(f"Error setting preference {key}: {e}") return False def reset_preferences(self) -> bool: """Reset all preferences to defaults.""" self.preferences = self.default_preferences.copy() return self.save_preferences() def export_preferences(self) -> str: """Export preferences as JSON string.""" try: return json.dumps(self.preferences, indent=2, ensure_ascii=False) except Exception as e: print(f"Error exporting preferences: {e}") return "{}" def import_preferences(self, json_data: str) -> bool: """Import preferences from JSON string.""" try: imported_prefs = json.loads(json_data) self.preferences = self.merge_preferences(self.default_preferences, imported_prefs) return self.save_preferences() except Exception as e: print(f"Error importing preferences: {e}") return False def get_user_session_preferences(self) -> Dict[str, Any]: """Get preferences for current user session.""" # For now, return global preferences # In the future, could be user-specific return self.preferences.copy() def get_preferences_js(self): """Generate JavaScript code for preferences management.""" return f""" // AniWorld User Preferences Manager class UserPreferencesManager {{ constructor() {{ this.preferences = {json.dumps(self.preferences)}; this.defaultPreferences = {json.dumps(self.default_preferences)}; this.changeListeners = new Map(); this.init(); }} init() {{ this.loadFromServer(); this.applyPreferences(); this.setupPreferencesUI(); this.setupAutoSave(); }} async loadFromServer() {{ try {{ const response = await fetch('/api/preferences'); if (response.ok) {{ this.preferences = await response.json(); this.applyPreferences(); }} }} catch (error) {{ console.error('Error loading preferences:', error); }} }} async saveToServer() {{ try {{ const response = await fetch('/api/preferences', {{ method: 'PUT', headers: {{ 'Content-Type': 'application/json' }}, body: JSON.stringify(this.preferences) }}); if (!response.ok) {{ console.error('Error saving preferences to server'); }} }} catch (error) {{ console.error('Error saving preferences:', error); }} }} get(key, defaultValue = null) {{ const keys = key.split('.'); let value = this.preferences; try {{ for (const k of keys) {{ value = value[k]; }} return value !== undefined ? value : defaultValue; }} catch (error) {{ return defaultValue; }} }} set(key, value, save = true) {{ const keys = key.split('.'); let obj = this.preferences; // Navigate to parent object for (let i = 0; i < keys.length - 1; i++) {{ const k = keys[i]; if (!obj[k] || typeof obj[k] !== 'object') {{ obj[k] = {{}}; }} obj = obj[k]; }} // Set the value const lastKey = keys[keys.length - 1]; const oldValue = obj[lastKey]; obj[lastKey] = value; // Apply the change immediately this.applyPreference(key, value); // Notify listeners this.notifyChangeListeners(key, value, oldValue); // Save to server if (save) {{ this.saveToServer(); }} // Store in localStorage as backup localStorage.setItem('aniworld_preferences', JSON.stringify(this.preferences)); }} applyPreferences() {{ // Apply all preferences this.applyTheme(); this.applyUIPreferences(); this.applyKeyboardShortcuts(); this.applyNotificationSettings(); }} applyPreference(key, value) {{ // Apply individual preference change if (key.startsWith('ui.theme')) {{ this.applyTheme(); }} else if (key.startsWith('ui.')) {{ this.applyUIPreferences(); }} else if (key.startsWith('keyboard_shortcuts.')) {{ this.applyKeyboardShortcuts(); }} else if (key.startsWith('notifications.')) {{ this.applyNotificationSettings(); }} }} applyTheme() {{ const theme = this.get('ui.theme', 'auto'); const html = document.documentElement; html.classList.remove('theme-light', 'theme-dark'); if (theme === 'auto') {{ // Use system preference const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; html.classList.add(prefersDark ? 'theme-dark' : 'theme-light'); }} else {{ html.classList.add(`theme-${{theme}}`); }} // Update Bootstrap theme html.setAttribute('data-bs-theme', theme === 'dark' || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'); }} applyUIPreferences() {{ const density = this.get('ui.density', 'comfortable'); const animations = this.get('ui.animations_enabled', true); const gridView = this.get('ui.grid_view', true); // Apply UI density document.body.className = document.body.className.replace(/density-\\w+/g, ''); document.body.classList.add(`density-${{density}}`); // Apply animations if (!animations) {{ document.body.classList.add('no-animations'); }} else {{ document.body.classList.remove('no-animations'); }} // Apply view mode const viewToggle = document.querySelector('.view-toggle'); if (viewToggle) {{ viewToggle.classList.toggle('grid-view', gridView); viewToggle.classList.toggle('list-view', !gridView); }} }} applyKeyboardShortcuts() {{ const enabled = this.get('keyboard_shortcuts.enabled', true); const shortcuts = this.get('keyboard_shortcuts.shortcuts', {{}}); if (window.keyboardManager) {{ window.keyboardManager.setEnabled(enabled); window.keyboardManager.updateShortcuts(shortcuts); }} }} applyNotificationSettings() {{ const browserNotifications = this.get('notifications.browser_notifications', true); // Request notification permission if needed if (browserNotifications && 'Notification' in window && Notification.permission === 'default') {{ Notification.requestPermission(); }} }} setupPreferencesUI() {{ this.createSettingsModal(); this.bindSettingsEvents(); }} createSettingsModal() {{ const existingModal = document.getElementById('preferences-modal'); if (existingModal) return; const modal = document.createElement('div'); modal.id = 'preferences-modal'; modal.className = 'modal fade'; modal.innerHTML = ` `; document.body.appendChild(modal); }} createUITab() {{ return `
`; }} createDownloadsTab() {{ return `
`; }} createNotificationsTab() {{ return `
General
Notification Types
`; }} createShortcutsTab() {{ return `
`; }} createAdvancedTab() {{ return `
`; }} bindSettingsEvents() {{ // Theme system preference listener window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {{ if (this.get('ui.theme') === 'auto') {{ this.applyTheme(); }} }}); // Settings modal events will be bound when modal is shown document.addEventListener('show.bs.modal', (e) => {{ if (e.target.id === 'preferences-modal') {{ this.populateSettingsForm(); }} }}); }} populateSettingsForm() {{ // Populate form fields with current preferences const fields = [ {{ id: 'pref-theme', key: 'ui.theme' }}, {{ id: 'pref-density', key: 'ui.density' }}, {{ id: 'pref-language', key: 'ui.language' }}, {{ id: 'pref-items-per-page', key: 'ui.items_per_page' }}, {{ id: 'pref-animations', key: 'ui.animations_enabled' }}, {{ id: 'pref-grid-view', key: 'ui.grid_view' }}, {{ id: 'pref-download-quality', key: 'downloads.download_quality' }}, {{ id: 'pref-concurrent-downloads', key: 'downloads.concurrent_downloads' }}, {{ id: 'pref-auto-download', key: 'downloads.auto_download' }}, {{ id: 'pref-retry-failed', key: 'downloads.retry_failed' }}, {{ id: 'pref-auto-organize', key: 'downloads.auto_organize' }}, {{ id: 'pref-browser-notifications', key: 'notifications.browser_notifications' }}, {{ id: 'pref-notification-sound', key: 'downloads.notification_sound' }}, {{ id: 'pref-shortcuts-enabled', key: 'keyboard_shortcuts.enabled' }}, {{ id: 'pref-debug-mode', key: 'advanced.debug_mode' }}, {{ id: 'pref-performance-mode', key: 'advanced.performance_mode' }}, {{ id: 'pref-cache-enabled', key: 'advanced.cache_enabled' }}, {{ id: 'pref-auto-backup', key: 'advanced.auto_backup' }} ]; fields.forEach(field => {{ const element = document.getElementById(field.id); if (element) {{ const value = this.get(field.key); if (element.type === 'checkbox') {{ element.checked = value; }} else {{ element.value = value; }} }} }}); }} setupAutoSave() {{ // Auto-save preferences on change document.addEventListener('change', (e) => {{ if (e.target.id && e.target.id.startsWith('pref-')) {{ this.saveFormValue(e.target); }} }}); }} saveFormValue(element) {{ const keyMap = {{ 'pref-theme': 'ui.theme', 'pref-density': 'ui.density', 'pref-language': 'ui.language', 'pref-items-per-page': 'ui.items_per_page', 'pref-animations': 'ui.animations_enabled', 'pref-grid-view': 'ui.grid_view', 'pref-download-quality': 'downloads.download_quality', 'pref-concurrent-downloads': 'downloads.concurrent_downloads', 'pref-auto-download': 'downloads.auto_download', 'pref-retry-failed': 'downloads.retry_failed', 'pref-auto-organize': 'downloads.auto_organize', 'pref-browser-notifications': 'notifications.browser_notifications', 'pref-notification-sound': 'downloads.notification_sound', 'pref-shortcuts-enabled': 'keyboard_shortcuts.enabled', 'pref-debug-mode': 'advanced.debug_mode', 'pref-performance-mode': 'advanced.performance_mode', 'pref-cache-enabled': 'advanced.cache_enabled', 'pref-auto-backup': 'advanced.auto_backup' }}; const key = keyMap[element.id]; if (key) {{ let value = element.type === 'checkbox' ? element.checked : element.value; if (element.type === 'number') {{ value = parseInt(value, 10); }} this.set(key, value); }} }} showPreferences() {{ const modal = document.getElementById('preferences-modal'); if (modal) {{ const bsModal = new bootstrap.Modal(modal); bsModal.show(); }} }} onPreferenceChange(key, callback) {{ if (!this.changeListeners.has(key)) {{ this.changeListeners.set(key, []); }} this.changeListeners.get(key).push(callback); }} notifyChangeListeners(key, newValue, oldValue) {{ const listeners = this.changeListeners.get(key) || []; listeners.forEach(callback => {{ try {{ callback(newValue, oldValue, key); }} catch (error) {{ console.error('Error in preference change listener:', error); }} }}); }} reset() {{ this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences)); this.applyPreferences(); this.saveToServer(); localStorage.removeItem('aniworld_preferences'); }} export() {{ const data = JSON.stringify(this.preferences, null, 2); const blob = new Blob([data], {{ type: 'application/json' }}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'aniworld_preferences.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }} import(file) {{ return new Promise((resolve, reject) => {{ const reader = new FileReader(); reader.onload = (e) => {{ try {{ const imported = JSON.parse(e.target.result); this.preferences = this.mergePreferences(this.defaultPreferences, imported); this.applyPreferences(); this.saveToServer(); resolve(true); }} catch (error) {{ reject(error); }} }}; reader.onerror = reject; reader.readAsText(file); }}); }} mergePreferences(defaults, userPrefs) {{ const result = {{ ...defaults }}; for (const [key, value] of Object.entries(userPrefs)) {{ if (key in result && typeof result[key] === 'object' && typeof value === 'object') {{ result[key] = this.mergePreferences(result[key], value); }} else {{ result[key] = value; }} }} return result; }} }} // Initialize preferences when DOM is loaded document.addEventListener('DOMContentLoaded', () => {{ window.preferencesManager = new UserPreferencesManager(); }}); """ def get_css(self): """Generate CSS for user preferences.""" return """ /* User Preferences Styles */ .density-compact { --spacing: 0.5rem; --font-size: 0.875rem; } .density-comfortable { --spacing: 1rem; --font-size: 1rem; } .density-spacious { --spacing: 1.5rem; --font-size: 1.125rem; } .no-animations * { animation-duration: 0s !important; transition-duration: 0s !important; } .theme-light { --bs-body-bg: #ffffff; --bs-body-color: #212529; --bs-primary: #0d6efd; } .theme-dark { --bs-body-bg: #121212; --bs-body-color: #e9ecef; --bs-primary: #0d6efd; } #preferences-modal .nav-tabs { border-bottom: 1px solid var(--bs-border-color); } #preferences-modal .tab-pane { min-height: 300px; } .preference-group { margin-bottom: 2rem; } .preference-group h6 { color: var(--bs-secondary); margin-bottom: 1rem; } /* Responsive preferences modal */ @media (max-width: 768px) { #preferences-modal .modal-dialog { max-width: 95vw; margin: 0.5rem; } #preferences-modal .nav-tabs { flex-wrap: wrap; } #preferences-modal .nav-link { font-size: 0.875rem; padding: 0.5rem; } } """ # Create the preferences API blueprint preferences_bp = Blueprint('preferences', __name__, url_prefix='/api') # Global preferences manager instance preferences_manager = UserPreferencesManager() @preferences_bp.route('/preferences', methods=['GET']) def get_preferences(): """Get user preferences.""" try: return jsonify(preferences_manager.get_user_session_preferences()) except Exception as e: return jsonify({'error': str(e)}), 500 @preferences_bp.route('/preferences', methods=['PUT']) def update_preferences(): """Update user preferences.""" try: data = request.get_json() preferences_manager.preferences = preferences_manager.merge_preferences( preferences_manager.default_preferences, data ) if preferences_manager.save_preferences(): return jsonify({'success': True, 'message': 'Preferences updated'}) else: return jsonify({'error': 'Failed to save preferences'}), 500 except Exception as e: return jsonify({'error': str(e)}), 500 @preferences_bp.route('/preferences/', methods=['GET']) def get_preference(key): """Get a specific preference.""" try: value = preferences_manager.get_preference(key) return jsonify({'key': key, 'value': value}) except Exception as e: return jsonify({'error': str(e)}), 500 @preferences_bp.route('/preferences/', methods=['PUT']) def set_preference(key): """Set a specific preference.""" try: data = request.get_json() value = data.get('value') if preferences_manager.set_preference(key, value): return jsonify({'success': True, 'key': key, 'value': value}) else: return jsonify({'error': 'Failed to set preference'}), 500 except Exception as e: return jsonify({'error': str(e)}), 500 @preferences_bp.route('/preferences/reset', methods=['POST']) def reset_preferences(): """Reset preferences to defaults.""" try: if preferences_manager.reset_preferences(): return jsonify({'success': True, 'message': 'Preferences reset to defaults'}) else: return jsonify({'error': 'Failed to reset preferences'}), 500 except Exception as e: return jsonify({'error': str(e)}), 500 @preferences_bp.route('/preferences/export', methods=['GET']) def export_preferences(): """Export preferences as JSON file.""" try: from flask import Response json_data = preferences_manager.export_preferences() return Response( json_data, mimetype='application/json', headers={'Content-Disposition': 'attachment; filename=aniworld_preferences.json'} ) except Exception as e: return jsonify({'error': str(e)}), 500 @preferences_bp.route('/preferences/import', methods=['POST']) def import_preferences(): """Import preferences from JSON file.""" try: if 'file' not in request.files: return jsonify({'error': 'No file provided'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'error': 'No file selected'}), 400 json_data = file.read().decode('utf-8') if preferences_manager.import_preferences(json_data): return jsonify({'success': True, 'message': 'Preferences imported successfully'}) else: return jsonify({'error': 'Failed to import preferences'}), 500 except Exception as e: return jsonify({'error': str(e)}), 500