diff --git a/.idea/AstroSessionWatcher.iml b/.idea/AstroSessionWatcher.iml index d0876a7..63ab128 100644 --- a/.idea/AstroSessionWatcher.iml +++ b/.idea/AstroSessionWatcher.iml @@ -1,8 +1,10 @@ - - + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 9d7e713..bec30ab 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -1,12 +1,41 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -14,16 +43,27 @@ "associatedIndex": 8 }]]> + + + @@ -42,11 +82,14 @@ + + + \ No newline at end of file diff --git a/astro_settings.json b/astro_settings.json new file mode 100644 index 0000000..fd39922 --- /dev/null +++ b/astro_settings.json @@ -0,0 +1,15 @@ +{ + "cameras": [ + "Canon 40D", + "Canon 400D", + "Canon 500D" + ], + "lenses": [ + "MTO-500A", + "Юпитер-21м", + "Tamron 18-200mm" + ], + "last_watch_folder": "C:/Users/Juliette/Documents/testwatcher", + "last_camera": "Canon 40D", + "last_lens": "MTO-500A" +} \ No newline at end of file diff --git a/build_exe.spec b/build_exe.spec index e69de29..ce5a8c7 100644 --- a/build_exe.spec +++ b/build_exe.spec @@ -0,0 +1,40 @@ +# -*- mode: python ; coding: utf-8 -*- + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=[ + ('resources/done.mp3', 'resources'), + ], + hiddenimports=['customtkinter', 'watchdog', 'pygame', 'ctypes', 'queue'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='AstroSessionWatcher', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, # Без консоли + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='resources/icon.ico', # Если есть иконка +) \ No newline at end of file diff --git a/celestial_bodies.json b/celestial_bodies.json new file mode 100644 index 0000000..c510679 --- /dev/null +++ b/celestial_bodies.json @@ -0,0 +1,14 @@ +[ + "M31 (Andromeda Galaxy)", + "M42 (Orion Nebula)", + "M45 (Pleiades)", + "M57 (Ring Nebula)", + "Солнце", + "Moon", + "Jupiter", + "Сатурн", + "M89", + "Венера", + "Меркурий", + "Нептун" +] \ No newline at end of file diff --git a/main.py b/main.py index e69de29..b079cf5 100644 --- a/main.py +++ b/main.py @@ -0,0 +1,33 @@ +""" +Astro Session Watcher - Главный входной файл +Приложение для астрофотографов с отслеживанием файлов и сортировкой по объектам +""" +import sys +import os +from pathlib import Path + +# Добавляем корневую директорию в путь +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from PySide6.QtWidgets import QApplication +from ui.main_window import MainWindow + + +def main(): + """Точка входа в приложение""" + app = QApplication(sys.argv) + + # Устанавливаем стиль Fusion для современного вида + app.setStyle("Fusion") + + # Тёмная палитра + app.setPalette(app.style().standardPalette()) + + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py index e69de29..cde0065 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -0,0 +1,4 @@ +from models.astro_object import AstroObject +from models.session import Session + +__all__ = ['AstroObject', 'Session'] \ No newline at end of file diff --git a/models/__pycache__/__init__.cpython-313.pyc b/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b164826 Binary files /dev/null and b/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/models/__pycache__/astro_object.cpython-313.pyc b/models/__pycache__/astro_object.cpython-313.pyc new file mode 100644 index 0000000..7d6fbd6 Binary files /dev/null and b/models/__pycache__/astro_object.cpython-313.pyc differ diff --git a/models/__pycache__/session.cpython-313.pyc b/models/__pycache__/session.cpython-313.pyc new file mode 100644 index 0000000..7f6a944 Binary files /dev/null and b/models/__pycache__/session.cpython-313.pyc differ diff --git a/models/astro_object.py b/models/astro_object.py index e69de29..c513182 100644 --- a/models/astro_object.py +++ b/models/astro_object.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class AstroObject: + """Модель астрономического объекта""" + name: str + folder: Path + photo_count: int = 0 + + def increment_photo_count(self): + self.photo_count += 1 + + def get_object_log_path(self) -> Path: + return self.folder / "ObjectLog.txt" + + def __str__(self): + return f"AstroObject(name='{self.name}', photos={self.photo_count})" \ No newline at end of file diff --git a/models/session.py b/models/session.py index e69de29..8c5443e 100644 --- a/models/session.py +++ b/models/session.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import List, Optional +from models.astro_object import AstroObject + + +@dataclass +class Session: + """Модель сессии наблюдения""" + camera: str + optics: str + start_time: datetime + end_time: Optional[datetime] = None + objects: List[AstroObject] = field(default_factory=list) + session_folder: Optional[Path] = None + + def add_object(self, astro_object: AstroObject): + self.objects.append(astro_object) + + def get_current_object(self) -> Optional[AstroObject]: + return self.objects[-1] if self.objects else None + + def get_session_name(self) -> str: + return f"AstroSession_{self.start_time.strftime('%Y-%m-%d')}" + + def finish(self): + self.end_time = datetime.now() + + def is_active(self) -> bool: + return self.end_time is None \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e69de29..5a4c3e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,2 @@ +PySide6>=6.5.0 +watchdog>=3.0.0 \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py index e69de29..91d3dc3 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -0,0 +1,6 @@ +from services.config_service import ConfigService +from services.file_service import FileService +from services.session_service import SessionService +from services.watch_service import WatchService + +__all__ = ['ConfigService', 'FileService', 'SessionService', 'WatchService'] \ No newline at end of file diff --git a/services/__pycache__/__init__.cpython-313.pyc b/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d667db5 Binary files /dev/null and b/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/services/__pycache__/config_service.cpython-313.pyc b/services/__pycache__/config_service.cpython-313.pyc new file mode 100644 index 0000000..035f70f Binary files /dev/null and b/services/__pycache__/config_service.cpython-313.pyc differ diff --git a/services/__pycache__/file_service.cpython-313.pyc b/services/__pycache__/file_service.cpython-313.pyc new file mode 100644 index 0000000..2a68435 Binary files /dev/null and b/services/__pycache__/file_service.cpython-313.pyc differ diff --git a/services/__pycache__/session_service.cpython-313.pyc b/services/__pycache__/session_service.cpython-313.pyc new file mode 100644 index 0000000..327b811 Binary files /dev/null and b/services/__pycache__/session_service.cpython-313.pyc differ diff --git a/services/__pycache__/watch_service.cpython-313.pyc b/services/__pycache__/watch_service.cpython-313.pyc new file mode 100644 index 0000000..d00565b Binary files /dev/null and b/services/__pycache__/watch_service.cpython-313.pyc differ diff --git a/services/config_service.py b/services/config_service.py index e69de29..5ada103 100644 --- a/services/config_service.py +++ b/services/config_service.py @@ -0,0 +1,161 @@ +""" +ConfigService - управление настройками приложения +""" +import json +import os +from typing import List, Optional +from dataclasses import dataclass, asdict + + +@dataclass +class AppConfig: + """Конфигурация приложения""" + cameras: List[str] + lenses: List[str] + celestial_bodies: List[str] + last_watch_folder: str + last_camera: str + last_lens: str + + +class ConfigService: + """Сервис для работы с конфигурацией""" + + SETTINGS_FILE = "astro_settings.json" + CELESTIAL_BODIES_FILE = "celestial_bodies.json" + + def __init__(self): + self.config = AppConfig( + cameras=[], + lenses=[], + celestial_bodies=[], + last_watch_folder="", + last_camera="", + last_lens="" + ) + self.load_all() + + def load_all(self): + """Загружает все настройки""" + self._load_settings() + self._load_celestial_bodies() + + if not self.config.celestial_bodies: + self.config.celestial_bodies = [ + "M31 (Andromeda Galaxy)", + "M42 (Orion Nebula)", + "M45 (Pleiades)", + "M57 (Ring Nebula)", + "Sun", + "Moon", + "Jupiter", + "Saturn" + ] + self._save_celestial_bodies() + + def _load_settings(self): + if os.path.exists(self.SETTINGS_FILE): + try: + with open(self.SETTINGS_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + self.config.cameras = data.get('cameras', []) + self.config.lenses = data.get('lenses', []) + self.config.last_watch_folder = data.get('last_watch_folder', '') + self.config.last_camera = data.get('last_camera', '') + self.config.last_lens = data.get('last_lens', '') + except Exception as e: + print(f"Ошибка загрузки настроек: {e}") + + def save_settings(self): + try: + with open(self.SETTINGS_FILE, 'w', encoding='utf-8') as f: + json.dump({ + 'cameras': self.config.cameras, + 'lenses': self.config.lenses, + 'last_watch_folder': self.config.last_watch_folder, + 'last_camera': self.config.last_camera, + 'last_lens': self.config.last_lens + }, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Ошибка сохранения настроек: {e}") + + def _load_celestial_bodies(self): + if os.path.exists(self.CELESTIAL_BODIES_FILE): + try: + with open(self.CELESTIAL_BODIES_FILE, 'r', encoding='utf-8') as f: + self.config.celestial_bodies = json.load(f) + except Exception as e: + print(f"Ошибка загрузки небесных тел: {e}") + + def _save_celestial_bodies(self): + try: + with open(self.CELESTIAL_BODIES_FILE, 'w', encoding='utf-8') as f: + json.dump(self.config.celestial_bodies, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Ошибка сохранения небесных тел: {e}") + + def get_cameras(self) -> List[str]: + return self.config.cameras.copy() + + def add_camera(self, camera: str): + if camera and camera not in self.config.cameras: + self.config.cameras.append(camera) + self.save_settings() + + def remove_camera(self, camera: str): + if camera in self.config.cameras: + self.config.cameras.remove(camera) + self.save_settings() + + def get_lenses(self) -> List[str]: + return self.config.lenses.copy() + + def add_lens(self, lens: str): + if lens and lens not in self.config.lenses: + self.config.lenses.append(lens) + self.save_settings() + + def remove_lens(self, lens: str): + if lens in self.config.lenses: + self.config.lenses.remove(lens) + self.save_settings() + + def get_celestial_bodies(self) -> List[str]: + return self.config.celestial_bodies.copy() + + def add_celestial_body(self, name: str): + if name and name not in self.config.celestial_bodies: + self.config.celestial_bodies.append(name) + self._save_celestial_bodies() + + def remove_celestial_body(self, name: str): + if name in self.config.celestial_bodies: + self.config.celestial_bodies.remove(name) + self._save_celestial_bodies() + + def update_celestial_body(self, old_name: str, new_name: str): + if old_name in self.config.celestial_bodies: + idx = self.config.celestial_bodies.index(old_name) + self.config.celestial_bodies[idx] = new_name + self._save_celestial_bodies() + + def get_last_watch_folder(self) -> str: + return self.config.last_watch_folder + + def set_last_watch_folder(self, folder: str): + self.config.last_watch_folder = folder + self.save_settings() + + def get_last_camera(self) -> str: + return self.config.last_camera + + def set_last_camera(self, camera: str): + self.config.last_camera = camera + self.save_settings() + + def get_last_lens(self) -> str: + return self.config.last_lens + + def set_last_lens(self, lens: str): + self.config.last_lens = lens + self.save_settings() \ No newline at end of file diff --git a/services/file_service.py b/services/file_service.py index e69de29..68d7990 100644 --- a/services/file_service.py +++ b/services/file_service.py @@ -0,0 +1,88 @@ +""" +FileService - сервис для работы с файлами +""" +import shutil +from datetime import datetime +from pathlib import Path +from typing import Optional + + +class FileService: + """Сервис для перемещения файлов и ведения логов""" + + SUPPORTED_EXTENSIONS = {'.cr2', '.dng', '.arw', '.jpg', '.jpeg', '.png', '.raw', '.tiff'} + + @classmethod + def is_photo(cls, file_path: Path) -> bool: + """Проверяет, является ли файл фотографией""" + return file_path.suffix.lower() in cls.SUPPORTED_EXTENSIONS + + @classmethod + def resolve_conflict(cls, target_path: Path) -> Path: + """Разрешает конфликт имён файлов""" + if not target_path.exists(): + return target_path + + counter = 1 + stem = target_path.stem + suffix = target_path.suffix + parent = target_path.parent + + while True: + new_name = f"{stem}_{counter}{suffix}" + new_path = parent / new_name + if not new_path.exists(): + return new_path + counter += 1 + + @classmethod + def write_object_log(cls, folder: Path, filename: str, camera: str, optics: str, + timestamp: Optional[datetime] = None) -> None: + """Записывает запись в лог объекта""" + if timestamp is None: + timestamp = datetime.now() + + log_file = folder / "ObjectLog.txt" + line = f"{timestamp} {camera if camera else 'Unknown'} {optics if optics else 'Unknown'} - {filename}\n" + + try: + with open(log_file, 'a', encoding='utf-8') as f: + f.write(line) + except Exception as e: + print(f"Ошибка записи лога: {e}") + + @classmethod + def move_file(cls, source: Path, target_folder: Path, camera: str, optics: str) -> bool: + """Перемещает файл в целевую папку""" + if not source.exists(): + return False + + if not cls.is_photo(source): + return False + + try: + target_folder.mkdir(parents=True, exist_ok=True) + creation_time = datetime.fromtimestamp(source.stat().st_ctime) + cls.write_object_log(target_folder, source.name, camera, optics, creation_time) + target_path = cls.resolve_conflict(target_folder / source.name) + shutil.move(str(source), str(target_path)) + return True + except Exception as e: + print(f"Ошибка перемещения {source.name}: {e}") + return False + + @classmethod + def clear_watch_folder(cls, folder: Path) -> int: + """Очищает папку наблюдения""" + if not folder.exists(): + return 0 + + count = 0 + for file_path in folder.iterdir(): + if file_path.is_file() and cls.is_photo(file_path): + try: + file_path.unlink() + count += 1 + except Exception as e: + print(f"Ошибка удаления {file_path.name}: {e}") + return count \ No newline at end of file diff --git a/services/session_service.py b/services/session_service.py index e69de29..9469d2c 100644 --- a/services/session_service.py +++ b/services/session_service.py @@ -0,0 +1,155 @@ +""" +SessionService - сервис управления сессией +""" +from datetime import datetime +from pathlib import Path +from typing import Optional, Callable +from models.astro_object import AstroObject +from models.session import Session +from services.file_service import FileService + + +class SessionService: + """Сервис для управления сессией наблюдения""" + + def __init__(self): + self._current_session: Optional[Session] = None + self._file_service = FileService() + + def start_session(self, watch_folder: Path, object_name: str, camera: str, optics: str) -> Session: + """Начинает новую сессию""" + start_time = datetime.now() + self._current_session = Session( + camera=camera, + optics=optics, + start_time=start_time + ) + + session_folder = watch_folder / self._current_session.get_session_name() + session_folder.mkdir(parents=True, exist_ok=True) + self._current_session.session_folder = session_folder + + self._log_session_event(f"Session started at {start_time}") + self._log_session_event(f"Camera: {camera}") + self._log_session_event(f"Optics: {optics}") + + self.create_new_object(object_name) + return self._current_session + + def create_new_object(self, object_name: str) -> AstroObject: + """Создаёт новый объект съёмки""" + if not self._current_session: + raise ValueError("Session not started") + + if self._current_session.get_current_object(): + current_obj = self._current_session.get_current_object() + self._log_session_event(f"Object finished: {current_obj.name}, photos: {current_obj.photo_count}") + + object_folder = self._current_session.session_folder / object_name + object_folder.mkdir(parents=True, exist_ok=True) + + astro_object = AstroObject( + name=object_name, + folder=object_folder, + photo_count=0 + ) + + self._current_session.add_object(astro_object) + self._log_session_event(f"New object: {object_name}") + return astro_object + + def handle_file(self, file_path: Path) -> bool: + """Обрабатывает новый файл""" + if not self._current_session: + return False + + current_object = self._current_session.get_current_object() + if not current_object: + return False + + success = self._file_service.move_file( + file_path, + current_object.folder, + self._current_session.camera, + self._current_session.optics + ) + + if success: + current_object.increment_photo_count() + + return success + + def move_remaining_files(self, watch_folder: Path, on_file_moved: Optional[Callable] = None) -> int: + """Перемещает все существующие файлы из папки наблюдения""" + if not self._current_session: + return 0 + + current_object = self._current_session.get_current_object() + if not current_object: + return 0 + + count = 0 + if watch_folder.exists(): + for file_path in watch_folder.iterdir(): + if file_path.is_file() and self._file_service.is_photo(file_path): + if self._file_service.move_file( + file_path, + current_object.folder, + self._current_session.camera, + self._current_session.optics + ): + current_object.increment_photo_count() + count += 1 + if on_file_moved: + on_file_moved(file_path) + return count + + def finish_session(self) -> Session: + """Завершает сессию""" + if not self._current_session: + raise ValueError("No active session") + + self._current_session.finish() + + self._log_session_event(f"Session finished at {self._current_session.end_time}") + self._log_session_event("=== SESSION SUMMARY ===") + for obj in self._current_session.objects: + self._log_session_event(f"Object: {obj.name}, Photos: {obj.photo_count}") + + return self._current_session + + def get_current_session(self) -> Optional[Session]: + return self._current_session + + def get_current_object(self) -> Optional[AstroObject]: + if self._current_session: + return self._current_session.get_current_object() + return None + + def get_current_object_folder(self) -> Optional[Path]: + current_obj = self.get_current_object() + return current_obj.folder if current_obj else None + + def change_camera(self, camera: str): + if self._current_session: + self._current_session.camera = camera + self._log_session_event(f"Camera changed to: {camera}") + + def change_optics(self, optics: str): + if self._current_session: + self._current_session.optics = optics + self._log_session_event(f"Optics changed to: {optics}") + + def _log_session_event(self, message: str): + if self._current_session and self._current_session.session_folder: + log_file = self._current_session.session_folder / "SessionLog.txt" + timestamp = datetime.now() + line = f"{timestamp} - {message}\n" + try: + with open(log_file, 'a', encoding='utf-8') as f: + f.write(line) + except Exception as e: + print(f"Ошибка записи лога: {e}") + + def is_active(self) -> bool: + return self._current_session is not None and self._current_session.is_active() \ No newline at end of file diff --git a/services/watch_service.py b/services/watch_service.py index e69de29..9ef8119 100644 --- a/services/watch_service.py +++ b/services/watch_service.py @@ -0,0 +1,99 @@ +""" +WatchService - сервис отслеживания файлов с очередью +""" +import time +import threading +import queue +from pathlib import Path +from typing import Callable, Optional +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler +from services.file_service import FileService + + +class PhotoHandler(FileSystemEventHandler): + """Обработчик событий файловой системы""" + + def __init__(self, callback: Callable[[Path], None]): + self.callback = callback + self._pending_files = queue.Queue() + self._processing = True + self._processor_thread = threading.Thread(target=self._process_queue, daemon=True) + self._processor_thread.start() + + def on_created(self, event): + if not event.is_directory: + src_path = Path(event.src_path) + if FileService.is_photo(src_path): + time.sleep(0.1) # Даём время на запись файла + self._pending_files.put(src_path) + + def _process_queue(self): + while self._processing: + try: + file_path = self._pending_files.get(timeout=1) + if self.callback and file_path.exists(): + self.callback(file_path) + except queue.Empty: + continue + except Exception as e: + print(f"Ошибка обработки файла: {e}") + + def stop(self): + self._processing = False + + +class WatchService: + """Сервис для отслеживания папки на новые файлы""" + + def __init__(self): + self._observer: Optional[Observer] = None + self._event_handler: Optional[PhotoHandler] = None + self._is_running = False + + def start(self, watch_folder: Path, on_new_file: Callable[[Path], None]) -> bool: + if self._is_running: + return False + + if not watch_folder.exists(): + return False + + try: + self._event_handler = PhotoHandler(on_new_file) + self._observer = Observer() + self._observer.schedule(self._event_handler, str(watch_folder), recursive=False) + self._observer.start() + self._is_running = True + return True + except Exception as e: + print(f"Ошибка запуска отслеживания: {e}") + return False + + def stop(self): + if self._observer: + self._observer.stop() + self._observer.join() + self._observer = None + + if self._event_handler: + self._event_handler.stop() + self._event_handler = None + + self._is_running = False + + def is_running(self) -> bool: + return self._is_running + + def move_all_existing_files(self, watch_folder: Path, on_file_moved: Callable[[Path], None]) -> int: + """Перемещает все существующие файлы""" + count = 0 + if watch_folder.exists(): + for file_path in watch_folder.iterdir(): + if file_path.is_file() and FileService.is_photo(file_path): + try: + on_file_moved(file_path) + count += 1 + time.sleep(0.05) + except Exception as e: + print(f"Ошибка перемещения {file_path.name}: {e}") + return count \ No newline at end of file diff --git a/ui/__init__.py b/ui/__init__.py index e69de29..17f31d2 100644 --- a/ui/__init__.py +++ b/ui/__init__.py @@ -0,0 +1,4 @@ +# UI package +from ui.main_window import MainWindow + +__all__ = ['MainWindow'] \ No newline at end of file diff --git a/ui/__pycache__/__init__.cpython-313.pyc b/ui/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..ac8a3f4 Binary files /dev/null and b/ui/__pycache__/__init__.cpython-313.pyc differ diff --git a/ui/__pycache__/main_window.cpython-313.pyc b/ui/__pycache__/main_window.cpython-313.pyc new file mode 100644 index 0000000..223c5b5 Binary files /dev/null and b/ui/__pycache__/main_window.cpython-313.pyc differ diff --git a/ui/dialogs/__init__.py b/ui/dialogs/__init__.py index e69de29..0db4844 100644 --- a/ui/dialogs/__init__.py +++ b/ui/dialogs/__init__.py @@ -0,0 +1,5 @@ +from ui.dialogs.equipment_dialog import EquipmentDialog +from ui.dialogs.celestial_dialog import CelestialDialog +from ui.dialogs.instructions_dialog import InstructionsDialog + +__all__ = ['EquipmentDialog', 'CelestialDialog', 'InstructionsDialog'] \ No newline at end of file diff --git a/ui/dialogs/__pycache__/__init__.cpython-313.pyc b/ui/dialogs/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b57a0cc Binary files /dev/null and b/ui/dialogs/__pycache__/__init__.cpython-313.pyc differ diff --git a/ui/dialogs/__pycache__/celestial_dialog.cpython-313.pyc b/ui/dialogs/__pycache__/celestial_dialog.cpython-313.pyc new file mode 100644 index 0000000..d7018e0 Binary files /dev/null and b/ui/dialogs/__pycache__/celestial_dialog.cpython-313.pyc differ diff --git a/ui/dialogs/__pycache__/equipment_dialog.cpython-313.pyc b/ui/dialogs/__pycache__/equipment_dialog.cpython-313.pyc new file mode 100644 index 0000000..a4d67bf Binary files /dev/null and b/ui/dialogs/__pycache__/equipment_dialog.cpython-313.pyc differ diff --git a/ui/dialogs/__pycache__/instructions_dialog.cpython-313.pyc b/ui/dialogs/__pycache__/instructions_dialog.cpython-313.pyc new file mode 100644 index 0000000..cc50530 Binary files /dev/null and b/ui/dialogs/__pycache__/instructions_dialog.cpython-313.pyc differ diff --git a/ui/dialogs/celestial_dialog.py b/ui/dialogs/celestial_dialog.py index e69de29..4798cf5 100644 --- a/ui/dialogs/celestial_dialog.py +++ b/ui/dialogs/celestial_dialog.py @@ -0,0 +1,160 @@ +""" +CelestialDialog - диалог управления небесными телами +Аналог CelestialBodiesDialogController из JavaFX версии +""" +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, + QPushButton, QLineEdit, QInputDialog, QMessageBox +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +from services.config_service import ConfigService + + +class CelestialDialog(QDialog): + """Диалог для управления списком небесных тел""" + + def __init__(self, parent, config_service: ConfigService): + super().__init__(parent) + + self.config_service = config_service + self.setWindowTitle("Небесные тела") + self.setMinimumSize(400, 500) + self.resize(450, 550) + + # Загружаем текущий список + self.celestial_bodies = self.config_service.get_celestial_bodies() + + self._create_ui() + self._update_list() + + def _create_ui(self): + """Создаёт интерфейс диалога""" + layout = QVBoxLayout(self) + layout.setSpacing(15) + layout.setContentsMargins(20, 20, 20, 20) + + # Заголовок + title_label = QLabel("Управление небесными телами") + title_font = QFont() + title_font.setPointSize(16) + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignCenter) + layout.addWidget(title_label) + + # Подпись + subtitle_label = QLabel("Список объектов для наблюдения") + subtitle_font = QFont() + subtitle_font.setPointSize(11) + subtitle_font.setBold(True) + subtitle_label.setFont(subtitle_font) + layout.addWidget(subtitle_label) + + # Список небесных тел + self.bodies_list = QListWidget() + self.bodies_list.itemClicked.connect(lambda item: self._select_body(item.text())) + layout.addWidget(self.bodies_list) + + # Поле для добавления нового + add_layout = QHBoxLayout() + + self.new_body_entry = QLineEdit() + self.new_body_entry.setPlaceholderText("Название объекта (например: M31, NGC 224)") + self.new_body_entry.returnPressed.connect(self._add_celestial_body) + add_layout.addWidget(self.new_body_entry) + + add_btn = QPushButton("➕ Добавить") + add_btn.clicked.connect(self._add_celestial_body) + add_layout.addWidget(add_btn) + + layout.addLayout(add_layout) + + # Кнопки удаления и редактирования + buttons_layout = QHBoxLayout() + + self.remove_btn = QPushButton("❌ Удалить выбранный") + self.remove_btn.setEnabled(False) + self.remove_btn.clicked.connect(self._remove_celestial_body) + buttons_layout.addWidget(self.remove_btn) + + self.edit_btn = QPushButton("✏ Редактировать") + self.edit_btn.setEnabled(False) + self.edit_btn.clicked.connect(self._edit_celestial_body) + buttons_layout.addWidget(self.edit_btn) + + layout.addLayout(buttons_layout) + + # Кнопка закрытия + close_btn = QPushButton("Закрыть") + close_btn.clicked.connect(self.accept) + close_layout = QHBoxLayout() + close_layout.addStretch() + close_layout.addWidget(close_btn) + layout.addLayout(close_layout) + + def _update_list(self): + """Обновляет отображение списка небесных тел""" + self.bodies_list.clear() + for body in self.celestial_bodies: + self.bodies_list.addItem(body) + self._selected_body = None + self.remove_btn.setEnabled(False) + self.edit_btn.setEnabled(False) + + def _select_body(self, body: str): + """Выделяет объект в списке""" + self._selected_body = body + self.remove_btn.setEnabled(True) + self.edit_btn.setEnabled(True) + + def _add_celestial_body(self): + """Добавляет новое небесное тело""" + new_body = self.new_body_entry.text() + if not new_body or not new_body.strip(): + QMessageBox.warning(self, "Ошибка", "Введите название объекта") + return + + new_name = new_body.strip() + if new_name in self.celestial_bodies: + QMessageBox.warning(self, "Ошибка", f"Объект '{new_name}' уже существует!") + return + + self.celestial_bodies.append(new_name) + self.config_service.add_celestial_body(new_name) + self._update_list() + self.new_body_entry.clear() + QMessageBox.information(self, "Успех", f"Объект '{new_name}' добавлен") + + def _remove_celestial_body(self): + """Удаляет выбранное небесное тело""" + if hasattr(self, '_selected_body') and self._selected_body: + reply = QMessageBox.question(self, "Подтверждение", + f"Удалить объект '{self._selected_body}'?", + QMessageBox.Yes | QMessageBox.No) + if reply == QMessageBox.Yes: + self.celestial_bodies.remove(self._selected_body) + self.config_service.remove_celestial_body(self._selected_body) + self._update_list() + QMessageBox.information(self, "Успех", f"Объект '{self._selected_body}' удалён") + + def _edit_celestial_body(self): + """Редактирует выбранное небесное тело""" + if hasattr(self, '_selected_body') and self._selected_body: + new_name, ok = QInputDialog.getText(self, "Редактировать", + f"Изменить '{self._selected_body}' на:", + text=self._selected_body) + if ok and new_name and new_name.strip(): + new_name = new_name.strip() + if new_name != self._selected_body: + if new_name in self.celestial_bodies: + QMessageBox.warning(self, "Ошибка", f"Объект '{new_name}' уже существует!") + return + + idx = self.celestial_bodies.index(self._selected_body) + old_name = self.celestial_bodies[idx] + self.celestial_bodies[idx] = new_name + self.config_service.update_celestial_body(old_name, new_name) + self._update_list() + QMessageBox.information(self, "Успех", f"Объект переименован в '{new_name}'") \ No newline at end of file diff --git a/ui/dialogs/equipment_dialog.py b/ui/dialogs/equipment_dialog.py index e69de29..b0d8784 100644 --- a/ui/dialogs/equipment_dialog.py +++ b/ui/dialogs/equipment_dialog.py @@ -0,0 +1,202 @@ +""" +EquipmentDialog - диалог управления оборудованием (камеры и объективы) +Аналог EquipmentDialogController из JavaFX версии +""" +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, + QPushButton, QInputDialog, QMessageBox, QListWidgetItem +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +from services.config_service import ConfigService + + +class EquipmentDialog(QDialog): + """Диалог для управления списками камер и объективов""" + + def __init__(self, parent, config_service: ConfigService): + super().__init__(parent) + + self.config_service = config_service + self.setWindowTitle("Управление оборудованием") + self.setMinimumSize(600, 400) + self.resize(650, 450) + + # Загружаем текущие списки + self.cameras = self.config_service.get_cameras() + self.lenses = self.config_service.get_lenses() + + self._create_ui() + self._update_cameras_list() + self._update_lenses_list() + + def _create_ui(self): + """Создаёт интерфейс диалога""" + layout = QVBoxLayout(self) + layout.setSpacing(15) + layout.setContentsMargins(20, 20, 20, 20) + + # Заголовок + title_label = QLabel("Управление оборудованием") + title_font = QFont() + title_font.setPointSize(16) + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignCenter) + layout.addWidget(title_label) + + # Контейнер для двух колонок + columns_layout = QHBoxLayout() + columns_layout.setSpacing(20) + + # Левая колонка - Камеры + left_layout = QVBoxLayout() + + cameras_label = QLabel("Камеры") + cameras_font = QFont() + cameras_font.setPointSize(12) + cameras_font.setBold(True) + cameras_label.setFont(cameras_font) + left_layout.addWidget(cameras_label) + + self.cameras_list = QListWidget() + self.cameras_list.itemClicked.connect(lambda item: self._select_camera(item.text())) + left_layout.addWidget(self.cameras_list) + + cameras_buttons_layout = QHBoxLayout() + + add_camera_btn = QPushButton("➕ Добавить") + add_camera_btn.clicked.connect(self._add_camera) + cameras_buttons_layout.addWidget(add_camera_btn) + + self.remove_camera_btn = QPushButton("❌ Удалить") + self.remove_camera_btn.setEnabled(False) + self.remove_camera_btn.clicked.connect(self._remove_camera) + cameras_buttons_layout.addWidget(self.remove_camera_btn) + + left_layout.addLayout(cameras_buttons_layout) + + # Правая колонка - Объективы + right_layout = QVBoxLayout() + + lenses_label = QLabel("Объективы") + lenses_label.setFont(cameras_font) + right_layout.addWidget(lenses_label) + + self.lenses_list = QListWidget() + self.lenses_list.itemClicked.connect(lambda item: self._select_lens(item.text())) + right_layout.addWidget(self.lenses_list) + + lenses_buttons_layout = QHBoxLayout() + + add_lens_btn = QPushButton("➕ Добавить") + add_lens_btn.clicked.connect(self._add_lens) + lenses_buttons_layout.addWidget(add_lens_btn) + + self.remove_lens_btn = QPushButton("❌ Удалить") + self.remove_lens_btn.setEnabled(False) + self.remove_lens_btn.clicked.connect(self._remove_lens) + lenses_buttons_layout.addWidget(self.remove_lens_btn) + + right_layout.addLayout(lenses_buttons_layout) + + columns_layout.addLayout(left_layout) + columns_layout.addLayout(right_layout) + layout.addLayout(columns_layout) + + # Кнопка закрытия + close_btn = QPushButton("Закрыть") + close_btn.clicked.connect(self.accept) + close_layout = QHBoxLayout() + close_layout.addStretch() + close_layout.addWidget(close_btn) + layout.addLayout(close_layout) + + def _update_cameras_list(self): + """Обновляет отображение списка камер""" + self.cameras_list.clear() + for camera in self.cameras: + self.cameras_list.addItem(camera) + self._selected_camera = None + self.remove_camera_btn.setEnabled(False) + + def _update_lenses_list(self): + """Обновляет отображение списка объективов""" + self.lenses_list.clear() + for lens in self.lenses: + self.lenses_list.addItem(lens) + self._selected_lens = None + self.remove_lens_btn.setEnabled(False) + + def _select_camera(self, camera: str): + """Выделяет камеру в списке""" + self._selected_camera = camera + self.remove_camera_btn.setEnabled(True) + + # Снимаем выделение с объективов + self.lenses_list.clearSelection() + self._selected_lens = None + self.remove_lens_btn.setEnabled(False) + + def _select_lens(self, lens: str): + """Выделяет объектив в списке""" + self._selected_lens = lens + self.remove_lens_btn.setEnabled(True) + + # Снимаем выделение с камер + self.cameras_list.clearSelection() + self._selected_camera = None + self.remove_camera_btn.setEnabled(False) + + def _add_camera(self): + """Добавляет новую камеру""" + new_camera, ok = QInputDialog.getText(self, "Добавить камеру", "Введите название камеры:") + if ok and new_camera and new_camera.strip(): + new_name = new_camera.strip() + if new_name in self.cameras: + QMessageBox.warning(self, "Ошибка", "Такая камера уже существует!") + return + + self.cameras.append(new_name) + self.config_service.add_camera(new_name) + self._update_cameras_list() + QMessageBox.information(self, "Успех", f"Камера '{new_name}' добавлена") + + def _remove_camera(self): + """Удаляет выбранную камеру""" + if hasattr(self, '_selected_camera') and self._selected_camera: + reply = QMessageBox.question(self, "Подтверждение", + f"Удалить камеру '{self._selected_camera}'?", + QMessageBox.Yes | QMessageBox.No) + if reply == QMessageBox.Yes: + self.cameras.remove(self._selected_camera) + self.config_service.remove_camera(self._selected_camera) + self._update_cameras_list() + QMessageBox.information(self, "Успех", f"Камера '{self._selected_camera}' удалена") + + def _add_lens(self): + """Добавляет новый объектив""" + new_lens, ok = QInputDialog.getText(self, "Добавить объектив", "Введите название объектива:") + if ok and new_lens and new_lens.strip(): + new_name = new_lens.strip() + if new_name in self.lenses: + QMessageBox.warning(self, "Ошибка", "Такой объектив уже существует!") + return + + self.lenses.append(new_name) + self.config_service.add_lens(new_name) + self._update_lenses_list() + QMessageBox.information(self, "Успех", f"Объектив '{new_name}' добавлен") + + def _remove_lens(self): + """Удаляет выбранный объектив""" + if hasattr(self, '_selected_lens') and self._selected_lens: + reply = QMessageBox.question(self, "Подтверждение", + f"Удалить объектив '{self._selected_lens}'?", + QMessageBox.Yes | QMessageBox.No) + if reply == QMessageBox.Yes: + self.lenses.remove(self._selected_lens) + self.config_service.remove_lens(self._selected_lens) + self._update_lenses_list() + QMessageBox.information(self, "Успех", f"Объектив '{self._selected_lens}' удалён") \ No newline at end of file diff --git a/ui/dialogs/instructions_dialog.py b/ui/dialogs/instructions_dialog.py index e69de29..64b8b28 100644 --- a/ui/dialogs/instructions_dialog.py +++ b/ui/dialogs/instructions_dialog.py @@ -0,0 +1,164 @@ +""" +InstructionsDialog - диалог с инструкцией по использованию +""" +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, + QPushButton, QScrollArea +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + + +class InstructionsDialog(QDialog): + """Диалог с подробной инструкцией пользователя""" + + def __init__(self, parent): + super().__init__(parent) + + self.setWindowTitle("Инструкция по использованию") + self.setMinimumSize(700, 500) + self.resize(750, 550) + + self._create_ui() + + def _create_ui(self): + """Создаёт интерфейс диалога""" + layout = QVBoxLayout(self) + layout.setSpacing(10) + layout.setContentsMargins(15, 15, 15, 15) + + # Заголовок + title_label = QLabel("Astro Session Watcher - Руководство пользователя") + title_font = QFont() + title_font.setPointSize(16) + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignCenter) + layout.addWidget(title_label) + + # Текст инструкции + text_edit = QTextEdit() + text_edit.setReadOnly(True) + text_edit.setFont(QFont("Consolas", 10)) + + instructions = """ +======================= ASTRO SESSION WATCHER ======================= + +Приложение автоматически отслеживает появление новых фотографий в указанной папке, +сортирует их по объектам съемки и ведет подробный лог всего процесса. + +📸 ДЛЯ ЧЕГО ЭТО НУЖНО? +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Когда вы снимаете астрономические объекты через EOS Utility или аналогичное ПО, +все фотографии сохраняются в одну папку. Astro Session Watcher помогает: + +• Автоматически распределять снимки по папкам объектов +• Вести лог каждой сессии +• Не пропустить ни одного кадра при смене объекта +• Хранить историю оборудования и небесных тел + +🚀 КАК ЭТО РАБОТАЕТ? +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +1. Вы выбираете папку, куда камера сохраняет снимки +2. Приложение создает папку сессии: "AstroSession_ГГГГ-ММ-ДД" +3. Каждый объект съемки получает свою подпапку внутри папки сессии +4. Когда вы меняете объект (нажимаете "Новая цель"), все накопленные + в папке наблюдения файлы автоматически ПЕРЕМЕЩАЮТСЯ в папку предыдущего объекта +5. При завершении сессии оставшиеся файлы также перемещаются в папку последнего объекта + +📝 ПОШАГОВАЯ ИНСТРУКЦИЯ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +█ 1. ПЕРВЫЙ ЗАПУСК (НАСТРОЙКА) +─────────────────────────────────────────────────────────────────────────────── + +• Откройте меню "Файл" → "Оборудование" и добавьте ваши камеры и объективы +• Откройте меню "Файл" → "Небесные тела" и добавьте объекты для наблюдения +• Все данные сохраняются автоматически в файлах настроек + +█ 2. ЗАПУСК СЕССИИ +─────────────────────────────────────────────────────────────────────────────── + +1. Нажмите "Обзор" и выберите папку, куда камера сохраняет снимки +2. Выберите камеру и объектив из выпадающих списков +3. Введите название цели (или выберите из списка небесных тел) +4. Нажмите ▶ "Начать отслеживание" + +✅ После запуска: + • Статус изменится на "● ON AIR" с мигающим красным текстом + • Кнопка "Новая цель" начнет мигать красным контуром + • В папке наблюдения создастся папка "AstroSession_дата" + • Внутри - папка с вашей первой целью + +█ 3. СМЕНА ОБЪЕКТА ВО ВРЕМЯ СЕССИИ +─────────────────────────────────────────────────────────────────────────────── + +Когда вы заканчиваете снимать один объект и переходите к другому: + +1. Нажмите кнопку "Новая цель" (или Ctrl+Shift+N) +2. Введите название нового объекта +3. Приложение автоматически: + • Переместит все накопленные файлы в папку предыдущего объекта + • Создаст новую папку для следующего объекта + • Сбросит счетчик файлов + • Продолжит отслеживание + +💡 ВАЖНО: Если перед сменой объекта в папке наблюдения уже есть файлы, + они НЕ ПОТЕРЯЮТСЯ - все будут перемещены в папку текущего объекта! + +█ 4. ЗАВЕРШЕНИЕ СЕССИИ +─────────────────────────────────────────────────────────────────────────────── + +1. Нажмите ■ "Остановить" (или Ctrl+X) +2. Приложение: + • Переместит все оставшиеся файлы в папку последнего объекта + • Запишет итоговый лог сессии + • Покажет диалог с предложением открыть папку сессии + • Восстановит интерфейс для новой сессии + +⌨️ ГОРЯЧИЕ КЛАВИШИ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Ctrl + O → Выбрать папку наблюдения +Ctrl + E → Управление оборудованием +Ctrl + B → Управление небесными телами +Ctrl + S → Начать сессию +Ctrl + X → Остановить сессию +Ctrl + F → Открыть папку текущей сессии +Ctrl + Shift+N → Создать новый объект +F1 → О программе +F2 → Эта инструкция + +🔧 ФАЙЛЫ НАСТРОЕК +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +📄 astro_settings.json ← камеры, объективы, последняя папка +📄 celestial_bodies.json ← список небесных тел + +Все файлы хранятся в папке с программой. Вы можете редактировать их вручную. + +📧 ТЕХНИЧЕСКАЯ ПОДДЕРЖКА +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Разработчик: Vic Sergeev +Версия: 0.3.0-alpha + +При обнаружении ошибок или для предложений по улучшению: +• Сообщите разработчику +• Приложите файлы логов (SessionLog.txt, ObjectLog.txt) +""" + + text_edit.setText(instructions) + layout.addWidget(text_edit) + + # Кнопка закрытия + close_layout = QHBoxLayout() + close_layout.addStretch() + + close_btn = QPushButton("Закрыть") + close_btn.clicked.connect(self.accept) + close_layout.addWidget(close_btn) + + layout.addLayout(close_layout) \ No newline at end of file diff --git a/ui/main_window.py b/ui/main_window.py index e69de29..eff6d48 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -0,0 +1,642 @@ +""" +MainWindow - главное окно приложения на PySide6 +""" +import sys +import subprocess +import platform +from pathlib import Path +from datetime import datetime +from typing import Optional + +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QLineEdit, QComboBox, QPushButton, QMenuBar, QMenu, + QMessageBox, QFileDialog, QInputDialog, QFrame, QApplication +) +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtGui import QFont, QIcon, QAction + +from services.config_service import ConfigService +from services.session_service import SessionService +from services.watch_service import WatchService +from services.file_service import FileService + + +class MainWindow(QMainWindow): + """Главное окно приложения""" + + def __init__(self): + super().__init__() + + # Сервисы + self.config_service = ConfigService() + self.session_service = SessionService() + self.watch_service = WatchService() + + # Переменные состояния + self.running = False + self.file_count = 0 + self._blink_timer = None + self._new_object_blink_timer = None + + # Настройка окна + self.setWindowTitle("Astro Session Watcher v0.3.0") + self.setMinimumSize(700, 500) + self.resize(800, 550) + + self.center_window() + self._create_menu_bar() + self._create_main_content() + self._load_saved_settings() + self._setup_hotkeys() + self._update_file_count_display() + + self.setAttribute(Qt.WA_DeleteOnClose) + + def center_window(self): + screen = QApplication.primaryScreen().availableGeometry() + self.setGeometry( + (screen.width() - self.width()) // 2, + (screen.height() - self.height()) // 2, + self.width(), + self.height() + ) + + def _create_menu_bar(self): + menubar = self.menuBar() + + # Меню Файл + file_menu = menubar.addMenu("Файл") + + select_folder_action = QAction("Выбрать папку...", self) + select_folder_action.setShortcut("Ctrl+O") + select_folder_action.triggered.connect(self.select_folder) + file_menu.addAction(select_folder_action) + + file_menu.addSeparator() + + equipment_action = QAction("Оборудование...", self) + equipment_action.setShortcut("Ctrl+E") + equipment_action.triggered.connect(self.open_equipment_dialog) + file_menu.addAction(equipment_action) + + celestial_action = QAction("Небесные тела...", self) + celestial_action.setShortcut("Ctrl+B") + celestial_action.triggered.connect(self.open_celestial_dialog) + file_menu.addAction(celestial_action) + + file_menu.addSeparator() + + exit_action = QAction("Выход", self) + exit_action.setShortcut("Ctrl+Q") + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # Меню Сессия + session_menu = menubar.addMenu("Сессия") + + start_action = QAction("Начать наблюдение", self) + start_action.setShortcut("Ctrl+S") + start_action.triggered.connect(self.start) + session_menu.addAction(start_action) + + stop_action = QAction("Остановить наблюдение", self) + stop_action.setShortcut("Ctrl+X") + stop_action.triggered.connect(self.stop) + session_menu.addAction(stop_action) + + session_menu.addSeparator() + + open_folder_action = QAction("Открыть папку сессии", self) + open_folder_action.setShortcut("Ctrl+F") + open_folder_action.triggered.connect(self.open_session_folder) + session_menu.addAction(open_folder_action) + + session_menu.addSeparator() + + new_object_action = QAction("Новая цель...", self) + new_object_action.setShortcut("Ctrl+Shift+N") + new_object_action.triggered.connect(self.set_new_object) + session_menu.addAction(new_object_action) + + # Меню Помощь + help_menu = menubar.addMenu("Помощь") + + instructions_action = QAction("Инструкция", self) + instructions_action.setShortcut("F2") + instructions_action.triggered.connect(self.show_instructions) + help_menu.addAction(instructions_action) + + help_menu.addSeparator() + + about_action = QAction("О программе", self) + about_action.setShortcut("F1") + about_action.triggered.connect(self.show_info) + help_menu.addAction(about_action) + + def _create_main_content(self): + central_widget = QWidget() + self.setCentralWidget(central_widget) + + main_layout = QVBoxLayout(central_widget) + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(15) + + grid_layout = QGridLayout() + grid_layout.setVerticalSpacing(12) + grid_layout.setHorizontalSpacing(15) + + # Row 0: Папка + folder_label = QLabel("Папка:") + folder_label.setFont(QFont("", 10, QFont.Bold)) + grid_layout.addWidget(folder_label, 0, 0, Qt.AlignRight | Qt.AlignVCenter) + + folder_widget = QWidget() + folder_layout = QHBoxLayout(folder_widget) + folder_layout.setContentsMargins(0, 0, 0, 0) + folder_layout.setSpacing(10) + + self.folder_entry = QLineEdit() + self.folder_entry.setPlaceholderText("Выберите папку для отслеживания") + folder_layout.addWidget(self.folder_entry) + + self.folder_button = QPushButton("Обзор...") + self.folder_button.setFixedWidth(80) + self.folder_button.clicked.connect(self.select_folder) + folder_layout.addWidget(self.folder_button) + + grid_layout.addWidget(folder_widget, 0, 1) + + # Row 1: Оборудование + equipment_label = QLabel("Оборудование:") + equipment_label.setFont(QFont("", 10, QFont.Bold)) + grid_layout.addWidget(equipment_label, 1, 0, Qt.AlignRight | Qt.AlignVCenter) + + equipment_widget = QWidget() + equipment_layout = QHBoxLayout(equipment_widget) + equipment_layout.setContentsMargins(0, 0, 0, 0) + equipment_layout.setSpacing(10) + + self.camera_combo = QComboBox() + self.camera_combo.setEditable(False) + equipment_layout.addWidget(self.camera_combo) + + self.lens_combo = QComboBox() + self.lens_combo.setEditable(False) + equipment_layout.addWidget(self.lens_combo) + + grid_layout.addWidget(equipment_widget, 1, 1) + + # Row 2: Цель + target_label = QLabel("Цель:") + target_label.setFont(QFont("", 10, QFont.Bold)) + grid_layout.addWidget(target_label, 2, 0, Qt.AlignRight | Qt.AlignVCenter) + + target_widget = QWidget() + target_layout = QHBoxLayout(target_widget) + target_layout.setContentsMargins(0, 0, 0, 0) + target_layout.setSpacing(10) + + self.object_combo = QComboBox() + self.object_combo.setEditable(True) + self.object_combo.setInsertPolicy(QComboBox.NoInsert) + # Настройка плейсхолдера + self.object_combo.lineEdit().setPlaceholderText("Введите название цели") + # Автодополнение при вводе + self.object_combo.lineEdit().textChanged.connect(self._on_object_text_changed) + target_layout.addWidget(self.object_combo) + + self.new_object_button = QPushButton("Новая цель") + self.new_object_button.setFixedWidth(100) + self.new_object_button.setEnabled(False) + self.new_object_button.clicked.connect(self.set_new_object) + target_layout.addWidget(self.new_object_button) + + grid_layout.addWidget(target_widget, 2, 1) + + # Row 3: Статистика + stats_label = QLabel("Статистика:") + stats_label.setFont(QFont("", 10, QFont.Bold)) + grid_layout.addWidget(stats_label, 3, 0, Qt.AlignRight | Qt.AlignVCenter) + + self.file_count_label = QLabel("Файлов получено: 0") + self.file_count_label.setFont(QFont("", 11)) + grid_layout.addWidget(self.file_count_label, 3, 1, Qt.AlignLeft) + + # Row 4: Статус + status_label = QLabel("Статус:") + status_label.setFont(QFont("", 10, QFont.Bold)) + grid_layout.addWidget(status_label, 4, 0, Qt.AlignRight | Qt.AlignVCenter) + + self.status_label = QLabel("IDLE") + self.status_label.setFont(QFont("", 12, QFont.Bold)) + self.status_label.setStyleSheet("color: #666666;") + grid_layout.addWidget(self.status_label, 4, 1, Qt.AlignLeft) + + main_layout.addLayout(grid_layout) + + separator = QFrame() + separator.setFrameShape(QFrame.HLine) + separator.setStyleSheet("background-color: #333333; max-height: 1px;") + main_layout.addWidget(separator) + + buttons_layout = QHBoxLayout() + buttons_layout.setSpacing(15) + buttons_layout.setAlignment(Qt.AlignCenter) + + self.start_button = QPushButton("▶ Начать отслеживание") + self.start_button.setFixedSize(180, 35) + self.start_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; + color: white; + font-weight: bold; + border-radius: 4px; + } + QPushButton:hover { + background-color: #45a049; + } + """) + self.start_button.clicked.connect(self.start) + buttons_layout.addWidget(self.start_button) + + self.stop_button = QPushButton("■ Остановить") + self.stop_button.setFixedSize(180, 35) + self.stop_button.setEnabled(False) + self.stop_button.setStyleSheet(""" + QPushButton { + background-color: #f44336; + color: white; + font-weight: bold; + border-radius: 4px; + } + QPushButton:hover { + background-color: #d32f2f; + } + """) + self.stop_button.clicked.connect(self.stop) + buttons_layout.addWidget(self.stop_button) + + main_layout.addLayout(buttons_layout) + + footer_layout = QHBoxLayout() + + version_label = QLabel("v0.3.0-alpha") + version_label.setStyleSheet("color: #666666; font-size: 11px;") + footer_layout.addWidget(version_label) + + footer_layout.addStretch() + + copyright_label = QLabel("Made by Vic Sergeev 2026") + copyright_label.setStyleSheet("color: #666666; font-size: 11px;") + footer_layout.addWidget(copyright_label) + + main_layout.addLayout(footer_layout) + + def _on_object_text_changed(self, text): + """Автодополнение при вводе названия цели""" + if not text: + return + + # Поиск совпадений в списке небесных тел + celestial_bodies = self.config_service.get_celestial_bodies() + matches = [body for body in celestial_bodies if body.lower().startswith(text.lower())] + + if matches and matches[0] != text: + # Временно отключаем сигнал, чтобы избежать рекурсии + self.object_combo.lineEdit().blockSignals(True) + self.object_combo.lineEdit().setText(matches[0]) + self.object_combo.lineEdit().setSelection(len(text), len(matches[0])) + self.object_combo.lineEdit().blockSignals(False) + + def _load_saved_settings(self): + cameras = self.config_service.get_cameras() + lenses = self.config_service.get_lenses() + celestial_bodies = self.config_service.get_celestial_bodies() + + if cameras: + self.camera_combo.addItems(cameras) + last_camera = self.config_service.get_last_camera() + if last_camera and last_camera in cameras: + self.camera_combo.setCurrentText(last_camera) + + if lenses: + self.lens_combo.addItems(lenses) + last_lens = self.config_service.get_last_lens() + if last_lens and last_lens in lenses: + self.lens_combo.setCurrentText(last_lens) + + if celestial_bodies: + self.object_combo.addItems(celestial_bodies) + + last_folder = self.config_service.get_last_watch_folder() + if last_folder: + self.folder_entry.setText(last_folder) + + def _setup_hotkeys(self): + pass + + def _set_running_state(self, state: bool): + self.running = state + + if state: + self.start_button.setEnabled(False) + self.stop_button.setEnabled(True) + self.new_object_button.setEnabled(True) + self.status_label.setText("● ON AIR") + self.status_label.setStyleSheet("color: #ff0000; font-weight: bold;") + self._start_blinking() + self._start_new_object_blinking() + else: + self.start_button.setEnabled(True) + self.stop_button.setEnabled(False) + self.new_object_button.setEnabled(False) + self.status_label.setText("IDLE") + self.status_label.setStyleSheet("color: #666666; font-weight: bold;") + self._stop_blinking() + self._stop_new_object_blinking() + + def _start_blinking(self): + self._blink_timer = QTimer() + self._blink_timer.timeout.connect(self._do_blink) + self._blink_timer.start(500) + + def _do_blink(self): + if not self.running: + return + current_style = self.status_label.styleSheet() + if "color: #ff0000" in current_style: + self.status_label.setStyleSheet("color: #ffffff; font-weight: bold;") + else: + self.status_label.setStyleSheet("color: #ff0000; font-weight: bold;") + + def _stop_blinking(self): + if self._blink_timer: + self._blink_timer.stop() + self._blink_timer = None + self.status_label.setStyleSheet("color: #666666; font-weight: bold;") + + def _start_new_object_blinking(self): + self._new_object_blink_timer = QTimer() + self._new_object_blink_timer.timeout.connect(self._do_new_object_blink) + self._new_object_blink_timer.start(500) + + def _do_new_object_blink(self): + if not self.running: + return + current_style = self.new_object_button.styleSheet() + if "border: 2px solid red" in current_style: + self.new_object_button.setStyleSheet("") + else: + self.new_object_button.setStyleSheet("border: 2px solid red; border-radius: 4px;") + + def _stop_new_object_blinking(self): + if self._new_object_blink_timer: + self._new_object_blink_timer.stop() + self._new_object_blink_timer = None + self.new_object_button.setStyleSheet("") + + def _update_file_count_display(self): + if self.running and self.session_service.get_current_object(): + current_obj = self.session_service.get_current_object() + self.file_count = current_obj.photo_count + self.file_count_label.setText(f"Файлов получено: {self.file_count}") + QTimer.singleShot(1000, self._update_file_count_display) + + def _on_file_received(self, file_path: Path): + """Обработчик получения нового файла""" + print(f"Обнаружен файл: {file_path}") + if self.session_service.handle_file(file_path): + self.file_count += 1 + self.file_count_label.setText(f"Файлов получено: {self.file_count}") + print(f"Файл обработан: {file_path.name}") + else: + print(f"Не удалось обработать файл: {file_path}") + + def select_folder(self): + folder = QFileDialog.getExistingDirectory(self, "Выберите папку для отслеживания") + if folder: + self.folder_entry.setText(folder) + self.config_service.set_last_watch_folder(folder) + + def start(self): + watch_folder = self.folder_entry.text() + object_name = self.object_combo.currentText() + + if not watch_folder: + QMessageBox.critical(self, "Ошибка", "Папка для отслеживания не выбрана") + return + + if not object_name: + QMessageBox.critical(self, "Ошибка", "Цель не указана") + return + + # Проверка, существует ли объект в списке небесных тел + celestial_bodies = self.config_service.get_celestial_bodies() + if object_name not in celestial_bodies: + reply = QMessageBox.question(self, "Новый объект", + f"Объект '{object_name}' не найден в списке.\nДобавить его в список?", + QMessageBox.Yes | QMessageBox.No) + if reply == QMessageBox.Yes: + self.config_service.add_celestial_body(object_name) + self.object_combo.addItem(object_name) + else: + return + + camera = self.camera_combo.currentText() + lens = self.lens_combo.currentText() + + if not camera or not lens: + reply = QMessageBox.question(self, "Предупреждение", + "Камера или объектив не выбраны. Продолжить?", + QMessageBox.Yes | QMessageBox.No) + if reply == QMessageBox.No: + return + + try: + watch_path = Path(watch_folder) + + # Очищаем папку наблюдения от старых файлов + FileService.clear_watch_folder(watch_path) + + camera_val = camera if camera else "Unknown" + lens_val = lens if lens else "Unknown" + + self.session_service.start_session(watch_path, object_name, camera_val, lens_val) + + self.config_service.set_last_camera(camera_val) + self.config_service.set_last_lens(lens_val) + + # Запускаем отслеживание + success = self.watch_service.start(watch_path, self._on_file_received) + if not success: + QMessageBox.critical(self, "Ошибка", "Не удалось запустить отслеживание папки") + return + + self._set_running_state(True) + + print(f"Отслеживание начато! Папка наблюдения: {watch_path}") + print(f"Папка сессии: {self.session_service.get_current_session().session_folder}") + + except Exception as e: + QMessageBox.critical(self, "Ошибка", f"Не удалось начать сессию: {e}") + import traceback + traceback.print_exc() + + def stop(self): + if not self.running: + return + + try: + watch_folder = Path(self.folder_entry.text()) + + print(f"Остановка сессии. Перемещаем файлы из {watch_folder}") + + # Перемещаем все оставшиеся файлы + moved_count = self.watch_service.move_all_existing_files(watch_folder, self._on_file_received) + print(f"Перемещено файлов: {moved_count}") + + # Останавливаем отслеживание + self.watch_service.stop() + + # Завершаем сессию + session = self.session_service.finish_session() + + self._set_running_state(False) + + # Показываем диалог завершения + self._show_session_end_dialog(session) + + except Exception as e: + QMessageBox.critical(self, "Ошибка", f"Ошибка при завершении сессии: {e}") + import traceback + traceback.print_exc() + + def set_new_object(self): + if not self.running: + QMessageBox.critical(self, "Ошибка", "Сессия не активна") + return + + # Перемещаем все накопленные файлы в папку текущего объекта + watch_folder = Path(self.folder_entry.text()) + moved_count = self.watch_service.move_all_existing_files(watch_folder, self._on_file_received) + + if moved_count > 0: + print(f"Перемещено файлов перед сменой объекта: {moved_count}") + + new_object, ok = QInputDialog.getText(self, "Новый объект", "Введите название объекта:") + + if ok and new_object and new_object.strip(): + new_name = new_object.strip() + + # Проверка, существует ли объект в списке + celestial_bodies = self.config_service.get_celestial_bodies() + if new_name not in celestial_bodies: + reply = QMessageBox.question(self, "Новый объект", + f"Объект '{new_name}' не найден в списке.\nДобавить его в список?", + QMessageBox.Yes | QMessageBox.No) + if reply == QMessageBox.Yes: + self.config_service.add_celestial_body(new_name) + self.object_combo.addItem(new_name) + else: + return + + self.session_service.create_new_object(new_name) + self.object_combo.setCurrentText(new_name) + QMessageBox.information(self, "Успех", f"Объект изменён на: {new_name}") + + def open_equipment_dialog(self): + from ui.dialogs.equipment_dialog import EquipmentDialog + dialog = EquipmentDialog(self, self.config_service) + dialog.exec() + + self.camera_combo.clear() + self.lens_combo.clear() + self.camera_combo.addItems(self.config_service.get_cameras()) + self.lens_combo.addItems(self.config_service.get_lenses()) + + def open_celestial_dialog(self): + from ui.dialogs.celestial_dialog import CelestialDialog + dialog = CelestialDialog(self, self.config_service) + dialog.exec() + + self.object_combo.clear() + self.object_combo.addItems(self.config_service.get_celestial_bodies()) + + def open_session_folder(self): + if self.running and self.session_service.get_current_session(): + folder = self.session_service.get_current_session().session_folder + if folder and folder.exists(): + try: + if platform.system() == "Windows": + subprocess.Popen(['explorer', str(folder)]) + elif platform.system() == "Darwin": + subprocess.Popen(['open', str(folder)]) + else: + subprocess.Popen(['xdg-open', str(folder)]) + except Exception as e: + QMessageBox.critical(self, "Ошибка", f"Не удалось открыть папку: {e}") + else: + QMessageBox.critical(self, "Ошибка", "Папка сессии не найдена") + else: + QMessageBox.information(self, "Информация", "Нет активной сессии") + + def show_instructions(self): + from ui.dialogs.instructions_dialog import InstructionsDialog + dialog = InstructionsDialog(self) + dialog.exec() + + def show_info(self): + QMessageBox.about(self, "О программе", + "Astro Session Watcher\nВерсия: 0.3.0-alpha\n\n" + "Приложение для автоматической сортировки астрофотографий\n\n" + "Особенности:\n" + "• Автоматическое отслеживание новых файлов\n" + "• Сортировка по объектам съёмки\n" + "• Ведение детальных логов\n" + "• Сохранение истории оборудования\n\n" + "Разработчик: Vic Sergeev\n2026") + + def _show_session_end_dialog(self, session): + current_object = session.get_current_object() + object_name = current_object.name if current_object else "Unknown" + photo_count = current_object.photo_count if current_object else 0 + session_folder = session.session_folder + + msg_box = QMessageBox(self) + msg_box.setWindowTitle("Сессия завершена") + msg_box.setIcon(QMessageBox.Information) + msg_box.setText(f"Наблюдение остановлено\n\nСессия для объекта '{object_name}' завершена.\nПолучено файлов: {photo_count}") + msg_box.setInformativeText(f"Папка с данными:\n{session_folder}") + + open_folder_btn = msg_box.addButton("📁 Открыть папку", QMessageBox.AcceptRole) + close_btn = msg_box.addButton("Закрыть", QMessageBox.RejectRole) + + msg_box.exec() + if msg_box.clickedButton() == open_folder_btn: + if session_folder and session_folder.exists(): + try: + if platform.system() == "Windows": + subprocess.Popen(['explorer', str(session_folder)]) + elif platform.system() == "Darwin": + subprocess.Popen(['open', str(session_folder)]) + else: + subprocess.Popen(['xdg-open', str(session_folder)]) + except Exception as e: + QMessageBox.critical(self, "Ошибка", f"Не удалось открыть папку: {e}") + + def closeEvent(self, event): + if self.running: + reply = QMessageBox.question(self, "Выход", + "Сессия активна. Остановить сессию и выйти?", + QMessageBox.Yes | QMessageBox.No) + if reply == QMessageBox.Yes: + try: + self.stop() + except: + pass + event.accept() + else: + event.ignore() + else: + event.accept() \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py index e69de29..92ecf6f 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -0,0 +1,4 @@ +# Utils package +from utils.sound_manager import SoundManager + +__all__ = ['SoundManager'] \ No newline at end of file diff --git a/utils/__pycache__/__init__.cpython-313.pyc b/utils/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..6404c8f Binary files /dev/null and b/utils/__pycache__/__init__.cpython-313.pyc differ