From 8656c4dac923c2ffbe2d350027e9c87eb1496944 Mon Sep 17 00:00:00 2001 From: benya Date: Thu, 19 Feb 2026 19:37:57 +0300 Subject: [PATCH] Improve UI and settings; ignore caches --- .gitignore | 6 + main.py | 602 ++++++++++++++++++++++++++++++++++++----------------- 2 files changed, 418 insertions(+), 190 deletions(-) diff --git a/.gitignore b/.gitignore index fbc37c1..71980e7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ /dist/ /build/ /.venv/ +/.idea/ +/.pytest_cache/ +/__pycache__/ +**/__pycache__/ +*.pyc +*.log diff --git a/main.py b/main.py index 8346dc2..79eaf16 100644 --- a/main.py +++ b/main.py @@ -140,7 +140,7 @@ class BackgroundFileCopyApp: def __init__(self, root): self.root = root self.root.title("Планировщик копирования бекапов") - self.root.geometry("900x700") + self.root.geometry("900x800") self.setup_window_icon() # Для работы с очередью сообщений из потоков @@ -148,6 +148,8 @@ class BackgroundFileCopyApp: # Список пар для копирования self.copy_pairs = [] + self.pair_counter = 0 + self.selected_pair_id = None # Планировщик self.scheduler = FileCopyScheduler(self) @@ -169,6 +171,7 @@ class BackgroundFileCopyApp: # Последний успешный запуск self.last_success_time: Optional[str] = None + self.last_result_summary: Optional[str] = None # Хеши последних копий self.last_hashes: Dict[str, str] = {} @@ -190,24 +193,48 @@ class BackgroundFileCopyApp: self.start_scheduler() def setup_ui(self): - # Основной фрейм main_frame = ttk.Frame(self.root, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) - # Заголовок - title_label = ttk.Label(main_frame, text="Планировщик копирования бекапов", + notebook = ttk.Notebook(main_frame) + notebook.pack(fill=tk.BOTH, expand=True) + + settings_tab = ttk.Frame(notebook) + log_tab = ttk.Frame(notebook) + help_tab = ttk.Frame(notebook) + notebook.add(settings_tab, text="Настройки") + notebook.add(log_tab, text="Лог") + notebook.add(help_tab, text="Справка") + + title_label = ttk.Label(settings_tab, text="Планировщик копирования бекапов", font=("Arial", 14, "bold")) - title_label.pack(pady=10) + title_label.pack(pady=8) - # Фрейм настроек расписания - schedule_frame = ttk.LabelFrame(main_frame, text="Настройки расписания", padding="10") - schedule_frame.pack(fill=tk.X, pady=10) + status_frame = ttk.LabelFrame(settings_tab, text="Состояние", padding="10") + status_frame.pack(fill=tk.X, pady=5) + + self.status_summary_label = ttk.Label(status_frame, text="—") + self.status_summary_label.pack(side=tk.LEFT) + + self.next_run_label = ttk.Label(status_frame, text="(следующий запуск: —)", + font=("Arial", 9, "italic")) + self.next_run_label.pack(side=tk.LEFT, padx=10) + + self.last_success_label = ttk.Label(status_frame, text="(последний успех: —)", + font=("Arial", 9, "italic")) + self.last_success_label.pack(side=tk.LEFT, padx=10) + + self.last_result_label = ttk.Label(status_frame, text="(последний результат: —)", + font=("Arial", 9, "italic")) + self.last_result_label.pack(side=tk.LEFT, padx=10) + + schedule_frame = ttk.LabelFrame(settings_tab, text="Расписание и поведение", padding="10") + schedule_frame.pack(fill=tk.X, pady=5) - # Время копирования time_frame = ttk.Frame(schedule_frame) time_frame.pack(fill=tk.X, pady=5) - ttk.Label(time_frame, text="Время копирования:", width=15).pack(side=tk.LEFT) + 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") @@ -221,117 +248,139 @@ class BackgroundFileCopyApp: ttk.Combobox(time_frame, textvariable=self.minute_var, values=minutes, width=5, state="readonly").pack(side=tk.LEFT, padx=2) - ttk.Label(time_frame, text="(ежедневно)", font=("Arial", 9, "italic")).pack(side=tk.LEFT, padx=10) - - # Включение/выключение планировщика - scheduler_ctrl_frame = ttk.Frame(schedule_frame) - scheduler_ctrl_frame.pack(fill=tk.X, pady=5) - self.scheduler_enabled = tk.BooleanVar(value=False) - self.scheduler_check = ttk.Checkbutton(scheduler_ctrl_frame, - text="Включить автоматическое копирование по расписанию", + self.scheduler_check = ttk.Checkbutton(time_frame, + text="Ежедневно", variable=self.scheduler_enabled, command=self.toggle_scheduler) - self.scheduler_check.pack(side=tk.LEFT) + self.scheduler_check.pack(side=tk.LEFT, padx=10) - self.scheduler_status = ttk.Label(scheduler_ctrl_frame, text="(остановлен)", + self.scheduler_status = ttk.Label(time_frame, text="(остановлен)", font=("Arial", 9, "italic")) self.scheduler_status.pack(side=tk.LEFT, padx=10) - self.next_run_label = ttk.Label(scheduler_ctrl_frame, text="(следующий запуск: —)", - font=("Arial", 9, "italic")) - self.next_run_label.pack(side=tk.LEFT, padx=10) - - self.last_success_label = ttk.Label(scheduler_ctrl_frame, text="(последний успех: —)", - font=("Arial", 9, "italic")) - self.last_success_label.pack(side=tk.LEFT, padx=10) - - # Автозапуск - autostart_frame = ttk.Frame(schedule_frame) - autostart_frame.pack(fill=tk.X, pady=5) + options_frame = ttk.Frame(schedule_frame) + options_frame.pack(fill=tk.X, pady=5) self.autostart_check = ttk.Checkbutton( - autostart_frame, - text="Добавить программу в автозапуск Windows", + options_frame, + text="Автозапуск Windows", variable=self.autostart_enabled, command=self.toggle_autostart ) self.autostart_check.pack(side=tk.LEFT) - minimize_frame = ttk.Frame(schedule_frame) - minimize_frame.pack(fill=tk.X, pady=5) + ttk.Button(options_frame, text="?", width=2, + command=lambda: self.show_hint("Автозапуск", + "Добавляет программу в автозапуск Windows.")).pack(side=tk.LEFT, padx=4) self.minimize_check = ttk.Checkbutton( - minimize_frame, + options_frame, text="Сворачивать в трей при закрытии", variable=self.minimize_to_tray_enabled ) - self.minimize_check.pack(side=tk.LEFT) + self.minimize_check.pack(side=tk.LEFT, padx=10) - verify_frame = ttk.Frame(schedule_frame) - verify_frame.pack(fill=tk.X, pady=5) + ttk.Button(options_frame, text="?", width=2, + command=lambda: self.show_hint("Сворачивание", + "По крестику окно скрывается в трей.")).pack(side=tk.LEFT, padx=4) self.verify_check = ttk.Checkbutton( - verify_frame, + options_frame, text="Только проверка (без копирования)", variable=self.verify_only ) - self.verify_check.pack(side=tk.LEFT) + self.verify_check.pack(side=tk.LEFT, padx=10) + + ttk.Button(options_frame, text="?", width=2, + command=lambda: self.show_hint("Проверка", + "Проверяет совпадение контрольных сумм без копирования.")).pack(side=tk.LEFT, padx=4) - # Кнопки управления buttons_frame = ttk.Frame(schedule_frame) buttons_frame.pack(fill=tk.X, pady=5) - ttk.Button(buttons_frame, text="✅ Проверить и сохранить", + ttk.Button(buttons_frame, text="🧭 Мастер настройки", + command=self.open_wizard).pack(side=tk.LEFT, padx=5) + ttk.Button(buttons_frame, text="💾 Сохранить", command=self.check_and_save).pack(side=tk.LEFT, padx=5) - ttk.Button(buttons_frame, text="▶ Запустить копирование сейчас", + ttk.Button(buttons_frame, text="▶ Запустить сейчас", command=self.start_manual_copy).pack(side=tk.LEFT, padx=5) - # Фрейм для списка путей - paths_frame = ttk.LabelFrame(main_frame, text="Пути для копирования", padding="10") - paths_frame.pack(fill=tk.BOTH, expand=True, pady=10) + paths_frame = ttk.LabelFrame(settings_tab, text="Пары копирования", padding="10") + paths_frame.pack(fill=tk.BOTH, expand=True, pady=5) - # Заголовки колонок - headers_frame = ttk.Frame(paths_frame) - headers_frame.pack(fill=tk.X, pady=5) - - ttk.Label(headers_frame, text="Откуда копировать (папка с бекапами)", - font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=(0, 20)) - ttk.Label(headers_frame, text="Куда копировать (сетевая папка)", - font=("Arial", 10, "bold")).pack(side=tk.LEFT, padx=(20, 0)) - - # Canvas для прокрутки списка - canvas = tk.Canvas(paths_frame, borderwidth=0, highlightthickness=0) - scrollbar = ttk.Scrollbar(paths_frame, orient="vertical", command=canvas.yview) - self.scrollable_frame = ttk.Frame(canvas) - - self.scrollable_frame.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + self.pairs_tree = ttk.Treeview( + paths_frame, + columns=("source", "dest", "mode", "status"), + show="headings", + selectmode="browse" ) + self.pairs_tree.heading("source", text="Источник") + self.pairs_tree.heading("dest", text="Назначение") + self.pairs_tree.heading("mode", text="Режим") + self.pairs_tree.heading("status", text="Статус") + self.pairs_tree.column("source", width=280) + self.pairs_tree.column("dest", width=280) + self.pairs_tree.column("mode", width=110, anchor=tk.CENTER) + self.pairs_tree.column("status", width=120, anchor=tk.CENTER) - canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) + tree_scroll = ttk.Scrollbar(paths_frame, orient="vertical", command=self.pairs_tree.yview) + self.pairs_tree.configure(yscrollcommand=tree_scroll.set) - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") + self.pairs_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) - # Кнопки управления списком - list_buttons_frame = ttk.Frame(paths_frame) - list_buttons_frame.pack(fill=tk.X, pady=10) + self.pairs_tree.tag_configure("ok", foreground="green") + self.pairs_tree.tag_configure("warn", foreground="orange") + self.pairs_tree.tag_configure("error", foreground="red") + self.pairs_tree.tag_configure("idle", foreground="gray") - ttk.Button(list_buttons_frame, text="➕ Добавить пару папок", + list_buttons_frame = ttk.Frame(settings_tab) + list_buttons_frame.pack(fill=tk.X, pady=5) + + ttk.Button(list_buttons_frame, text="➕ Добавить", command=self.add_path_pair).pack(side=tk.LEFT, padx=5) - ttk.Button(list_buttons_frame, text="❌ Удалить все", - command=self.remove_all_pairs).pack(side=tk.LEFT, padx=5) - ttk.Button(list_buttons_frame, text="🔍 Проверить пути", - command=self.check_paths).pack(side=tk.LEFT, padx=5) + ttk.Button(list_buttons_frame, text="❌ Удалить", + command=self.remove_selected_pair).pack(side=tk.LEFT, padx=5) + ttk.Button(list_buttons_frame, text="📂 Открыть назначение", + command=self.open_selected_destination).pack(side=tk.LEFT, padx=5) + ttk.Button(list_buttons_frame, text="🔁 Переключить режим", + command=self.toggle_selected_mode).pack(side=tk.LEFT, padx=5) - # Лог операций - log_frame = ttk.LabelFrame(main_frame, text="Лог операций", padding="5") + edit_frame = ttk.LabelFrame(settings_tab, text="Редактирование выбранной пары", padding="10") + edit_frame.pack(fill=tk.X, pady=5) + + ttk.Label(edit_frame, text="Источник:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=2) + ttk.Label(edit_frame, text="Назначение:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=2) + + self.edit_source = ttk.Entry(edit_frame) + self.edit_dest = ttk.Entry(edit_frame) + self.edit_source.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=2) + self.edit_dest.grid(row=1, column=1, sticky=tk.EW, padx=5, pady=2) + + ttk.Button(edit_frame, text="📁", width=3, + command=lambda: self.browse_folder(self.edit_source)).grid(row=0, column=2, padx=3) + ttk.Button(edit_frame, text="📁", width=3, + command=lambda: self.browse_folder(self.edit_dest)).grid(row=1, column=2, padx=3) + + self.edit_full_copy = tk.BooleanVar(value=False) + ttk.Checkbutton(edit_frame, text="Вся папка", variable=self.edit_full_copy).grid( + row=0, column=3, rowspan=2, padx=10 + ) + ttk.Button(edit_frame, text="?", width=2, + command=lambda: self.show_hint("Вся папка", + "Копирует всю папку и подпапки, а не только последний файл.")).grid(row=0, column=4, rowspan=2, padx=3) + + edit_frame.columnconfigure(1, weight=1) + + self.edit_source.bind("", lambda _e: self.update_selected_pair_from_edit()) + self.edit_dest.bind("", lambda _e: self.update_selected_pair_from_edit()) + self.edit_full_copy.trace_add("write", lambda *_: self.update_selected_pair_from_edit()) + self.pairs_tree.bind("<>", lambda _e: self.on_pair_select()) + + log_frame = ttk.LabelFrame(log_tab, text="Лог операций", padding="5") log_frame.pack(fill=tk.BOTH, expand=True, pady=5) - # Кнопки управления логом log_buttons = ttk.Frame(log_frame) log_buttons.pack(fill=tk.X, pady=2) @@ -340,17 +389,23 @@ class BackgroundFileCopyApp: ttk.Button(log_buttons, text="🔍 Отладка", command=self.debug_settings).pack(side=tk.RIGHT, padx=2) - # Текст лога self.log_text = scrolledtext.ScrolledText(log_frame, height=8, wrap=tk.WORD) self.log_text.pack(fill=tk.BOTH, expand=True) - # Настройка тегов для цветного лога self.log_text.tag_configure("success", foreground="green") self.log_text.tag_configure("error", foreground="red") self.log_text.tag_configure("warning", foreground="orange") self.log_text.tag_configure("info", foreground="blue") - # Статус бар + help_text = ( + "Быстрый старт:\n" + "1) Нажмите 'Добавить' и заполните источник/назначение.\n" + "2) Выберите режим: последний файл или вся папка.\n" + "3) Настройте расписание и нажмите 'Сохранить'.\n\n" + "Подсказки доступны по кнопке '?'." + ) + ttk.Label(help_tab, text=help_text, justify=tk.LEFT).pack(anchor=tk.W, padx=10, pady=10) + self.status_bar = ttk.Label(self.root, text="Готов к работе", relief=tk.SUNKEN, anchor=tk.W) self.status_bar.pack(side=tk.BOTTOM, fill=tk.X) @@ -499,8 +554,7 @@ class BackgroundFileCopyApp: else: source, dest = item full_copy = False - self.add_path_pair(source, dest) - self.copy_pairs[-1]['full_copy'].set(full_copy) + self.add_path_pair(source, dest, full_copy) self.log_message(f"✅ Загружено {len(pairs_data)} пар путей", "success") else: # Если нет сохраненных пар, добавляем одну пустую @@ -519,8 +573,10 @@ class BackgroundFileCopyApp: 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: @@ -530,8 +586,10 @@ class BackgroundFileCopyApp: 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") @@ -542,8 +600,10 @@ class BackgroundFileCopyApp: 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): """Сохраняет настройки в файл""" @@ -551,17 +611,13 @@ class BackgroundFileCopyApp: # Собираем данные из полей ввода pairs_data = [] for pair in self.copy_pairs: - source = pair['source'].get().strip() - dest = pair['dest'].get().strip() - if source.startswith("Например: "): - source = "" - if dest.startswith("Например: "): - dest = "" + source = pair.get("source", "").strip() + dest = pair.get("dest", "").strip() if source or dest: # Сохраняем даже если одно поле пустое pairs_data.append({ "source": source, "dest": dest, - "full_copy": pair['full_copy'].get() + "full_copy": bool(pair.get("full_copy", False)) }) settings = { @@ -572,6 +628,7 @@ class BackgroundFileCopyApp: '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 } @@ -594,88 +651,141 @@ class BackgroundFileCopyApp: self.log_message(f"❌ Ошибка при сохранении настроек: {e}", "error") messagebox.showerror("Ошибка", f"Не удалось сохранить настройки:\n{e}") - def add_path_pair(self, source="", dest=""): - """Добавляет новую пару полей для ввода путей""" - pair_frame = ttk.Frame(self.scrollable_frame) - pair_frame.pack(fill=tk.X, pady=5) + def add_path_pair(self, source="", dest="", full_copy: bool = False): + """Добавляет новую пару в таблицу.""" + pair_id = f"pair_{self.pair_counter}" + self.pair_counter += 1 - # Поле "Откуда" - source_frame = ttk.Frame(pair_frame) - source_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) + pair = { + "id": pair_id, + "source": source, + "dest": dest, + "full_copy": bool(full_copy), + "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 + self.copy_pairs.append(pair) - source_entry = ttk.Entry(source_frame) - source_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) - if source: - source_entry.insert(0, source) - else: - self.create_placeholder(source_entry, "Например: C:\\Backups") + mode_text = "Вся папка" if pair["full_copy"] else "Последний файл" + self.pairs_tree.insert("", "end", iid=pair_id, + values=(source, dest, mode_text, pair["status"]), + tags=(pair["status_tag"],)) + self.pairs_tree.selection_set(pair_id) + self.on_pair_select() - ttk.Button(source_frame, text="📁", width=3, - command=lambda: self.browse_folder(source_entry)).pack(side=tk.RIGHT, padx=2) + def remove_selected_pair(self): + pair_id = self.selected_pair_id + if not pair_id: + return + self.copy_pairs = [p for p in self.copy_pairs if p["id"] != pair_id] + with contextlib.suppress(Exception): + self.pairs_tree.delete(pair_id) + self.selected_pair_id = None + self.edit_source.delete(0, tk.END) + self.edit_dest.delete(0, tk.END) + self.edit_full_copy.set(False) - # Поле "Куда" - dest_frame = ttk.Frame(pair_frame) - dest_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(5, 0)) - - dest_entry = ttk.Entry(dest_frame) - dest_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) - if dest: - dest_entry.insert(0, dest) - else: - self.create_placeholder(dest_entry, "Например: D:\\BackupArchive") - - ttk.Button(dest_frame, text="📁", width=3, - command=lambda: self.browse_folder(dest_entry)).pack(side=tk.RIGHT, padx=2) - - # Опции пары - options_frame = ttk.Frame(pair_frame) - options_frame.pack(side=tk.LEFT, padx=5) - - full_copy_var = tk.BooleanVar(value=False) - full_copy_check = ttk.Checkbutton(options_frame, text="Вся папка", variable=full_copy_var) - full_copy_check.pack(side=tk.LEFT) - - ttk.Button(options_frame, text="📂", width=3, - command=lambda: self.open_destination(dest_entry.get())).pack(side=tk.LEFT, padx=2) - - # Кнопка удаления - ttk.Button(pair_frame, text="✖", width=3, - command=lambda: self.remove_path_pair(pair_frame)).pack(side=tk.RIGHT, padx=5) - - # Сохраняем ссылки на entry - self.copy_pairs.append({ - 'frame': pair_frame, - 'source': source_entry, - 'dest': dest_entry, - 'full_copy': full_copy_var - }) - - # Прокручиваем к новому элементу - self.scrollable_frame.update_idletasks() - canvas = self.scrollable_frame.master - canvas.yview_moveto(1.0) - - def remove_path_pair(self, frame): - """Удаляет пару полей""" - for pair in self.copy_pairs: - if pair['frame'] == frame: - self.copy_pairs.remove(pair) - break - frame.destroy() - - # Если не осталось пар, добавляем пустую - if not self.copy_pairs: - self.add_path_pair() + def remove_path_pair(self, pair_id): + """Совместимость: удалить пару по id.""" + if pair_id: + self.selected_pair_id = pair_id + self.remove_selected_pair() def remove_all_pairs(self, silent=False): """Удаляет все пары""" if not silent: if not messagebox.askyesno("Подтверждение", "Удалить все пути?"): return - - for pair in self.copy_pairs[:]: - pair['frame'].destroy() self.copy_pairs.clear() + for item in self.pairs_tree.get_children(): + self.pairs_tree.delete(item) + self.selected_pair_id = None + + def get_pair_by_id(self, pair_id: str): + for pair in self.copy_pairs: + if pair["id"] == pair_id: + return pair + return None + + def update_pair_row(self, pair): + mode_text = "Вся папка" if pair["full_copy"] else "Последний файл" + self.pairs_tree.item(pair["id"], values=(pair["source"], pair["dest"], mode_text, pair["status"]), + tags=(pair["status_tag"],)) + + def on_pair_select(self): + selection = self.pairs_tree.selection() + if not selection: + return + pair_id = selection[0] + self.selected_pair_id = pair_id + pair = self.get_pair_by_id(pair_id) + if not pair: + return + + self.edit_source.delete(0, tk.END) + self.edit_dest.delete(0, tk.END) + self.edit_source.insert(0, pair["source"]) + self.edit_dest.insert(0, pair["dest"]) + self.edit_full_copy.set(pair["full_copy"]) + + def update_selected_pair_from_edit(self): + pair_id = self.selected_pair_id + if not pair_id: + return + pair = self.get_pair_by_id(pair_id) + if not pair: + return + pair["source"] = self.edit_source.get().strip() + pair["dest"] = self.edit_dest.get().strip() + pair["full_copy"] = bool(self.edit_full_copy.get()) + status_text, status_tag = self.validate_pair(pair["source"], pair["dest"]) + pair["status"] = status_text + pair["status_tag"] = status_tag + self.update_pair_row(pair) + + def validate_pair(self, source: str, dest: str): + if not source or not dest: + return "?", "idle" + + if not os.path.exists(source): + return "Нет источника", "error" + + if not os.path.exists(dest): + return "Нет назначения", "error" + + test_file = os.path.join(dest, 'test_write.tmp') + try: + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + except Exception: + return "Нет доступа", "error" + + return "OK", "ok" + + def open_selected_destination(self): + pair_id = self.selected_pair_id + if not pair_id: + return + pair = self.get_pair_by_id(pair_id) + if not pair: + return + self.open_destination(pair["dest"]) + + def toggle_selected_mode(self): + pair_id = self.selected_pair_id + if not pair_id: + return + pair = self.get_pair_by_id(pair_id) + if not pair: + return + pair["full_copy"] = not pair["full_copy"] + self.edit_full_copy.set(pair["full_copy"]) + self.update_pair_row(pair) def browse_folder(self, entry): """Открывает диалог выбора папки""" @@ -691,18 +801,122 @@ class BackgroundFileCopyApp: with contextlib.suppress(Exception): os.startfile(path) + def show_hint(self, title: str, message: str): + messagebox.showinfo(title, message) + + def open_wizard(self): + if getattr(self, "wizard_window", None) is not None: + try: + self.wizard_window.lift() + return + except Exception: + pass + + wiz = tk.Toplevel(self.root) + wiz.title("Мастер настройки") + wiz.geometry("520x320") + wiz.transient(self.root) + wiz.grab_set() + self.wizard_window = wiz + + step_var = tk.IntVar(value=0) + source_var = tk.StringVar() + dest_var = tk.StringVar() + full_copy_var = tk.BooleanVar(value=False) + schedule_enabled_var = tk.BooleanVar(value=self.scheduler_enabled.get()) + hour_var = tk.StringVar(value=self.hour_var.get()) + minute_var = tk.StringVar(value=self.minute_var.get()) + autostart_var = tk.BooleanVar(value=self.autostart_enabled.get()) + minimize_var = tk.BooleanVar(value=self.minimize_to_tray_enabled.get()) + verify_var = tk.BooleanVar(value=self.verify_only.get()) + + def show_step(index: int): + step_var.set(index) + for i, frame in enumerate(steps): + frame.pack_forget() + if i == index: + frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + back_btn.config(state=tk.NORMAL if index > 0 else tk.DISABLED) + next_btn.config(state=tk.NORMAL if index < len(steps) - 1 else tk.DISABLED) + finish_btn.config(state=tk.NORMAL if index == len(steps) - 1 else tk.DISABLED) + + steps = [] + + step1 = ttk.Frame(wiz) + ttk.Label(step1, text="Шаг 1/3: Пути", font=("Arial", 11, "bold")).pack(anchor=tk.W, pady=5) + ttk.Label(step1, text="Источник:").pack(anchor=tk.W) + src_entry = ttk.Entry(step1, textvariable=source_var) + src_entry.pack(fill=tk.X, pady=2) + ttk.Button(step1, text="Выбрать...", command=lambda: self.browse_folder(src_entry)).pack(anchor=tk.W, pady=2) + ttk.Label(step1, text="Назначение:").pack(anchor=tk.W, pady=5) + dst_entry = ttk.Entry(step1, textvariable=dest_var) + dst_entry.pack(fill=tk.X, pady=2) + ttk.Button(step1, text="Выбрать...", command=lambda: self.browse_folder(dst_entry)).pack(anchor=tk.W, pady=2) + steps.append(step1) + + step2 = ttk.Frame(wiz) + ttk.Label(step2, text="Шаг 2/3: Режим копирования", font=("Arial", 11, "bold")).pack(anchor=tk.W, pady=5) + ttk.Checkbutton(step2, text="Копировать всю папку с подпапками", variable=full_copy_var).pack(anchor=tk.W) + steps.append(step2) + + step3 = ttk.Frame(wiz) + ttk.Label(step3, text="Шаг 3/3: Расписание и поведение", font=("Arial", 11, "bold")).pack(anchor=tk.W, pady=5) + time_frame = ttk.Frame(step3) + time_frame.pack(fill=tk.X, pady=4) + ttk.Label(time_frame, text="Время:").pack(side=tk.LEFT) + ttk.Combobox(time_frame, textvariable=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=minute_var, values=[f"{m:02d}" for m in range(60)], width=5, state="readonly").pack(side=tk.LEFT, padx=2) + ttk.Checkbutton(step3, text="Ежедневно", variable=schedule_enabled_var).pack(anchor=tk.W, pady=4) + ttk.Checkbutton(step3, text="Автозапуск Windows", variable=autostart_var).pack(anchor=tk.W) + ttk.Checkbutton(step3, text="Сворачивать в трей при закрытии", variable=minimize_var).pack(anchor=tk.W) + ttk.Checkbutton(step3, text="Только проверка (без копирования)", variable=verify_var).pack(anchor=tk.W) + steps.append(step3) + + controls = ttk.Frame(wiz) + controls.pack(fill=tk.X, pady=5) + back_btn = ttk.Button(controls, text="Назад", command=lambda: show_step(step_var.get() - 1)) + next_btn = ttk.Button(controls, text="Далее", command=lambda: show_step(step_var.get() + 1)) + + def finish(): + if source_var.get().strip() and dest_var.get().strip(): + self.add_path_pair(source_var.get().strip(), dest_var.get().strip(), full_copy_var.get()) + self.hour_var.set(hour_var.get()) + self.minute_var.set(minute_var.get()) + self.scheduler_enabled.set(schedule_enabled_var.get()) + self.autostart_enabled.set(autostart_var.get()) + self.minimize_to_tray_enabled.set(minimize_var.get()) + self.verify_only.set(verify_var.get()) + if self.scheduler_enabled.get(): + self.start_scheduler() + else: + self.stop_scheduler() + if self.autostart_enabled.get() != self.is_autostart_enabled(): + self.set_autostart_enabled(self.autostart_enabled.get()) + wiz.destroy() + self.wizard_window = None + + finish_btn = ttk.Button(controls, text="Готово", command=finish) + back_btn.pack(side=tk.LEFT, padx=5) + next_btn.pack(side=tk.LEFT, padx=5) + finish_btn.pack(side=tk.RIGHT, padx=5) + + def on_close(): + self.wizard_window = None + wiz.destroy() + + wiz.protocol("WM_DELETE_WINDOW", on_close) + show_step(0) + def get_valid_pairs(self) -> List[tuple]: """Возвращает список валидных пар папок""" valid_pairs = [] for pair in self.copy_pairs: - source = pair['source'].get().strip() - dest = pair['dest'].get().strip() - if source.startswith("Например: "): - source = "" - if dest.startswith("Например: "): - dest = "" + source = pair.get("source", "").strip() + dest = pair.get("dest", "").strip() + full_copy = bool(pair.get("full_copy", False)) if source and dest: - valid_pairs.append((source, dest, pair['full_copy'].get())) + valid_pairs.append((source, dest, full_copy)) return valid_pairs def check_paths(self): @@ -719,29 +933,22 @@ class BackgroundFileCopyApp: mode_text = "вся папка" if full_copy else "последний файл" self.log_message(f" Режим: {mode_text}", "info") - # Проверяем исходную папку + status_text, status_tag = self.validate_pair(source, dest) + if status_tag == "ok": + self.log_message(f" ✅ Проверка: OK", "success") + else: + self.log_message(f" ❌ Проверка: {status_text}", "error") + if os.path.exists(source): - self.log_message(f" ✅ Исходная папка: {source}", "success") - # Считаем файлы .bak (без дублей из-за регистра) bak_files = [p for p in Path(source).iterdir() if p.is_file() and p.suffix.lower() == '.bak'] self.log_message(f" Найдено .bak файлов: {len(bak_files)}", "info") - else: - self.log_message(f" ❌ Исходная папка НЕ существует: {source}", "error") - # Проверяем целевую папку - if os.path.exists(dest): - self.log_message(f" ✅ Целевая папка: {dest}", "success") - # Проверяем права на запись - test_file = os.path.join(dest, 'test_write.tmp') - try: - with open(test_file, 'w') as f: - f.write('test') - os.remove(test_file) - self.log_message(f" Права на запись: есть", "success") - except: - self.log_message(f" ❌ Права на запись: нет", "error") - else: - self.log_message(f" ❌ Целевая папка НЕ существует: {dest}", "error") + # Обновляем статусы в таблице + for pair in self.copy_pairs: + status_text, status_tag = self.validate_pair(pair.get("source", ""), pair.get("dest", "")) + pair["status"] = status_text + pair["status_tag"] = status_tag + self.update_pair_row(pair) def find_latest_file(self, folder_path: str) -> Optional[Path]: """Находит самый последний файл в папке""" @@ -769,6 +976,9 @@ class BackgroundFileCopyApp: """Планирует выполнение функции в UI-потоке.""" self.queue.put({'type': 'ui', 'func': func}) + def set_status_text(self, text: str): + self.root.after(0, lambda: self.status_bar.config(text=text)) + def check_previous_hash(self, target_file: Path): key = str(target_file) if key in self.last_hashes: @@ -813,6 +1023,7 @@ class BackgroundFileCopyApp: src_file = Path(root) / fname dst_file = dest_root / fname try: + self.set_status_text(f"Копируется: {src_file.name}") self.check_previous_hash(dst_file) if dst_file.exists() else None if self.verify_only.get(): if self.verify_only_mode(src_file, dst_file): @@ -890,6 +1101,7 @@ class BackgroundFileCopyApp: 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(): @@ -935,6 +1147,9 @@ class BackgroundFileCopyApp: f"Копирование завершено!\n\n✅ Скопировано: {copied_files}\n⏭️ Пропущено: {skipped_files}\n❌ Ошибок: {error_files}" )) + self.last_result_summary = f"✅ {copied_files} / ⏭️ {skipped_files} / ❌ {error_files}" + self.run_on_ui(self.update_status_summary) + if error_files == 0: self.last_success_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') self.run_on_ui(self.update_last_success_label) @@ -1015,6 +1230,11 @@ class BackgroundFileCopyApp: else: self.last_success_label.config(text="(последний успех: —)") + def update_status_summary(self): + summary = self.last_result_summary or "—" + self.status_summary_label.config(text=summary) + self.last_result_label.config(text=f"(последний результат: {summary})") + def debug_settings(self): """Отладочный метод для проверки загрузки настроек""" self.log_message("\n" + "=" * 50, "info") @@ -1039,11 +1259,13 @@ class BackgroundFileCopyApp: self.log_message(f" Пар в интерфейсе: {len(self.copy_pairs)}", "info") for i, pair in enumerate(self.copy_pairs, 1): - source = pair['source'].get() - dest = pair['dest'].get() + source = pair.get("source", "") + dest = pair.get("dest", "") + mode = "вся папка" if pair.get("full_copy", False) else "последний файл" self.log_message(f" Пара {i}:", "info") self.log_message(f" Откуда: '{source}'", "info") self.log_message(f" Куда: '{dest}'", "info") + self.log_message(f" Режим: {mode}", "info") self.log_message("=" * 50, "info")