pomera-ai-commander 1.2.5 → 1.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/backup_recovery_manager.py +223 -60
- package/core/collapsible_panel.py +197 -0
- package/core/database_settings_manager.py +20 -9
- package/core/migration_manager.py +12 -11
- package/core/settings_defaults_registry.py +8 -0
- package/core/tool_search_widget.py +417 -0
- package/mcp.json +1 -1
- package/package.json +1 -1
- package/pomera.py +403 -69
- package/tools/curl_history.py +45 -5
- package/tools/curl_tool.py +42 -12
- package/tools/diff_viewer.py +2 -9
- package/tools/tool_loader.py +263 -15
|
@@ -168,60 +168,144 @@ class BackupRecoveryManager:
|
|
|
168
168
|
self.logger.error(f"Failed to create JSON backup: {e}")
|
|
169
169
|
return None
|
|
170
170
|
|
|
171
|
+
def _validate_database_for_backup(self, connection_manager) -> tuple[bool, str]:
|
|
172
|
+
"""
|
|
173
|
+
Validate that database has meaningful content before creating backup.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
connection_manager: Database connection manager
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Tuple of (is_valid, message)
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
conn = connection_manager.get_connection()
|
|
183
|
+
|
|
184
|
+
# Check that key tables exist and have content
|
|
185
|
+
critical_tables = ['core_settings', 'tool_settings']
|
|
186
|
+
total_records = 0
|
|
187
|
+
|
|
188
|
+
for table in critical_tables:
|
|
189
|
+
try:
|
|
190
|
+
cursor = conn.execute(f"SELECT COUNT(*) FROM {table}")
|
|
191
|
+
count = cursor.fetchone()[0]
|
|
192
|
+
total_records += count
|
|
193
|
+
except sqlite3.Error:
|
|
194
|
+
pass # Table doesn't exist
|
|
195
|
+
|
|
196
|
+
if total_records == 0:
|
|
197
|
+
return False, "Database appears empty - no records in core settings tables"
|
|
198
|
+
|
|
199
|
+
# Check database file size
|
|
200
|
+
try:
|
|
201
|
+
from core.data_directory import get_database_path
|
|
202
|
+
db_path = get_database_path('settings.db')
|
|
203
|
+
if os.path.exists(db_path):
|
|
204
|
+
file_size = os.path.getsize(db_path)
|
|
205
|
+
if file_size < 100: # Less than 100 bytes is essentially empty
|
|
206
|
+
return False, f"Database file too small ({file_size} bytes) - appears corrupted or empty"
|
|
207
|
+
except (ImportError, OSError):
|
|
208
|
+
pass # Can't check file size, continue anyway
|
|
209
|
+
|
|
210
|
+
return True, f"Database validated: {total_records} records in core tables"
|
|
211
|
+
|
|
212
|
+
except Exception as e:
|
|
213
|
+
self.logger.warning(f"Database validation error: {e}")
|
|
214
|
+
return False, f"Validation error: {e}"
|
|
215
|
+
|
|
171
216
|
def create_database_backup(self, connection_manager,
|
|
172
217
|
backup_type: BackupType = BackupType.MANUAL,
|
|
173
|
-
description: Optional[str] = None
|
|
218
|
+
description: Optional[str] = None,
|
|
219
|
+
skip_validation: bool = False) -> Optional[BackupInfo]:
|
|
174
220
|
"""
|
|
175
|
-
Create a database backup.
|
|
221
|
+
Create a database backup including both settings.db and notes.db.
|
|
222
|
+
|
|
223
|
+
Creates a ZIP archive containing both database files for complete backup.
|
|
176
224
|
|
|
177
225
|
Args:
|
|
178
|
-
connection_manager: Database connection manager
|
|
226
|
+
connection_manager: Database connection manager for settings.db
|
|
179
227
|
backup_type: Type of backup being created
|
|
180
228
|
description: Optional description for the backup
|
|
229
|
+
skip_validation: If True, skip database validation (use for emergency backups)
|
|
181
230
|
|
|
182
231
|
Returns:
|
|
183
232
|
BackupInfo if successful, None otherwise
|
|
184
233
|
"""
|
|
234
|
+
import zipfile
|
|
235
|
+
|
|
185
236
|
try:
|
|
237
|
+
# Validate database content before backup (unless skipped for emergency)
|
|
238
|
+
if not skip_validation:
|
|
239
|
+
is_valid, validation_msg = self._validate_database_for_backup(connection_manager)
|
|
240
|
+
if not is_valid:
|
|
241
|
+
self.logger.warning(f"Database validation failed: {validation_msg}")
|
|
242
|
+
# For manual backups, refuse to create empty backup
|
|
243
|
+
if backup_type == BackupType.MANUAL:
|
|
244
|
+
self.logger.error("Refusing to create backup of empty/corrupted database. Use emergency backup if needed.")
|
|
245
|
+
return None
|
|
246
|
+
else:
|
|
247
|
+
self.logger.debug(validation_msg)
|
|
248
|
+
|
|
186
249
|
timestamp = datetime.now()
|
|
250
|
+
# Use .zip extension for the combined database archive
|
|
187
251
|
filename = self._generate_backup_filename("db", backup_type, timestamp)
|
|
188
|
-
|
|
252
|
+
archive_filename = f"{filename}.zip"
|
|
253
|
+
archive_path = self.backup_dir / archive_filename
|
|
254
|
+
|
|
255
|
+
# Get database paths
|
|
256
|
+
try:
|
|
257
|
+
from core.data_directory import get_database_path
|
|
258
|
+
settings_db_path = get_database_path('settings.db')
|
|
259
|
+
notes_db_path = get_database_path('notes.db')
|
|
260
|
+
except ImportError:
|
|
261
|
+
settings_db_path = str(self.backup_dir.parent / 'settings.db')
|
|
262
|
+
notes_db_path = str(self.backup_dir.parent / 'notes.db')
|
|
189
263
|
|
|
190
|
-
#
|
|
191
|
-
|
|
264
|
+
# First, create the settings.db backup using connection_manager
|
|
265
|
+
temp_settings_backup = self.backup_dir / f"temp_settings_{int(time.time())}.db"
|
|
266
|
+
success = connection_manager.backup_to_disk(str(temp_settings_backup))
|
|
192
267
|
if not success:
|
|
193
|
-
self.logger.error("
|
|
268
|
+
self.logger.error("Settings database backup failed")
|
|
194
269
|
return None
|
|
195
270
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
with
|
|
200
|
-
|
|
201
|
-
|
|
271
|
+
try:
|
|
272
|
+
# Create ZIP archive containing both databases
|
|
273
|
+
compression = zipfile.ZIP_DEFLATED if self.enable_compression else zipfile.ZIP_STORED
|
|
274
|
+
with zipfile.ZipFile(archive_path, 'w', compression=compression) as zf:
|
|
275
|
+
# Add settings.db from the temp backup
|
|
276
|
+
zf.write(temp_settings_backup, 'settings.db')
|
|
277
|
+
self.logger.debug(f"Added settings.db to backup archive")
|
|
278
|
+
|
|
279
|
+
# Add notes.db if it exists
|
|
280
|
+
if os.path.exists(notes_db_path):
|
|
281
|
+
zf.write(notes_db_path, 'notes.db')
|
|
282
|
+
self.logger.debug(f"Added notes.db to backup archive")
|
|
283
|
+
else:
|
|
284
|
+
self.logger.debug("notes.db not found - skipping")
|
|
202
285
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
286
|
+
finally:
|
|
287
|
+
# Clean up temp file
|
|
288
|
+
if temp_settings_backup.exists():
|
|
289
|
+
os.remove(temp_settings_backup)
|
|
290
|
+
|
|
291
|
+
format_type = BackupFormat.COMPRESSED if self.enable_compression else BackupFormat.SQLITE
|
|
209
292
|
|
|
210
293
|
# Get file size
|
|
211
|
-
size_bytes = os.path.getsize(
|
|
294
|
+
size_bytes = os.path.getsize(archive_path)
|
|
212
295
|
|
|
213
296
|
# Calculate checksum
|
|
214
|
-
checksum = self._calculate_checksum(
|
|
297
|
+
checksum = self._calculate_checksum(str(archive_path))
|
|
215
298
|
|
|
216
|
-
# Get database info
|
|
299
|
+
# Get database info (includes both databases)
|
|
217
300
|
db_info = self._get_database_info(connection_manager)
|
|
301
|
+
db_info['includes_notes_db'] = os.path.exists(notes_db_path)
|
|
218
302
|
|
|
219
303
|
# Create backup info
|
|
220
304
|
backup_info = BackupInfo(
|
|
221
305
|
timestamp=timestamp,
|
|
222
306
|
backup_type=backup_type,
|
|
223
307
|
format=format_type,
|
|
224
|
-
filepath=str(
|
|
308
|
+
filepath=str(archive_path),
|
|
225
309
|
size_bytes=size_bytes,
|
|
226
310
|
checksum=checksum,
|
|
227
311
|
description=description,
|
|
@@ -231,7 +315,7 @@ class BackupRecoveryManager:
|
|
|
231
315
|
# Record backup
|
|
232
316
|
self._record_backup(backup_info)
|
|
233
317
|
|
|
234
|
-
self.logger.info(f"Database backup created: {
|
|
318
|
+
self.logger.info(f"Database backup created: {archive_path}")
|
|
235
319
|
return backup_info
|
|
236
320
|
|
|
237
321
|
except Exception as e:
|
|
@@ -281,6 +365,9 @@ class BackupRecoveryManager:
|
|
|
281
365
|
"""
|
|
282
366
|
Restore database from a backup.
|
|
283
367
|
|
|
368
|
+
Handles both new ZIP format (contains settings.db and notes.db) and
|
|
369
|
+
legacy single-file formats (.db or .db.gz).
|
|
370
|
+
|
|
284
371
|
Args:
|
|
285
372
|
backup_info: Information about the backup to restore
|
|
286
373
|
connection_manager: Database connection manager
|
|
@@ -288,6 +375,8 @@ class BackupRecoveryManager:
|
|
|
288
375
|
Returns:
|
|
289
376
|
True if restore successful, False otherwise
|
|
290
377
|
"""
|
|
378
|
+
import zipfile
|
|
379
|
+
|
|
291
380
|
try:
|
|
292
381
|
filepath = backup_info.filepath
|
|
293
382
|
|
|
@@ -301,35 +390,82 @@ class BackupRecoveryManager:
|
|
|
301
390
|
if current_checksum != backup_info.checksum:
|
|
302
391
|
self.logger.warning(f"Backup checksum mismatch: {filepath}")
|
|
303
392
|
|
|
304
|
-
#
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
# Decompress to temporary file
|
|
308
|
-
temp_path = self.backup_dir / f"temp_restore_{int(time.time())}.db"
|
|
309
|
-
with gzip.open(filepath, 'rb') as f_in:
|
|
310
|
-
with open(temp_path, 'wb') as f_out:
|
|
311
|
-
shutil.copyfileobj(f_in, f_out)
|
|
312
|
-
restore_path = str(temp_path)
|
|
393
|
+
# Determine backup format
|
|
394
|
+
is_zip_archive = filepath.endswith('.zip')
|
|
395
|
+
is_gzip = filepath.endswith('.gz') and not is_zip_archive
|
|
313
396
|
|
|
397
|
+
# Get database paths
|
|
314
398
|
try:
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
399
|
+
from core.data_directory import get_database_path
|
|
400
|
+
notes_db_path = get_database_path('notes.db')
|
|
401
|
+
except ImportError:
|
|
402
|
+
notes_db_path = str(self.backup_dir.parent / 'notes.db')
|
|
403
|
+
|
|
404
|
+
temp_dir = self.backup_dir / f"temp_restore_{int(time.time())}"
|
|
405
|
+
temp_dir.mkdir(exist_ok=True)
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
if is_zip_archive:
|
|
409
|
+
# New format: ZIP archive with both databases
|
|
410
|
+
with zipfile.ZipFile(filepath, 'r') as zf:
|
|
411
|
+
zf.extractall(temp_dir)
|
|
412
|
+
|
|
413
|
+
# Restore settings.db
|
|
414
|
+
temp_settings = temp_dir / 'settings.db'
|
|
415
|
+
if temp_settings.exists():
|
|
416
|
+
success = connection_manager.restore_from_disk(str(temp_settings))
|
|
417
|
+
if not success:
|
|
418
|
+
self.logger.error(f"Settings database restore failed: {filepath}")
|
|
419
|
+
return False
|
|
420
|
+
self.logger.info(f"Settings database restored from backup: {filepath}")
|
|
421
|
+
else:
|
|
422
|
+
self.logger.error(f"settings.db not found in backup archive: {filepath}")
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
# Restore notes.db if present in backup
|
|
426
|
+
temp_notes = temp_dir / 'notes.db'
|
|
427
|
+
if temp_notes.exists():
|
|
428
|
+
try:
|
|
429
|
+
shutil.copy2(str(temp_notes), notes_db_path)
|
|
430
|
+
self.logger.info(f"Notes database restored from backup: {filepath}")
|
|
431
|
+
except Exception as e:
|
|
432
|
+
self.logger.warning(f"Failed to restore notes.db: {e}")
|
|
433
|
+
# Don't fail the whole restore if notes.db restore fails
|
|
434
|
+
else:
|
|
435
|
+
self.logger.debug("notes.db not present in backup archive")
|
|
436
|
+
|
|
437
|
+
elif is_gzip:
|
|
438
|
+
# Legacy format: gzipped single database file
|
|
439
|
+
temp_db = temp_dir / "settings.db"
|
|
440
|
+
with gzip.open(filepath, 'rb') as f_in:
|
|
441
|
+
with open(temp_db, 'wb') as f_out:
|
|
442
|
+
shutil.copyfileobj(f_in, f_out)
|
|
443
|
+
|
|
444
|
+
success = connection_manager.restore_from_disk(str(temp_db))
|
|
445
|
+
if not success:
|
|
446
|
+
self.logger.error(f"Database restore failed: {filepath}")
|
|
447
|
+
return False
|
|
448
|
+
self.logger.info(f"Database restored from legacy backup: {filepath}")
|
|
449
|
+
|
|
320
450
|
else:
|
|
321
|
-
|
|
451
|
+
# Legacy format: plain database file
|
|
452
|
+
success = connection_manager.restore_from_disk(filepath)
|
|
453
|
+
if not success:
|
|
454
|
+
self.logger.error(f"Database restore failed: {filepath}")
|
|
455
|
+
return False
|
|
456
|
+
self.logger.info(f"Database restored from legacy backup: {filepath}")
|
|
322
457
|
|
|
323
|
-
return
|
|
458
|
+
return True
|
|
324
459
|
|
|
325
460
|
finally:
|
|
326
|
-
# Clean up
|
|
327
|
-
if
|
|
328
|
-
|
|
461
|
+
# Clean up temp directory
|
|
462
|
+
if temp_dir.exists():
|
|
463
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
329
464
|
|
|
330
465
|
except Exception as e:
|
|
331
466
|
self.logger.error(f"Failed to restore from database backup: {e}")
|
|
332
467
|
return False
|
|
468
|
+
|
|
333
469
|
|
|
334
470
|
def create_migration_backup(self, json_filepath: str) -> Optional[BackupInfo]:
|
|
335
471
|
"""
|
|
@@ -375,11 +511,12 @@ class BackupRecoveryManager:
|
|
|
375
511
|
try:
|
|
376
512
|
self.logger.info("Starting database repair procedure")
|
|
377
513
|
|
|
378
|
-
# Create emergency backup first
|
|
514
|
+
# Create emergency backup first (skip validation - we want to backup even if corrupted)
|
|
379
515
|
emergency_backup = self.create_database_backup(
|
|
380
516
|
connection_manager,
|
|
381
517
|
BackupType.EMERGENCY,
|
|
382
|
-
"Emergency backup before repair"
|
|
518
|
+
"Emergency backup before repair",
|
|
519
|
+
skip_validation=True
|
|
383
520
|
)
|
|
384
521
|
|
|
385
522
|
if not emergency_backup:
|
|
@@ -1023,17 +1160,18 @@ class BackupRecoveryManager:
|
|
|
1023
1160
|
return hash_md5.hexdigest()
|
|
1024
1161
|
|
|
1025
1162
|
def _get_database_info(self, connection_manager) -> Dict[str, Any]:
|
|
1026
|
-
"""Get database information for backup metadata."""
|
|
1163
|
+
"""Get database information for backup metadata (includes both settings.db and notes.db)."""
|
|
1164
|
+
import sqlite3
|
|
1165
|
+
|
|
1166
|
+
table_counts = {}
|
|
1167
|
+
|
|
1027
1168
|
try:
|
|
1169
|
+
# Get settings.db table counts
|
|
1028
1170
|
conn = connection_manager.get_connection()
|
|
1171
|
+
settings_tables = ['core_settings', 'tool_settings', 'tab_content',
|
|
1172
|
+
'performance_settings', 'font_settings', 'dialog_settings']
|
|
1029
1173
|
|
|
1030
|
-
|
|
1031
|
-
table_counts = {}
|
|
1032
|
-
tables = ['core_settings', 'tool_settings', 'tab_content',
|
|
1033
|
-
'performance_settings', 'font_settings', 'dialog_settings',
|
|
1034
|
-
'notes', 'notes_fts']
|
|
1035
|
-
|
|
1036
|
-
for table in tables:
|
|
1174
|
+
for table in settings_tables:
|
|
1037
1175
|
try:
|
|
1038
1176
|
cursor = conn.execute(f"SELECT COUNT(*) FROM {table}")
|
|
1039
1177
|
count = cursor.fetchone()[0]
|
|
@@ -1041,15 +1179,40 @@ class BackupRecoveryManager:
|
|
|
1041
1179
|
except sqlite3.Error:
|
|
1042
1180
|
table_counts[table] = 0
|
|
1043
1181
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1182
|
+
except Exception as e:
|
|
1183
|
+
self.logger.warning(f"Failed to get settings.db info: {e}")
|
|
1184
|
+
|
|
1185
|
+
# Get notes.db table counts (separate database file)
|
|
1186
|
+
try:
|
|
1187
|
+
from core.data_directory import get_database_path
|
|
1188
|
+
notes_db_path = get_database_path('notes.db')
|
|
1049
1189
|
|
|
1190
|
+
if os.path.exists(notes_db_path):
|
|
1191
|
+
notes_conn = sqlite3.connect(notes_db_path, timeout=5.0)
|
|
1192
|
+
try:
|
|
1193
|
+
for table in ['notes', 'notes_fts']:
|
|
1194
|
+
try:
|
|
1195
|
+
cursor = notes_conn.execute(f"SELECT COUNT(*) FROM {table}")
|
|
1196
|
+
count = cursor.fetchone()[0]
|
|
1197
|
+
table_counts[table] = count
|
|
1198
|
+
except sqlite3.Error:
|
|
1199
|
+
table_counts[table] = 0
|
|
1200
|
+
finally:
|
|
1201
|
+
notes_conn.close()
|
|
1202
|
+
else:
|
|
1203
|
+
table_counts['notes'] = 0
|
|
1204
|
+
table_counts['notes_fts'] = 0
|
|
1205
|
+
|
|
1050
1206
|
except Exception as e:
|
|
1051
|
-
self.logger.warning(f"Failed to get
|
|
1052
|
-
|
|
1207
|
+
self.logger.warning(f"Failed to get notes.db info: {e}")
|
|
1208
|
+
table_counts['notes'] = 0
|
|
1209
|
+
table_counts['notes_fts'] = 0
|
|
1210
|
+
|
|
1211
|
+
return {
|
|
1212
|
+
'data_type': 'sqlite_database',
|
|
1213
|
+
'table_counts': table_counts,
|
|
1214
|
+
'total_records': sum(table_counts.values())
|
|
1215
|
+
}
|
|
1053
1216
|
|
|
1054
1217
|
def _record_backup(self, backup_info: BackupInfo) -> None:
|
|
1055
1218
|
"""Record backup in history."""
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collapsible Panel Widget - Reusable collapsible container for UI sections.
|
|
3
|
+
|
|
4
|
+
This module provides a collapsible panel widget that can be used to hide/show
|
|
5
|
+
sections of the UI, saving screen space while keeping functionality accessible.
|
|
6
|
+
|
|
7
|
+
Author: Pomera AI Commander Team
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import tkinter as tk
|
|
11
|
+
from tkinter import ttk
|
|
12
|
+
from typing import Optional, Callable
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CollapsiblePanel(ttk.Frame):
|
|
20
|
+
"""
|
|
21
|
+
A reusable collapsible panel widget.
|
|
22
|
+
|
|
23
|
+
Features:
|
|
24
|
+
- Toggle button with chevron icon (▼/▲)
|
|
25
|
+
- Smooth collapse/expand animation
|
|
26
|
+
- State persistence via callback
|
|
27
|
+
- Keyboard shortcut support
|
|
28
|
+
- Optional title display
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
panel = CollapsiblePanel(
|
|
32
|
+
parent,
|
|
33
|
+
title="Options",
|
|
34
|
+
collapsed=False,
|
|
35
|
+
on_state_change=lambda collapsed: save_state(collapsed)
|
|
36
|
+
)
|
|
37
|
+
# Add content to panel.content_frame
|
|
38
|
+
ttk.Label(panel.content_frame, text="Panel content").pack()
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# Animation settings
|
|
42
|
+
ANIMATION_DURATION_MS = 200
|
|
43
|
+
ANIMATION_STEPS = 10
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
parent: tk.Widget,
|
|
48
|
+
title: str = "",
|
|
49
|
+
collapsed: bool = False,
|
|
50
|
+
on_state_change: Optional[Callable[[bool], None]] = None,
|
|
51
|
+
show_title: bool = True,
|
|
52
|
+
**kwargs
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Initialize the collapsible panel.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
parent: Parent widget
|
|
59
|
+
title: Panel title text
|
|
60
|
+
collapsed: Initial collapsed state
|
|
61
|
+
on_state_change: Callback when collapse state changes, receives bool
|
|
62
|
+
show_title: Whether to show the title text
|
|
63
|
+
**kwargs: Additional keyword arguments for ttk.Frame
|
|
64
|
+
"""
|
|
65
|
+
super().__init__(parent, **kwargs)
|
|
66
|
+
|
|
67
|
+
self.title = title
|
|
68
|
+
self._collapsed = collapsed
|
|
69
|
+
self._on_state_change = on_state_change
|
|
70
|
+
self._show_title = show_title
|
|
71
|
+
self._animation_id: Optional[str] = None
|
|
72
|
+
self._content_height: int = 0
|
|
73
|
+
|
|
74
|
+
self._create_widgets()
|
|
75
|
+
self._apply_initial_state()
|
|
76
|
+
|
|
77
|
+
def _create_widgets(self) -> None:
|
|
78
|
+
"""Create the panel widgets."""
|
|
79
|
+
# Header frame with toggle button
|
|
80
|
+
self.header_frame = ttk.Frame(self)
|
|
81
|
+
self.header_frame.pack(fill=tk.X)
|
|
82
|
+
|
|
83
|
+
# Toggle button with chevron
|
|
84
|
+
self.toggle_btn = ttk.Button(
|
|
85
|
+
self.header_frame,
|
|
86
|
+
text=self._get_toggle_text(),
|
|
87
|
+
command=self.toggle,
|
|
88
|
+
width=3
|
|
89
|
+
)
|
|
90
|
+
self.toggle_btn.pack(side=tk.LEFT, padx=(0, 5))
|
|
91
|
+
|
|
92
|
+
# Title label with inline shortcut hint
|
|
93
|
+
if self._show_title and self.title:
|
|
94
|
+
self.title_label = ttk.Label(
|
|
95
|
+
self.header_frame,
|
|
96
|
+
text=f"{self.title} (Ctrl+Shift+H)",
|
|
97
|
+
font=("TkDefaultFont", 9, "bold")
|
|
98
|
+
)
|
|
99
|
+
self.title_label.pack(side=tk.LEFT)
|
|
100
|
+
# Make title clickable too
|
|
101
|
+
self.title_label.bind("<Button-1>", lambda e: self.toggle())
|
|
102
|
+
|
|
103
|
+
# Content frame (what gets collapsed)
|
|
104
|
+
self.content_frame = ttk.Frame(self)
|
|
105
|
+
if not self._collapsed:
|
|
106
|
+
self.content_frame.pack(fill=tk.BOTH, expand=True, pady=(5, 0))
|
|
107
|
+
|
|
108
|
+
def _get_toggle_text(self) -> str:
|
|
109
|
+
"""Get the toggle button text based on collapsed state."""
|
|
110
|
+
return "▲" if self._collapsed else "▼"
|
|
111
|
+
|
|
112
|
+
def _apply_initial_state(self) -> None:
|
|
113
|
+
"""Apply the initial collapsed state."""
|
|
114
|
+
if self._collapsed:
|
|
115
|
+
self.content_frame.pack_forget()
|
|
116
|
+
self.toggle_btn.configure(text=self._get_toggle_text())
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def collapsed(self) -> bool:
|
|
120
|
+
"""Get the current collapsed state."""
|
|
121
|
+
return self._collapsed
|
|
122
|
+
|
|
123
|
+
@collapsed.setter
|
|
124
|
+
def collapsed(self, value: bool) -> None:
|
|
125
|
+
"""Set the collapsed state."""
|
|
126
|
+
if value != self._collapsed:
|
|
127
|
+
self._collapsed = value
|
|
128
|
+
self._update_state()
|
|
129
|
+
|
|
130
|
+
def toggle(self) -> None:
|
|
131
|
+
"""Toggle the collapsed state."""
|
|
132
|
+
self._collapsed = not self._collapsed
|
|
133
|
+
self._update_state()
|
|
134
|
+
|
|
135
|
+
# Notify callback
|
|
136
|
+
if self._on_state_change:
|
|
137
|
+
try:
|
|
138
|
+
self._on_state_change(self._collapsed)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.warning(f"Error in on_state_change callback: {e}")
|
|
141
|
+
|
|
142
|
+
def _update_state(self) -> None:
|
|
143
|
+
"""Update the visual state based on collapsed flag."""
|
|
144
|
+
# Cancel any running animation
|
|
145
|
+
if self._animation_id:
|
|
146
|
+
self.after_cancel(self._animation_id)
|
|
147
|
+
self._animation_id = None
|
|
148
|
+
|
|
149
|
+
# Update toggle button
|
|
150
|
+
self.toggle_btn.configure(text=self._get_toggle_text())
|
|
151
|
+
|
|
152
|
+
# Show/hide content
|
|
153
|
+
if self._collapsed:
|
|
154
|
+
self.content_frame.pack_forget()
|
|
155
|
+
else:
|
|
156
|
+
self.content_frame.pack(fill=tk.BOTH, expand=True, pady=(5, 0))
|
|
157
|
+
|
|
158
|
+
logger.debug(f"Panel '{self.title}' collapsed={self._collapsed}")
|
|
159
|
+
|
|
160
|
+
def expand(self) -> None:
|
|
161
|
+
"""Expand the panel if collapsed."""
|
|
162
|
+
if self._collapsed:
|
|
163
|
+
self.toggle()
|
|
164
|
+
|
|
165
|
+
def collapse(self) -> None:
|
|
166
|
+
"""Collapse the panel if expanded."""
|
|
167
|
+
if not self._collapsed:
|
|
168
|
+
self.toggle()
|
|
169
|
+
|
|
170
|
+
def set_content_widget(self, widget: tk.Widget) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Set a widget as the panel content.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
widget: Widget to place inside the content frame
|
|
176
|
+
"""
|
|
177
|
+
# Clear existing content
|
|
178
|
+
for child in self.content_frame.winfo_children():
|
|
179
|
+
child.destroy()
|
|
180
|
+
|
|
181
|
+
# Add new content
|
|
182
|
+
widget.pack(in_=self.content_frame, fill=tk.BOTH, expand=True)
|
|
183
|
+
|
|
184
|
+
def bind_shortcut(self, root: tk.Tk, shortcut: str = "<Control-Shift-H>") -> None:
|
|
185
|
+
"""
|
|
186
|
+
Bind a keyboard shortcut to toggle the panel.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
root: Root window to bind the shortcut to
|
|
190
|
+
shortcut: Key sequence (default: Ctrl+Shift+H)
|
|
191
|
+
"""
|
|
192
|
+
root.bind_all(shortcut, lambda e: self.toggle())
|
|
193
|
+
logger.debug(f"Bound shortcut {shortcut} to panel '{self.title}'")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Module availability flag for import checking
|
|
197
|
+
COLLAPSIBLE_PANEL_AVAILABLE = True
|
|
@@ -45,12 +45,17 @@ class NestedSettingsProxy:
|
|
|
45
45
|
def __getitem__(self, key: str) -> Any:
|
|
46
46
|
"""Handle nested access like settings["tool_settings"]["Tool Name"]."""
|
|
47
47
|
if key not in self._data:
|
|
48
|
-
# For tool_settings,
|
|
48
|
+
# For tool_settings, first try to load existing settings from database
|
|
49
49
|
if self.parent_key == "tool_settings":
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
existing_settings = self.settings_manager.get_tool_settings(key)
|
|
51
|
+
if existing_settings and not (len(existing_settings) == 1 and 'initialized' in existing_settings):
|
|
52
|
+
# Found real settings in database - use them
|
|
53
|
+
self._data[key] = existing_settings
|
|
54
|
+
else:
|
|
55
|
+
# No existing settings - create empty tool settings
|
|
56
|
+
self._data[key] = {}
|
|
57
|
+
# Save initialized marker to database
|
|
58
|
+
self.settings_manager.set_tool_setting(key, "initialized", True)
|
|
54
59
|
else:
|
|
55
60
|
raise KeyError(f"Key '{key}' not found in {self.parent_key}")
|
|
56
61
|
|
|
@@ -91,10 +96,16 @@ class NestedSettingsProxy:
|
|
|
91
96
|
def get(self, key: str, default: Any = None) -> Any:
|
|
92
97
|
"""Handle nested_settings.get("key", default) calls."""
|
|
93
98
|
if key not in self._data and self.parent_key == "tool_settings":
|
|
94
|
-
# For tool_settings,
|
|
95
|
-
self.
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
# For tool_settings, first try to load existing settings from database
|
|
100
|
+
existing_settings = self.settings_manager.get_tool_settings(key)
|
|
101
|
+
if existing_settings and not (len(existing_settings) == 1 and 'initialized' in existing_settings):
|
|
102
|
+
# Found real settings in database - use them
|
|
103
|
+
self._data[key] = existing_settings
|
|
104
|
+
else:
|
|
105
|
+
# No existing settings - create empty tool settings
|
|
106
|
+
self._data[key] = {}
|
|
107
|
+
# Save initialized marker to database
|
|
108
|
+
self.settings_manager.set_tool_setting(key, "initialized", True)
|
|
98
109
|
|
|
99
110
|
value = self._data.get(key, default)
|
|
100
111
|
|