pomera-ai-commander 1.2.5 → 1.2.8

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.
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pomera AI Commander - Desktop Shortcut Creator
5
+ *
6
+ * This script creates a desktop shortcut for the Pomera GUI.
7
+ */
8
+
9
+ const { spawn } = require('child_process');
10
+ const path = require('path');
11
+
12
+ // Get the path to create_shortcut.py
13
+ const shortcutScript = path.join(__dirname, '..', 'create_shortcut.py');
14
+
15
+ // Find Python executable
16
+ function findPython() {
17
+ const { execSync } = require('child_process');
18
+
19
+ try {
20
+ execSync('python3 --version', { stdio: 'ignore' });
21
+ return 'python3';
22
+ } catch (e) {
23
+ try {
24
+ execSync('python --version', { stdio: 'ignore' });
25
+ return 'python';
26
+ } catch (e) {
27
+ console.error('Error: Python is not installed or not in PATH');
28
+ process.exit(1);
29
+ }
30
+ }
31
+ }
32
+
33
+ const pythonCmd = findPython();
34
+
35
+ // Get command line arguments
36
+ const args = process.argv.slice(2);
37
+
38
+ // Spawn the Python script
39
+ const proc = spawn(pythonCmd, [shortcutScript, ...args], {
40
+ stdio: 'inherit',
41
+ cwd: path.join(__dirname, '..')
42
+ });
43
+
44
+ proc.on('close', (code) => {
45
+ process.exit(code || 0);
46
+ });
47
+
48
+ proc.on('error', (err) => {
49
+ console.error('Failed to create shortcut:', err.message);
50
+ process.exit(1);
51
+ });
package/bin/pomera.js ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pomera AI Commander - GUI launcher
5
+ *
6
+ * This script launches the Pomera GUI application.
7
+ */
8
+
9
+ const { spawn } = require('child_process');
10
+ const path = require('path');
11
+
12
+ // Get the path to pomera.py
13
+ const pomeraPath = path.join(__dirname, '..', 'pomera.py');
14
+
15
+ // Find Python executable
16
+ function findPython() {
17
+ const { execSync } = require('child_process');
18
+
19
+ // Try pythonw first (Windows - no console)
20
+ if (process.platform === 'win32') {
21
+ try {
22
+ execSync('pythonw --version', { stdio: 'ignore' });
23
+ return 'pythonw';
24
+ } catch (e) {
25
+ // Fall through
26
+ }
27
+ }
28
+
29
+ // Try python3 (Linux/macOS)
30
+ try {
31
+ execSync('python3 --version', { stdio: 'ignore' });
32
+ return 'python3';
33
+ } catch (e) {
34
+ // Fall back to python
35
+ try {
36
+ execSync('python --version', { stdio: 'ignore' });
37
+ return 'python';
38
+ } catch (e) {
39
+ console.error('Error: Python is not installed or not in PATH');
40
+ console.error('Please install Python 3.8 or higher');
41
+ process.exit(1);
42
+ }
43
+ }
44
+ }
45
+
46
+ const pythonCmd = findPython();
47
+
48
+ // Spawn the Python GUI
49
+ const app = spawn(pythonCmd, [pomeraPath], {
50
+ stdio: 'inherit',
51
+ cwd: path.join(__dirname, '..'),
52
+ detached: process.platform !== 'win32' // Detach on non-Windows
53
+ });
54
+
55
+ // Handle process exit
56
+ app.on('close', (code) => {
57
+ process.exit(code || 0);
58
+ });
59
+
60
+ // Handle errors
61
+ app.on('error', (err) => {
62
+ console.error('Failed to start Pomera:', err.message);
63
+ process.exit(1);
64
+ });
65
+
66
+ // Forward signals
67
+ process.on('SIGINT', () => app.kill('SIGINT'));
68
+ process.on('SIGTERM', () => app.kill('SIGTERM'));
@@ -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) -> Optional[BackupInfo]:
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
- filepath = self.backup_dir / filename
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
- # Create database backup
191
- success = connection_manager.backup_to_disk(str(filepath))
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("Database backup failed")
268
+ self.logger.error("Settings database backup failed")
194
269
  return None
195
270
 
196
- # Compress if enabled
197
- if self.enable_compression:
198
- compressed_path = f"{filepath}.gz"
199
- with open(filepath, 'rb') as f_in:
200
- with gzip.open(compressed_path, 'wb') as f_out:
201
- shutil.copyfileobj(f_in, f_out)
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
- # Remove uncompressed file
204
- os.remove(filepath)
205
- filepath = compressed_path
206
- format_type = BackupFormat.COMPRESSED
207
- else:
208
- format_type = BackupFormat.SQLITE
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(filepath)
294
+ size_bytes = os.path.getsize(archive_path)
212
295
 
213
296
  # Calculate checksum
214
- checksum = self._calculate_checksum(filepath)
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(filepath),
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: {filepath}")
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
- # Prepare restore file
305
- restore_path = filepath
306
- if backup_info.format == BackupFormat.COMPRESSED:
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
- # Restore database
316
- success = connection_manager.restore_from_disk(restore_path)
317
-
318
- if success:
319
- self.logger.info(f"Database restored from backup: {filepath}")
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
- self.logger.error(f"Database restore failed: {filepath}")
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 success
458
+ return True
324
459
 
325
460
  finally:
326
- # Clean up temporary file
327
- if restore_path != filepath and os.path.exists(restore_path):
328
- os.remove(restore_path)
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
- # Get table counts
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
- return {
1045
- 'data_type': 'sqlite_database',
1046
- 'table_counts': table_counts,
1047
- 'total_records': sum(table_counts.values())
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 database info: {e}")
1052
- return {'data_type': 'sqlite_database', 'error': str(e)}
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."""