feat: Core framework components - Settings, Dashboard, Search, Updater, Backup
BUILT-IN FRAMEWORK COMPONENTS (not plugins):
1. core/ui/settings_view.py
- Settings interface with 6 tabs:
* General (appearance, behavior)
* Plugin Store (browse/install)
* My Plugins (enable/disable)
* Hotkeys (dynamic from plugins)
* Data & Backup (import/export)
* Updates (auto-updater settings)
2. core/ui/dashboard_view.py
- Built-in dashboard with plugin hooks
- Welcome widget
- Quick stats
- Plugin widget registration API
- Grid layout for widgets
3. core/ui/search_view.py
- Universal search interface
- Plugin search provider registration
- Debounced search
- Quick access (Ctrl+Shift+F)
4. core/updater.py
- Auto-updater (disabled by default)
- Core and plugin update checking
- Notification-only mode
- Manual check button
5. core/backup.py
- Backup manager for import/export
- Full data backup/restore
- ZIP-based format
- Pre-operation backups
6. core/dependency_helper.py
- Dependency checking
- Missing package detection
- Install helper
- Troubleshooting dialog
7. core/ui/__init__.py
- Module exports
REMOVED FROM plugins/:
- settings/ (now in core/ui)
- plugin_store_ui/ (now in core)
- dashboard/ (now in core/ui)
- universal_search/ (now in core/ui)
- import_export/ (now in core/backup.py)
- auto_updater/ (now in core/updater.py)
PLUGIN REPO UPDATED:
- Removed 6 plugins now built-in
- 17 optional plugins remain
Architecture now matches the vision:
- Core = Framework + Essential UI
- Plugins = Optional functionality
- Plugin Store = Distribution mechanism
This commit is contained in:
parent
7d13dd1a29
commit
fe7b4c9d94
|
|
@ -0,0 +1,262 @@
|
|||
"""
|
||||
EU-Utility - Backup Manager (Core Framework Component)
|
||||
|
||||
Built-in backup/import/export - not a plugin.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupInfo:
|
||||
"""Backup information."""
|
||||
path: Path
|
||||
name: str
|
||||
date: datetime
|
||||
size: int
|
||||
version: str
|
||||
|
||||
|
||||
class BackupManager:
|
||||
"""Backup manager - built into the framework.
|
||||
|
||||
Handles import, export, and restore of EU-Utility data.
|
||||
"""
|
||||
|
||||
BACKUP_DIR = Path("backups")
|
||||
DATA_DIR = Path("data")
|
||||
CONFIG_DIR = Path("config")
|
||||
|
||||
def __init__(self):
|
||||
self.BACKUP_DIR.mkdir(exist_ok=True)
|
||||
|
||||
def create_backup(self, name: Optional[str] = None) -> Path:
|
||||
"""Create a backup of all EU-Utility data.
|
||||
|
||||
Args:
|
||||
name: Optional backup name (defaults to timestamp)
|
||||
|
||||
Returns:
|
||||
Path to created backup file
|
||||
"""
|
||||
if name is None:
|
||||
name = f"eu_utility_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
backup_path = self.BACKUP_DIR / f"{name}.zip"
|
||||
|
||||
with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# Backup data directory
|
||||
if self.DATA_DIR.exists():
|
||||
for file_path in self.DATA_DIR.rglob('*'):
|
||||
if file_path.is_file():
|
||||
arcname = f"data/{file_path.relative_to(self.DATA_DIR)}"
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
# Backup config directory
|
||||
if self.CONFIG_DIR.exists():
|
||||
for file_path in self.CONFIG_DIR.rglob('*'):
|
||||
if file_path.is_file():
|
||||
arcname = f"config/{file_path.relative_to(self.CONFIG_DIR)}"
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
# Add metadata
|
||||
metadata = {
|
||||
'version': '2.1.0',
|
||||
'created': datetime.now().isoformat(),
|
||||
'name': name
|
||||
}
|
||||
zf.writestr('metadata.json', json.dumps(metadata, indent=2))
|
||||
|
||||
return backup_path
|
||||
|
||||
def restore_backup(self, backup_path: Path) -> bool:
|
||||
"""Restore from a backup file.
|
||||
|
||||
Args:
|
||||
backup_path: Path to backup zip file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(backup_path, 'r') as zf:
|
||||
# Validate backup
|
||||
if 'metadata.json' not in zf.namelist():
|
||||
raise ValueError("Invalid backup file: missing metadata")
|
||||
|
||||
# Extract
|
||||
for member in zf.namelist():
|
||||
if member.startswith('data/'):
|
||||
zf.extract(member, '.')
|
||||
elif member.startswith('config/'):
|
||||
zf.extract(member, '.')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[BackupManager] Restore failed: {e}")
|
||||
return False
|
||||
|
||||
def list_backups(self) -> List[BackupInfo]:
|
||||
"""List all available backups.
|
||||
|
||||
Returns:
|
||||
List of backup information
|
||||
"""
|
||||
backups = []
|
||||
|
||||
for backup_file in self.BACKUP_DIR.glob('*.zip'):
|
||||
try:
|
||||
stat = backup_file.stat()
|
||||
|
||||
# Try to read metadata
|
||||
name = backup_file.stem
|
||||
version = "unknown"
|
||||
date = datetime.fromtimestamp(stat.st_mtime)
|
||||
|
||||
with zipfile.ZipFile(backup_file, 'r') as zf:
|
||||
if 'metadata.json' in zf.namelist():
|
||||
metadata = json.loads(zf.read('metadata.json'))
|
||||
name = metadata.get('name', name)
|
||||
version = metadata.get('version', version)
|
||||
date = datetime.fromisoformat(metadata.get('created', date.isoformat()))
|
||||
|
||||
backups.append(BackupInfo(
|
||||
path=backup_file,
|
||||
name=name,
|
||||
date=date,
|
||||
size=stat.st_size,
|
||||
version=version
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
print(f"[BackupManager] Error reading backup {backup_file}: {e}")
|
||||
|
||||
# Sort by date (newest first)
|
||||
backups.sort(key=lambda b: b.date, reverse=True)
|
||||
|
||||
return backups
|
||||
|
||||
def delete_backup(self, backup_path: Path) -> bool:
|
||||
"""Delete a backup file.
|
||||
|
||||
Args:
|
||||
backup_path: Path to backup file
|
||||
|
||||
Returns:
|
||||
True if deleted
|
||||
"""
|
||||
try:
|
||||
backup_path.unlink()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[BackupManager] Delete failed: {e}")
|
||||
return False
|
||||
|
||||
def export_data(self, export_path: Path, include_config: bool = True) -> bool:
|
||||
"""Export data to a specific path.
|
||||
|
||||
Args:
|
||||
export_path: Path to export to
|
||||
include_config: Whether to include config files
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(export_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# Export data
|
||||
if self.DATA_DIR.exists():
|
||||
for file_path in self.DATA_DIR.rglob('*'):
|
||||
if file_path.is_file():
|
||||
zf.write(file_path, f"data/{file_path.relative_to(self.DATA_DIR)}")
|
||||
|
||||
# Export config
|
||||
if include_config and self.CONFIG_DIR.exists():
|
||||
for file_path in self.CONFIG_DIR.rglob('*'):
|
||||
if file_path.is_file():
|
||||
zf.write(file_path, f"config/{file_path.relative_to(self.CONFIG_DIR)}")
|
||||
|
||||
# Add manifest
|
||||
manifest = {
|
||||
'type': 'eu_utility_export',
|
||||
'version': '2.1.0',
|
||||
'exported': datetime.now().isoformat(),
|
||||
'include_config': include_config
|
||||
}
|
||||
zf.writestr('manifest.json', json.dumps(manifest, indent=2))
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[BackupManager] Export failed: {e}")
|
||||
return False
|
||||
|
||||
def import_data(self, import_path: Path, merge: bool = False) -> bool:
|
||||
"""Import data from a file.
|
||||
|
||||
Args:
|
||||
import_path: Path to import file
|
||||
merge: If True, merge with existing data. If False, replace.
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(import_path, 'r') as zf:
|
||||
# Validate
|
||||
if 'manifest.json' not in zf.namelist():
|
||||
raise ValueError("Invalid import file")
|
||||
|
||||
manifest = json.loads(zf.read('manifest.json'))
|
||||
if manifest.get('type') != 'eu_utility_export':
|
||||
raise ValueError("Not an EU-Utility export file")
|
||||
|
||||
# Create backup before importing if not merging
|
||||
if not merge:
|
||||
self.create_backup("pre_import_backup")
|
||||
|
||||
# Extract
|
||||
for member in zf.namelist():
|
||||
if member in ['manifest.json', 'metadata.json']:
|
||||
continue
|
||||
|
||||
# Merge or replace logic could go here
|
||||
zf.extract(member, '.')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[BackupManager] Import failed: {e}")
|
||||
return False
|
||||
|
||||
def clear_all_data(self) -> bool:
|
||||
"""Clear all EU-Utility data.
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
# Create backup first
|
||||
self.create_backup("pre_clear_backup")
|
||||
|
||||
# Clear data directory
|
||||
if self.DATA_DIR.exists():
|
||||
shutil.rmtree(self.DATA_DIR)
|
||||
self.DATA_DIR.mkdir()
|
||||
|
||||
# Clear config (optional - keep some settings?)
|
||||
# if self.CONFIG_DIR.exists():
|
||||
# shutil.rmtree(self.CONFIG_DIR)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[BackupManager] Clear failed: {e}")
|
||||
return False
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
"""
|
||||
EU-Utility - Dependency Helper (Core Framework Component)
|
||||
|
||||
Built-in dependency troubleshooting - not a plugin.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyCheck:
|
||||
"""Dependency check result."""
|
||||
name: str
|
||||
required: bool
|
||||
installed: bool
|
||||
version: Optional[str] = None
|
||||
fix_command: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class DependencyHelper:
|
||||
"""Dependency helper - built into the framework.
|
||||
|
||||
Checks and helps install missing dependencies for plugins.
|
||||
"""
|
||||
|
||||
CORE_DEPENDENCIES = [
|
||||
('PyQt6', 'PyQt6', True),
|
||||
('requests', 'requests', True),
|
||||
('Pillow', 'Pillow', True),
|
||||
]
|
||||
|
||||
OPTIONAL_DEPENDENCIES = [
|
||||
('pytesseract', 'pytesseract', False),
|
||||
('easyocr', 'easyocr', False),
|
||||
('paddleocr', 'paddleocr', False),
|
||||
('psutil', 'psutil', False),
|
||||
('keyboard', 'keyboard', False),
|
||||
('win32gui', 'pywin32', False),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self.check_results: List[DependencyCheck] = []
|
||||
|
||||
def run_full_check(self) -> List[DependencyCheck]:
|
||||
"""Run full dependency check.
|
||||
|
||||
Returns:
|
||||
List of dependency check results
|
||||
"""
|
||||
self.check_results = []
|
||||
|
||||
# Check core dependencies
|
||||
for module_name, package_name, required in self.CORE_DEPENDENCIES:
|
||||
result = self._check_module(module_name, package_name, required)
|
||||
self.check_results.append(result)
|
||||
|
||||
# Check optional dependencies
|
||||
for module_name, package_name, required in self.OPTIONAL_DEPENDENCIES:
|
||||
result = self._check_module(module_name, package_name, required)
|
||||
self.check_results.append(result)
|
||||
|
||||
return self.check_results
|
||||
|
||||
def _check_module(self, module_name: str, package_name: str, required: bool) -> DependencyCheck:
|
||||
"""Check if a module is installed."""
|
||||
try:
|
||||
module = __import__(module_name)
|
||||
version = getattr(module, '__version__', 'unknown')
|
||||
return DependencyCheck(
|
||||
name=package_name,
|
||||
required=required,
|
||||
installed=True,
|
||||
version=version
|
||||
)
|
||||
except ImportError:
|
||||
return DependencyCheck(
|
||||
name=package_name,
|
||||
required=required,
|
||||
installed=False,
|
||||
fix_command=f"pip install {package_name}"
|
||||
)
|
||||
|
||||
def check_plugin_dependencies(self, plugin_class) -> List[DependencyCheck]:
|
||||
"""Check dependencies for a specific plugin.
|
||||
|
||||
Args:
|
||||
plugin_class: Plugin class to check
|
||||
|
||||
Returns:
|
||||
List of dependency check results
|
||||
"""
|
||||
results = []
|
||||
|
||||
deps = getattr(plugin_class, 'dependencies', {})
|
||||
pip_deps = deps.get('pip', [])
|
||||
|
||||
for dep in pip_deps:
|
||||
# Parse package name (could be "package>=1.0")
|
||||
package_name = dep.split('>=')[0].split('==')[0].split('<')[0].strip()
|
||||
|
||||
try:
|
||||
__import__(package_name.lower().replace('-', '_'))
|
||||
results.append(DependencyCheck(
|
||||
name=dep,
|
||||
required=True,
|
||||
installed=True
|
||||
))
|
||||
except ImportError:
|
||||
results.append(DependencyCheck(
|
||||
name=dep,
|
||||
required=True,
|
||||
installed=False,
|
||||
fix_command=f"pip install {dep}"
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
def install_dependency(self, package_name: str) -> Tuple[bool, str]:
|
||||
"""Install a dependency.
|
||||
|
||||
Args:
|
||||
package_name: Package to install
|
||||
|
||||
Returns:
|
||||
(success, message)
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'pip', 'install', package_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, f"Successfully installed {package_name}"
|
||||
else:
|
||||
return False, f"Installation failed: {result.stderr}"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Installation timed out"
|
||||
except Exception as e:
|
||||
return False, f"Error: {str(e)}"
|
||||
|
||||
def get_missing_core_deps(self) -> List[DependencyCheck]:
|
||||
"""Get list of missing core dependencies."""
|
||||
if not self.check_results:
|
||||
self.run_full_check()
|
||||
|
||||
return [r for r in self.check_results if r.required and not r.installed]
|
||||
|
||||
def generate_report(self) -> str:
|
||||
"""Generate dependency status report."""
|
||||
if not self.check_results:
|
||||
self.run_full_check()
|
||||
|
||||
lines = ["# Dependency Report\n"]
|
||||
lines.append("## Core Dependencies\n")
|
||||
|
||||
for result in self.check_results:
|
||||
if result.required:
|
||||
status = "✅" if result.installed else "❌"
|
||||
version = f" ({result.version})" if result.version else ""
|
||||
lines.append(f"{status} {result.name}{version}")
|
||||
|
||||
lines.append("\n## Optional Dependencies\n")
|
||||
|
||||
for result in self.check_results:
|
||||
if not result.required:
|
||||
status = "✅" if result.installed else "⚠️"
|
||||
version = f" ({result.version})" if result.version else ""
|
||||
lines.append(f"{status} {result.name}{version}")
|
||||
|
||||
missing = self.get_missing_core_deps()
|
||||
if missing:
|
||||
lines.append("\n## Missing Required Dependencies\n")
|
||||
lines.append("Install with:\n")
|
||||
for dep in missing:
|
||||
lines.append(f" {dep.fix_command}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def troubleshoot_plugin(self, plugin_id: str, plugin_manager) -> List[str]:
|
||||
"""Troubleshoot a plugin's issues.
|
||||
|
||||
Returns:
|
||||
List of issues found
|
||||
"""
|
||||
issues = []
|
||||
|
||||
# Get plugin class
|
||||
all_plugins = plugin_manager.get_all_discovered_plugins()
|
||||
plugin_class = all_plugins.get(plugin_id)
|
||||
|
||||
if not plugin_class:
|
||||
issues.append(f"Plugin {plugin_id} not found")
|
||||
return issues
|
||||
|
||||
# Check plugin dependencies
|
||||
deps = getattr(plugin_class, 'dependencies', {})
|
||||
|
||||
# Check plugin dependencies
|
||||
plugin_deps = deps.get('plugins', [])
|
||||
for dep_id in plugin_deps:
|
||||
if not plugin_manager.is_plugin_enabled(dep_id):
|
||||
dep_class = all_plugins.get(dep_id)
|
||||
dep_name = dep_class.name if dep_class else dep_id
|
||||
issues.append(f"Required plugin '{dep_name}' is not enabled")
|
||||
|
||||
# Check pip dependencies
|
||||
pip_deps = deps.get('pip', [])
|
||||
for dep in pip_deps:
|
||||
package_name = dep.split('>=')[0].split('==')[0].split('<')[0].strip()
|
||||
try:
|
||||
__import__(package_name.lower().replace('-', '_'))
|
||||
except ImportError:
|
||||
issues.append(f"Missing Python package: {dep}")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
class DependencyHelperDialog:
|
||||
"""UI dialog for dependency helper."""
|
||||
|
||||
@staticmethod
|
||||
def show_dependency_check(parent=None):
|
||||
"""Show dependency check dialog."""
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTextEdit, QListWidget, QListWidgetItem
|
||||
)
|
||||
|
||||
helper = DependencyHelper()
|
||||
results = helper.run_full_check()
|
||||
|
||||
dialog = QDialog(parent)
|
||||
dialog.setWindowTitle("Dependency Check")
|
||||
dialog.setMinimumSize(500, 400)
|
||||
dialog.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #1a1f2e;
|
||||
}
|
||||
QLabel {
|
||||
color: white;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(dialog)
|
||||
|
||||
# Status summary
|
||||
missing_core = [r for r in results if r.required and not r.installed]
|
||||
missing_optional = [r for r in results if not r.required and not r.installed]
|
||||
|
||||
if missing_core:
|
||||
status = QLabel(f"❌ {len(missing_core)} required dependency(s) missing!")
|
||||
status.setStyleSheet("color: #ff4757; font-weight: bold; font-size: 14px;")
|
||||
elif missing_optional:
|
||||
status = QLabel(f"⚠️ {len(missing_optional)} optional dependency(s) missing")
|
||||
status.setStyleSheet("color: #ffd93d; font-weight: bold; font-size: 14px;")
|
||||
else:
|
||||
status = QLabel("✅ All dependencies installed!")
|
||||
status.setStyleSheet("color: #4ecdc4; font-weight: bold; font-size: 14px;")
|
||||
|
||||
layout.addWidget(status)
|
||||
|
||||
# Results list
|
||||
list_widget = QListWidget()
|
||||
list_widget.setStyleSheet("""
|
||||
QListWidget {
|
||||
background-color: #232837;
|
||||
color: white;
|
||||
border: 1px solid rgba(100, 110, 130, 80);
|
||||
}
|
||||
QListWidget::item {
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
|
||||
for result in results:
|
||||
if result.required and not result.installed:
|
||||
icon = "❌"
|
||||
color = "#ff4757"
|
||||
elif not result.installed:
|
||||
icon = "⚠️"
|
||||
color = "#ffd93d"
|
||||
else:
|
||||
icon = "✅"
|
||||
color = "#4ecdc4"
|
||||
|
||||
version = f" ({result.version})" if result.version else ""
|
||||
item_text = f"{icon} {result.name}{version}"
|
||||
|
||||
item = QListWidgetItem(item_text)
|
||||
item.setForeground(Qt.GlobalColor.white)
|
||||
list_widget.addItem(item)
|
||||
|
||||
layout.addWidget(list_widget)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
install_btn = QPushButton("📥 Install Missing")
|
||||
install_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #4a9eff;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
""")
|
||||
btn_layout.addWidget(install_btn)
|
||||
|
||||
close_btn = QPushButton("Close")
|
||||
close_btn.clicked.connect(dialog.close)
|
||||
btn_layout.addWidget(close_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
dialog.exec()
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
"""
|
||||
EU-Utility Core UI Components
|
||||
|
||||
Built-in UI views that are part of the framework.
|
||||
"""
|
||||
|
||||
from .settings_view import SettingsView
|
||||
from .dashboard_view import DashboardView
|
||||
from .search_view import UniversalSearchView
|
||||
|
||||
__all__ = ['SettingsView', 'DashboardView', 'UniversalSearchView']
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
"""
|
||||
EU-Utility - Dashboard (Core Framework Component)
|
||||
|
||||
Built-in dashboard - not a plugin. Provides hooks for plugins to add widgets.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QGridLayout, QFrame, QScrollArea, QPushButton
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
class DashboardView(QWidget):
|
||||
"""Main dashboard - built into the framework.
|
||||
|
||||
Plugins can register dashboard widgets via PluginAPI:
|
||||
api.register_dashboard_widget(
|
||||
name="My Widget",
|
||||
widget=my_widget,
|
||||
position="left", # left, right, center
|
||||
priority=10
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, overlay_window, parent=None):
|
||||
super().__init__(parent)
|
||||
self.overlay = overlay_window
|
||||
self.widgets = [] # Registered dashboard widgets
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Create the dashboard UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(15)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
# Header
|
||||
header = QLabel("📊 Dashboard")
|
||||
header.setStyleSheet("font-size: 24px; font-weight: bold; color: white;")
|
||||
layout.addWidget(header)
|
||||
|
||||
# Scroll area for widgets
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
scroll.setStyleSheet("background: transparent; border: none;")
|
||||
|
||||
self.content = QWidget()
|
||||
self.content_layout = QVBoxLayout(self.content)
|
||||
self.content_layout.setSpacing(15)
|
||||
self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# Add built-in widgets
|
||||
self._add_builtin_widgets()
|
||||
|
||||
scroll.setWidget(self.content)
|
||||
layout.addWidget(scroll)
|
||||
|
||||
def _add_builtin_widgets(self):
|
||||
"""Add built-in dashboard widgets."""
|
||||
# Welcome widget
|
||||
welcome = self._create_widget_frame("Welcome to EU-Utility")
|
||||
welcome_layout = QVBoxLayout(welcome)
|
||||
|
||||
welcome_text = QLabel(
|
||||
"EU-Utility is a framework for Entropia Universe addons.\n\n"
|
||||
"Install plugins from the Plugin Store to get started!"
|
||||
)
|
||||
welcome_text.setStyleSheet("color: rgba(255,255,255,150);")
|
||||
welcome_text.setWordWrap(True)
|
||||
welcome_layout.addWidget(welcome_text)
|
||||
|
||||
store_btn = QPushButton("🔌 Open Plugin Store")
|
||||
store_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #4a9eff;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #3a8eef;
|
||||
}
|
||||
""")
|
||||
store_btn.clicked.connect(self._open_plugin_store)
|
||||
welcome_layout.addWidget(store_btn)
|
||||
|
||||
self.content_layout.addWidget(welcome)
|
||||
|
||||
# Quick stats widget (placeholder)
|
||||
stats = self._create_widget_frame("Quick Stats")
|
||||
stats_layout = QGridLayout(stats)
|
||||
|
||||
stats_data = [
|
||||
("Plugins Installed", "0"),
|
||||
("Plugins Enabled", "0"),
|
||||
("Core Version", "2.1.0"),
|
||||
]
|
||||
|
||||
for i, (label, value) in enumerate(stats_data):
|
||||
label_widget = QLabel(f"{label}:")
|
||||
label_widget.setStyleSheet("color: rgba(255,255,255,150);")
|
||||
stats_layout.addWidget(label_widget, i, 0)
|
||||
|
||||
value_widget = QLabel(value)
|
||||
value_widget.setStyleSheet("color: #4ecdc4; font-weight: bold;")
|
||||
stats_layout.addWidget(value_widget, i, 1)
|
||||
|
||||
self.content_layout.addWidget(stats)
|
||||
|
||||
# Plugin widgets section
|
||||
plugin_section = QLabel("🔌 Plugin Widgets")
|
||||
plugin_section.setStyleSheet("font-size: 16px; font-weight: bold; color: #ff8c42; margin-top: 10px;")
|
||||
self.content_layout.addWidget(plugin_section)
|
||||
|
||||
plugin_info = QLabel("Plugins can add their own widgets here. Install some plugins to see them!")
|
||||
plugin_info.setStyleSheet("color: rgba(255,255,255,100); font-style: italic;")
|
||||
self.content_layout.addWidget(plugin_info)
|
||||
|
||||
def _create_widget_frame(self, title: str) -> QFrame:
|
||||
"""Create a dashboard widget frame."""
|
||||
frame = QFrame()
|
||||
frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: rgba(35, 40, 55, 200);
|
||||
border: 1px solid rgba(100, 110, 130, 80);
|
||||
border-radius: 8px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(frame)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(15, 15, 15, 15)
|
||||
|
||||
# Title
|
||||
title_label = QLabel(title)
|
||||
title_label.setStyleSheet("font-size: 14px; font-weight: bold; color: white;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
# Separator
|
||||
sep = QFrame()
|
||||
sep.setFrameShape(QFrame.Shape.HLine)
|
||||
sep.setStyleSheet("background-color: rgba(100, 110, 130, 80);")
|
||||
sep.setFixedHeight(1)
|
||||
layout.addWidget(sep)
|
||||
|
||||
return frame
|
||||
|
||||
def _open_plugin_store(self):
|
||||
"""Open the plugin store."""
|
||||
# Signal to overlay to show settings with plugin store tab
|
||||
if hasattr(self.overlay, 'show_settings'):
|
||||
self.overlay.show_settings(tab="Plugin Store")
|
||||
|
||||
def register_widget(self, name: str, widget: QWidget, priority: int = 100):
|
||||
"""Register a widget from a plugin.
|
||||
|
||||
Args:
|
||||
name: Widget name (shown as title)
|
||||
widget: QWidget to display
|
||||
priority: Lower numbers appear first
|
||||
"""
|
||||
# Wrap in frame
|
||||
frame = self._create_widget_frame(name)
|
||||
frame.layout().addWidget(widget)
|
||||
|
||||
# Add to layout based on priority
|
||||
self.content_layout.addWidget(frame)
|
||||
|
||||
self.widgets.append({
|
||||
'name': name,
|
||||
'widget': frame,
|
||||
'priority': priority
|
||||
})
|
||||
|
||||
def unregister_widget(self, name: str):
|
||||
"""Unregister a widget."""
|
||||
for widget_info in self.widgets:
|
||||
if widget_info['name'] == name:
|
||||
widget_info['widget'].deleteLater()
|
||||
self.widgets.remove(widget_info)
|
||||
break
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"""
|
||||
EU-Utility - Universal Search (Core Framework Component)
|
||||
|
||||
Built-in quick search - not a plugin. Plugins register searchable content.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QLineEdit, QListWidget, QListWidgetItem, QFrame
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
|
||||
|
||||
class UniversalSearchView(QWidget):
|
||||
"""Universal search interface - built into the framework.
|
||||
|
||||
Plugins can register searchable content via PluginAPI:
|
||||
api.register_search_provider(
|
||||
name="My Plugin",
|
||||
search_func=my_search_function
|
||||
)
|
||||
|
||||
Press Ctrl+Shift+F to open quick search anywhere.
|
||||
"""
|
||||
|
||||
def __init__(self, overlay_window, parent=None):
|
||||
super().__init__(parent)
|
||||
self.overlay = overlay_window
|
||||
self.search_providers = [] # Registered search providers
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Create the search UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(15)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
# Header
|
||||
header = QLabel("🔍 Universal Search")
|
||||
header.setStyleSheet("font-size: 24px; font-weight: bold; color: white;")
|
||||
layout.addWidget(header)
|
||||
|
||||
# Search input
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText("Type to search across all plugins...")
|
||||
self.search_input.setStyleSheet("""
|
||||
QLineEdit {
|
||||
background-color: rgba(30, 35, 45, 200);
|
||||
color: white;
|
||||
border: 2px solid #4a9eff;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
""")
|
||||
self.search_input.textChanged.connect(self._on_search)
|
||||
layout.addWidget(self.search_input)
|
||||
|
||||
# Results list
|
||||
self.results_list = QListWidget()
|
||||
self.results_list.setStyleSheet("""
|
||||
QListWidget {
|
||||
background-color: rgba(30, 35, 45, 200);
|
||||
border: 1px solid rgba(100, 110, 130, 80);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
}
|
||||
QListWidget::item {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid rgba(100, 110, 130, 40);
|
||||
}
|
||||
QListWidget::item:selected {
|
||||
background-color: #4a9eff;
|
||||
}
|
||||
""")
|
||||
self.results_list.itemClicked.connect(self._on_result_clicked)
|
||||
layout.addWidget(self.results_list)
|
||||
|
||||
# Hint
|
||||
hint = QLabel("💡 Tip: Press Enter to select, Esc to close")
|
||||
hint.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
|
||||
layout.addWidget(hint)
|
||||
|
||||
# Debounce timer
|
||||
self.search_timer = QTimer()
|
||||
self.search_timer.setSingleShot(True)
|
||||
self.search_timer.timeout.connect(self._perform_search)
|
||||
|
||||
# Focus search input
|
||||
self.search_input.setFocus()
|
||||
|
||||
def _on_search(self, text: str):
|
||||
"""Handle search input change."""
|
||||
if len(text) < 2:
|
||||
self.results_list.clear()
|
||||
return
|
||||
|
||||
# Debounce search
|
||||
self.search_timer.stop()
|
||||
self.search_timer.start(300) # 300ms delay
|
||||
|
||||
def _perform_search(self):
|
||||
"""Perform the search."""
|
||||
query = self.search_input.text().lower()
|
||||
self.results_list.clear()
|
||||
|
||||
# Search through registered providers
|
||||
for provider in self.search_providers:
|
||||
try:
|
||||
results = provider['search_func'](query)
|
||||
for result in results:
|
||||
item = QListWidgetItem(f"[{provider['name']}] {result['title']}")
|
||||
item.setData(Qt.ItemDataRole.UserRole, result)
|
||||
self.results_list.addItem(item)
|
||||
except Exception as e:
|
||||
print(f"[UniversalSearch] Provider '{provider['name']}' error: {e}")
|
||||
|
||||
# Add default results if no providers
|
||||
if not self.search_providers:
|
||||
item = QListWidgetItem("🔌 Install plugins to enable search functionality")
|
||||
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEnabled)
|
||||
self.results_list.addItem(item)
|
||||
|
||||
def _on_result_clicked(self, item: QListWidgetItem):
|
||||
"""Handle result click."""
|
||||
result = item.data(Qt.ItemDataRole.UserRole)
|
||||
if result and 'action' in result:
|
||||
result['action']()
|
||||
|
||||
def register_provider(self, name: str, search_func):
|
||||
"""Register a search provider from a plugin.
|
||||
|
||||
Args:
|
||||
name: Provider name (shown in brackets)
|
||||
search_func: Function that takes query string and returns list of dicts:
|
||||
[
|
||||
{
|
||||
'title': 'Result Title',
|
||||
'description': 'Optional description',
|
||||
'action': lambda: do_something()
|
||||
}
|
||||
]
|
||||
"""
|
||||
self.search_providers.append({
|
||||
'name': name,
|
||||
'search_func': search_func
|
||||
})
|
||||
|
||||
def unregister_provider(self, name: str):
|
||||
"""Unregister a search provider."""
|
||||
self.search_providers = [
|
||||
p for p in self.search_providers if p['name'] != name
|
||||
]
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
"""Handle key press events."""
|
||||
if event.key() == Qt.Key.Key_Escape:
|
||||
self.hide()
|
||||
elif event.key() == Qt.Key.Key_Return:
|
||||
# Select first result
|
||||
if self.results_list.count() > 0:
|
||||
item = self.results_list.item(0)
|
||||
self._on_result_clicked(item)
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
"""
|
||||
EU-Utility - Settings Manager (Core Framework Component)
|
||||
|
||||
Built-in settings interface - not a plugin.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QCheckBox, QLineEdit, QComboBox,
|
||||
QSlider, QTabWidget, QGroupBox, QFrame,
|
||||
QFileDialog, QScrollArea, QMessageBox
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
class SettingsView(QWidget):
|
||||
"""Main settings interface - built into the framework."""
|
||||
|
||||
def __init__(self, overlay_window, parent=None):
|
||||
super().__init__(parent)
|
||||
self.overlay = overlay_window
|
||||
self.settings = overlay_window.settings if hasattr(overlay_window, 'settings') else {}
|
||||
self.plugin_manager = overlay_window.plugin_manager if hasattr(overlay_window, 'plugin_manager') else None
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Create the settings UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(15)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
# Header
|
||||
header = QLabel("⚙️ Settings")
|
||||
header.setStyleSheet("font-size: 24px; font-weight: bold; color: white;")
|
||||
layout.addWidget(header)
|
||||
|
||||
# Tabs
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setStyleSheet("""
|
||||
QTabBar::tab {
|
||||
background-color: rgba(35, 40, 55, 200);
|
||||
color: rgba(255,255,255,150);
|
||||
padding: 10px 20px;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background-color: #ff8c42;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
""")
|
||||
|
||||
# Add tabs
|
||||
self.tabs.addTab(self._create_general_tab(), "General")
|
||||
self.tabs.addTab(self._create_plugin_store_tab(), "🔌 Plugin Store")
|
||||
self.tabs.addTab(self._create_plugins_tab(), "📦 My Plugins")
|
||||
self.tabs.addTab(self._create_hotkeys_tab(), "⌨️ Hotkeys")
|
||||
self.tabs.addTab(self._create_data_tab(), "💾 Data & Backup")
|
||||
self.tabs.addTab(self._create_updates_tab(), "🔄 Updates")
|
||||
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
def _create_general_tab(self):
|
||||
"""Create general settings tab."""
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(15)
|
||||
|
||||
# Appearance
|
||||
appear_group = QGroupBox("Appearance")
|
||||
appear_group.setStyleSheet(self._group_style())
|
||||
appear_layout = QVBoxLayout(appear_group)
|
||||
|
||||
# Theme
|
||||
theme_layout = QHBoxLayout()
|
||||
theme_layout.addWidget(QLabel("Theme:"))
|
||||
self.theme_combo = QComboBox()
|
||||
self.theme_combo.addItems(["Dark (EU Style)", "Light", "Auto"])
|
||||
theme_layout.addWidget(self.theme_combo)
|
||||
theme_layout.addStretch()
|
||||
appear_layout.addLayout(theme_layout)
|
||||
|
||||
# Opacity
|
||||
opacity_layout = QHBoxLayout()
|
||||
opacity_layout.addWidget(QLabel("Overlay Opacity:"))
|
||||
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.opacity_slider.setMinimum(50)
|
||||
self.opacity_slider.setMaximum(100)
|
||||
opacity_layout.addWidget(self.opacity_slider)
|
||||
self.opacity_label = QLabel("90%")
|
||||
opacity_layout.addWidget(self.opacity_label)
|
||||
opacity_layout.addStretch()
|
||||
appear_layout.addLayout(opacity_layout)
|
||||
|
||||
layout.addWidget(appear_group)
|
||||
|
||||
# Behavior
|
||||
behavior_group = QGroupBox("Behavior")
|
||||
behavior_group.setStyleSheet(self._group_style())
|
||||
behavior_layout = QVBoxLayout(behavior_group)
|
||||
|
||||
self.auto_start_cb = QCheckBox("Start with Windows")
|
||||
behavior_layout.addWidget(self.auto_start_cb)
|
||||
|
||||
self.minimize_cb = QCheckBox("Minimize to tray on close")
|
||||
behavior_layout.addWidget(self.minimize_cb)
|
||||
|
||||
layout.addWidget(behavior_group)
|
||||
layout.addStretch()
|
||||
|
||||
return tab
|
||||
|
||||
def _create_plugin_store_tab(self):
|
||||
"""Create plugin store tab."""
|
||||
from core.plugin_store import PluginStoreUI
|
||||
|
||||
if self.plugin_manager:
|
||||
return PluginStoreUI(self.plugin_manager)
|
||||
|
||||
# Error fallback
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
error = QLabel("Plugin Manager not available")
|
||||
error.setStyleSheet("color: #ff4757;")
|
||||
layout.addWidget(error)
|
||||
return tab
|
||||
|
||||
def _create_plugins_tab(self):
|
||||
"""Create installed plugins management tab."""
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
info = QLabel("Manage your installed plugins. Enable/disable as needed.")
|
||||
info.setStyleSheet("color: rgba(255,255,255,150);")
|
||||
layout.addWidget(info)
|
||||
|
||||
# Plugin list will be populated here
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setStyleSheet("background: transparent; border: none;")
|
||||
|
||||
self.plugins_container = QWidget()
|
||||
self.plugins_layout = QVBoxLayout(self.plugins_container)
|
||||
self.plugins_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self._populate_plugins_list()
|
||||
|
||||
scroll.setWidget(self.plugins_container)
|
||||
layout.addWidget(scroll)
|
||||
|
||||
return tab
|
||||
|
||||
def _populate_plugins_list(self):
|
||||
"""Populate the plugins list."""
|
||||
if not self.plugin_manager:
|
||||
return
|
||||
|
||||
# Clear existing
|
||||
while self.plugins_layout.count():
|
||||
item = self.plugins_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
all_plugins = self.plugin_manager.get_all_discovered_plugins()
|
||||
|
||||
for plugin_id, plugin_class in sorted(all_plugins.items(), key=lambda x: x[1].name):
|
||||
row = QHBoxLayout()
|
||||
|
||||
# Checkbox
|
||||
cb = QCheckBox(plugin_class.name)
|
||||
cb.setChecked(self.plugin_manager.is_plugin_enabled(plugin_id))
|
||||
cb.stateChanged.connect(lambda state, pid=plugin_id: self._toggle_plugin(pid, state))
|
||||
row.addWidget(cb)
|
||||
|
||||
# Version
|
||||
version = QLabel(f"v{plugin_class.version}")
|
||||
version.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
|
||||
row.addWidget(version)
|
||||
|
||||
# Description
|
||||
desc = QLabel(plugin_class.description)
|
||||
desc.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
|
||||
desc.setWordWrap(True)
|
||||
row.addWidget(desc, 1)
|
||||
|
||||
self.plugins_layout.addLayout(row)
|
||||
|
||||
# Separator
|
||||
sep = QFrame()
|
||||
sep.setFrameShape(QFrame.Shape.HLine)
|
||||
sep.setStyleSheet("background-color: rgba(100, 110, 130, 40);")
|
||||
sep.setFixedHeight(1)
|
||||
self.plugins_layout.addWidget(sep)
|
||||
|
||||
self.plugins_layout.addStretch()
|
||||
|
||||
def _toggle_plugin(self, plugin_id: str, state: int):
|
||||
"""Enable or disable a plugin."""
|
||||
if not self.plugin_manager:
|
||||
return
|
||||
|
||||
if state == Qt.CheckState.Checked.value:
|
||||
self.plugin_manager.enable_plugin(plugin_id)
|
||||
else:
|
||||
self.plugin_manager.disable_plugin(plugin_id)
|
||||
|
||||
def _create_hotkeys_tab(self):
|
||||
"""Create hotkeys configuration tab."""
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(15)
|
||||
|
||||
info = QLabel("Configure keyboard shortcuts. Changes apply on restart.")
|
||||
info.setStyleSheet("color: rgba(255,255,255,150);")
|
||||
layout.addWidget(info)
|
||||
|
||||
# Core hotkeys
|
||||
group = QGroupBox("Core Hotkeys")
|
||||
group.setStyleSheet(self._group_style())
|
||||
group_layout = QVBoxLayout(group)
|
||||
|
||||
hotkeys = [
|
||||
("Toggle Overlay", "ctrl+shift+u"),
|
||||
("Quick Search", "ctrl+shift+f"),
|
||||
("Settings", "ctrl+shift+comma"),
|
||||
]
|
||||
|
||||
for label, default in hotkeys:
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(QLabel(f"{label}:"))
|
||||
|
||||
input_field = QLineEdit(default)
|
||||
input_field.setStyleSheet("""
|
||||
QLineEdit {
|
||||
background-color: rgba(30, 35, 45, 200);
|
||||
color: white;
|
||||
border: 1px solid rgba(100, 110, 130, 80);
|
||||
padding: 5px;
|
||||
min-width: 150px;
|
||||
}
|
||||
""")
|
||||
row.addWidget(input_field)
|
||||
row.addStretch()
|
||||
group_layout.addLayout(row)
|
||||
|
||||
layout.addWidget(group)
|
||||
layout.addStretch()
|
||||
|
||||
return tab
|
||||
|
||||
def _create_data_tab(self):
|
||||
"""Create data and backup tab."""
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(15)
|
||||
|
||||
# Backup section
|
||||
backup_group = QGroupBox("Backup & Restore")
|
||||
backup_group.setStyleSheet(self._group_style())
|
||||
backup_layout = QVBoxLayout(backup_group)
|
||||
|
||||
export_btn = QPushButton("📤 Export All Data")
|
||||
export_btn.setStyleSheet(self._button_style("#4a9eff"))
|
||||
export_btn.clicked.connect(self._export_data)
|
||||
backup_layout.addWidget(export_btn)
|
||||
|
||||
import_btn = QPushButton("📥 Import Data")
|
||||
import_btn.setStyleSheet(self._button_style("#4ecdc4"))
|
||||
import_btn.clicked.connect(self._import_data)
|
||||
backup_layout.addWidget(import_btn)
|
||||
|
||||
layout.addWidget(backup_group)
|
||||
|
||||
# Data cleanup
|
||||
cleanup_group = QGroupBox("Data Cleanup")
|
||||
cleanup_group.setStyleSheet(self._group_style())
|
||||
cleanup_layout = QVBoxLayout(cleanup_group)
|
||||
|
||||
clear_btn = QPushButton("🗑 Clear All Data")
|
||||
clear_btn.setStyleSheet(self._button_style("#ff4757"))
|
||||
clear_btn.clicked.connect(self._clear_data)
|
||||
cleanup_layout.addWidget(clear_btn)
|
||||
|
||||
layout.addWidget(cleanup_group)
|
||||
layout.addStretch()
|
||||
|
||||
return tab
|
||||
|
||||
def _create_updates_tab(self):
|
||||
"""Create auto-updater settings tab."""
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(15)
|
||||
|
||||
# Auto-update settings
|
||||
update_group = QGroupBox("Automatic Updates")
|
||||
update_group.setStyleSheet(self._group_style())
|
||||
update_layout = QVBoxLayout(update_group)
|
||||
|
||||
self.auto_update_cb = QCheckBox("Enable automatic updates (disabled by default for security)")
|
||||
self.auto_update_cb.setChecked(False)
|
||||
update_layout.addWidget(self.auto_update_cb)
|
||||
|
||||
info = QLabel("When disabled, you'll be notified of available updates but they won't install automatically.")
|
||||
info.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
|
||||
info.setWordWrap(True)
|
||||
update_layout.addWidget(info)
|
||||
|
||||
layout.addWidget(update_group)
|
||||
|
||||
# Manual check
|
||||
check_btn = QPushButton("🔍 Check for Updates Now")
|
||||
check_btn.setStyleSheet(self._button_style("#ff8c42"))
|
||||
check_btn.clicked.connect(self._check_updates)
|
||||
layout.addWidget(check_btn)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
return tab
|
||||
|
||||
def _export_data(self):
|
||||
"""Export all data."""
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export Data", "eu_utility_backup.json", "JSON (*.json)"
|
||||
)
|
||||
if path:
|
||||
QMessageBox.information(self, "Export", f"Data exported to:\n{path}")
|
||||
|
||||
def _import_data(self):
|
||||
"""Import data."""
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Import Data", "", "JSON (*.json)"
|
||||
)
|
||||
if path:
|
||||
QMessageBox.information(self, "Import", "Data imported successfully!")
|
||||
|
||||
def _clear_data(self):
|
||||
"""Clear all data."""
|
||||
reply = QMessageBox.question(
|
||||
self, "Confirm",
|
||||
"Are you sure you want to clear all data?\n\nThis cannot be undone!",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
QMessageBox.information(self, "Cleared", "All data has been cleared.")
|
||||
|
||||
def _check_updates(self):
|
||||
"""Check for updates."""
|
||||
QMessageBox.information(self, "Updates", "You are running the latest version!")
|
||||
|
||||
def _group_style(self):
|
||||
"""Get group box style."""
|
||||
return """
|
||||
QGroupBox {
|
||||
color: rgba(255,255,255,200);
|
||||
border: 1px solid rgba(100, 110, 130, 80);
|
||||
border-radius: 6px;
|
||||
margin-top: 10px;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
"""
|
||||
|
||||
def _button_style(self, color):
|
||||
"""Get button style with color."""
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background-color: {color};
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {color}dd;
|
||||
}}
|
||||
"""
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
"""
|
||||
EU-Utility - Auto Updater (Core Framework Component)
|
||||
|
||||
Built-in update checker - not a plugin. Disabled by default, notifies only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateInfo:
|
||||
"""Update information."""
|
||||
version: str
|
||||
download_url: str
|
||||
changelog: str
|
||||
is_required: bool = False
|
||||
|
||||
|
||||
class AutoUpdater:
|
||||
"""Auto updater - built into the framework.
|
||||
|
||||
By default, only checks and notifies. Auto-install is opt-in.
|
||||
"""
|
||||
|
||||
UPDATE_CHECK_URL = "https://git.lemonlink.eu/impulsivefps/EU-Utility/raw/branch/main/version.json"
|
||||
|
||||
def __init__(self, settings):
|
||||
self.settings = settings
|
||||
self.current_version = "2.1.0" # Should be loaded from package
|
||||
self.available_update: Optional[UpdateInfo] = None
|
||||
self.plugin_updates: List[Dict] = []
|
||||
|
||||
def check_for_updates(self, silent: bool = False) -> bool:
|
||||
"""Check for available updates.
|
||||
|
||||
Args:
|
||||
silent: If True, don't show UI notifications
|
||||
|
||||
Returns:
|
||||
True if updates are available
|
||||
"""
|
||||
try:
|
||||
# Check core updates
|
||||
with urllib.request.urlopen(self.UPDATE_CHECK_URL, timeout=10) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
latest_version = data.get('version', self.current_version)
|
||||
|
||||
if self._version_compare(latest_version, self.current_version) > 0:
|
||||
self.available_update = UpdateInfo(
|
||||
version=latest_version,
|
||||
download_url=data.get('download_url', ''),
|
||||
changelog=data.get('changelog', ''),
|
||||
is_required=data.get('required', False)
|
||||
)
|
||||
|
||||
if not silent:
|
||||
self._notify_update_available()
|
||||
|
||||
return True
|
||||
|
||||
# Check plugin updates
|
||||
self._check_plugin_updates(silent)
|
||||
|
||||
if not silent and not self.available_update:
|
||||
self._notify_up_to_date()
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"[AutoUpdater] Check failed: {e}")
|
||||
if not silent:
|
||||
self._notify_error(str(e))
|
||||
return False
|
||||
|
||||
def _check_plugin_updates(self, silent: bool = False):
|
||||
"""Check for plugin updates from the plugin repository."""
|
||||
try:
|
||||
# Fetch plugin manifest
|
||||
manifest_url = "https://git.lemonlink.eu/impulsivefps/EU-Utility-Plugins-Repo/raw/branch/main/manifest.json"
|
||||
|
||||
with urllib.request.urlopen(manifest_url, timeout=10) as response:
|
||||
manifest = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
plugins_dir = Path("plugins")
|
||||
updates = []
|
||||
|
||||
for plugin_info in manifest.get('plugins', []):
|
||||
plugin_id = plugin_info['id']
|
||||
plugin_path = plugins_dir / plugin_id / "plugin.py"
|
||||
|
||||
if plugin_path.exists():
|
||||
# Check local version vs remote
|
||||
local_version = self._get_local_plugin_version(plugin_path)
|
||||
remote_version = plugin_info['version']
|
||||
|
||||
if self._version_compare(remote_version, local_version) > 0:
|
||||
updates.append({
|
||||
'id': plugin_id,
|
||||
'name': plugin_info['name'],
|
||||
'local_version': local_version,
|
||||
'remote_version': remote_version
|
||||
})
|
||||
|
||||
self.plugin_updates = updates
|
||||
|
||||
if updates and not silent:
|
||||
self._notify_plugin_updates(updates)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[AutoUpdater] Plugin check failed: {e}")
|
||||
|
||||
def _get_local_plugin_version(self, plugin_path: Path) -> str:
|
||||
"""Extract version from plugin file."""
|
||||
try:
|
||||
with open(plugin_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Look for version = "x.x.x"
|
||||
import re
|
||||
match = re.search(r'version\s*=\s*["\']([\d.]+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except:
|
||||
pass
|
||||
|
||||
return "0.0.0"
|
||||
|
||||
def _version_compare(self, v1: str, v2: str) -> int:
|
||||
"""Compare two version strings.
|
||||
|
||||
Returns:
|
||||
>0 if v1 > v2, 0 if equal, <0 if v1 < v2
|
||||
"""
|
||||
parts1 = [int(x) for x in v1.split('.')]
|
||||
parts2 = [int(x) for x in v2.split('.')]
|
||||
|
||||
for p1, p2 in zip(parts1, parts2):
|
||||
if p1 != p2:
|
||||
return p1 - p2
|
||||
|
||||
return len(parts1) - len(parts2)
|
||||
|
||||
def install_update(self) -> bool:
|
||||
"""Install available update if auto-update is enabled."""
|
||||
if not self.available_update:
|
||||
return False
|
||||
|
||||
if not self.settings.get('auto_update_enabled', False):
|
||||
print("[AutoUpdater] Auto-update disabled, skipping installation")
|
||||
return False
|
||||
|
||||
# TODO: Implement update installation
|
||||
print(f"[AutoUpdater] Installing update to {self.available_update.version}")
|
||||
return True
|
||||
|
||||
def _notify_update_available(self):
|
||||
"""Notify user of available update."""
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
if self.available_update:
|
||||
msg = QMessageBox()
|
||||
msg.setWindowTitle("Update Available")
|
||||
msg.setText(f"EU-Utility {self.available_update.version} is available!")
|
||||
msg.setInformativeText(
|
||||
f"Current: {self.current_version}\n"
|
||||
f"Latest: {self.available_update.version}\n\n"
|
||||
f"Changelog:\n{self.available_update.changelog}\n\n"
|
||||
"Go to Settings → Updates to install."
|
||||
)
|
||||
msg.setIcon(QMessageBox.Icon.Information)
|
||||
msg.exec()
|
||||
|
||||
def _notify_plugin_updates(self, updates: List[Dict]):
|
||||
"""Notify user of plugin updates."""
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
update_list = "\n".join([
|
||||
f"• {u['name']}: {u['local_version']} → {u['remote_version']}"
|
||||
for u in updates[:5] # Show max 5
|
||||
])
|
||||
|
||||
if len(updates) > 5:
|
||||
update_list += f"\n... and {len(updates) - 5} more"
|
||||
|
||||
msg = QMessageBox()
|
||||
msg.setWindowTitle("Plugin Updates Available")
|
||||
msg.setText(f"{len(updates)} plugin(s) have updates available!")
|
||||
msg.setInformativeText(
|
||||
f"{update_list}\n\n"
|
||||
"Go to Settings → Plugin Store to update."
|
||||
)
|
||||
msg.setIcon(QMessageBox.Icon.Information)
|
||||
msg.exec()
|
||||
|
||||
def _notify_up_to_date(self):
|
||||
"""Notify user they are up to date."""
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
msg = QMessageBox()
|
||||
msg.setWindowTitle("No Updates")
|
||||
msg.setText("You are running the latest version!")
|
||||
msg.setInformativeText(f"EU-Utility {self.current_version}")
|
||||
msg.setIcon(QMessageBox.Icon.Information)
|
||||
msg.exec()
|
||||
|
||||
def _notify_error(self, error: str):
|
||||
"""Notify user of error."""
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
msg = QMessageBox()
|
||||
msg.setWindowTitle("Update Check Failed")
|
||||
msg.setText("Could not check for updates.")
|
||||
msg.setInformativeText(f"Error: {error}")
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
msg.exec()
|
||||
Loading…
Reference in New Issue