pomera-ai-commander 1.2.1 → 1.2.2

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,549 @@
1
+ """
2
+ Cross-platform user data directory management.
3
+
4
+ This module provides platform-aware paths for storing application data,
5
+ ensuring databases are stored in user-specific locations that survive
6
+ package updates (npm, pip) rather than in the installation directory.
7
+
8
+ Platform Locations:
9
+ - Windows: %LOCALAPPDATA%/Pomera-AI-Commander
10
+ - Linux: $XDG_DATA_HOME/Pomera-AI-Commander or ~/.local/share/Pomera-AI-Commander
11
+ - macOS: ~/Library/Application Support/Pomera-AI-Commander
12
+
13
+ Portable Mode:
14
+ - Use --portable CLI flag or POMERA_PORTABLE=1 environment variable
15
+ - Data stays in installation directory (legacy behavior)
16
+
17
+ Config File (for custom data directory):
18
+ - Windows: %APPDATA%/Pomera-AI-Commander/config.json
19
+ - Linux: ~/.config/Pomera-AI-Commander/config.json
20
+ - macOS: ~/Library/Preferences/Pomera-AI-Commander/config.json
21
+ """
22
+
23
+ import os
24
+ import sys
25
+ import json
26
+ import shutil
27
+ import logging
28
+ import platform
29
+ from pathlib import Path
30
+ from typing import Optional, Dict, Any
31
+
32
+ # Try platformdirs first, fallback to manual detection
33
+ try:
34
+ from platformdirs import user_data_dir, user_config_dir
35
+ PLATFORMDIRS_AVAILABLE = True
36
+ except ImportError:
37
+ PLATFORMDIRS_AVAILABLE = False
38
+
39
+ APP_NAME = "Pomera-AI-Commander"
40
+ APP_AUTHOR = "PomeraAI"
41
+ CONFIG_FILENAME = "config.json"
42
+
43
+ # Global portable mode flag (set via CLI --portable or environment variable)
44
+ _portable_mode: Optional[bool] = None
45
+
46
+ # Cached config
47
+ _config_cache: Optional[Dict[str, Any]] = None
48
+
49
+ # Logger for this module
50
+ _logger: Optional[logging.Logger] = None
51
+
52
+
53
+ def _get_logger() -> logging.Logger:
54
+ """Get logger for this module."""
55
+ global _logger
56
+ if _logger is None:
57
+ _logger = logging.getLogger(__name__)
58
+ return _logger
59
+
60
+
61
+ # =============================================================================
62
+ # Config File Management
63
+ # =============================================================================
64
+
65
+ def _get_config_dir() -> Path:
66
+ """
67
+ Get platform-specific config directory.
68
+
69
+ This is separate from data directory and stores only the path preference.
70
+
71
+ Returns:
72
+ Path to config directory
73
+ """
74
+ system = platform.system()
75
+
76
+ if PLATFORMDIRS_AVAILABLE:
77
+ try:
78
+ return Path(user_config_dir(APP_NAME, APP_AUTHOR))
79
+ except Exception:
80
+ pass # Fall through to manual detection
81
+
82
+ if system == "Windows":
83
+ base = os.environ.get("APPDATA", os.path.expanduser("~"))
84
+ return Path(base) / APP_NAME
85
+ elif system == "Darwin": # macOS
86
+ return Path.home() / "Library" / "Preferences" / APP_NAME
87
+ else: # Linux and others
88
+ xdg_config = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
89
+ return Path(xdg_config) / APP_NAME
90
+
91
+
92
+ def _get_config_path() -> Path:
93
+ """Get full path to config file."""
94
+ return _get_config_dir() / CONFIG_FILENAME
95
+
96
+
97
+ def load_config() -> Dict[str, Any]:
98
+ """
99
+ Load config from config file.
100
+
101
+ Returns cached config if available.
102
+
103
+ Returns:
104
+ Config dictionary with keys: data_directory, portable_mode, pending_migration
105
+ """
106
+ global _config_cache
107
+
108
+ if _config_cache is not None:
109
+ return _config_cache
110
+
111
+ config_path = _get_config_path()
112
+ default_config = {
113
+ "data_directory": None, # None means use platform default
114
+ "portable_mode": False,
115
+ "pending_migration": None # {"from": path, "to": path} when migration pending
116
+ }
117
+
118
+ if config_path.exists():
119
+ try:
120
+ with open(config_path, 'r', encoding='utf-8') as f:
121
+ loaded = json.load(f)
122
+ # Merge with defaults to ensure all keys exist
123
+ for key, value in default_config.items():
124
+ if key not in loaded:
125
+ loaded[key] = value
126
+ _config_cache = loaded
127
+ return _config_cache
128
+ except Exception as e:
129
+ _get_logger().warning(f"Failed to load config: {e}")
130
+
131
+ _config_cache = default_config
132
+ return _config_cache
133
+
134
+
135
+ def save_config(config: Dict[str, Any]) -> bool:
136
+ """
137
+ Save config to config file.
138
+
139
+ Args:
140
+ config: Config dictionary to save
141
+
142
+ Returns:
143
+ True if saved successfully
144
+ """
145
+ global _config_cache
146
+
147
+ config_dir = _get_config_dir()
148
+ config_path = _get_config_path()
149
+
150
+ try:
151
+ config_dir.mkdir(parents=True, exist_ok=True)
152
+ with open(config_path, 'w', encoding='utf-8') as f:
153
+ json.dump(config, f, indent=2)
154
+ _config_cache = config
155
+ _get_logger().info(f"Config saved to {config_path}")
156
+ return True
157
+ except Exception as e:
158
+ _get_logger().error(f"Failed to save config: {e}")
159
+ return False
160
+
161
+
162
+ def set_custom_data_directory(path: str, migrate: bool = True) -> Dict[str, Any]:
163
+ """
164
+ Set a custom data directory.
165
+
166
+ This sets up a pending migration that will execute on next app start.
167
+
168
+ Args:
169
+ path: New data directory path (or None for platform default)
170
+ migrate: If True, schedule migration of existing data
171
+
172
+ Returns:
173
+ Dict with status and any warnings
174
+ """
175
+ config = load_config()
176
+ current_dir = get_user_data_dir()
177
+ new_dir = Path(path) if path else None
178
+
179
+ result = {
180
+ "success": True,
181
+ "message": "",
182
+ "restart_required": False
183
+ }
184
+
185
+ # Check if new path is different from current
186
+ if new_dir and str(new_dir.resolve()) == str(current_dir.resolve()):
187
+ result["message"] = "New location is same as current location"
188
+ return result
189
+
190
+ # Set up pending migration if data exists and migration requested
191
+ if migrate and current_dir.exists():
192
+ databases = ['settings.db', 'notes.db', 'settings.json']
193
+ has_data = any((current_dir / db).exists() for db in databases)
194
+
195
+ if has_data:
196
+ config["pending_migration"] = {
197
+ "from": str(current_dir),
198
+ "to": path
199
+ }
200
+ result["restart_required"] = True
201
+ result["message"] = "Migration scheduled. Please restart the application."
202
+
203
+ # Update config
204
+ config["data_directory"] = path
205
+
206
+ if save_config(config):
207
+ result["success"] = True
208
+ else:
209
+ result["success"] = False
210
+ result["message"] = "Failed to save configuration"
211
+
212
+ return result
213
+
214
+
215
+ def check_and_execute_pending_migration() -> Optional[str]:
216
+ """
217
+ Check for and execute any pending data migration.
218
+
219
+ MUST be called at application startup BEFORE any database connections.
220
+
221
+ Returns:
222
+ Migration message if migration occurred, None otherwise
223
+ """
224
+ config = load_config()
225
+ pending = config.get("pending_migration")
226
+
227
+ if not pending:
228
+ return None
229
+
230
+ from_path = Path(pending.get("from", ""))
231
+ to_path = pending.get("to")
232
+
233
+ # Determine target directory
234
+ if to_path:
235
+ target_dir = Path(to_path)
236
+ else:
237
+ # Reset to platform default
238
+ target_dir = _get_default_data_dir()
239
+
240
+ log = _get_logger()
241
+
242
+ if not from_path.exists():
243
+ log.warning(f"Migration source does not exist: {from_path}")
244
+ config["pending_migration"] = None
245
+ save_config(config)
246
+ return None
247
+
248
+ # Create target directory
249
+ target_dir.mkdir(parents=True, exist_ok=True)
250
+
251
+ # Migrate files
252
+ databases = ['settings.db', 'notes.db', 'settings.json']
253
+ migrated = []
254
+
255
+ for db_name in databases:
256
+ src = from_path / db_name
257
+ dst = target_dir / db_name
258
+
259
+ if src.exists() and not dst.exists():
260
+ try:
261
+ shutil.move(str(src), str(dst))
262
+ migrated.append(db_name)
263
+ log.info(f"Migrated {db_name} to {target_dir}")
264
+ except Exception as e:
265
+ log.error(f"Failed to migrate {db_name}: {e}")
266
+
267
+ # Clear pending migration
268
+ config["pending_migration"] = None
269
+ save_config(config)
270
+
271
+ if migrated:
272
+ return f"Migrated {', '.join(migrated)} to {target_dir}"
273
+ return None
274
+
275
+
276
+ def _get_default_data_dir() -> Path:
277
+ """Get the platform default data directory (ignoring custom config)."""
278
+ if PLATFORMDIRS_AVAILABLE:
279
+ return Path(user_data_dir(APP_NAME, APP_AUTHOR))
280
+ return _get_fallback_data_dir()
281
+
282
+
283
+ # =============================================================================
284
+ # Original Functions (updated to use config)
285
+ # =============================================================================
286
+
287
+ def is_portable_mode() -> bool:
288
+ """
289
+ Check if running in portable mode.
290
+
291
+ Portable mode keeps data in the installation directory.
292
+ Enabled via:
293
+ - --portable CLI flag
294
+ - POMERA_PORTABLE environment variable set to 1, true, or yes
295
+
296
+ Returns:
297
+ True if in portable mode, False otherwise
298
+ """
299
+ global _portable_mode
300
+ if _portable_mode is not None:
301
+ return _portable_mode
302
+
303
+ # Check environment variable
304
+ if os.environ.get("POMERA_PORTABLE", "").lower() in ("1", "true", "yes"):
305
+ return True
306
+
307
+ # Check CLI arguments
308
+ if "--portable" in sys.argv:
309
+ return True
310
+
311
+ return False
312
+
313
+
314
+ def set_portable_mode(enabled: bool) -> None:
315
+ """
316
+ Set portable mode programmatically.
317
+
318
+ Call this early during app initialization if you need to override
319
+ the automatic detection.
320
+
321
+ Args:
322
+ enabled: True to enable portable mode
323
+ """
324
+ global _portable_mode
325
+ _portable_mode = enabled
326
+ _get_logger().info(f"Portable mode set to: {enabled}")
327
+
328
+
329
+ def _get_installation_dir() -> Path:
330
+ """
331
+ Get the installation/project directory.
332
+
333
+ Returns:
334
+ Path to the project root directory
335
+ """
336
+ # Navigate from core/data_directory.py to project root
337
+ return Path(__file__).parent.parent
338
+
339
+
340
+ def get_user_data_dir() -> Path:
341
+ """
342
+ Get the platform-appropriate user data directory.
343
+
344
+ Priority order:
345
+ 1. Custom directory from config file
346
+ 2. Portable mode (installation directory)
347
+ 3. Platform default from platformdirs
348
+ 4. Fallback platform-specific path
349
+
350
+ Returns:
351
+ Path to user data directory (created if doesn't exist)
352
+ """
353
+ # Check for custom directory in config
354
+ config = load_config()
355
+ custom_dir = config.get("data_directory")
356
+
357
+ if custom_dir:
358
+ data_dir = Path(custom_dir)
359
+ _get_logger().debug(f"Using custom data directory: {data_dir}")
360
+ elif is_portable_mode():
361
+ data_dir = _get_installation_dir()
362
+ _get_logger().debug(f"Using portable data directory: {data_dir}")
363
+ elif PLATFORMDIRS_AVAILABLE:
364
+ data_dir = Path(user_data_dir(APP_NAME, APP_AUTHOR))
365
+ _get_logger().debug(f"Using platformdirs data directory: {data_dir}")
366
+ else:
367
+ data_dir = _get_fallback_data_dir()
368
+ _get_logger().debug(f"Using fallback data directory: {data_dir}")
369
+
370
+ # Ensure directory exists
371
+ data_dir.mkdir(parents=True, exist_ok=True)
372
+ return data_dir
373
+
374
+
375
+ def _get_fallback_data_dir() -> Path:
376
+ """
377
+ Fallback data directory when platformdirs is not available.
378
+
379
+ Returns:
380
+ Path to platform-specific data directory
381
+ """
382
+ import platform
383
+ system = platform.system()
384
+
385
+ if system == "Windows":
386
+ base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
387
+ return Path(base) / APP_NAME
388
+ elif system == "Darwin": # macOS
389
+ return Path.home() / "Library" / "Application Support" / APP_NAME
390
+ else: # Linux and others
391
+ xdg_data = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
392
+ return Path(xdg_data) / APP_NAME
393
+
394
+
395
+ def get_database_path(db_name: str = "settings.db") -> str:
396
+ """
397
+ Get full path for a database file in user data directory.
398
+
399
+ Args:
400
+ db_name: Name of the database file (e.g., "settings.db", "notes.db")
401
+
402
+ Returns:
403
+ Full path to the database file as string
404
+ """
405
+ return str(get_user_data_dir() / db_name)
406
+
407
+
408
+ def get_backup_dir() -> Path:
409
+ """
410
+ Get path to backup directory within user data.
411
+
412
+ Returns:
413
+ Path to backup directory (created if doesn't exist)
414
+ """
415
+ backup_dir = get_user_data_dir() / "backups"
416
+ backup_dir.mkdir(parents=True, exist_ok=True)
417
+ return backup_dir
418
+
419
+
420
+ def migrate_legacy_databases(logger: Optional[logging.Logger] = None) -> None:
421
+ """
422
+ Migrate databases from legacy (installation dir) to user data dir.
423
+
424
+ Only migrates if:
425
+ 1. Not in portable mode
426
+ 2. Legacy files exist in installation dir
427
+ 3. Files don't already exist in user data dir
428
+
429
+ Logs INFO message to console if migration occurs.
430
+
431
+ Args:
432
+ logger: Optional logger instance to use
433
+ """
434
+ if is_portable_mode():
435
+ return # No migration needed in portable mode
436
+
437
+ log = logger or _get_logger()
438
+ user_dir = get_user_data_dir()
439
+ legacy_dir = _get_installation_dir()
440
+
441
+ # Skip if user_dir is same as legacy_dir
442
+ try:
443
+ if user_dir.resolve() == legacy_dir.resolve():
444
+ return
445
+ except (OSError, ValueError):
446
+ # Path resolution failed, skip migration
447
+ return
448
+
449
+ databases = ['settings.db', 'notes.db', 'settings.json']
450
+ migrated = []
451
+
452
+ for db_name in databases:
453
+ legacy_path = legacy_dir / db_name
454
+ new_path = user_dir / db_name
455
+
456
+ if legacy_path.exists() and not new_path.exists():
457
+ try:
458
+ shutil.copy2(legacy_path, new_path)
459
+ migrated.append(db_name)
460
+ log.debug(f"Copied {db_name} from {legacy_path} to {new_path}")
461
+ except Exception as e:
462
+ log.warning(f"Failed to migrate {db_name}: {e}")
463
+
464
+ if migrated:
465
+ log.info(f"Migrated {', '.join(migrated)} to {user_dir}")
466
+
467
+
468
+ def get_data_directory_info() -> dict:
469
+ """
470
+ Get information about current data directory configuration.
471
+
472
+ Useful for debugging and status display.
473
+
474
+ Returns:
475
+ Dictionary with data directory information
476
+ """
477
+ return {
478
+ 'user_data_dir': str(get_user_data_dir()),
479
+ 'backup_dir': str(get_backup_dir()),
480
+ 'portable_mode': is_portable_mode(),
481
+ 'platformdirs_available': PLATFORMDIRS_AVAILABLE,
482
+ 'installation_dir': str(_get_installation_dir()),
483
+ }
484
+
485
+
486
+ def check_portable_mode_warning(show_console_warning: bool = True) -> dict:
487
+ """
488
+ Check for portable mode data loss risk and optionally display warning.
489
+
490
+ This should be called on application startup to warn users about
491
+ potential data loss when using portable mode with npm/pip.
492
+
493
+ Args:
494
+ show_console_warning: If True, prints warning to console
495
+
496
+ Returns:
497
+ Dict with warning details if risk detected, empty dict otherwise
498
+ """
499
+ if not is_portable_mode():
500
+ return {} # No risk when using platform directories
501
+
502
+ install_dir = _get_installation_dir()
503
+ databases = ['settings.db', 'notes.db', 'settings.json']
504
+ found_databases = []
505
+
506
+ for db_name in databases:
507
+ db_path = install_dir / db_name
508
+ if db_path.exists():
509
+ found_databases.append({
510
+ 'name': db_name,
511
+ 'path': str(db_path),
512
+ 'size_bytes': db_path.stat().st_size
513
+ })
514
+
515
+ if not found_databases:
516
+ return {} # No databases to lose
517
+
518
+ warning_info = {
519
+ 'portable_mode': True,
520
+ 'installation_dir': str(install_dir),
521
+ 'databases_at_risk': found_databases,
522
+ 'warning': (
523
+ "PORTABLE MODE WARNING: Your data is stored in the installation directory. "
524
+ "Running 'npm update' or 'pip install --upgrade' will DELETE your data! "
525
+ "Please export your settings before updating, or consider switching to "
526
+ "platform data directories by running without the --portable flag."
527
+ )
528
+ }
529
+
530
+ if show_console_warning:
531
+ log = _get_logger()
532
+ log.warning("=" * 70)
533
+ log.warning("⚠️ PORTABLE MODE DATA WARNING ⚠️")
534
+ log.warning("=" * 70)
535
+ log.warning("")
536
+ log.warning("Your databases are stored in the installation directory:")
537
+ for db in found_databases:
538
+ log.warning(f" • {db['name']} ({db['size_bytes'] / 1024:.1f} KB)")
539
+ log.warning("")
540
+ log.warning("🚨 These files WILL BE DELETED if you run npm/pip update!")
541
+ log.warning("")
542
+ log.warning("📋 Before updating, please export your settings or copy")
543
+ log.warning(f" database files from: {install_dir}")
544
+ log.warning("")
545
+ log.warning("💡 Recommended: Run without --portable to use safe platform directories.")
546
+ log.warning("=" * 70)
547
+
548
+ return warning_info
549
+