From c1725151106ccb4cfee202a85e4e4655a8a556ad Mon Sep 17 00:00:00 2001 From: benya Date: Thu, 19 Feb 2026 20:18:11 +0300 Subject: [PATCH] Refine scheduling UI and progress --- .gitignore | 1 + main.py | 785 ++++++++++++++++++++++++++++++--------- tests/test_copy_logic.py | 72 +++- 3 files changed, 683 insertions(+), 175 deletions(-) diff --git a/.gitignore b/.gitignore index 71980e7..553596b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ **/__pycache__/ *.pyc *.log +backup_copier_settings.json diff --git a/main.py b/main.py index 79eaf16..073680c 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ import tkinter as tk -from tkinter import ttk, filedialog, messagebox, scrolledtext +from tkinter import ttk, filedialog, messagebox, scrolledtext, simpledialog import shutil import os from pathlib import Path @@ -18,6 +18,9 @@ import winreg import contextlib import logging import logging.handlers +import fnmatch +import ctypes +from concurrent.futures import ThreadPoolExecutor, as_completed try: import pystray from PIL import Image, ImageDraw @@ -34,22 +37,47 @@ ICON_PATH = "icon.ico" APP_NAME = "BackupCopier" COPY_RETRIES = 2 COPY_RETRY_DELAY = 2 +DEFAULT_MIN_FREE_GB = 1 +DEFAULT_MAX_WORKERS = 3 +DEFAULT_INCLUDE_MASKS = "*.bak;*.sql;*.backup" +DEFAULT_EXCLUDE_MASKS = "*.tmp;*.temp" +MUTEX_NAME = "Global\\BackupCopierMutex" -def find_latest_file_in_folder(folder_path: str, extensions=DEFAULT_EXTENSIONS) -> Optional[Path]: +def find_latest_file_in_folder(folder_path: str, extensions=DEFAULT_EXTENSIONS, + include_masks: Optional[List[str]] = None, + exclude_masks: Optional[List[str]] = None) -> Optional[Path]: """Возвращает самый новый файл из папки по времени модификации.""" folder = Path(folder_path) if not folder.exists(): return None files: List[Path] = [] - for ext in extensions: - files.extend(folder.glob(ext)) + include_masks = include_masks or [] + exclude_masks = exclude_masks or [] - if not files: + if include_masks: + for mask in include_masks: + files.extend(folder.glob(mask)) + else: + for ext in extensions: + files.extend(folder.glob(ext)) + + filtered = [] + seen = set() + for f in files: + if not f.is_file(): + continue + if f.name in seen: + continue + if matches_masks(f.name, include_masks, exclude_masks): + filtered.append(f) + seen.add(f.name) + + if not filtered: return None - return max(files, key=lambda f: f.stat().st_mtime) + return max(filtered, key=lambda f: f.stat().st_mtime) def should_copy_file(source: Path, target: Path) -> bool: @@ -77,6 +105,45 @@ def verify_copy(source: Path, target: Path) -> bool: return compute_file_checksum(source) == compute_file_checksum(target) +def get_resource_path(relative_path: str) -> str: + """Возвращает абсолютный путь к ресурсу (поддерживает PyInstaller).""" + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + return os.path.join(sys._MEIPASS, relative_path) + return os.path.abspath(relative_path) + + +def parse_masks(mask_text: str) -> List[str]: + parts = [p.strip() for p in (mask_text or "").split(";")] + return [p for p in parts if p] + + +def matches_masks(filename: str, include_masks: List[str], exclude_masks: List[str]) -> bool: + if include_masks: + if not any(fnmatch.fnmatch(filename, m) for m in include_masks): + return False + if exclude_masks: + if any(fnmatch.fnmatch(filename, m) for m in exclude_masks): + return False + return True + + +def get_free_space_gb(path: Path) -> float: + try: + usage = shutil.disk_usage(str(path)) + return usage.free / (1024 ** 3) + except Exception: + return 0.0 + + +def ensure_single_instance() -> Optional[int]: + """Создает системный mutex. Возвращает handle или None если уже запущено.""" + mutex = ctypes.windll.kernel32.CreateMutexW(None, True, MUTEX_NAME) + already_exists = ctypes.windll.kernel32.GetLastError() == 183 + if already_exists: + return None + return mutex + + class FileCopyScheduler: """Класс для управления расписанием копирования""" @@ -105,23 +172,35 @@ class FileCopyScheduler: schedule.run_pending() time_module.sleep(1) - def schedule_copy_job(self, job_id: str, time_str: str, pairs: List[tuple]): - """Добавляет задание в планировщик""" + def schedule_copy_job(self, job_id: str, schedules: List[dict], pairs: List[tuple]): + """Добавляет задания в планировщик""" # Очищаем предыдущие задания с таким же ID schedule.clear(job_id) - # Парсим время - try: - hour, minute = map(int, time_str.split(':')) - except: - hour, minute = 3, 0 # По умолчанию 3:00 + if not schedules: + return - # Добавляем ежедневное задание - schedule.every().day.at(f"{hour:02d}:{minute:02d}").do( - self._execute_copy_job, pairs=pairs - ).tag(job_id) + day_map = { + "Mon": schedule.every().monday, + "Tue": schedule.every().tuesday, + "Wed": schedule.every().wednesday, + "Thu": schedule.every().thursday, + "Fri": schedule.every().friday, + "Sat": schedule.every().saturday, + "Sun": schedule.every().sunday, + } - self.app.log_message(f"📅 Запланировано копирование на {hour:02d}:{minute:02d} ежедневно", "info") + for entry in schedules: + time_str = entry.get("time", "03:00") + days = entry.get("days", []) + if not days: + schedule.every().day.at(time_str).do(self._execute_copy_job, pairs=pairs).tag(job_id) + self.app.log_message(f"📅 Запланировано копирование на {time_str} ежедневно", "info") + else: + for day in days: + if day in day_map: + day_map[day].at(time_str).do(self._execute_copy_job, pairs=pairs).tag(job_id) + self.app.log_message(f"📅 Запланировано копирование на {time_str} ({', '.join(days)})", "info") def _execute_copy_job(self, pairs: List[tuple]): """Выполняет задание копирования""" @@ -172,10 +251,29 @@ class BackgroundFileCopyApp: # Последний успешный запуск self.last_success_time: Optional[str] = None self.last_result_summary: Optional[str] = None + self.last_error_detail: Optional[str] = None # Хеши последних копий self.last_hashes: Dict[str, str] = {} + # Профили + self.profiles: Dict[str, dict] = {} + self.active_profile = tk.StringVar(value="Default") + self.include_masks_var = tk.StringVar(value=DEFAULT_INCLUDE_MASKS) + self.exclude_masks_var = tk.StringVar(value=DEFAULT_EXCLUDE_MASKS) + self.min_free_gb_var = tk.DoubleVar(value=DEFAULT_MIN_FREE_GB) + self.max_workers_var = tk.IntVar(value=DEFAULT_MAX_WORKERS) + self.schedules: List[dict] = [] + self.schedule_days_vars = { + "Mon": tk.BooleanVar(value=False), + "Tue": tk.BooleanVar(value=False), + "Wed": tk.BooleanVar(value=False), + "Thu": tk.BooleanVar(value=False), + "Fri": tk.BooleanVar(value=False), + "Sat": tk.BooleanVar(value=False), + "Sun": tk.BooleanVar(value=False), + } + # Логгер в файл self.file_logger = self.setup_file_logger() @@ -210,6 +308,20 @@ class BackgroundFileCopyApp: font=("Arial", 14, "bold")) title_label.pack(pady=8) + profile_frame = ttk.Frame(settings_tab) + profile_frame.pack(fill=tk.X, pady=4) + + ttk.Label(profile_frame, text="Профиль:").pack(side=tk.LEFT) + self.profile_combo = ttk.Combobox(profile_frame, textvariable=self.active_profile, state="readonly", width=20) + self.profile_combo.pack(side=tk.LEFT, padx=5) + self.profile_combo.bind("<>", lambda _e: self.load_profile(self.active_profile.get())) + + ttk.Button(profile_frame, text="➕", width=3, command=self.add_profile).pack(side=tk.LEFT, padx=2) + ttk.Button(profile_frame, text="✏", width=3, command=self.rename_profile).pack(side=tk.LEFT, padx=2) + ttk.Button(profile_frame, text="🗑", width=3, command=self.delete_profile).pack(side=tk.LEFT, padx=2) + ttk.Button(profile_frame, text="Импорт", command=self.import_profiles).pack(side=tk.LEFT, padx=6) + ttk.Button(profile_frame, text="Экспорт", command=self.export_profiles).pack(side=tk.LEFT, padx=2) + status_frame = ttk.LabelFrame(settings_tab, text="Состояние", padding="10") status_frame.pack(fill=tk.X, pady=5) @@ -234,23 +346,12 @@ class BackgroundFileCopyApp: time_frame = ttk.Frame(schedule_frame) time_frame.pack(fill=tk.X, pady=5) - ttk.Label(time_frame, text="Время копирования:", width=18).pack(side=tk.LEFT) - self.hour_var = tk.StringVar(value="03") self.minute_var = tk.StringVar(value="00") - hours = [f"{h:02d}" for h in range(24)] - minutes = [f"{m:02d}" for m in range(60)] - - ttk.Combobox(time_frame, textvariable=self.hour_var, values=hours, - width=5, state="readonly").pack(side=tk.LEFT, padx=2) - ttk.Label(time_frame, text=":").pack(side=tk.LEFT) - ttk.Combobox(time_frame, textvariable=self.minute_var, values=minutes, - width=5, state="readonly").pack(side=tk.LEFT, padx=2) - self.scheduler_enabled = tk.BooleanVar(value=False) self.scheduler_check = ttk.Checkbutton(time_frame, - text="Ежедневно", + text="Включить расписание", variable=self.scheduler_enabled, command=self.toggle_scheduler) self.scheduler_check.pack(side=tk.LEFT, padx=10) @@ -259,6 +360,12 @@ class BackgroundFileCopyApp: font=("Arial", 9, "italic")) self.scheduler_status.pack(side=tk.LEFT, padx=10) + schedule_summary_frame = ttk.Frame(schedule_frame) + schedule_summary_frame.pack(fill=tk.X, pady=4) + self.schedule_summary_label = ttk.Label(schedule_summary_frame, text="Расписание: —") + self.schedule_summary_label.pack(side=tk.LEFT) + ttk.Button(schedule_summary_frame, text="Расписание...", command=self.open_schedule_dialog).pack(side=tk.LEFT, padx=8) + options_frame = ttk.Frame(schedule_frame) options_frame.pack(fill=tk.X, pady=5) @@ -296,6 +403,24 @@ class BackgroundFileCopyApp: command=lambda: self.show_hint("Проверка", "Проверяет совпадение контрольных сумм без копирования.")).pack(side=tk.LEFT, padx=4) + perf_frame = ttk.Frame(schedule_frame) + perf_frame.pack(fill=tk.X, pady=4) + ttk.Label(perf_frame, text="Мин. свободно, ГБ:").pack(side=tk.LEFT) + self.min_free_entry = ttk.Entry(perf_frame, textvariable=self.min_free_gb_var, width=6) + self.min_free_entry.pack(side=tk.LEFT, padx=4) + ttk.Label(perf_frame, text="Параллельных потоков:").pack(side=tk.LEFT, padx=8) + self.max_workers_entry = ttk.Entry(perf_frame, textvariable=self.max_workers_var, width=4) + self.max_workers_entry.pack(side=tk.LEFT, padx=4) + + masks_frame = ttk.Frame(schedule_frame) + masks_frame.pack(fill=tk.X, pady=4) + ttk.Label(masks_frame, text="Включать маски:").pack(side=tk.LEFT) + self.include_entry = ttk.Entry(masks_frame, textvariable=self.include_masks_var) + self.include_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4) + ttk.Label(masks_frame, text="Исключать маски:").pack(side=tk.LEFT, padx=6) + self.exclude_entry = ttk.Entry(masks_frame, textvariable=self.exclude_masks_var) + self.exclude_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4) + buttons_frame = ttk.Frame(schedule_frame) buttons_frame.pack(fill=tk.X, pady=5) @@ -388,6 +513,10 @@ class BackgroundFileCopyApp: command=self.clear_log).pack(side=tk.RIGHT, padx=2) ttk.Button(log_buttons, text="🔍 Отладка", command=self.debug_settings).pack(side=tk.RIGHT, padx=2) + ttk.Button(log_buttons, text="📂 Открыть лог", + command=self.open_log_file).pack(side=tk.LEFT, padx=2) + ttk.Button(log_buttons, text="📁 Папка настроек", + command=self.open_settings_folder).pack(side=tk.LEFT, padx=2) self.log_text = scrolledtext.ScrolledText(log_frame, height=8, wrap=tk.WORD) self.log_text.pack(fill=tk.BOTH, expand=True) @@ -410,11 +539,12 @@ class BackgroundFileCopyApp: relief=tk.SUNKEN, anchor=tk.W) self.status_bar.pack(side=tk.BOTTOM, fill=tk.X) - self.progress = ttk.Progressbar(self.root, mode="indeterminate") + self.progress_var = tk.IntVar(value=0) + self.progress = ttk.Progressbar(self.root, mode="determinate", variable=self.progress_var) self.progress.pack(side=tk.BOTTOM, fill=tk.X) def setup_window_icon(self): - icon_path = os.path.abspath(ICON_PATH) + icon_path = get_resource_path(ICON_PATH) if os.path.exists(icon_path): with contextlib.suppress(Exception): self.root.iconbitmap(icon_path) @@ -530,36 +660,30 @@ class BackgroundFileCopyApp: with open(settings_path, 'r', encoding='utf-8') as f: settings = json.load(f) - # Загружаем время - self.hour_var.set(settings.get('hour', '03')) - self.minute_var.set(settings.get('minute', '00')) self.scheduler_enabled.set(settings.get('enabled', False)) desired_autostart = settings.get('autostart_enabled') self.minimize_to_tray_enabled.set(settings.get('minimize_to_tray', True)) - # Загружаем пары путей - pairs_data = settings.get('pairs', []) - self.log_message(f"📦 Найдено {len(pairs_data)} пар путей в файле", "info") + # Профили + profiles = settings.get("profiles") + if not isinstance(profiles, dict): + # Миграция со старого формата + profiles = {"Default": self.default_profile()} + profiles["Default"]["pairs"] = settings.get('pairs', []) + profiles["Default"]["include_masks"] = settings.get('include_masks', DEFAULT_INCLUDE_MASKS) + profiles["Default"]["exclude_masks"] = settings.get('exclude_masks', DEFAULT_EXCLUDE_MASKS) + profiles["Default"]["min_free_gb"] = settings.get('min_free_gb', DEFAULT_MIN_FREE_GB) + profiles["Default"]["max_workers"] = settings.get('max_workers', DEFAULT_MAX_WORKERS) + profiles["Default"]["verify_only"] = settings.get('verify_only', False) + profiles["Default"]["last_success_time"] = settings.get('last_success_time') + profiles["Default"]["last_result_summary"] = settings.get('last_result_summary') + profiles["Default"]["last_hashes"] = settings.get('last_hashes', {}) + profiles["Default"]["schedules"] = settings.get('schedules', []) - # Очищаем текущие пары - self.remove_all_pairs(silent=True) - - # Добавляем загруженные пары - if pairs_data: - for item in pairs_data: - if isinstance(item, dict): - source = item.get('source', '') - dest = item.get('dest', '') - full_copy = bool(item.get('full_copy', False)) - else: - source, dest = item - full_copy = False - self.add_path_pair(source, dest, full_copy) - self.log_message(f"✅ Загружено {len(pairs_data)} пар путей", "success") - else: - # Если нет сохраненных пар, добавляем одну пустую - self.add_path_pair() - self.log_message("➕ Добавлена пустая пара для ввода", "info") + self.profiles = profiles + self.refresh_profile_combo() + active = settings.get("active_profile") or self.active_profile.get() + self.load_profile(active) # Настройки автозапуска actual_autostart = self.is_autostart_enabled() @@ -571,66 +695,35 @@ class BackgroundFileCopyApp: if desired_autostart != actual_autostart: self.set_autostart_enabled(desired_autostart) - self.verify_only.set(settings.get('verify_only', False)) - self.last_success_time = settings.get('last_success_time') - self.last_result_summary = settings.get('last_result_summary') - self.last_hashes = settings.get('last_hashes', {}) - self.update_last_success_label() - self.update_status_summary() - self.log_message(f"📂 Настройки загружены из {settings_path}", "info") else: # Если файла нет, добавляем пустую пару self.log_message("🆕 Файл настроек не найден, создаем новую конфигурацию", "info") - self.add_path_pair() + self.profiles = {"Default": self.default_profile()} + self.refresh_profile_combo() + self.load_profile("Default") self.autostart_enabled.set(self.is_autostart_enabled()) - self.verify_only.set(False) - self.last_success_time = None - self.last_result_summary = None - self.last_hashes = {} - self.update_last_success_label() - self.update_status_summary() except Exception as e: self.log_message(f"❌ Ошибка при загрузке настроек: {e}", "error") import traceback traceback.print_exc() - # В случае ошибки добавляем пустую пару - self.add_path_pair() + self.profiles = {"Default": self.default_profile()} + self.refresh_profile_combo() + self.load_profile("Default") self.autostart_enabled.set(self.is_autostart_enabled()) - self.verify_only.set(False) - self.last_success_time = None - self.last_result_summary = None - self.last_hashes = {} - self.update_last_success_label() - self.update_status_summary() def save_settings(self): """Сохраняет настройки в файл""" try: - # Собираем данные из полей ввода - pairs_data = [] - for pair in self.copy_pairs: - source = pair.get("source", "").strip() - dest = pair.get("dest", "").strip() - if source or dest: # Сохраняем даже если одно поле пустое - pairs_data.append({ - "source": source, - "dest": dest, - "full_copy": bool(pair.get("full_copy", False)) - }) + self.save_active_profile() settings = { - 'hour': self.hour_var.get(), - 'minute': self.minute_var.get(), 'enabled': self.scheduler_enabled.get(), 'autostart_enabled': self.autostart_enabled.get(), 'minimize_to_tray': self.minimize_to_tray_enabled.get(), - 'verify_only': self.verify_only.get(), - 'last_success_time': self.last_success_time, - 'last_result_summary': self.last_result_summary, - 'last_hashes': self.last_hashes, - 'pairs': pairs_data + 'active_profile': self.active_profile.get(), + 'profiles': self.profiles } settings_path = self.get_settings_path() @@ -639,7 +732,7 @@ class BackgroundFileCopyApp: with open(settings_path, 'w', encoding='utf-8') as f: json.dump(settings, f, ensure_ascii=False, indent=2) - self.log_message(f"💾 Настройки сохранены ({len(pairs_data)} пар путей)", "success") + self.log_message(f"💾 Настройки сохранены (профилей: {len(self.profiles)})", "success") self.log_message(f"📁 Файл: {settings_path}", "info") messagebox.showinfo("Успех", f"Настройки сохранены!\n\nФайл: {settings_path}") @@ -661,9 +754,13 @@ class BackgroundFileCopyApp: "source": source, "dest": dest, "full_copy": bool(full_copy), - "status": "?", + "status": "—", "status_tag": "idle" } + if source or dest: + status_text, status_tag = self.validate_pair(source, dest) + pair["status"] = status_text + pair["status_tag"] = status_tag if source or dest: status_text, status_tag = self.validate_pair(source, dest) pair["status"] = status_text @@ -801,6 +898,240 @@ class BackgroundFileCopyApp: with contextlib.suppress(Exception): os.startfile(path) + def open_log_file(self): + log_path = os.path.join(os.path.dirname(self.get_settings_path()), "backup_copier.log") + if os.path.exists(log_path): + with contextlib.suppress(Exception): + os.startfile(log_path) + + def open_settings_folder(self): + folder = os.path.dirname(self.get_settings_path()) + if os.path.exists(folder): + with contextlib.suppress(Exception): + os.startfile(folder) + + def open_schedule_dialog(self): + dlg = tk.Toplevel(self.root) + dlg.title("Расписание") + dlg.geometry("420x360") + dlg.transient(self.root) + dlg.grab_set() + + time_frame = ttk.Frame(dlg) + time_frame.pack(fill=tk.X, pady=5, padx=10) + ttk.Label(time_frame, text="Время:").pack(side=tk.LEFT) + ttk.Combobox(time_frame, textvariable=self.hour_var, values=[f"{h:02d}" for h in range(24)], width=5, state="readonly").pack(side=tk.LEFT, padx=2) + ttk.Label(time_frame, text=":").pack(side=tk.LEFT) + ttk.Combobox(time_frame, textvariable=self.minute_var, values=[f"{m:02d}" for m in range(60)], width=5, state="readonly").pack(side=tk.LEFT, padx=2) + + days_frame = ttk.Frame(dlg) + days_frame.pack(fill=tk.X, pady=5, padx=10) + ttk.Label(days_frame, text="Дни:").pack(side=tk.LEFT) + day_labels = [("Mon", "Пн"), ("Tue", "Вт"), ("Wed", "Ср"), ("Thu", "Чт"), ("Fri", "Пт"), ("Sat", "Сб"), ("Sun", "Вс")] + for key, label in day_labels: + ttk.Checkbutton(days_frame, text=label, variable=self.schedule_days_vars[key]).pack(side=tk.LEFT, padx=2) + + list_frame = ttk.Frame(dlg) + list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) + tree = ttk.Treeview(list_frame, columns=("time", "days"), show="headings", height=8) + tree.heading("time", text="Время") + tree.heading("days", text="Дни") + tree.column("time", width=80, anchor=tk.CENTER) + tree.column("days", width=220) + scroll = ttk.Scrollbar(list_frame, orient="vertical", command=tree.yview) + tree.configure(yscrollcommand=scroll.set) + tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scroll.pack(side=tk.RIGHT, fill=tk.Y) + + def refresh_tree(): + for item in tree.get_children(): + tree.delete(item) + for idx, entry in enumerate(self.schedules): + days = entry.get("days", []) + days_text = ",".join(days) if days else "Ежедневно" + tree.insert("", "end", iid=f"s{idx}", values=(entry.get("time"), days_text)) + + def add_entry(): + time_str = f"{self.hour_var.get()}:{self.minute_var.get()}" + days = [k for k, v in self.schedule_days_vars.items() if v.get()] + self.schedules.append({"time": time_str, "days": days}) + refresh_tree() + self.update_schedule_summary_label() + + def remove_entry(): + sel = tree.selection() + if not sel: + return + idx = int(sel[0].lstrip("s")) + if 0 <= idx < len(self.schedules): + self.schedules.pop(idx) + refresh_tree() + self.update_schedule_summary_label() + + btns = ttk.Frame(dlg) + btns.pack(fill=tk.X, padx=10, pady=8) + ttk.Button(btns, text="➕ Добавить", command=add_entry).pack(side=tk.LEFT, padx=4) + ttk.Button(btns, text="❌ Удалить", command=remove_entry).pack(side=tk.LEFT, padx=4) + ttk.Button(btns, text="Закрыть", command=dlg.destroy).pack(side=tk.RIGHT, padx=4) + + refresh_tree() + + def refresh_profile_combo(self): + names = sorted(self.profiles.keys()) if self.profiles else ["Default"] + if not self.profiles: + self.profiles["Default"] = self.default_profile() + self.profile_combo["values"] = names + if self.active_profile.get() not in names: + self.active_profile.set(names[0]) + + def default_profile(self) -> dict: + return { + "pairs": [], + "schedules": [], + "include_masks": DEFAULT_INCLUDE_MASKS, + "exclude_masks": DEFAULT_EXCLUDE_MASKS, + "min_free_gb": DEFAULT_MIN_FREE_GB, + "max_workers": DEFAULT_MAX_WORKERS, + "verify_only": False, + "last_success_time": None, + "last_result_summary": None, + "last_hashes": {}, + } + + def save_active_profile(self): + name = self.active_profile.get() or "Default" + profile = self.profiles.get(name, self.default_profile()) + profile["pairs"] = [ + { + "source": p.get("source", ""), + "dest": p.get("dest", ""), + "full_copy": bool(p.get("full_copy", False)), + } + for p in self.copy_pairs + ] + profile["schedules"] = list(self.schedules) + profile["include_masks"] = self.include_masks_var.get() + profile["exclude_masks"] = self.exclude_masks_var.get() + profile["min_free_gb"] = float(self.min_free_gb_var.get() or DEFAULT_MIN_FREE_GB) + profile["max_workers"] = int(self.max_workers_var.get() or DEFAULT_MAX_WORKERS) + profile["verify_only"] = bool(self.verify_only.get()) + profile["last_success_time"] = self.last_success_time + profile["last_result_summary"] = self.last_result_summary + profile["last_hashes"] = self.last_hashes + self.profiles[name] = profile + + def load_profile(self, name: str): + self.save_active_profile() + profile = self.profiles.get(name) or self.default_profile() + self.active_profile.set(name) + self.include_masks_var.set(profile.get("include_masks", DEFAULT_INCLUDE_MASKS)) + self.exclude_masks_var.set(profile.get("exclude_masks", DEFAULT_EXCLUDE_MASKS)) + self.min_free_gb_var.set(profile.get("min_free_gb", DEFAULT_MIN_FREE_GB)) + self.max_workers_var.set(profile.get("max_workers", DEFAULT_MAX_WORKERS)) + self.verify_only.set(profile.get("verify_only", False)) + self.last_success_time = profile.get("last_success_time") + self.last_result_summary = profile.get("last_result_summary") + self.last_hashes = profile.get("last_hashes", {}) + self.update_last_success_label() + self.update_status_summary() + + self.remove_all_pairs(silent=True) + for item in profile.get("pairs", []): + self.add_path_pair(item.get("source", ""), item.get("dest", ""), item.get("full_copy", False)) + self.schedules = profile.get("schedules", []) + self.update_schedule_summary_label() + if self.schedules: + first_time = self.schedules[0].get("time", "03:00") + if ":" in first_time: + hour, minute = first_time.split(":") + self.hour_var.set(hour) + self.minute_var.set(minute) + if self.scheduler_enabled.get(): + self.start_scheduler() + + def add_profile(self): + name = simpledialog.askstring("Новый профиль", "Введите имя профиля:") + if not name: + return + if name in self.profiles: + messagebox.showwarning("Профиль", "Профиль с таким именем уже существует.") + return + self.save_active_profile() + self.profiles[name] = self.default_profile() + self.active_profile.set(name) + self.refresh_profile_combo() + self.load_profile(name) + + def rename_profile(self): + old = self.active_profile.get() + if not old: + return + new = simpledialog.askstring("Переименовать профиль", "Новое имя профиля:", initialvalue=old) + if not new or new == old: + return + if new in self.profiles: + messagebox.showwarning("Профиль", "Профиль с таким именем уже существует.") + return + self.save_active_profile() + self.profiles[new] = self.profiles.pop(old) + self.active_profile.set(new) + self.refresh_profile_combo() + + def delete_profile(self): + name = self.active_profile.get() + if not name: + return + if not messagebox.askyesno("Профили", f"Удалить профиль '{name}'?"): + return + self.profiles.pop(name, None) + self.refresh_profile_combo() + self.load_profile(self.active_profile.get()) + + def export_profiles(self): + self.save_active_profile() + path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON", "*.json")]) + if not path: + return + data = { + "active_profile": self.active_profile.get(), + "profiles": self.profiles + } + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + messagebox.showinfo("Экспорт", "Профили экспортированы.") + + def import_profiles(self): + path = filedialog.askopenfilename(filetypes=[("JSON", "*.json")]) + if not path: + return + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + profiles = data.get("profiles") + if isinstance(profiles, dict): + self.profiles.update(profiles) + self.refresh_profile_combo() + self.load_profile(data.get("active_profile") or self.active_profile.get()) + + def refresh_schedules_tree(self): + self.update_schedule_summary_label() + + def add_schedule_from_inputs(self): + time_str = f"{self.hour_var.get()}:{self.minute_var.get()}" + days = [k for k, v in self.schedule_days_vars.items() if v.get()] + entry = {"time": time_str, "days": days} + self.schedules.append(entry) + self.update_schedule_summary_label() + if self.scheduler_enabled.get(): + self.start_scheduler() + + def remove_selected_schedule(self): + if not self.schedules: + return + self.schedules.pop() + self.update_schedule_summary_label() + if self.scheduler_enabled.get(): + self.start_scheduler() + def show_hint(self, title: str, message: str): messagebox.showinfo(title, message) @@ -953,7 +1284,9 @@ class BackgroundFileCopyApp: def find_latest_file(self, folder_path: str) -> Optional[Path]: """Находит самый последний файл в папке""" try: - latest_file = find_latest_file_in_folder(folder_path) + include_masks = parse_masks(self.include_masks_var.get()) + exclude_masks = parse_masks(self.exclude_masks_var.get()) + latest_file = find_latest_file_in_folder(folder_path, include_masks=include_masks, exclude_masks=exclude_masks) if not latest_file: if not Path(folder_path).exists(): self.log_message(f"❌ Папка не существует: {folder_path}", "error") @@ -979,6 +1312,17 @@ class BackgroundFileCopyApp: def set_status_text(self, text: str): self.root.after(0, lambda: self.status_bar.config(text=text)) + def set_progress_total(self, total: int): + def _set(): + self.progress.config(maximum=max(1, total)) + self.progress_var.set(0) + self.root.after(0, _set) + + def step_progress(self, step: int = 1): + def _step(): + self.progress_var.set(self.progress_var.get() + step) + self.root.after(0, _step) + def check_previous_hash(self, target_file: Path): key = str(target_file) if key in self.last_hashes: @@ -1005,12 +1349,26 @@ class BackgroundFileCopyApp: if not target.exists(): self.log_message(f"❌ Нет целевого файла для проверки: {target.name}", "error") return False + if not should_copy_file(source, target): + self.log_message(f"⏭️ Проверка пропущена (не изменен): {target.name}", "warning") + return True if verify_copy(source, target): self.log_message(f"✅ Проверка OK: {target.name}", "success") return True self.log_message(f"❌ Проверка не пройдена: {target.name}", "error") return False + def has_enough_space(self, dest_path: Path, size_bytes: int) -> bool: + min_gb = float(self.min_free_gb_var.get() or DEFAULT_MIN_FREE_GB) + free_gb = get_free_space_gb(dest_path) + if free_gb < min_gb: + self.log_message(f"❌ Недостаточно места на диске: свободно {free_gb:.2f} ГБ, минимум {min_gb} ГБ", "error") + return False + if size_bytes > 0 and (free_gb * 1024 ** 3) < size_bytes: + self.log_message(f"❌ Недостаточно места для файла: {size_bytes} байт", "error") + return False + return True + def copy_full_folder(self, source: Path, dest: Path) -> tuple: copied = 0 skipped = 0 @@ -1030,20 +1388,113 @@ class BackgroundFileCopyApp: skipped += 1 else: errors += 1 + self.step_progress() continue if should_copy_file(src_file, dst_file): + if not self.has_enough_space(dest_root, src_file.stat().st_size): + errors += 1 + self.step_progress() + continue if self.copy_file_with_retries(src_file, dst_file) and verify_copy(src_file, dst_file): copied += 1 self.last_hashes[str(dst_file)] = compute_file_checksum(dst_file) else: errors += 1 + self.step_progress() else: skipped += 1 + self.step_progress() except Exception as e: errors += 1 - self.log_message(f"❌ Ошибка при копировании {src_file.name}: {e}", "error") + self.step_progress() + msg = f"❌ Ошибка при копировании {src_file.name}: {e}" + self.log_message(msg, "error") + self.record_error_detail(msg) return copied, skipped, errors + def process_pair(self, source: str, dest: str, full_copy: bool) -> tuple: + copied_files = 0 + skipped_files = 0 + error_files = 0 + + self.log_message(f"\n📁 Обработка папки: {source}", "info") + src_path = Path(source) + dest_path = Path(dest) + + if full_copy: + if not src_path.exists(): + msg = f"❌ Папка не существует: {source}" + self.log_message(msg, "error") + self.record_error_detail(msg) + return copied_files, skipped_files, error_files + 1 + try: + dest_path.mkdir(parents=True, exist_ok=True) + except Exception as e: + msg = f"❌ Не могу создать папку {dest}: {e}" + self.log_message(msg, "error") + self.record_error_detail(msg) + return copied_files, skipped_files, error_files + 1 + + c, s, e = self.copy_full_folder(src_path, dest_path) + return copied_files + c, skipped_files + s, error_files + e + + latest_file = self.find_latest_file(source) + if latest_file: + try: + dest_path.mkdir(parents=True, exist_ok=True) + except Exception as e: + msg = f"❌ Не могу создать папку {dest}: {e}" + self.log_message(msg, "error") + self.record_error_detail(msg) + return copied_files, skipped_files, error_files + 1 + + target_file = dest_path / latest_file.name + try: + self.set_status_text(f"Копируется: {latest_file.name}") + if target_file.exists(): + self.check_previous_hash(target_file) + if self.verify_only.get(): + if self.verify_only_mode(latest_file, target_file): + skipped_files += 1 + else: + error_files += 1 + self.step_progress() + return copied_files, skipped_files, error_files + + target_existed = target_file.exists() + if should_copy_file(latest_file, target_file): + if not self.has_enough_space(dest_path, latest_file.stat().st_size): + self.step_progress() + return copied_files, skipped_files, error_files + 1 + if self.copy_file_with_retries(latest_file, target_file) and verify_copy(latest_file, target_file): + copied_files += 1 + self.last_hashes[str(target_file)] = compute_file_checksum(target_file) + if target_existed: + self.log_message(f"✅ Обновлен: {latest_file.name} (новее)", "success") + else: + self.log_message(f"✅ Скопирован: {latest_file.name}", "success") + else: + error_files += 1 + msg = f"❌ Контрольная сумма не совпала: {latest_file.name}" + self.log_message(msg, "error") + self.record_error_detail(msg) + self.step_progress() + else: + skipped_files += 1 + self.log_message(f"⏭️ Пропущен: {latest_file.name} (уже актуален)", "warning") + self.step_progress() + except Exception as e: + error_files += 1 + self.step_progress() + msg = f"❌ Ошибка при копировании {latest_file.name}: {e}" + self.log_message(msg, "error") + self.record_error_detail(msg) + else: + error_files += 1 + self.step_progress() + + return copied_files, skipped_files, error_files + def copy_files_thread(self, pairs: List[tuple], background: bool = False): """Поток для копирования последних файлов""" if not self.copy_lock.acquire(blocking=False): @@ -1052,7 +1503,20 @@ class BackgroundFileCopyApp: self.is_copying = True self.root.after(0, lambda: self.status_bar.config(text="Идет копирование...")) - self.root.after(0, lambda: self.progress.start(10)) + total_units = 0 + for source, dest, full_copy in pairs: + if full_copy: + if os.path.exists(source): + for root, _dirs, files in os.walk(source): + for fname in files: + total_units += 1 + else: + include_masks = parse_masks(self.include_masks_var.get()) + exclude_masks = parse_masks(self.exclude_masks_var.get()) + latest = find_latest_file_in_folder(source, include_masks=include_masks, exclude_masks=exclude_masks) + if latest: + total_units += 1 + self.set_progress_total(total_units or 1) try: copied_files = 0 @@ -1063,74 +1527,20 @@ class BackgroundFileCopyApp: self.log_message("\n" + "=" * 50, "info") self.log_message("🚀 Начало копирования последних файлов", "info") - for source, dest, full_copy in pairs: - self.log_message(f"\n📁 Обработка папки: {source}", "info") - - src_path = Path(source) - dest_path = Path(dest) - if full_copy: - if not src_path.exists(): - self.log_message(f"❌ Папка не существует: {source}", "error") - error_files += 1 - continue + max_workers = max(1, int(self.max_workers_var.get() or DEFAULT_MAX_WORKERS)) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [executor.submit(self.process_pair, source, dest, full_copy) for source, dest, full_copy in pairs] + for future in as_completed(futures): try: - dest_path.mkdir(parents=True, exist_ok=True) - except Exception as e: - self.log_message(f"❌ Не могу создать папку {dest}: {e}", "error") - error_files += 1 - continue - - c, s, e = self.copy_full_folder(src_path, dest_path) - copied_files += c - skipped_files += s - error_files += e - continue - - # Находим последний файл - latest_file = self.find_latest_file(source) - - if latest_file: - # Создаем целевую папку, если её нет - try: - dest_path.mkdir(parents=True, exist_ok=True) - except Exception as e: - self.log_message(f"❌ Не могу создать папку {dest}: {e}", "error") - error_files += 1 - continue - - target_file = dest_path / latest_file.name - - try: - self.set_status_text(f"Копируется: {latest_file.name}") - if target_file.exists(): - self.check_previous_hash(target_file) - if self.verify_only.get(): - if self.verify_only_mode(latest_file, target_file): - skipped_files += 1 - else: - error_files += 1 - continue - target_existed = target_file.exists() - if should_copy_file(latest_file, target_file): - if self.copy_file_with_retries(latest_file, target_file) and verify_copy(latest_file, target_file): - copied_files += 1 - self.last_hashes[str(target_file)] = compute_file_checksum(target_file) - if target_existed: - self.log_message(f"✅ Обновлен: {latest_file.name} (новее)", "success") - else: - self.log_message(f"✅ Скопирован: {latest_file.name}", "success") - else: - error_files += 1 - self.log_message(f"❌ Контрольная сумма не совпала: {latest_file.name}", "error") - else: - skipped_files += 1 - self.log_message(f"⏭️ Пропущен: {latest_file.name} (уже актуален)", "warning") - + c, s, e = future.result() + copied_files += c + skipped_files += s + error_files += e except Exception as e: error_files += 1 - self.log_message(f"❌ Ошибка при копировании {latest_file.name}: {e}", "error") - else: - error_files += 1 + msg = f"❌ Ошибка задачи копирования: {e}" + self.log_message(msg, "error") + self.record_error_detail(msg) # Итог self.log_message("\n" + "=" * 50, "info") @@ -1155,7 +1565,8 @@ class BackgroundFileCopyApp: self.run_on_ui(self.update_last_success_label) self.tray_notify("Копирование завершено", f"Успешно: {copied_files}, Пропущено: {skipped_files}") else: - self.tray_notify("Копирование с ошибками", f"Ошибок: {error_files}") + detail = self.last_error_detail or "Неизвестная ошибка" + self.tray_notify("Копирование с ошибками", f"Ошибок: {error_files}. {detail}") except Exception as e: self.log_message(f"❌ Критическая ошибка: {e}\n{traceback.format_exc()}", "error") @@ -1164,7 +1575,7 @@ class BackgroundFileCopyApp: self.is_copying = False self.copy_lock.release() self.root.after(0, lambda: self.status_bar.config(text="Готов к работе")) - self.root.after(0, lambda: self.progress.stop()) + self.root.after(0, lambda: self.progress_var.set(0)) def start_manual_copy(self): """Запускает ручное копирование""" @@ -1199,14 +1610,18 @@ class BackgroundFileCopyApp: self.scheduler_enabled.set(False) return - time_str = f"{self.hour_var.get()}:{self.minute_var.get()}" - self.scheduler.schedule_copy_job("backup_job", time_str, valid_pairs) + if not self.schedules: + # создаем расписание по умолчанию из полей времени + self.schedules = [{"time": f"{self.hour_var.get()}:{self.minute_var.get()}", "days": []}] + self.refresh_schedules_tree() + + self.scheduler.schedule_copy_job("backup_job", self.schedules, valid_pairs) self.scheduler.start() - self.scheduler_status.config(text=f"(активен, копирование в {time_str})", foreground="green") + self.scheduler_status.config(text=f"(активен, заданий: {len(self.schedules)})", foreground="green") self.update_next_run_label() self.update_last_success_label() - self.log_message(f"🕒 Планировщик запущен. Копирование ежедневно в {time_str}", "info") + self.log_message("🕒 Планировщик запущен.", "info") def stop_scheduler(self): """Останавливает планировщик""" @@ -1235,6 +1650,21 @@ class BackgroundFileCopyApp: self.status_summary_label.config(text=summary) self.last_result_label.config(text=f"(последний результат: {summary})") + def update_schedule_summary_label(self): + if not self.schedules: + self.schedule_summary_label.config(text="Расписание: —") + return + parts = [] + for entry in self.schedules: + time_str = entry.get("time", "—") + days = entry.get("days", []) + if days: + days_text = ",".join(days) + parts.append(f"{time_str} ({days_text})") + else: + parts.append(f"{time_str} (ежедневно)") + self.schedule_summary_label.config(text="Расписание: " + "; ".join(parts)) + def debug_settings(self): """Отладочный метод для проверки загрузки настроек""" self.log_message("\n" + "=" * 50, "info") @@ -1285,6 +1715,9 @@ class BackgroundFileCopyApp: except Exception: pass + def record_error_detail(self, message: str): + self.last_error_detail = message + def clear_log(self): """Очищает лог""" self.log_text.delete(1.0, tk.END) @@ -1316,7 +1749,7 @@ class BackgroundFileCopyApp: def create_tray_image(self): if Image is None: return None - icon_path = os.path.abspath(ICON_PATH) + icon_path = get_resource_path(ICON_PATH) if os.path.exists(icon_path): with contextlib.suppress(Exception): return Image.open(icon_path) @@ -1392,6 +1825,10 @@ class BackgroundFileCopyApp: def main(): + mutex = ensure_single_instance() + if mutex is None: + ctypes.windll.user32.MessageBoxW(None, "Приложение уже запущено.", APP_NAME, 0x00000010) + return root = tk.Tk() app = BackgroundFileCopyApp(root) diff --git a/tests/test_copy_logic.py b/tests/test_copy_logic.py index c3f1118..4dc23ef 100644 --- a/tests/test_copy_logic.py +++ b/tests/test_copy_logic.py @@ -4,7 +4,15 @@ from pathlib import Path import pytest -from main import find_latest_file_in_folder, should_copy_file, compute_file_checksum, verify_copy +import tkinter as tk + +from main import ( + BackgroundFileCopyApp, + find_latest_file_in_folder, + should_copy_file, + compute_file_checksum, + verify_copy, +) @@ -93,3 +101,65 @@ def test_verify_copy_false_for_different_files(tmp_path): src.write_text('data1', encoding='utf-8') dst.write_text('data2', encoding='utf-8') assert verify_copy(src, dst) is False + + +def _create_app(): + root = tk.Tk() + root.withdraw() + app = BackgroundFileCopyApp(root) + return app, root + + +def test_copy_full_folder(tmp_path): + app, root = _create_app() + try: + src = tmp_path / "src" + dst = tmp_path / "dst" + (src / "sub").mkdir(parents=True) + (src / "a.bak").write_text("a", encoding="utf-8") + (src / "sub" / "b.sql").write_text("b", encoding="utf-8") + + app.min_free_gb_var.set(0) + app.include_masks_var.set("*.bak;*.sql") + app.exclude_masks_var.set("") + + copied, skipped, errors = app.copy_full_folder(src, dst) + assert errors == 0 + assert copied == 2 + assert (dst / "a.bak").exists() + assert (dst / "sub" / "b.sql").exists() + finally: + root.destroy() + + +def test_save_and_load_settings(tmp_path, monkeypatch): + app, root = _create_app() + try: + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(app, "get_settings_path", lambda: str(settings_path)) + + app.add_path_pair("C:/src", "D:/dst", False) + app.include_masks_var.set("*.bak") + app.exclude_masks_var.set("*.tmp") + app.min_free_gb_var.set(2) + app.max_workers_var.set(4) + app.schedules = [{"time": "01:00", "days": ["Mon", "Wed"]}] + app.refresh_schedules_tree() + app.save_settings() + + app2, root2 = _create_app() + try: + monkeypatch.setattr(app2, "get_settings_path", lambda: str(settings_path)) + app2.load_settings() + profile = app2.profiles.get(app2.active_profile.get()) + assert profile is not None + assert profile.get("include_masks") == "*.bak" + assert profile.get("exclude_masks") == "*.tmp" + assert int(profile.get("max_workers")) == 4 + assert profile.get("min_free_gb") == 2 + assert profile.get("schedules") == [{"time": "01:00", "days": ["Mon", "Wed"]}] + assert len(profile.get("pairs")) == 1 + finally: + root2.destroy() + finally: + root.destroy()