diff --git a/app/archiver/manager.py b/app/archiver/manager.py index 17641d3..f8b57e7 100644 --- a/app/archiver/manager.py +++ b/app/archiver/manager.py @@ -18,6 +18,9 @@ logger = logging.getLogger(__name__) # Global scheduler instance _scheduler: Optional[BackgroundScheduler] = None +# Job IDs +CLEANUP_JOB_ID = 'daily_cleanup' + def get_archive_path(archive_date: str) -> Path: """ @@ -225,6 +228,180 @@ def _archive_job(): logger.error(f"Archive job failed: {result.get('error', 'Unknown error')}") +def _cleanup_job(): + """ + Background job that runs daily at 01:00 UTC to clean up contacts. + Uses saved cleanup settings to filter and delete contacts. + """ + logger.info("Running daily cleanup job...") + + try: + # Import here to avoid circular imports + from app.routes.api import ( + get_cleanup_settings, + get_protected_contacts, + _filter_contacts_by_criteria + ) + + # Get cleanup settings + settings = get_cleanup_settings() + + if not settings.get('enabled'): + logger.info("Auto-cleanup is disabled, skipping") + return + + # Get contacts from device + import requests + response = requests.get( + f"{config.BRIDGE_URL}/cli", + json={'args': ['contacts']}, + timeout=30 + ) + + if response.status_code != 200: + logger.error(f"Failed to get contacts: HTTP {response.status_code}") + return + + data = response.json() + if not data.get('success'): + logger.error(f"Failed to get contacts: {data.get('error', 'Unknown error')}") + return + + # Parse contacts from output + contacts = [] + output = data.get('output', '') + import json as json_module + for line in output.strip().split('\n'): + if line.strip(): + try: + contact = json_module.loads(line) + contacts.append(contact) + except json_module.JSONDecodeError: + continue + + if not contacts: + logger.info("No contacts found, nothing to clean up") + return + + # Filter contacts using saved criteria + criteria = { + 'types': settings.get('types', [1, 2, 3, 4]), + 'date_field': settings.get('date_field', 'last_advert'), + 'days': settings.get('days', 30), + 'name_filter': settings.get('name_filter', '') + } + + # Get protected contacts + protected = get_protected_contacts() + + # Filter contacts (this function excludes protected contacts) + matching_contacts = _filter_contacts_by_criteria(contacts, criteria, protected) + + if not matching_contacts: + logger.info("No contacts match cleanup criteria") + return + + logger.info(f"Found {len(matching_contacts)} contacts to clean up") + + # Delete matching contacts + deleted_count = 0 + for contact in matching_contacts: + name = contact.get('name', '') + if not name: + continue + + try: + delete_response = requests.post( + f"{config.BRIDGE_URL}/cli", + json={'args': ['contact', '-d', name]}, + timeout=30 + ) + + if delete_response.status_code == 200: + delete_data = delete_response.json() + if delete_data.get('success'): + deleted_count += 1 + logger.debug(f"Deleted contact: {name}") + else: + logger.warning(f"Failed to delete contact {name}: {delete_data.get('error')}") + else: + logger.warning(f"Failed to delete contact {name}: HTTP {delete_response.status_code}") + except Exception as e: + logger.warning(f"Error deleting contact {name}: {e}") + + logger.info(f"Cleanup job completed: deleted {deleted_count}/{len(matching_contacts)} contacts") + + except Exception as e: + logger.error(f"Cleanup job failed: {e}", exc_info=True) + + +def schedule_cleanup(enabled: bool) -> bool: + """ + Add or remove the cleanup job from the scheduler. + + Args: + enabled: True to enable cleanup job, False to disable + + Returns: + True if successful, False otherwise + """ + global _scheduler + + if _scheduler is None: + logger.warning("Scheduler not initialized, cannot schedule cleanup") + return False + + try: + if enabled: + # Add cleanup job at 01:00 UTC + trigger = CronTrigger(hour=1, minute=0) + + _scheduler.add_job( + func=_cleanup_job, + trigger=trigger, + id=CLEANUP_JOB_ID, + name='Daily Contact Cleanup', + replace_existing=True + ) + + logger.info("Cleanup job scheduled - will run daily at 01:00 UTC") + else: + # Remove cleanup job if it exists + try: + _scheduler.remove_job(CLEANUP_JOB_ID) + logger.info("Cleanup job removed from scheduler") + except Exception: + # Job might not exist, that's OK + pass + + return True + + except Exception as e: + logger.error(f"Error scheduling cleanup: {e}", exc_info=True) + return False + + +def init_cleanup_schedule(): + """ + Initialize cleanup schedule from saved settings. + Called at startup after scheduler is started. + """ + try: + # Import here to avoid circular imports + from app.routes.api import get_cleanup_settings + + settings = get_cleanup_settings() + + if settings.get('enabled'): + schedule_cleanup(enabled=True) + logger.info("Auto-cleanup enabled from saved settings") + else: + logger.info("Auto-cleanup is disabled in saved settings") + + except Exception as e: + logger.error(f"Error initializing cleanup schedule: {e}", exc_info=True) + + def schedule_daily_archiving(): """ Initialize and start the background scheduler for daily archiving. @@ -261,6 +438,9 @@ def schedule_daily_archiving(): logger.info("Archive scheduler started - will run daily at 00:00 UTC") + # Initialize cleanup schedule from saved settings + init_cleanup_schedule() + except Exception as e: logger.error(f"Failed to start archive scheduler: {e}", exc_info=True) diff --git a/app/routes/api.py b/app/routes/api.py index 5de1676..a0b36fd 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -177,6 +177,84 @@ def save_protected_contacts(protected_list: list) -> bool: return False +# ============================================================================= +# Cleanup Settings Management +# ============================================================================= + +def get_cleanup_settings() -> dict: + """ + Get auto-cleanup settings from .webui_settings.json. + + Returns: + Dict with cleanup settings: + { + 'enabled': bool, + 'types': list[int], + 'date_field': str, + 'days': int, + 'name_filter': str + } + """ + from pathlib import Path + defaults = { + 'enabled': False, + 'types': [1, 2, 3, 4], + 'date_field': 'last_advert', + 'days': 30, + 'name_filter': '' + } + + settings_path = Path(config.MC_CONFIG_DIR) / ".webui_settings.json" + + try: + if not settings_path.exists(): + return defaults + + with open(settings_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + cleanup = settings.get('cleanup_settings', {}) + # Merge with defaults to ensure all fields exist + return {**defaults, **cleanup} + except Exception as e: + logger.error(f"Failed to read cleanup settings: {e}") + return defaults + + +def save_cleanup_settings(cleanup_settings: dict) -> bool: + """ + Save auto-cleanup settings to .webui_settings.json (atomic write). + + Args: + cleanup_settings: Dict with cleanup configuration + + Returns: + True if successful, False otherwise + """ + from pathlib import Path + settings_path = Path(config.MC_CONFIG_DIR) / ".webui_settings.json" + + try: + # Read existing settings + settings = {} + if settings_path.exists(): + with open(settings_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + + # Update cleanup settings + settings['cleanup_settings'] = cleanup_settings + + # Write back atomically + temp_file = settings_path.with_suffix('.tmp') + with open(temp_file, 'w', encoding='utf-8') as f: + json.dump(settings, f, indent=2, ensure_ascii=False) + temp_file.replace(settings_path) + + return True + except Exception as e: + logger.error(f"Failed to save cleanup settings: {e}") + return False + + @api_bp.route('/messages', methods=['GET']) def get_messages(): """ @@ -1895,6 +1973,128 @@ def toggle_contact_protection(public_key): }), 500 +@api_bp.route('/contacts/cleanup-settings', methods=['GET']) +def get_cleanup_settings_api(): + """ + Get auto-cleanup settings. + + Returns: + JSON with cleanup settings: + { + "success": true, + "settings": { + "enabled": false, + "types": [1, 2, 3, 4], + "date_field": "last_advert", + "days": 30, + "name_filter": "" + } + } + """ + try: + settings = get_cleanup_settings() + return jsonify({ + 'success': True, + 'settings': settings + }), 200 + except Exception as e: + logger.error(f"Error getting cleanup settings: {e}") + return jsonify({ + 'success': False, + 'error': str(e), + 'settings': { + 'enabled': False, + 'types': [1, 2, 3, 4], + 'date_field': 'last_advert', + 'days': 30, + 'name_filter': '' + } + }), 500 + + +@api_bp.route('/contacts/cleanup-settings', methods=['POST']) +def update_cleanup_settings_api(): + """ + Update auto-cleanup settings. + + JSON body: + { + "enabled": true, + "types": [1, 2], + "date_field": "last_advert", + "days": 30, + "name_filter": "" + } + + Returns: + JSON with update result: + { + "success": true, + "message": "Cleanup settings updated", + "settings": {...} + } + """ + try: + data = request.get_json() or {} + + # Validate fields + if 'types' in data: + if not isinstance(data['types'], list) or not all(t in [1, 2, 3, 4] for t in data['types']): + return jsonify({ + 'success': False, + 'error': 'Invalid types (must be list of 1, 2, 3, 4)' + }), 400 + + if 'date_field' in data: + if data['date_field'] not in ['last_advert', 'lastmod']: + return jsonify({ + 'success': False, + 'error': 'Invalid date_field (must be "last_advert" or "lastmod")' + }), 400 + + if 'days' in data: + if not isinstance(data['days'], int) or data['days'] < 0: + return jsonify({ + 'success': False, + 'error': 'Invalid days (must be non-negative integer)' + }), 400 + + if 'enabled' in data: + if not isinstance(data['enabled'], bool): + return jsonify({ + 'success': False, + 'error': 'Invalid enabled (must be boolean)' + }), 400 + + # Get current settings and merge with new values + current = get_cleanup_settings() + updated = {**current, **data} + + # Save settings + if not save_cleanup_settings(updated): + return jsonify({ + 'success': False, + 'error': 'Failed to save cleanup settings' + }), 500 + + # Update scheduler based on enabled state + from app.archiver.manager import schedule_cleanup + schedule_cleanup(enabled=updated.get('enabled', False)) + + return jsonify({ + 'success': True, + 'message': 'Cleanup settings updated', + 'settings': updated + }), 200 + + except Exception as e: + logger.error(f"Error updating cleanup settings: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + # ============================================================================= # Contact Management (Pending Contacts & Settings) # ============================================================================= diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index e5d0617..3f38df9 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -51,6 +51,10 @@ let protectedContacts = []; // List of protected public_keys let sortBy = 'last_advert'; // 'name' or 'last_advert' let sortOrder = 'desc'; // 'asc' or 'desc' +// Auto-cleanup state +let autoCleanupSettings = null; +let cleanupSaveDebounceTimer = null; + // Map state (Leaflet) let leafletMap = null; let markersGroup = null; @@ -168,6 +172,9 @@ function initManagePage() { // Load contact counts for badges loadContactCounts(); + // Load cleanup settings (populates form and auto-cleanup status) + loadCleanupSettings(); + // Attach event listeners for manage page attachManageEventListeners(); } @@ -190,6 +197,33 @@ function attachManageEventListeners() { if (confirmCleanupBtn) { confirmCleanupBtn.addEventListener('click', handleCleanupConfirm); } + + // Auto-cleanup toggle + const autoCleanupSwitch = document.getElementById('autoCleanupSwitch'); + if (autoCleanupSwitch) { + autoCleanupSwitch.addEventListener('change', handleAutoCleanupToggle); + } + + // Debounced auto-save for cleanup filter inputs + const cleanupNameFilter = document.getElementById('cleanupNameFilter'); + if (cleanupNameFilter) { + cleanupNameFilter.addEventListener('input', debouncedSaveCleanupCriteria); + } + + const cleanupDays = document.getElementById('cleanupDays'); + if (cleanupDays) { + cleanupDays.addEventListener('input', debouncedSaveCleanupCriteria); + } + + // Type filter checkboxes + document.querySelectorAll('.cleanup-type-filter').forEach(cb => { + cb.addEventListener('change', debouncedSaveCleanupCriteria); + }); + + // Date field radio buttons + document.querySelectorAll('input[name="cleanupDateField"]').forEach(radio => { + radio.addEventListener('change', debouncedSaveCleanupCriteria); + }); } async function loadContactCounts() { @@ -238,6 +272,221 @@ async function loadContactCounts() { } } +// ============================================================================= +// Auto-Cleanup Settings Management +// ============================================================================= + +/** + * Load cleanup settings from server and apply to UI. + */ +async function loadCleanupSettings() { + const statusText = document.getElementById('autoCleanupStatusText'); + if (statusText) statusText.textContent = 'Loading...'; + + try { + const response = await fetch('/api/contacts/cleanup-settings'); + const data = await response.json(); + + if (data.success) { + autoCleanupSettings = data.settings; + applyCleanupSettingsToUI(autoCleanupSettings); + console.log('Loaded cleanup settings:', autoCleanupSettings); + } else { + console.error('Failed to load cleanup settings:', data.error); + if (statusText) statusText.textContent = 'Error loading settings'; + } + } catch (error) { + console.error('Error loading cleanup settings:', error); + if (statusText) statusText.textContent = 'Network error'; + } +} + +/** + * Apply cleanup settings to form inputs. + * @param {Object} settings - Cleanup settings object + */ +function applyCleanupSettingsToUI(settings) { + // Name filter + const nameInput = document.getElementById('cleanupNameFilter'); + if (nameInput) { + nameInput.value = settings.name_filter || ''; + } + + // Days + const daysInput = document.getElementById('cleanupDays'); + if (daysInput) { + daysInput.value = settings.days || 0; + } + + // Date field + const dateFieldValue = settings.date_field || 'last_advert'; + const dateRadio = document.querySelector(`input[name="cleanupDateField"][value="${dateFieldValue}"]`); + if (dateRadio) { + dateRadio.checked = true; + } + + // Contact types + const types = settings.types || [1, 2, 3, 4]; + document.querySelectorAll('.cleanup-type-filter').forEach(cb => { + cb.checked = types.includes(parseInt(cb.value)); + }); + + // Auto-cleanup switch and status + const autoCleanupSwitch = document.getElementById('autoCleanupSwitch'); + const statusText = document.getElementById('autoCleanupStatusText'); + + if (autoCleanupSwitch) { + autoCleanupSwitch.checked = settings.enabled || false; + } + + if (statusText) { + if (settings.enabled) { + statusText.textContent = 'Enabled (runs daily at 01:00 UTC)'; + statusText.classList.remove('text-muted'); + statusText.classList.add('text-success'); + } else { + statusText.textContent = 'Disabled'; + statusText.classList.remove('text-success'); + statusText.classList.add('text-muted'); + } + } +} + +/** + * Handle auto-cleanup toggle change. + * Validates criteria before enabling. + */ +async function handleAutoCleanupToggle(event) { + const enabled = event.target.checked; + const statusText = document.getElementById('autoCleanupStatusText'); + + // Validate before enabling + if (enabled) { + const criteria = collectCleanupCriteria(); + + // Check if days > 0 + if (criteria.days <= 0) { + showToast('Set "Days of Inactivity" > 0 before enabling auto-cleanup', 'warning'); + event.target.checked = false; + return; + } + + // Check if at least one type is selected + if (criteria.types.length === 0) { + showToast('Select at least one contact type before enabling auto-cleanup', 'warning'); + event.target.checked = false; + return; + } + } + + // Update status text while saving + if (statusText) { + statusText.textContent = 'Saving...'; + statusText.classList.remove('text-success', 'text-muted'); + } + + // Save settings with new enabled state + const success = await saveCleanupSettings(enabled); + + if (!success) { + // Revert switch on failure + event.target.checked = !enabled; + } +} + +/** + * Debounced save for cleanup criteria changes. + * Only saves criteria, does not change enabled state. + */ +function debouncedSaveCleanupCriteria() { + // Clear existing timer + if (cleanupSaveDebounceTimer) { + clearTimeout(cleanupSaveDebounceTimer); + } + + // Set new timer (500ms debounce) + cleanupSaveDebounceTimer = setTimeout(() => { + // Only save if auto-cleanup settings have been loaded + if (autoCleanupSettings !== null) { + // Preserve current enabled state + saveCleanupSettings(autoCleanupSettings.enabled); + } + }, 500); +} + +/** + * Save cleanup settings to server. + * @param {boolean} enabled - Whether auto-cleanup should be enabled + * @returns {Promise} True if save was successful + */ +async function saveCleanupSettings(enabled) { + const criteria = collectCleanupCriteria(); + const statusText = document.getElementById('autoCleanupStatusText'); + + const settings = { + enabled: enabled, + types: criteria.types, + date_field: criteria.date_field, + days: criteria.days, + name_filter: criteria.name_filter + }; + + try { + const response = await fetch('/api/contacts/cleanup-settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(settings) + }); + + const data = await response.json(); + + if (data.success) { + autoCleanupSettings = data.settings; + + // Update status text + if (statusText) { + if (data.settings.enabled) { + statusText.textContent = 'Enabled (runs daily at 01:00 UTC)'; + statusText.classList.remove('text-muted'); + statusText.classList.add('text-success'); + } else { + statusText.textContent = 'Disabled'; + statusText.classList.remove('text-success'); + statusText.classList.add('text-muted'); + } + } + + console.log('Cleanup settings saved:', data.settings); + return true; + } else { + console.error('Failed to save cleanup settings:', data.error); + showToast('Failed to save settings: ' + data.error, 'danger'); + + // Restore previous status + if (statusText && autoCleanupSettings) { + if (autoCleanupSettings.enabled) { + statusText.textContent = 'Enabled (runs daily at 01:00 UTC)'; + } else { + statusText.textContent = 'Disabled'; + } + } + + return false; + } + } catch (error) { + console.error('Error saving cleanup settings:', error); + showToast('Network error saving settings', 'danger'); + + if (statusText) { + statusText.textContent = 'Save failed'; + } + + return false; + } +} + // Global variable to store preview contacts let cleanupPreviewContacts = []; diff --git a/app/templates/contacts-manage.html b/app/templates/contacts-manage.html index eae9b99..62ea286 100644 --- a/app/templates/contacts-manage.html +++ b/app/templates/contacts-manage.html @@ -124,6 +124,21 @@ Contacts inactive for more than this many days will be selected + + +
+
+ + + +
+ Status: Loading... +