diff --git a/activate-vocalinux.sh b/activate-vocalinux.sh index 51430cf..23b86b9 100755 --- a/activate-vocalinux.sh +++ b/activate-vocalinux.sh @@ -1,29 +1,5 @@ #!/bin/bash -# Vocalinux Virtual Environment Activation Script -# This script activates the virtual environment and sets up the Python path - -# Get the directory where this script is located -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Check if virtual environment exists -if [ ! -d "$SCRIPT_DIR/venv" ]; then - echo "Error: Virtual environment not found at $SCRIPT_DIR/venv" - echo "Please run the installer script first: ./install.sh" - return 1 2>/dev/null || exit 1 -fi - -# Activate the virtual environment -source "$SCRIPT_DIR/venv/bin/activate" - -# Set up Python path for development -export PYTHONPATH="$SCRIPT_DIR/src:$PYTHONPATH" - -echo "Vocalinux development environment activated!" -echo "Virtual environment: $SCRIPT_DIR/venv" -echo "Python path includes: $SCRIPT_DIR/src" -echo "" -echo "You can now:" -echo " - Run tests: pytest tests/" -echo " - Run Vocalinux: python -m vocalinux.main" -echo " - Install in development mode: pip install -e ." -echo "" +# This script activates the Vocalinux virtual environment +source "$(dirname "$(realpath "$0")")/venv/bin/activate" +echo "Vocalinux virtual environment activated." +echo "To start the application, run: vocalinux" diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index 9a3e07f..213b6d9 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -86,6 +86,11 @@ def main(): logging.getLogger().setLevel(logging.DEBUG) logger.debug("Debug logging enabled") + # Initialize logging manager early + from .ui.logging_manager import initialize_logging + initialize_logging() + logger.info("Logging system initialized") + # Check dependencies first if not check_dependencies(): logger.error("Cannot start Vocalinux due to missing dependencies") diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 3858379..d385221 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -196,10 +196,15 @@ class TextInjector: logger.debug("Empty text provided, skipping injection") return True - logger.debug(f"Injecting text: {text}") + logger.info(f"Starting text injection: '{text}' (length: {len(text)})") + logger.debug(f"Environment: {self.environment}") + + # Get information about the current window/application + self._log_current_window_info() # Escape special characters for shell escaped_text = self._escape_text(text) + logger.debug(f"Escaped text: '{escaped_text}'") try: if ( @@ -222,9 +227,10 @@ class TextInjector: self._inject_with_xdotool(escaped_text) else: raise + logger.info(f"Text injection completed successfully") return True except Exception as e: - logger.error(f"Failed to inject text: {e}") + logger.error(f"Failed to inject text: {e}", exc_info=True) try: from ..ui.audio_feedback import play_error_sound play_error_sound() # Play error sound when text injection fails @@ -310,17 +316,25 @@ class TextInjector: # Inject text using xdotool try: max_retries = 2 + logger.debug(f"Starting xdotool injection with {max_retries} max retries") + for retry in range(max_retries + 1): try: # Inject in smaller chunks to avoid issues with very long text chunk_size = 20 # Reduced chunk size for better reliability + total_chunks = (len(text) + chunk_size - 1) // chunk_size + logger.debug(f"Splitting text into {total_chunks} chunks of max {chunk_size} chars") + for i in range(0, len(text), chunk_size): chunk = text[i : i + chunk_size] + chunk_num = (i // chunk_size) + 1 # First try with clearmodifiers cmd = ["xdotool", "type", "--clearmodifiers", chunk] + logger.debug(f"Injecting chunk {chunk_num}/{total_chunks}: '{chunk}'") + result = subprocess.run( - cmd, env=env, check=True, stderr=subprocess.PIPE, text=True + cmd, env=env, check=True, stderr=subprocess.PIPE, text=True, timeout=5 ) # Add a larger delay between chunks @@ -338,7 +352,15 @@ class TextInjector: ) time.sleep(0.5) # Wait before retry else: + logger.error(f"Final attempt failed: {chunk_error.stderr}") raise # Re-raise on final attempt + except subprocess.TimeoutExpired: + if retry < max_retries: + logger.warning(f"Text injection timeout, retrying (attempt {retry+1}/{max_retries})") + time.sleep(0.5) + else: + logger.error("Text injection timed out on final attempt") + raise # Try to reset any stuck modifiers try: @@ -450,3 +472,94 @@ class TextInjector: else: logger.warning(f"Keyboard shortcuts not supported with {self.wayland_tool}") return False + + def _log_current_window_info(self): + """Log information about the current window/application for debugging.""" + try: + if ( + self.environment == DesktopEnvironment.X11 + or self.environment == DesktopEnvironment.WAYLAND_XDOTOOL + ): + self._log_x11_window_info() + else: + logger.debug("Window info logging not available for pure Wayland") + except Exception as e: + logger.debug(f"Could not get window info: {e}") + + def _log_x11_window_info(self): + """Log X11 window information.""" + env = os.environ.copy() + + if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL: + env["GDK_BACKEND"] = "x11" + env["QT_QPA_PLATFORM"] = "xcb" + if "DISPLAY" not in env or not env["DISPLAY"]: + env["DISPLAY"] = ":0" + + try: + # Get active window ID + result = subprocess.run( + ["xdotool", "getactivewindow"], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + timeout=2 + ) + window_id = result.stdout.strip() + logger.debug(f"Active window ID: {window_id}") + + # Get window name + result = subprocess.run( + ["xdotool", "getwindowname", window_id], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + timeout=2 + ) + window_name = result.stdout.strip() + logger.info(f"Target window: '{window_name}' (ID: {window_id})") + + # Get window class + result = subprocess.run( + ["xdotool", "getwindowclassname", window_id], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + timeout=2 + ) + window_class = result.stdout.strip() + logger.debug(f"Window class: {window_class}") + + # Get window PID + result = subprocess.run( + ["xdotool", "getwindowpid", window_id], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + timeout=2 + ) + window_pid = result.stdout.strip() + logger.debug(f"Window PID: {window_pid}") + + # Try to get process name + try: + with open(f"/proc/{window_pid}/comm", "r") as f: + process_name = f.read().strip() + logger.info(f"Target process: {process_name} (PID: {window_pid})") + except: + pass + + except subprocess.TimeoutExpired: + logger.warning("Timeout getting window information") + except subprocess.CalledProcessError as e: + logger.debug(f"xdotool command failed: {e.stderr}") + except Exception as e: + logger.debug(f"Error getting window info: {e}") diff --git a/src/vocalinux/ui/__init__.py b/src/vocalinux/ui/__init__.py index 1f1c6c4..7f496b2 100644 --- a/src/vocalinux/ui/__init__.py +++ b/src/vocalinux/ui/__init__.py @@ -2,4 +2,4 @@ User interface module for Vocalinux """ -from . import audio_feedback, config_manager, keyboard_shortcuts, tray_indicator +from . import audio_feedback, config_manager, keyboard_shortcuts, logging_manager, tray_indicator diff --git a/src/vocalinux/ui/logging_dialog.py b/src/vocalinux/ui/logging_dialog.py new file mode 100644 index 0000000..3fed20d --- /dev/null +++ b/src/vocalinux/ui/logging_dialog.py @@ -0,0 +1,472 @@ +""" +Logging viewer dialog for VocaLinux. + +This module provides a GTK dialog for viewing, filtering, and exporting +application logs for debugging purposes. +""" + +import logging +import os +import threading +from datetime import datetime +from typing import Optional + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib, GObject, Gtk + +from .logging_manager import LogRecord, get_logging_manager + +logger = logging.getLogger(__name__) + + +class LoggingDialog(Gtk.Dialog): + """GTK Dialog for viewing and managing application logs.""" + + def __init__(self, parent: Optional[Gtk.Window] = None): + super().__init__( + title="VocaLinux Logs", + transient_for=parent, + flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, + modal=False + ) + + self.logging_manager = get_logging_manager() + self.auto_scroll = True + self.filter_level = None + self.filter_module = None + + # Set dialog properties + self.set_default_size(900, 700) + self.set_border_width(10) + + # Add buttons + self.add_button("Close", Gtk.ResponseType.CLOSE) + self.add_button("Copy All", Gtk.ResponseType.HELP) + self.add_button("Export", Gtk.ResponseType.APPLY) + self.add_button("Clear", Gtk.ResponseType.REJECT) + + # Create UI + self._create_ui() + + # Register for new log records + self.logging_manager.register_callback(self._on_new_log_record) + + # Load existing logs + self._refresh_logs() + + # Connect signals + self.connect("response", self._on_response) + self.connect("destroy", self._on_destroy) + + def _create_ui(self): + """Create the user interface.""" + content_area = self.get_content_area() + + # Create main container + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + content_area.add(main_box) + + # Create toolbar + toolbar_box = self._create_toolbar() + main_box.pack_start(toolbar_box, False, False, 0) + + # Create log view (give it most of the space) + log_view_box = self._create_log_view() + main_box.pack_start(log_view_box, True, True, 0) + + # Create status bar + status_box = self._create_status_bar() + main_box.pack_start(status_box, False, False, 0) + + # Show all widgets + main_box.show_all() + + def _create_toolbar(self): + """Create the toolbar with filters and controls.""" + toolbar_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + + # Level filter + level_label = Gtk.Label(label="Level:") + toolbar_box.pack_start(level_label, False, False, 0) + + self.level_combo = Gtk.ComboBoxText() + self.level_combo.append("ALL", "All Levels") + self.level_combo.append("DEBUG", "Debug") + self.level_combo.append("INFO", "Info") + self.level_combo.append("WARNING", "Warning") + self.level_combo.append("ERROR", "Error") + self.level_combo.append("CRITICAL", "Critical") + self.level_combo.set_active(0) + self.level_combo.connect("changed", self._on_filter_changed) + toolbar_box.pack_start(self.level_combo, False, False, 0) + + # Module filter + module_label = Gtk.Label(label="Module:") + toolbar_box.pack_start(module_label, False, False, 0) + + self.module_entry = Gtk.Entry() + self.module_entry.set_placeholder_text("Filter by module name...") + self.module_entry.connect("changed", self._on_filter_changed) + toolbar_box.pack_start(self.module_entry, True, True, 0) + + # Auto-scroll toggle + self.auto_scroll_check = Gtk.CheckButton(label="Auto-scroll") + self.auto_scroll_check.set_active(True) + self.auto_scroll_check.connect("toggled", self._on_auto_scroll_toggled) + toolbar_box.pack_start(self.auto_scroll_check, False, False, 0) + + # Refresh button + refresh_button = Gtk.Button(label="Refresh") + refresh_button.connect("clicked", self._on_refresh_clicked) + toolbar_box.pack_start(refresh_button, False, False, 0) + + return toolbar_box + + def _create_log_view(self): + """Create the main log viewing area.""" + # Create scrolled window + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.set_shadow_type(Gtk.ShadowType.IN) + # Set minimum height to ensure the text view takes up most of the dialog space + scrolled.set_min_content_height(400) + scrolled.set_vexpand(True) + scrolled.set_hexpand(True) + + # Create text view + self.text_view = Gtk.TextView() + self.text_view.set_editable(False) + self.text_view.set_cursor_visible(False) + self.text_view.set_wrap_mode(Gtk.WrapMode.WORD) + # Ensure the text view expands to fill available space + self.text_view.set_vexpand(True) + self.text_view.set_hexpand(True) + + # Set monospace font + font_desc = self.text_view.get_pango_context().get_font_description() + font_desc.set_family("monospace") + font_desc.set_size(9 * 1024) # 9pt + self.text_view.override_font(font_desc) + + # Get text buffer + self.text_buffer = self.text_view.get_buffer() + + # Create text tags for different log levels + self._create_text_tags() + + scrolled.add(self.text_view) + + # Store reference to scrolled window for auto-scrolling + self.scrolled_window = scrolled + + return scrolled + + def _create_text_tags(self): + """Create text tags for different log levels.""" + # Debug - gray + debug_tag = self.text_buffer.create_tag("DEBUG") + debug_tag.set_property("foreground", "#666666") + + # Info - default color + info_tag = self.text_buffer.create_tag("INFO") + + # Warning - orange + warning_tag = self.text_buffer.create_tag("WARNING") + warning_tag.set_property("foreground", "#FF8C00") + warning_tag.set_property("weight", 600) + + # Error - red + error_tag = self.text_buffer.create_tag("ERROR") + error_tag.set_property("foreground", "#DC143C") + error_tag.set_property("weight", 700) + + # Critical - red background + critical_tag = self.text_buffer.create_tag("CRITICAL") + critical_tag.set_property("foreground", "#FFFFFF") + critical_tag.set_property("background", "#DC143C") + critical_tag.set_property("weight", 700) + + def _create_status_bar(self): + """Create the status bar.""" + status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + + self.status_label = Gtk.Label() + self.status_label.set_halign(Gtk.Align.START) + status_box.pack_start(self.status_label, True, True, 0) + + # Update status + self._update_status() + + return status_box + + def _update_status(self): + """Update the status bar with current log statistics.""" + stats = self.logging_manager.get_log_stats() + + if stats['total'] == 0: + status_text = "No log records" + else: + level_counts = [] + for level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']: + count = stats['by_level'].get(level, 0) + if count > 0: + level_counts.append(f"{level}: {count}") + + status_text = f"Total: {stats['total']} | " + " | ".join(level_counts) + + self.status_label.set_text(status_text) + + def _on_filter_changed(self, widget): + """Handle filter changes.""" + # Get current filter values + level_id = self.level_combo.get_active_id() + self.filter_level = None if level_id == "ALL" else level_id + + module_text = self.module_entry.get_text().strip() + self.filter_module = module_text if module_text else None + + # Refresh logs with new filters + self._refresh_logs() + + def _on_auto_scroll_toggled(self, widget): + """Handle auto-scroll toggle.""" + self.auto_scroll = widget.get_active() + + def _on_refresh_clicked(self, widget): + """Handle refresh button click.""" + self._refresh_logs() + + def _refresh_logs(self): + """Refresh the log display with current filters.""" + # Get filtered logs + records = self.logging_manager.get_logs( + level_filter=self.filter_level, + module_filter=self.filter_module + ) + + # Clear text buffer + self.text_buffer.set_text("") + + # Add log records + for record in records: + self._append_log_record(record) + + # Update status + self._update_status() + + # Auto-scroll to bottom + if self.auto_scroll: + self._scroll_to_bottom() + + def _append_log_record(self, record: LogRecord): + """ + Append a log record to the text view. + + Args: + record: The log record to append + """ + # Format the log line + log_line = str(record) + "\n" + + # Get end iterator + end_iter = self.text_buffer.get_end_iter() + + # Insert text with appropriate tag + tag_name = record.level + if tag_name in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: + self.text_buffer.insert_with_tags_by_name(end_iter, log_line, tag_name) + else: + self.text_buffer.insert(end_iter, log_line) + + def _scroll_to_bottom(self): + """Scroll the text view to the bottom.""" + mark = self.text_buffer.get_insert() + end_iter = self.text_buffer.get_end_iter() + self.text_buffer.place_cursor(end_iter) + self.text_view.scroll_to_mark(mark, 0.0, False, 0.0, 0.0) + + def _on_new_log_record(self, record: LogRecord): + """ + Handle new log record from the logging manager. + + Args: + record: The new log record + """ + # Check if record matches current filters + if self.filter_level and record.level != self.filter_level: + return + + if self.filter_module and self.filter_module.lower() not in record.logger_name.lower(): + return + + # Add to UI in main thread + GLib.idle_add(self._append_log_record_safe, record) + + def _append_log_record_safe(self, record: LogRecord): + """ + Safely append a log record in the main thread. + + Args: + record: The log record to append + """ + try: + self._append_log_record(record) + + # Auto-scroll if enabled + if self.auto_scroll: + self._scroll_to_bottom() + + # Update status + self._update_status() + + except Exception as e: + print(f"Error appending log record: {e}") + + return False # Remove from idle queue + + def _on_response(self, dialog, response_id): + """Handle dialog responses.""" + if response_id == Gtk.ResponseType.APPLY: + self._export_logs() + elif response_id == Gtk.ResponseType.REJECT: + self._clear_logs() + elif response_id == Gtk.ResponseType.HELP: + self._copy_logs_to_clipboard() + elif response_id == Gtk.ResponseType.CLOSE: + self.destroy() + + def _export_logs(self): + """Export logs to a file.""" + # Create file chooser dialog + file_dialog = Gtk.FileChooserDialog( + title="Export Logs", + parent=self, + action=Gtk.FileChooserAction.SAVE + ) + + file_dialog.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK + ) + + # Set default filename + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + default_filename = f"vocalinux_logs_{timestamp}.txt" + file_dialog.set_current_name(default_filename) + + # Add file filter + file_filter = Gtk.FileFilter() + file_filter.set_name("Text files") + file_filter.add_pattern("*.txt") + file_dialog.add_filter(file_filter) + + response = file_dialog.run() + + if response == Gtk.ResponseType.OK: + filepath = file_dialog.get_filename() + success = self.logging_manager.export_logs( + filepath, + level_filter=self.filter_level, + module_filter=self.filter_module + ) + + if success: + self._show_message("Export successful", f"Logs exported to:\n{filepath}") + else: + self._show_message("Export failed", "Failed to export logs. Check the logs for details.", Gtk.MessageType.ERROR) + + file_dialog.destroy() + + def _copy_logs_to_clipboard(self): + """Copy all visible logs to clipboard.""" + try: + # Get all text from the text buffer + start_iter = self.text_buffer.get_start_iter() + end_iter = self.text_buffer.get_end_iter() + text_content = self.text_buffer.get_text(start_iter, end_iter, False) + + if not text_content.strip(): + self._show_message("No logs to copy", "There are no logs to copy to clipboard.") + return + + # Get the clipboard + from gi.repository import Gdk + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + + # Create header for clipboard content + from datetime import datetime + header = f"VocaLinux Logs - Copied at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + header += "=" * 80 + "\n\n" + + # Combine header and content + clipboard_content = header + text_content + + # Set clipboard content + clipboard.set_text(clipboard_content, -1) + clipboard.store() + + # Count lines for user feedback + line_count = len(text_content.strip().split('\n')) + self._show_message( + "Logs copied to clipboard", + f"Successfully copied {line_count} log lines to clipboard.\n\n" + "You can now paste them into any text editor or document." + ) + + except Exception as e: + logger.error(f"Failed to copy logs to clipboard: {e}") + self._show_message( + "Copy failed", + f"Failed to copy logs to clipboard: {e}", + Gtk.MessageType.ERROR + ) + + def _clear_logs(self): + """Clear all logs after confirmation.""" + # Show confirmation dialog + confirm_dialog = Gtk.MessageDialog( + transient_for=self, + flags=0, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text="Clear all logs?" + ) + confirm_dialog.format_secondary_text( + "This will permanently remove all log records from memory. " + "This action cannot be undone." + ) + + response = confirm_dialog.run() + confirm_dialog.destroy() + + if response == Gtk.ResponseType.YES: + self.logging_manager.clear_logs() + self._refresh_logs() + self._show_message("Logs cleared", "All log records have been cleared.") + + def _show_message(self, title: str, message: str, message_type: Gtk.MessageType = Gtk.MessageType.INFO): + """ + Show a message dialog. + + Args: + title: Dialog title + message: Message text + message_type: Type of message + """ + dialog = Gtk.MessageDialog( + transient_for=self, + flags=0, + message_type=message_type, + buttons=Gtk.ButtonsType.OK, + text=title + ) + dialog.format_secondary_text(message) + dialog.run() + dialog.destroy() + + def _on_destroy(self, widget): + """Handle dialog destruction.""" + # Unregister callback + self.logging_manager.unregister_callback(self._on_new_log_record) \ No newline at end of file diff --git a/src/vocalinux/ui/logging_manager.py b/src/vocalinux/ui/logging_manager.py new file mode 100644 index 0000000..61bad91 --- /dev/null +++ b/src/vocalinux/ui/logging_manager.py @@ -0,0 +1,295 @@ +""" +Logging manager for VocaLinux. + +This module provides centralized logging functionality with UI integration, +allowing users to view, filter, and export application logs for debugging. +""" + +import logging +import os +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import Callable, List, Optional + +logger = logging.getLogger(__name__) + + +class LogRecord: + """Represents a single log record with additional metadata.""" + + def __init__(self, timestamp: datetime, level: str, logger_name: str, message: str, module: str = ""): + self.timestamp = timestamp + self.level = level + self.logger_name = logger_name + self.message = message + self.module = module + + def to_dict(self): + """Convert to dictionary for easy serialization.""" + return { + 'timestamp': self.timestamp.isoformat(), + 'level': self.level, + 'logger_name': self.logger_name, + 'message': self.message, + 'module': self.module + } + + def __str__(self): + """String representation for display.""" + time_str = self.timestamp.strftime("%H:%M:%S.%f")[:-3] # Include milliseconds + return f"[{time_str}] {self.level:8} {self.logger_name:20} | {self.message}" + + +class LoggingManager: + """ + Centralized logging manager for VocaLinux. + + This class captures all application logs, stores them in memory, + and provides functionality for viewing, filtering, and exporting logs. + """ + + def __init__(self, max_records: int = 1000): + """ + Initialize the logging manager. + + Args: + max_records: Maximum number of log records to keep in memory + """ + self.max_records = max_records + self.log_records: List[LogRecord] = [] + self.log_callbacks: List[Callable[[LogRecord], None]] = [] + self.lock = threading.Lock() + + # Create logs directory + self.logs_dir = Path.home() / ".local" / "share" / "vocalinux" / "logs" + self.logs_dir.mkdir(parents=True, exist_ok=True) + + # Set up custom log handler + self.handler = LoggingHandler(self) + self.handler.setLevel(logging.DEBUG) + + # Create formatter + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + self.handler.setFormatter(formatter) + + # Add handler to root logger + root_logger = logging.getLogger() + root_logger.addHandler(self.handler) + + logger.info("Logging manager initialized") + + def add_log_record(self, record: LogRecord): + """ + Add a new log record. + + Args: + record: The log record to add + """ + with self.lock: + self.log_records.append(record) + + # Trim old records if we exceed max_records + if len(self.log_records) > self.max_records: + self.log_records = self.log_records[-self.max_records:] + + # Notify callbacks + for callback in self.log_callbacks: + try: + callback(record) + except Exception as e: + # Don't let callback errors break logging + print(f"Error in log callback: {e}") + + def register_callback(self, callback: Callable[[LogRecord], None]): + """ + Register a callback to be called when new log records are added. + + Args: + callback: Function to call with new log records + """ + with self.lock: + self.log_callbacks.append(callback) + + def unregister_callback(self, callback: Callable[[LogRecord], None]): + """ + Unregister a log callback. + + Args: + callback: The callback to remove + """ + with self.lock: + try: + self.log_callbacks.remove(callback) + except ValueError: + pass + + def get_logs(self, level_filter: Optional[str] = None, + module_filter: Optional[str] = None, + last_n: Optional[int] = None) -> List[LogRecord]: + """ + Get log records with optional filtering. + + Args: + level_filter: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + module_filter: Filter by module name (partial match) + last_n: Return only the last N records + + Returns: + List of filtered log records + """ + with self.lock: + records = self.log_records.copy() + + # Apply filters + if level_filter: + records = [r for r in records if r.level == level_filter] + + if module_filter: + records = [r for r in records if module_filter.lower() in r.logger_name.lower()] + + # Return last N records + if last_n: + records = records[-last_n:] + + return records + + def export_logs(self, filepath: str, level_filter: Optional[str] = None, + module_filter: Optional[str] = None) -> bool: + """ + Export logs to a file. + + Args: + filepath: Path to save the log file + level_filter: Filter by log level + module_filter: Filter by module name + + Returns: + True if export was successful, False otherwise + """ + try: + records = self.get_logs(level_filter, module_filter) + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(f"VocaLinux Log Export\n") + f.write(f"Generated: {datetime.now().isoformat()}\n") + f.write(f"Total Records: {len(records)}\n") + if level_filter: + f.write(f"Level Filter: {level_filter}\n") + if module_filter: + f.write(f"Module Filter: {module_filter}\n") + f.write("=" * 80 + "\n\n") + + for record in records: + f.write(str(record) + "\n") + + logger.info(f"Exported {len(records)} log records to {filepath}") + return True + + except Exception as e: + logger.error(f"Failed to export logs: {e}") + return False + + def clear_logs(self): + """Clear all stored log records.""" + with self.lock: + self.log_records.clear() + logger.info("Log records cleared") + + def get_log_stats(self) -> dict: + """ + Get statistics about the current logs. + + Returns: + Dictionary with log statistics + """ + with self.lock: + records = self.log_records.copy() + + if not records: + return { + 'total': 0, + 'by_level': {}, + 'by_module': {}, + 'oldest': None, + 'newest': None + } + + # Count by level + by_level = {} + for record in records: + by_level[record.level] = by_level.get(record.level, 0) + 1 + + # Count by module + by_module = {} + for record in records: + module = record.logger_name.split('.')[0] if '.' in record.logger_name else record.logger_name + by_module[module] = by_module.get(module, 0) + 1 + + return { + 'total': len(records), + 'by_level': by_level, + 'by_module': by_module, + 'oldest': records[0].timestamp if records else None, + 'newest': records[-1].timestamp if records else None + } + + +class LoggingHandler(logging.Handler): + """Custom logging handler that feeds records to the LoggingManager.""" + + def __init__(self, logging_manager: LoggingManager): + super().__init__() + self.logging_manager = logging_manager + + def emit(self, record: logging.LogRecord): + """ + Emit a log record to the logging manager. + + Args: + record: The logging record to emit + """ + try: + # Create our custom log record + log_record = LogRecord( + timestamp=datetime.fromtimestamp(record.created), + level=record.levelname, + logger_name=record.name, + message=record.getMessage(), + module=getattr(record, 'module', record.name.split('.')[0]) + ) + + # Add to logging manager + self.logging_manager.add_log_record(log_record) + + except Exception: + # Don't let logging errors break the application + self.handleError(record) + + +# Global logging manager instance +_logging_manager: Optional[LoggingManager] = None + + +def get_logging_manager() -> LoggingManager: + """ + Get the global logging manager instance. + + Returns: + The global LoggingManager instance + """ + global _logging_manager + if _logging_manager is None: + _logging_manager = LoggingManager() + return _logging_manager + + +def initialize_logging(): + """Initialize the global logging manager.""" + global _logging_manager + if _logging_manager is None: + _logging_manager = LoggingManager() + logger.info("Global logging manager initialized") \ No newline at end of file diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index ab891c3..81dada1 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -165,6 +165,7 @@ class TrayIndicator: self._add_menu_item("Stop Voice Typing", self._on_stop_clicked) self._add_menu_separator() self._add_menu_item("Settings", self._on_settings_clicked) + self._add_menu_item("View Logs", self._on_logs_clicked) self._add_menu_separator() self._add_menu_item("About", self._on_about_clicked) self._add_menu_item("Quit", self._on_quit_clicked) @@ -283,6 +284,17 @@ class TrayIndicator: # Show the dialog (non-modal) dialog.show() + def _on_logs_clicked(self, widget): + """Handle click on the View Logs menu item.""" + logger.debug("View Logs clicked") + + # Import here to avoid circular imports + from .logging_dialog import LoggingDialog + + # Create and show the logging dialog + dialog = LoggingDialog(parent=None) + dialog.show() + def _on_settings_dialog_response(self, dialog, response): """Handle responses from the settings dialog.""" if response == Gtk.ResponseType.APPLY: