diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0b46ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +debug.log +.venv +.venv.bak +.idea +.vscode +__pycache__ diff --git a/Changelog b/Changelog new file mode 100644 index 0000000..abc8833 --- /dev/null +++ b/Changelog @@ -0,0 +1,33 @@ +Changelog for shared_libs + +## [Unreleased] + + - add Info Window for user in delete logfile + bevore delete logfile. + + + ### Added +03-06-2025 + + - add method for logfile Button. + + ### Added +02-06-2025 + + - add Button for another logfiles. + - eception handling for logfile when modul is not found. + + + ### Added +31-05-2025 + + - Add menu for logviewer. + - Add KontextMenu for textfield. + - Resizeable logviewer with minsize. + + + ### Added +30-05-2025 + + - Create shared_libs for better structure in projects by git.ilunix.de + moduls from shared_libs can be used in other projects. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b77a6e0 --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/python3 + + diff --git a/common_tools.py b/common_tools.py new file mode 100755 index 0000000..ae495a1 --- /dev/null +++ b/common_tools.py @@ -0,0 +1,573 @@ +""" Classes Method and Functions for lx Apps """ + +import logging +import signal +import base64 +from subprocess import CompletedProcess, run +import re +import sys +import shutil +import tkinter as tk +from typing import Optional, Dict, Any, NoReturn +from pathlib import Path +from tkinter import ttk, Toplevel + + +class CryptoUtil: + """ + This class is for the creation of the folders and files + required by Wire-Py, as well as for decryption + the tunnel from the user's home directory + """ + + @staticmethod + def decrypt(user, path) -> None: + """ + Starts SSL dencrypt + """ + if len([file.stem for file in path.glob("*.dat")]) == 0: + pass + else: + process: CompletedProcess[str] = run( + ["pkexec", "/usr/local/bin/ssl_decrypt.py", "--user", user], + capture_output=True, + text=True, + check=False, + ) + + # Output from Openssl Error + if process.stderr: + logging.error(process.stderr, exc_info=True) + + if process.returncode == 0: + logging.info("Files successfully decrypted...", exc_info=True) + else: + logging.error( + f"Error process decrypt: Code {process.returncode}", exc_info=True + ) + + @staticmethod + def encrypt(user) -> None: + """ + Starts SSL encryption + """ + process: CompletedProcess[str] = run( + ["pkexec", "/usr/local/bin/ssl_encrypt.py", "--user", user], + capture_output=True, + text=True, + check=False, + ) + + # Output from Openssl Error + if process.stderr: + logging.error(process.stderr, exc_info=True) + + if process.returncode == 0: + logging.info("Files successfully encrypted...", exc_info=True) + else: + logging.error( + f"Error process encrypt: Code {process.returncode}", exc_info=True + ) + + @staticmethod + def find_key(key: str = "") -> bool: + """ + Checks if the private key already exists in the system using an external script. + Returns True only if the full key is found exactly (no partial match). + """ + process: CompletedProcess[str] = run( + ["pkexec", "/usr/local/bin/match_found.py", key], + capture_output=True, + text=True, + check=False, + ) + if "True" in process.stdout: + return True + elif "False" in process.stdout: + return False + logging.error( + f"Unexpected output from the external script:\nSTDOUT: {process.stdout}\nSTDERR: {process.stderr}", + exc_info=True, + ) + return False + + @staticmethod + def is_valid_base64(key: str) -> bool: + """ + Validates if the input is a valid Base64 string (WireGuard private key format). + Returns True only for non-empty strings that match the expected length. + """ + # Check for empty string + if not key or key.strip() == "": + return False + + # Regex pattern to validate Base64: [A-Za-z0-9+/]+={0,2} + base64_pattern = r"^[A-Za-z0-9+/]+={0,2}$" + if not re.match(base64_pattern, key): + return False + + try: + # Decode and check length (WireGuard private keys are 32 bytes long) + decoded = base64.b64decode(key) + if len(decoded) != 32: # 32 bytes = 256 bits + return False + except Exception as e: + logging.error(f"Error on decode Base64: {e}", exc_info=True) + return False + + return True + + +class LxTools: + """ + Class LinuxTools methods that can also be used for other apps + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + @staticmethod + def center_window_cross_platform(window, width, height): + """ + Centers a window on the primary monitor in a way that works on both X11 and Wayland + + Args: + window: The tkinter window to center + width: Window width + height: Window height + """ + # Calculate the position before showing the window + + # First attempt: Try to use GDK if available (works on both X11 and Wayland) + try: + import gi + + gi.require_version("Gdk", "3.0") + from gi.repository import Gdk + + display = Gdk.Display.get_default() + monitor = display.get_primary_monitor() or display.get_monitor(0) + geometry = monitor.get_geometry() + scale_factor = monitor.get_scale_factor() + + # Calculate center position on the primary monitor + x = geometry.x + (geometry.width - width // scale_factor) // 2 + y = geometry.y + (geometry.height - height // scale_factor) // 2 + + # Set window geometry + window.geometry(f"{width}x{height}+{x}+{y}") + return + except (ImportError, AttributeError): + pass + + # Second attempt: Try xrandr for X11 + try: + import subprocess + + output = subprocess.check_output( + ["xrandr", "--query"], universal_newlines=True + ) + + # Parse the output to find the primary monitor + primary_info = None + for line in output.splitlines(): + if "primary" in line: + parts = line.split() + for part in parts: + if "x" in part and "+" in part: + primary_info = part + break + break + + if primary_info: + # Parse the geometry: WIDTH x HEIGHT+X+Y + geometry = primary_info.split("+") + dimensions = geometry[0].split("x") + primary_width = int(dimensions[0]) + primary_height = int(dimensions[1]) + primary_x = int(geometry[1]) + primary_y = int(geometry[2]) + + # Calculate center position on the primary monitor + x = primary_x + (primary_width - width) // 2 + y = primary_y + (primary_height - height) // 2 + + # Set window geometry + window.geometry(f"{width}x{height}+{x}+{y}") + return + except (ImportError, IndexError, ValueError): + pass + + # Final fallback: Use standard Tkinter method + screen_width = window.winfo_screenwidth() + screen_height = window.winfo_screenheight() + + # Try to make an educated guess for multi-monitor setups + # If screen width is much larger than height, assume multiple monitors side by side + if ( + screen_width > screen_height * 1.8 + ): # Heuristic for detecting multiple monitors + # Assume the primary monitor is on the left half + screen_width = screen_width // 2 + + x = (screen_width - width) // 2 + y = (screen_height - height) // 2 + window.geometry(f"{width}x{height}+{x}+{y}") + + @staticmethod + def clean_files(tmp_dir: Path = None, file: Path = None) -> None: + """ + Deletes temporary files and directories for cleanup when exiting the application. + + This method safely removes an optional directory defined by `AppConfig.TEMP_DIR` + and a single file to free up resources at the end of the program's execution. + All operations are performed securely, and errors such as `FileNotFoundError` + are ignored if the target files or directories do not exist. + :param tmp_dir: (Path, optional): Path to the temporary directory that should be deleted. + If `None`, the value of `AppConfig.TEMP_DIR` is used. + :param file: (Path, optional): Path to the file that should be deleted. + If `None`, no additional file will be deleted. + + Returns: + None: The method does not return any value. + """ + + if tmp_dir is not None: + shutil.rmtree(tmp_dir, ignore_errors=True) + try: + if file is not None: + Path.unlink(file) + + except FileNotFoundError: + pass + + @staticmethod + def msg_window( + image_path: Path, + image_path2: Path, + w_title: str, + w_txt: str, + txt2: Optional[str] = None, + com: Optional[str] = None, + ) -> None: + """ + Creates message windows + + :param image_path2: + :param image_path: + AppConfig.IMAGE_PATHS["icon_info"] = Image for TK window which is displayed to the left of the text + AppConfig.IMAGE_PATHS["icon_vpn"] = Image for Task Icon + :argument w_title = Windows Title + :argument w_txt = Text for Tk Window + :argument txt2 = Text for Button two + :argument com = function for Button command + """ + msg: tk.Toplevel = tk.Toplevel() + msg.resizable(width=False, height=False) + msg.title(w_title) + msg.configure(pady=15, padx=15) + + # load first image for a window + try: + msg.img = tk.PhotoImage(file=image_path) + msg.i_window = tk.Label(msg, image=msg.img) + except Exception as e: + logging.error(f"Error on load Window Image: {e}", exc_info=True) + msg.i_window = tk.Label(msg, text="Image not found") + + label: tk.Label = tk.Label(msg, text=w_txt) + label.grid(column=1, row=0) + + if txt2 is not None and com is not None: + label.config(font=("Ubuntu", 11), padx=15, justify="left") + msg.i_window.grid(column=0, row=0, sticky="nw") + button2: ttk.Button = ttk.Button( + msg, text=f"{txt2}", command=com, padding=4 + ) + button2.grid(column=0, row=1, sticky="e", columnspan=2) + button: ttk.Button = ttk.Button( + msg, text="OK", command=msg.destroy, padding=4 + ) + button.grid(column=0, row=1, sticky="w", columnspan=2) + else: + label.config(font=("Ubuntu", 11), padx=15) + msg.i_window.grid(column=0, row=0) + button: ttk.Button = ttk.Button( + msg, text="OK", command=msg.destroy, padding=4 + ) + button.grid(column=0, columnspan=2, row=1) + + try: + icon = tk.PhotoImage(file=image_path2) + msg.iconphoto(True, icon) + except Exception as e: + logging.error(f"Error loading the window icon: {e}", exc_info=True) + + msg.columnconfigure(0, weight=1) + msg.rowconfigure(0, weight=1) + msg.winfo_toplevel() + + @staticmethod + def sigi(file_path: Optional[Path] = None, file: Optional[Path] = None) -> None: + """ + Function for cleanup after a program interruption + + :param file: Optional - File to be deleted + :param file_path: Optional - Directory to be deleted + """ + + def signal_handler(signum: int, frame: Any) -> NoReturn: + """ + Determines clear text names for signal numbers and handles signals + + Args: + signum: The signal number + frame: The current stack frame + + Returns: + NoReturn since the function either exits the program or continues execution + """ + + signals_to_names_dict: Dict[int, str] = dict( + (getattr(signal, n), n) + for n in dir(signal) + if n.startswith("SIG") and "_" not in n + ) + + signal_name: str = signals_to_names_dict.get( + signum, f"Unnamed signal: {signum}" + ) + + # End program for certain signals, report to others only reception + if signum in (signal.SIGINT, signal.SIGTERM): + exit_code: int = 1 + logging.error( + f"\nSignal {signal_name} {signum} received. => Aborting with exit code {exit_code}.", + exc_info=True, + ) + LxTools.clean_files(file_path, file) + logging.info("Breakdown by user...") + sys.exit(exit_code) + else: + logging.info(f"Signal {signum} received and ignored.") + LxTools.clean_files(file_path, file) + logging.error("Process unexpectedly ended...") + + # Register signal handlers for various signals + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGHUP, signal_handler) + + +# ConfigManager with caching +class ConfigManager: + """ + Universal class for managing configuration files with caching support. + + This class provides a general solution to load, save, and manage configuration + files across different projects. It uses a caching system to optimize access efficiency. + The `init()` method initializes the configuration file path, while `load()` and `save()` + synchronize data between the file and internal memory structures. + + Key Features: + - Caching to minimize I/O operations. + - Default values for missing or corrupted configuration files. + - Reusability across different projects and use cases. + + The class is designed for central application configuration management, working closely + with `ThemeManager` to dynamically manage themes or other settings. + """ + + _config = None + _config_file = None + + @classmethod + def init(cls, config_file): + """Initial the Configmanager with the given config file""" + cls._config_file = config_file + cls._config = None # Reset the cache + + @classmethod + def load(cls): + """Load the config file and return the config as dict""" + if not cls._config: + try: + lines = Path(cls._config_file).read_text(encoding="utf-8").splitlines() + cls._config = { + "updates": lines[1].strip(), + "theme": lines[3].strip(), + "tooltips": lines[5].strip() + == "True", # is converted here to boolean!!! + "autostart": lines[7].strip() if len(lines) > 7 else "off", + "logfile": lines[9].strip(), + } + except (IndexError, FileNotFoundError): + # DeDefault values in case of error + cls._config = { + "updates": "on", + "theme": "light", + "tooltips": "True", # Default Value as string! + "autostart": "off", + "logfile": LOG_FILE_PATH, + } + return cls._config + + @classmethod + def save(cls): + """Save the config to the config file""" + if cls._config: + lines = [ + "# Configuration\n", + f"{cls._config['updates']}\n", + "# Theme\n", + f"{cls._config['theme']}\n", + "# Tooltips\n", + f"{str(cls._config['tooltips'])}\n", + "# Autostart\n", + f"{cls._config['autostart']}\n", + "# Logfile\n", + f"{cls._config['logfile']}\n", + ] + Path(cls._config_file).write_text("".join(lines), encoding="utf-8") + + @classmethod + def set(cls, key, value): + """Sets a configuration value and saves the change""" + cls.load() + cls._config[key] = value + cls.save() + + @classmethod + def get(cls, key, default=None): + """Returns a configuration value""" + config = cls.load() + return config.get(key, default) + + +class ThemeManager: + """ + Class for central theme management and UI customization. + + This static class allows dynamic adjustment of the application's appearance. + The method `change_theme()` updates the current theme and saves + the selection in the configuration file via `ConfigManager`. + It ensures a consistent visual design across the entire project. + + Key Features: + - Central control over themes. + - Automatic saving of theme settings to the configuration file. + - Tight integration with `ConfigManager` for persistent storage of preferences. + + The class is designed to apply themes consistently throughout the application, + ensuring that changes are traceable and uniform across all parts of the project. + """ + + @staticmethod + def change_theme(root, theme_in_use, theme_name=None): + """Change application theme centrally""" + root.tk.call("set_theme", theme_in_use) + if theme_in_use == theme_name: + ConfigManager.set("theme", theme_in_use) + + +class Tooltip: + """Class for Tooltip + from common_tools.py import Tooltip + example: Tooltip(label, "Show tooltip on label") + example: Tooltip(button, "Show tooltip on button") + example: Tooltip(widget, "Text", state_var=tk.BooleanVar()) + example: Tooltip(widget, "Text", state_var=tk.BooleanVar(), x_offset=20, y_offset=10) + + info: label and button are parent widgets. + NOTE: When using with state_var, pass the tk.BooleanVar object directly, + NOT its value. For example: use state_var=my_bool_var, NOT state_var=my_bool_var.get() + """ + + def __init__( + self, + widget: Any, + text: str, + state_var: Optional[tk.BooleanVar] = None, + x_offset: int = 65, + y_offset: int = 40, + ) -> None: + """Tooltip Class""" + self.widget: Any = widget + self.text: str = text + self.tooltip_window: Optional[Toplevel] = None + self.state_var = state_var + self.x_offset = x_offset + self.y_offset = y_offset + + # Initial binding based on the current state + self.update_bindings() + + # Add trace to the state_var if provided + if self.state_var is not None: + self.state_var.trace_add("write", self.update_bindings) + + def update_bindings(self, *args) -> None: + """Updates the bindings based on the current state""" + # Remove existing bindings first + self.widget.unbind("") + self.widget.unbind("") + + # Add new bindings if tooltips are enabled + if self.state_var is None or self.state_var.get(): + self.widget.bind("", self.show_tooltip) + self.widget.bind("", self.hide_tooltip) + + def show_tooltip(self, event: Optional[Any] = None) -> None: + """Shows the tooltip""" + if self.tooltip_window or not self.text: + return + + x: int + y: int + cx: int + cy: int + + x, y, cx, cy = self.widget.bbox("insert") + x += self.widget.winfo_rootx() + self.x_offset + y += self.widget.winfo_rooty() + self.y_offset + + self.tooltip_window = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(True) + tw.wm_geometry(f"+{x}+{y}") + + label: tk.Label = tk.Label( + tw, + text=self.text, + background="lightgreen", + foreground="black", + relief="solid", + borderwidth=1, + padx=5, + pady=5, + ) + label.grid() + + self.tooltip_window.after(2200, lambda: tw.destroy()) + + def hide_tooltip(self, event: Optional[Any] = None) -> None: + """Hides the tooltip""" + if self.tooltip_window: + self.tooltip_window.destroy() + self.tooltip_window = None + + +class LogConfig: + @staticmethod + def logger(file_path) -> None: + + file_handler = logging.FileHandler( + filename=f"{file_path}", + mode="a", + encoding="utf-8", + ) + formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + file_handler.setFormatter(formatter) + file_handler.setLevel(logging.DEBUG) + + logger = logging.getLogger() + logger.addHandler(file_handler) diff --git a/file_and_dir_ensure.py b/file_and_dir_ensure.py new file mode 100644 index 0000000..1b54fde --- /dev/null +++ b/file_and_dir_ensure.py @@ -0,0 +1,22 @@ +#!/usr/bin/python3 +"""Utility functions for setting up the application.""" + +from logview_app_config import AppConfig +from pathlib import Path + + +# Logging +LOG_DIR = Path.home() / ".local/share/lxlogs" +Path(LOG_DIR).mkdir(parents=True, exist_ok=True) +LOG_FILE_PATH = LOG_DIR / "logviewer.log" + + +def prepare_app_environment() -> None: + """Ensures that all required files and directories exist.""" + AppConfig.ensure_directories() + AppConfig.create_default_settings() + AppConfig.ensure_log() + + +if __name__ == "__main__": + prepare_app_environment() diff --git a/gitea.py b/gitea.py new file mode 100644 index 0000000..7818509 --- /dev/null +++ b/gitea.py @@ -0,0 +1,143 @@ +#!/usr/bin/python3 +import gettext +import locale +import requests +from pathlib import Path +import subprocess +import shutil +from shared_libs.common_tools import LxTools + + +class GiteaUpdate: + """ + Calling download requests the download URL of the running script, + the taskbar image for the “Download OK” window, the taskbar image for the + “Download error” window, and the variable res + """ + + @staticmethod + def api_down(update_api_url: str, version: str, update_setting: str = None) -> str: + """ + Checks for updates via API + + Args: + update_api_url: Update API URL + version: Current version + update_setting: Update setting from ConfigManager (on/off) + + Returns: + New version or status message + """ + # If updates are disabled, return immediately + if update_setting != "on": + return "False" + + try: + response: requests.Response = requests.get(update_api_url, timeout=10) + response.raise_for_status() # Raise exception for HTTP errors + + response_data = response.json() + if not response_data: + return "No Updates" + + latest_version = response_data[0].get("tag_name") + if not latest_version: + return "Invalid API Response" + + # Compare versions (strip 'v. ' prefix if present) + current_version = version[3:] if version.startswith("v. ") else version + + if current_version != latest_version: + return latest_version + else: + return "No Updates" + + except requests.exceptions.RequestException: + return "No Internet Connection!" + except (ValueError, KeyError, IndexError): + return "Invalid API Response" + + @staticmethod + def download(urld: str, res: str) -> None: + """ + Downloads new version of application + + :param urld: Download URL + :param res: Result filename + """ + + try: + to_down: str = f"wget -qP {Path.home()} {" "} {urld}" + result: int = subprocess.call(to_down, shell=True) + if result == 0: + shutil.chown(f"{Path.home()}/{res}.zip", 1000, 1000) + + LxTools.msg_window( + AppConfig.IMAGE_PATHS["icon_info"], + AppConfig.IMAGE_PATHS["icon_download"], + Msg.STR["title"], + Msg.STR["ok_message"], + ) + + else: + + LxTools.msg_window( + AppConfig.IMAGE_PATHS["icon_error"], + AppConfig.IMAGE_PATHS["icon_download_error"], + Msg.STR["error_title"], + Msg.STR["error_massage"], + ) + + except subprocess.CalledProcessError: + + LxTools.msg_window( + AppConfig.IMAGE_PATHS["icon_error"], + AppConfig.IMAGE_PATHS["icon_msg"], + Msg.STR["error_title"], + Msg.STR["error_no_internet"], + ) + + +class AppConfig: + + # Localization + APP_NAME: str = "gitea" + LOCALE_DIR: Path = Path("/usr/share/locale/") + + @staticmethod + def setup_translations() -> gettext.gettext: + """ + Initialize translations and set the translation function + Special method for translating strings in this file + + Returns: + The gettext translation function + """ + locale.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR) + gettext.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR) + gettext.textdomain(AppConfig.APP_NAME) + return gettext.gettext + + # Images and icons paths + IMAGE_PATHS: dict[str, Path] = { + "icon_info": "/usr/share/icons/lx-icons/64/info.png", + "icon_error": "/usr/share/icons/lx-icons/64/error.png", + "icon_download": "/usr/share/icons/lx-icons/48/download.png", + "icon_download_error": "/usr/share/icons/lx-icons/48/download_error.png", + } + + +# here is initializing the class for translation strings +_ = AppConfig.setup_translations() + + +class Msg: + + STR: dict[str, str] = { + # Strings for messages + "title": _("Download Successful"), + "ok_message": _("Your zip file is in home directory"), + "error_title": _("Download error"), + "error_message": _("Download failed! Please try again"), + "error_no_internet": _("Download failed! No internet connection!"), + } diff --git a/logview_app_config.py b/logview_app_config.py new file mode 100644 index 0000000..bff77e9 --- /dev/null +++ b/logview_app_config.py @@ -0,0 +1,146 @@ +"""Configuration for the LogViewer application.""" + +import gettext +import locale +from pathlib import Path +from typing import Dict, Any + + +class AppConfig: + """Central configuration and system setup manager for the LogViewer application. + + This class serves as a singleton-like container for all global configuration data, + including paths, UI settings, localization, versioning, and system-specific resources. + It ensures that required directories, files, and services are created and configured + before the application starts. Additionally, it provides tools for managing translations, + default settings, and autostart functionality to maintain a consistent user experience. + + Key Responsibilities: + - Centralizes all configuration values (paths, UI preferences, localization). + - Ensures required directories and files exist on startup. + - Handles translation setup via `gettext` for multilingual support. + - Manages default settings file generation. + - Configures autostart services using systemd for user-specific launch behavior. + + This class is used globally across the application to access configuration data + consistently and perform system-level setup tasks. + """ + + # Logging + LOG_DIR = Path.home() / ".local/share/lxlogs" + Path(LOG_DIR).mkdir(parents=True, exist_ok=True) + LOG_FILE_PATH = LOG_DIR / "logviewer.log" + + # Localization + APP_NAME: str = "logviewer" + LOCALE_DIR: Path = Path("/usr/share/locale/") + + # Base paths + BASE_DIR: Path = Path.home() + CONFIG_DIR: Path = BASE_DIR / ".config/logviewer" + + # Configuration files + SETTINGS_FILE: Path = CONFIG_DIR / "settings" + DEFAULT_SETTINGS: Dict[str, str] = { + "# Configuration": "on", + "# Theme": "light", + "# Tooltips": True, + "# Autostart": "off", + "# Logfile": LOG_FILE_PATH, + } + + # Updates + # 1 = 1. Year, 09 = Month of the Year, 2924 = Day and Year of the Year + VERSION: str = "v. 1.06.3125" + UPDATE_URL: str = "https://git.ilunix.de/api/v1/repos/punix/Wire-Py/releases" + DOWNLOAD_URL: str = "https://git.ilunix.de/punix/Wire-Py/archive" + + # UI configuration + UI_CONFIG: Dict[str, Any] = { + "window_title2": "LogViewer", + "window_size": (600, 383), + "font_family": "Ubuntu", + "font_size": 11, + "resizable_window": (True, True), + } + + # Images and icons paths + IMAGE_PATHS: Dict[str, Path] = { + "icon_info": "/usr/share/icons/lx-icons/64/info.png", + "icon_error": "/usr/share/icons/lx-icons/64/error.png", + "icon_log": "/usr/share/icons/lx-icons/48/log.png", + } + + # System-dependent paths + SYSTEM_PATHS: Dict[str, Path] = { + "tcl_path": "/usr/share/TK-Themes", + } + + @staticmethod + def setup_translations() -> gettext.gettext: + """ + Initialize translations and set the translation function + Special method for translating strings in this file + + Returns: + The gettext translation function + """ + locale.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR) + gettext.bindtextdomain(AppConfig.APP_NAME, AppConfig.LOCALE_DIR) + gettext.textdomain(AppConfig.APP_NAME) + return gettext.gettext + + @classmethod + def create_default_settings(cls) -> None: + """Creates default settings if they don't exist""" + if not cls.SETTINGS_FILE.exists(): + content = "\n".join( + f"[{k.upper()}]\n{v}" for k, v in cls.DEFAULT_SETTINGS.items() + ) + cls.SETTINGS_FILE.write_text(content) + + @classmethod + def ensure_directories(cls) -> None: + """Ensures that all required directories exist""" + if not cls.CONFIG_DIR.exists(): + cls.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + @classmethod + def ensure_log(cls) -> None: + """Ensures that the log file exists""" + if not cls.LOG_FILE_PATH.exists(): + cls.LOG_FILE_PATH.touch() + + +# here is initializing the class for translation strings +_ = AppConfig.setup_translations() + + +class Msg: + """ + A utility class that provides centralized access to translated message strings. + + This class contains a dictionary of message strings used throughout the Wire-Py application. + All strings are prepared for translation using gettext. The short key names make the code + more concise while maintaining readability. + + Attributes: + STR (dict): A dictionary mapping short keys to translated message strings. + Keys are abbreviated for brevity but remain descriptive. + + Usage: + Import this class and access messages using the dictionary: + `Msg.STR["sel_tl"]` returns the translated "Select tunnel" message. + + Note: + Ensure that gettext translation is properly initialized before + accessing these strings to ensure correct localization. + """ + + STR: Dict[str, str] = { + # Strings for messages + } + TTIP: Dict[str, str] = { + # Strings for Tooltips + "settings": _("Click for Settings"), + } diff --git a/logviewer.py b/logviewer.py new file mode 100755 index 0000000..1ae6fce --- /dev/null +++ b/logviewer.py @@ -0,0 +1,526 @@ +#!/usr/bin/python3 +import argparse +import logging +import tkinter as tk +from tkinter import TclError, filedialog, ttk +from pathlib import Path +from shared_libs.gitea import GiteaUpdate +from shared_libs.common_tools import ( + LogConfig, + ConfigManager, + ThemeManager, + LxTools, + Tooltip, +) +import sys +from file_and_dir_ensure import prepare_app_environment +import webbrowser + + +class LogViewer(tk.Tk): + def __init__(self, modul_name): + super().__init__() + + self.my_tool_tip = None + self.modul_name = modul_name # Save the module name + # from here the calls must be made with the module name + _ = modul_name.AppConfig.setup_translations() + + self.x_width = modul_name.AppConfig.UI_CONFIG["window_size"][0] + self.y_height = modul_name.AppConfig.UI_CONFIG["window_size"][1] + # Set the window size + self.geometry(f"{self.x_width}x{self.y_height}") + self.minsize( + modul_name.AppConfig.UI_CONFIG["window_size"][0], + modul_name.AppConfig.UI_CONFIG["window_size"][1], + ) + self.title(modul_name.AppConfig.UI_CONFIG["window_title2"]) + self.tk.call( + "source", f"{modul_name.AppConfig.SYSTEM_PATHS['tcl_path']}/water.tcl" + ) + ConfigManager.init(modul_name.AppConfig.SETTINGS_FILE) + theme = ConfigManager.get("theme") + ThemeManager.change_theme(self, theme) + LxTools.center_window_cross_platform(self, self.x_width, self.y_height) + self.createWidgets(_) + self.load_file(_, modul_name=modul_name) + self.log_icon = tk.PhotoImage(file=modul_name.AppConfig.IMAGE_PATHS["icon_log"]) + self.iconphoto(True, self.log_icon) + self.grid_rowconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) + self.grid_columnconfigure(0, weight=1) + + # StringVar-Variables initialization + self.tooltip_state = tk.BooleanVar() + # Get value from configuration + state = ConfigManager.get("tooltips") + # NOTE: ConfigManager.get("tooltips") can return either a boolean value or a string, + # depending on whether the value was loaded from the file (bool) or the default value is used (string). + # The expression 'lines[5].strip() == "True"' in ConfigManager.load() converts the string to a boolean. + # Convert to boolean and set + if isinstance(state, bool): + # If it's already a boolean, use directly + self.tooltip_state.set(state) + else: + # If it's a string or something else + self.tooltip_state.set(str(state) == "True") + + self.tooltip_label = ( + tk.StringVar() + ) # StringVar-Variable for tooltip label for view Disabled/Enabled + self.tooltip_update_label(modul_name, _) + self.update_label = tk.StringVar() # StringVar-Variable for update label + self.update_tooltip = ( + tk.StringVar() + ) # StringVar-Variable for update tooltip please not remove! + self.update_foreground = tk.StringVar(value="red") + + # Frame for Menu + self.menu_frame = ttk.Frame(self) + self.menu_frame.configure(relief="flat") + if "'logview_app_config'" in f"{modul_name}".split(): + self.menu_frame.grid(column=0, row=0, columnspan=4, sticky=tk.NSEW) + + # App Menu + self.version_lb = ttk.Label(self.menu_frame, text=modul_name.AppConfig.VERSION) + self.version_lb.config(font=("Ubuntu", 11), foreground="#00c4ff") + self.version_lb.grid(column=0, row=0, rowspan=4, padx=10, pady=10) + + Tooltip( + self.version_lb, + f"Version: {modul_name.AppConfig.VERSION[2:]}", + self.tooltip_state, + ) + self.load_button = ttk.Button( + self.menu_frame, + text=_("Load Log"), + style="Toolbutton", + command=lambda: self.directory_load(modul_name, _), + ) + self.load_button.grid(column=1, row=0) + self.options_btn = ttk.Menubutton(self.menu_frame, text=_("Options")) + self.options_btn.grid(column=2, row=0) + + Tooltip(self.options_btn, modul_name.Msg.TTIP["settings"], self.tooltip_state) + + self.set_update = tk.IntVar() + self.settings = tk.Menu(self, relief="flat") + self.options_btn.configure(menu=self.settings, style="Toolbutton") + self.settings.add_checkbutton( + label=_("Disable Updates"), + command=lambda: self.update_setting(self.set_update.get(), modul_name, _), + variable=self.set_update, + ) + + self.updates_lb = ttk.Label(self.menu_frame, textvariable=self.update_label) + self.updates_lb.grid(column=5, row=0, padx=10) + self.updates_lb.grid_remove() + self.update_label.trace_add("write", self.update_label_display) + self.update_foreground.trace_add("write", self.update_label_display) + res = GiteaUpdate.api_down( + modul_name.AppConfig.UPDATE_URL, + modul_name.AppConfig.VERSION, + ConfigManager.get("updates"), + ) + self.update_ui_for_update(res, modul_name, _) + + # Tooltip Menu + self.settings.add_command( + label=self.tooltip_label.get(), + command=lambda: self.tooltips_toggle(modul_name, _), + ) + # Label show dark or light + self.theme_label = tk.StringVar() + self.update_theme_label(modul_name, _) + self.settings.add_command( + label=self.theme_label.get(), + command=lambda: self.on_theme_toggle(modul_name, _), + ) + + # About BTN Menu / Label + self.about_btn = ttk.Button( + self.menu_frame, + text=_("About"), + style="Toolbutton", + command=lambda: self.about(modul_name, _), + ) + self.about_btn.grid(column=3, row=0) + self.readme = tk.Menu(self) + # self.grid_rowconfigure(0, weight=) + self.grid_rowconfigure(1, weight=25) + self.grid_columnconfigure(0, weight=1) + + # Method that is called when the variable changes + def update_label_display(self, *args): + # Set the foreground color + self.updates_lb.configure(foreground=self.update_foreground.get()) + + # Show or hide the label based on whether it contains text + if self.update_label.get(): + # Make sure the label is in the correct position every time it's shown + self.updates_lb.grid(column=5, row=0, padx=10) + else: + self.updates_lb.grid_remove() + + # Update the labels based on the result + def update_ui_for_update(self, res, modul_name, _): + """Update UI elements based on an update check result""" + # First, remove the update button if it exists to avoid conflicts + if hasattr(self, "update_btn"): + self.update_btn.grid_forget() + delattr(self, "update_btn") + + if res == "False": + self.set_update.set(value=1) + self.update_label.set(_("Update search off")) + self.update_tooltip.set(_("Updates you have disabled")) + # Clear the foreground color as requested + self.update_foreground.set("") + # Set the tooltip for the label + Tooltip(self.updates_lb, self.update_tooltip.get(), self.tooltip_state) + + elif res == "No Internet Connection!": + self.update_label.set(_("No Server Connection!")) + self.update_foreground.set("red") + # Set the tooltip for "No Server Connection" + Tooltip( + self.updates_lb, + _("Could not connect to update server"), + self.tooltip_state, + ) + + elif res == "No Updates": + self.update_label.set(_("No Updates")) + self.update_tooltip.set(_("Congratulations! Wire-Py is up to date")) + self.update_foreground.set("") + # Set the tooltip for the label + Tooltip(self.updates_lb, self.update_tooltip.get(), self.tooltip_state) + + else: + self.set_update.set(value=0) + update_text = f"Update {res} {_('available!')}" + + # Clear the label text since we'll show the button instead + self.update_label.set("") + + # Create the update button + self.update_btn = ttk.Menubutton(self.menu_frame, text=update_text) + self.update_btn.grid(column=5, row=0, padx=0) + Tooltip( + self.update_btn, _("Click to download new version"), self.tooltip_state + ) + + self.download = tk.Menu(self, relief="flat") + self.update_btn.configure(menu=self.download, style="Toolbutton") + self.download.add_command( + label=_("Download"), + command=lambda: GiteaUpdate.download( + f"{modul_name.AppConfig.DOWNLOAD_URL}/{res}.zip", res + ), + ) + + @staticmethod + def about(modul_name, _) -> None: + """ + a tk.Toplevel window + """ + + def link_btn() -> None: + webbrowser.open("https://git.ilunix.de/punix/shared_libs") + + msg_t = _( + "Logviewer a simple Gui for View Logfiles.\n\n" + "Logviewer is open source software written in Python.\n\n" + "Email: polunga40@unity-mail.de also likes for donation.\n\n" + "Use without warranty!\n" + ) + + LxTools.msg_window( + modul_name.AppConfig.IMAGE_PATHS["icon_log"], + modul_name.AppConfig.IMAGE_PATHS["icon_log"], + _("Info"), + msg_t, + _("Go to shared_libs git"), + link_btn, + ) + + def update_setting(self, update_res, modul_name, _) -> None: + """write off or on in file + Args: + update_res (int): argument that is passed contains 0 or 1 + """ + if update_res == 1: + # Disable updates + ConfigManager.set("updates", "off") + # When updates are disabled, we know the result should be "False" + self.update_ui_for_update("False", modul_name, _) + else: + # Enable updates + ConfigManager.set("updates", "on") + # When enabling updates, we need to actually check for updates + try: + # Force a fresh check by passing "on" as the update setting + res = GiteaUpdate.api_down( + modul_name.AppConfig.UPDATE_URL, modul_name.AppConfig.VERSION, "on" + ) + + # Make sure the UI is updated regardless of the previous state + if hasattr(self, "update_btn"): + self.update_btn.grid_forget() + if hasattr(self, "updates_lb"): + self.updates_lb.grid_forget() + + # Now update the UI with the fresh result + self.update_ui_for_update(res, modul_name, _) + except Exception as e: + logging.error(f"Error checking for updates: {e}") + # Fallback to a default message if there's an error + self.update_ui_for_update("No Internet Connection!", modul_name, _) + + def tooltip_update_label(self, modul_name, _) -> None: + """Updates the tooltip menu label based on the current tooltip status""" + # Set the menu text based on the current status + if self.tooltip_state.get(): + # If tooltips are enabled, the menu option should be to disable them + self.tooltip_label.set(_("Disable Tooltips")) + else: + # If tooltips are disabled, the menu option should be to enable them + self.tooltip_label.set(_("Enable Tooltips")) + + def tooltips_toggle(self, modul_name, _): + """ + Toggles the visibility of tooltips (on/off) and updates + the corresponding menu label. Inverts the current tooltip state + (`self.tooltip_state`), saves the new value in the configuration, + and applies the change immediately. Updates the menu entry's label to + reflect the new tooltip status (e.g., "Tooltips: On" or "Tooltips: Off"). + """ + # Toggle the boolean state + new_bool_state = not self.tooltip_state.get() + # Save the converted value in the configuration + ConfigManager.set("tooltips", str(new_bool_state)) + # Update the tooltip_state variable for immediate effect + self.tooltip_state.set(new_bool_state) + + # Update the menu label + self.tooltip_update_label(modul_name, _) + + # Update the menu entry - find the correct index + # This assumes it's the third item (index 2) in your menu + self.settings.entryconfigure(1, label=self.tooltip_label.get()) + + def update_theme_label(self, modul_name, _) -> None: + """Update the theme label based on the current theme""" + current_theme = ConfigManager.get("theme") + if current_theme == "light": + self.theme_label.set(_("Dark")) + else: + self.theme_label.set(_("Light")) + + def on_theme_toggle(self, modul_name, _) -> None: + """Toggle between light and dark theme""" + current_theme = ConfigManager.get("theme") + new_theme = "dark" if current_theme == "light" else "light" + ThemeManager.change_theme(self, new_theme, new_theme) + self.update_theme_label(modul_name, _) # Update the theme label + # Update Menulfield + self.settings.entryconfigure(2, label=self.theme_label.get()) + + def createWidgets(self, _): + + text_frame = ttk.Frame(self) + text_frame.grid(row=1, column=0, padx=5, pady=5, sticky=tk.NSEW) + text_frame.rowconfigure(0, weight=3) + text_frame.columnconfigure(0, weight=1) + next_frame = ttk.Frame(self) + next_frame.grid(row=2, column=0, sticky=tk.NSEW) + next_frame.rowconfigure(2, weight=1) + next_frame.columnconfigure(1, weight=1) + # Create a Text widget for displaying the log file + self.text_area = tk.Text( + text_frame, wrap=tk.WORD, padx=5, pady=5, relief="flat" + ) + self.text_area.grid(row=0, column=0, sticky=tk.NSEW) + self.text_area.tag_configure( + "found-tag", foreground="yellow", background="green" + ) + # Create a vertical scrollbar for the Text widget + v_scrollbar = ttk.Scrollbar( + text_frame, orient="vertical", command=self.text_area.yview + ) + v_scrollbar.grid(row=0, column=1, sticky=tk.NS) + self.text_area.configure(yscrollcommand=v_scrollbar.set) + + self._entry = ttk.Entry(next_frame) + self._entry.bind("", lambda e: self._onFind()) + self._entry.grid(row=0, column=1, padx=5, sticky=tk.EW) + # Add a context menu to the Text widget + self.context_menu = tk.Menu(self, tearoff=0) + self.context_menu.add_command(label=_("Copy"), command=self.copy_text) + self.context_menu.add_command(label=_("Paste"), command=self.paste_into_entry) + self.text_area.bind("", self.show_context_menu) + self._entry.bind("", self.show_context_menu) + + search_button = ttk.Button(next_frame, text="Search", command=self._onFind) + search_button.grid(row=0, column=0, padx=5, pady=5, sticky=tk.EW) + + delete_button = ttk.Button( + next_frame, text="Delete_Log", command=self.delete_file + ) + delete_button.grid(row=0, column=2, padx=5, pady=5, sticky=tk.EW) + + def show_text_menu(self, event): + try: + self.configure.tk_popup(event.x_root, event.y_root) + finally: + self.context_menu.grab_release() + + def copy_text(self): + + try: + selected_text = self.text_area.selection_get() + self.clipboard_clear() + self.clipboard_append(selected_text) + except tk.TclError: + # No Text selected + pass + + def show_context_menu(self, event): + try: + self.context_menu.tk_popup(event.x_root, event.y_root) + finally: + self.context_menu.grab_release() + + def paste_into_entry(self): + try: + text = self.clipboard_get() + self._entry.delete(0, tk.END) + self._entry.insert(tk.END, text) + except tk.TclError: + # No Text on Clipboard + pass + + def _onFind(self): + searchText = self._entry.get() + if len(searchText) == 0: + return + + # Set the search start position to the last found position (initial value: "1.0") + start_pos = self.last_search_pos if hasattr(self, "last_search_pos") else "1.0" + + var = tk.IntVar() + foundIndex = self.text_area.search( + searchText, + start_pos, + stopindex=tk.END, + nocase=tk.YES, + count=var, + regexp=tk.YES, + ) + + if not foundIndex: + # No further entry found, reset to the beginning + self.last_search_pos = "1.0" + return + + count = var.get() + lastIndex = self.text_area.index(f"{foundIndex} + {count}c") + + # Remove and reapply highlighting + self.text_area.tag_remove("found-tag", "1.0", tk.END) + self.text_area.tag_add("found-tag", foundIndex, lastIndex) + + # Update the start position for the next search + self.last_search_pos = lastIndex + self.text_area.see(foundIndex) + + def delete_file(self, modul_name): + Path.unlink(modul_name.AppConfig.LOG_FILE_PATH) + modul_name.AppConfig.ensure_log() + + def load_file(self, _, modul_name): + + try: + if not modul_name.AppConfig.LOG_FILE_PATH: + return + + with open( + modul_name.AppConfig.LOG_FILE_PATH, "r", encoding="utf-8" + ) as file: + self.text_area.delete(1.0, tk.END) + self.text_area.insert(tk.END, file.read()) + except Exception as e: + logging.error(_(f"A mistake occurred: {str(e)}")) + LxTools.msg_window( + modul_name.AppConfig.IMAGE_PATHS["icon_error"], + modul_name.AppConfig.IMAGE_PATHS["icon_log"], + "LogViewer", + _(f"A mistake occurred:\n{str(e)}\n"), + ) + + def directory_load(self, modul_name, _): + + filepath = filedialog.askopenfilename( + initialdir=f"{Path.home() / ".local/share/lxlogs/"}", + title="Select a Logfile File", + filetypes=[("Logfiles", "*.log")], + ) + + try: + with open(filepath, "r", encoding="utf-8") as file: + self.text_area.delete(1.0, tk.END) + self.text_area.insert(tk.END, file.read()) + except (IsADirectoryError, TypeError, FileNotFoundError): + print("File load: abort by user...") + except Exception as e: + logging.error(_(f"A mistake occurred: {e}")) + LxTools.msg_window( + modul_name.AppConfig.IMAGE_PATHS["icon_error"], + modul_name.AppConfig.IMAGE_PATHS["icon_log"], + "LogViewer", + _(f"A mistake occurred:\n{e}\n"), + ) + + +def main(): + + # Create an ArgumentParser object + parser = argparse.ArgumentParser( + description="LogViewer with optional module loading." + ) + parser.add_argument( + "--modul", + type=str, + default="logview_app_config", + help="Give the name of the module to load.", + ) + args = parser.parse_args() + import importlib + + try: + modul = importlib.import_module(args.modul) + except ModuleNotFoundError: + print(f"Modul '{args.modul}' not found") + print("For help use logviewer -h") + sys.exit(1) + except Exception as e: + print(f"Error load Modul: {str(e)}") + sys.exit(1) + + prepare_app_environment() + app = LogViewer(modul) + LogConfig.logger(ConfigManager.get("logfile")) + """ + the hidden files are hidden in Filedialog + """ + try: + app.tk.call("tk_getOpenFile", "-foobarbaz") + except TclError: + pass + app.tk.call("set", "::tk::dialog::file::showHiddenBtn", "1") + app.tk.call("set", "::tk::dialog::file::showHiddenVar", "0") + app.mainloop() + + +if __name__ == "__main__": + main()