diff --git a/main.py b/main.py index 073680c..0008333 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,7 @@ import logging import logging.handlers import fnmatch import ctypes +import calendar from concurrent.futures import ThreadPoolExecutor, as_completed try: import pystray @@ -135,6 +136,22 @@ def get_free_space_gb(path: Path) -> float: return 0.0 +def get_total_copy_size(source: Path, dest: Path) -> int: + total = 0 + for root, _dirs, files in os.walk(source): + rel = os.path.relpath(root, source) + dest_root = dest / rel if rel != "." else dest + for fname in files: + src_file = Path(root) / fname + dst_file = dest_root / fname + try: + if should_copy_file(src_file, dst_file): + total += src_file.stat().st_size + except Exception: + pass + return total + + def ensure_single_instance() -> Optional[int]: """Создает системный mutex. Возвращает handle или None если уже запущено.""" mutex = ctypes.windll.kernel32.CreateMutexW(None, True, MUTEX_NAME) @@ -192,15 +209,20 @@ class FileCopyScheduler: for entry in schedules: time_str = entry.get("time", "03:00") + sched_type = entry.get("type", "daily") 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: + if sched_type == "monthly": + day_num = int(entry.get("day", 1)) + schedule.every().day.at(time_str).do(self._execute_copy_job_if_monthday, pairs=pairs, day=day_num).tag(job_id) + self.app.log_message(f"📅 Запланировано копирование на {time_str} (ежемесячно, день {day_num})", "info") + elif sched_type == "weekly" and days: 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") + else: + schedule.every().day.at(time_str).do(self._execute_copy_job, pairs=pairs).tag(job_id) + self.app.log_message(f"📅 Запланировано копирование на {time_str} ежедневно", "info") def _execute_copy_job(self, pairs: List[tuple]): """Выполняет задание копирования""" @@ -214,12 +236,21 @@ class FileCopyScheduler: ) copy_thread.start() + def _execute_copy_job_if_monthday(self, pairs: List[tuple], day: int): + today = datetime.now() + last_day = calendar.monthrange(today.year, today.month)[1] + run_day = min(int(day), last_day) + if today.day != run_day: + return + self._execute_copy_job(pairs) + class BackgroundFileCopyApp: def __init__(self, root): self.root = root self.root.title("Планировщик копирования бекапов") self.root.geometry("900x800") + self.root.minsize(900, 700) self.setup_window_icon() # Для работы с очередью сообщений из потоков @@ -229,6 +260,13 @@ class BackgroundFileCopyApp: self.copy_pairs = [] self.pair_counter = 0 self.selected_pair_id = None + self.inline_editor = None + self.profile_combo = None + self.cancel_event = threading.Event() + self.autosave_job = None + self.autosave_enabled = True + self.skip_cleanup_confirm = False + self.loading_settings = False # Планировщик self.scheduler = FileCopyScheduler(self) @@ -263,6 +301,7 @@ class BackgroundFileCopyApp: 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.cleanup_old_var = tk.BooleanVar(value=False) self.schedules: List[dict] = [] self.schedule_days_vars = { "Mon": tk.BooleanVar(value=False), @@ -273,6 +312,8 @@ class BackgroundFileCopyApp: "Sat": tk.BooleanVar(value=False), "Sun": tk.BooleanVar(value=False), } + self.schedule_type_var = tk.StringVar(value="daily") + self.schedule_monthday_var = tk.IntVar(value=1) # Логгер в файл self.file_logger = self.setup_file_logger() @@ -308,19 +349,37 @@ 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) + toolbar = ttk.Frame(settings_tab) + toolbar.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())) + toolbar_row1 = ttk.Frame(toolbar) + toolbar_row1.pack(fill=tk.X, pady=2) + toolbar_row2 = ttk.Frame(toolbar) + toolbar_row2.pack(fill=tk.X, pady=2) - 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) + ttk.Button(toolbar_row1, text="➕ Добавить", command=self.add_path_pair).pack(side=tk.LEFT, padx=4) + ttk.Button(toolbar_row1, text="❌ Удалить", command=self.remove_selected_pair).pack(side=tk.LEFT, padx=4) + ttk.Button(toolbar_row1, text="📂 Источник", command=lambda: self.pick_folder_for_item(self.selected_pair_id, "source", "Выберите папку источника")).pack(side=tk.LEFT, padx=4) + ttk.Button(toolbar_row1, text="📂 Назначение", command=lambda: self.pick_folder_for_item(self.selected_pair_id, "dest", "Выберите папку назначения")).pack(side=tk.LEFT, padx=4) + ttk.Button(toolbar_row1, text="▶ Запустить", command=self.start_manual_copy).pack(side=tk.LEFT, padx=4) + ttk.Button(toolbar_row1, text="⏹ Остановить", command=self.stop_copying).pack(side=tk.LEFT, padx=4) + ttk.Button(toolbar_row1, text="💾 Сохранить", command=self.check_and_save).pack(side=tk.LEFT, padx=4) + + self.search_var = tk.StringVar(value="") + self.filter_status_var = tk.StringVar(value="Все") + self.compact_view_var = tk.BooleanVar(value=False) + ttk.Label(toolbar_row2, text="Поиск:").pack(side=tk.LEFT, padx=6) + search_entry = ttk.Entry(toolbar_row2, textvariable=self.search_var, width=20) + search_entry.pack(side=tk.LEFT, padx=4) + search_entry.bind("", lambda _e: self.render_pairs()) + ttk.Label(toolbar_row2, text="Статус:").pack(side=tk.LEFT, padx=6) + status_combo = ttk.Combobox(toolbar_row2, textvariable=self.filter_status_var, state="readonly", + values=["Все", "OK", "Нет источника", "Нет назначения", "Нет доступа", "—"], width=14) + status_combo.pack(side=tk.LEFT, padx=4) + status_combo.bind("<>", lambda _e: self.render_pairs()) + ttk.Checkbutton(toolbar_row2, text="Компактно", variable=self.compact_view_var, command=self.toggle_compact_view).pack(side=tk.LEFT, padx=6) + + # Профили скрыты по запросу пользователя status_frame = ttk.LabelFrame(settings_tab, text="Состояние", padding="10") status_frame.pack(fill=tk.X, pady=5) @@ -369,57 +428,79 @@ class BackgroundFileCopyApp: options_frame = ttk.Frame(schedule_frame) options_frame.pack(fill=tk.X, pady=5) + options_row1 = ttk.Frame(options_frame) + options_row1.pack(fill=tk.X, pady=2) + options_row2 = ttk.Frame(options_frame) + options_row2.pack(fill=tk.X, pady=2) + self.autostart_check = ttk.Checkbutton( - options_frame, + options_row1, text="Автозапуск Windows", variable=self.autostart_enabled, command=self.toggle_autostart ) self.autostart_check.pack(side=tk.LEFT) - ttk.Button(options_frame, text="?", width=2, + ttk.Button(options_row1, text="?", width=2, command=lambda: self.show_hint("Автозапуск", "Добавляет программу в автозапуск Windows.")).pack(side=tk.LEFT, padx=4) self.minimize_check = ttk.Checkbutton( - options_frame, + options_row1, text="Сворачивать в трей при закрытии", variable=self.minimize_to_tray_enabled ) self.minimize_check.pack(side=tk.LEFT, padx=10) - ttk.Button(options_frame, text="?", width=2, + ttk.Button(options_row1, text="?", width=2, command=lambda: self.show_hint("Сворачивание", "По крестику окно скрывается в трей.")).pack(side=tk.LEFT, padx=4) self.verify_check = ttk.Checkbutton( - options_frame, + options_row2, text="Только проверка (без копирования)", variable=self.verify_only ) - self.verify_check.pack(side=tk.LEFT, padx=10) + self.verify_check.pack(side=tk.LEFT) - ttk.Button(options_frame, text="?", width=2, + ttk.Button(options_row2, text="?", width=2, command=lambda: self.show_hint("Проверка", "Проверяет совпадение контрольных сумм без копирования.")).pack(side=tk.LEFT, padx=4) + self.cleanup_check = ttk.Checkbutton( + options_row2, + text="Удалять старые файлы в назначении", + variable=self.cleanup_old_var, + command=self.on_cleanup_toggle + ) + self.cleanup_check.pack(side=tk.LEFT, padx=10) + + ttk.Button(options_row2, text="?", width=2, + 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) + self.min_free_entry.bind("", lambda _e: self.request_autosave()) 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) + self.max_workers_entry.bind("", lambda _e: self.request_autosave()) 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) + self.include_entry.bind("", lambda _e: self.request_autosave()) 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) + self.exclude_entry.bind("", lambda _e: self.request_autosave()) + ttk.Button(masks_frame, text="Шаблоны", command=self.show_mask_templates).pack(side=tk.LEFT, padx=6) buttons_frame = ttk.Frame(schedule_frame) buttons_frame.pack(fill=tk.X, pady=5) @@ -436,17 +517,19 @@ class BackgroundFileCopyApp: self.pairs_tree = ttk.Treeview( paths_frame, - columns=("source", "dest", "mode", "status"), + columns=("source", "dest", "mode", "last", "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("last", 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("source", width=240) + self.pairs_tree.column("dest", width=240) self.pairs_tree.column("mode", width=110, anchor=tk.CENTER) + self.pairs_tree.column("last", width=160) self.pairs_tree.column("status", width=120, anchor=tk.CENTER) tree_scroll = ttk.Scrollbar(paths_frame, orient="vertical", command=self.pairs_tree.yview) @@ -459,50 +542,15 @@ class BackgroundFileCopyApp: self.pairs_tree.tag_configure("warn", foreground="orange") self.pairs_tree.tag_configure("error", foreground="red") self.pairs_tree.tag_configure("idle", foreground="gray") - - 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_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) - - 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("", self.on_tree_double_click) + self.pairs_tree.bind("", self.on_tree_f2_edit) + self.pairs_tree.bind("", self.on_tree_right_click) + self.pairs_tree.bind("", self.on_tree_motion) + self.pairs_tree.bind("", lambda _e: self.remove_selected_pair()) + self.pairs_tree.bind("", lambda _e: self.open_selected_destination()) 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) @@ -535,13 +583,16 @@ class BackgroundFileCopyApp: ) 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="Готов к работе", + footer = ttk.Frame(self.root) + footer.pack(side=tk.BOTTOM, fill=tk.X) + + self.status_bar = ttk.Label(footer, text="F2 — редактировать, двойной клик — выбрать папку, ПКМ — меню", relief=tk.SUNKEN, anchor=tk.W) - self.status_bar.pack(side=tk.BOTTOM, fill=tk.X) + self.status_bar.pack(side=tk.TOP, fill=tk.X) 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) + self.progress = ttk.Progressbar(footer, mode="determinate", variable=self.progress_var) + self.progress.pack(side=tk.TOP, fill=tk.X) def setup_window_icon(self): icon_path = get_resource_path(ICON_PATH) @@ -653,6 +704,8 @@ class BackgroundFileCopyApp: def load_settings(self): """Загружает настройки из файла и отображает их""" try: + self.loading_settings = True + self.autosave_enabled = False settings_path = self.get_settings_path() self.log_message(f"🔍 Загрузка настроек из: {settings_path}", "info") @@ -712,8 +765,11 @@ class BackgroundFileCopyApp: self.refresh_profile_combo() self.load_profile("Default") self.autostart_enabled.set(self.is_autostart_enabled()) + finally: + self.autosave_enabled = True + self.loading_settings = False - def save_settings(self): + def save_settings(self, show_message: bool = True): """Сохраняет настройки в файл""" try: self.save_active_profile() @@ -732,10 +788,10 @@ 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(self.profiles)})", "success") - self.log_message(f"📁 Файл: {settings_path}", "info") - - messagebox.showinfo("Успех", f"Настройки сохранены!\n\nФайл: {settings_path}") + if show_message: + self.log_message(f"💾 Настройки сохранены (профилей: {len(self.profiles)})", "success") + self.log_message(f"📁 Файл: {settings_path}", "info") + messagebox.showinfo("Успех", f"Настройки сохранены!\n\nФайл: {settings_path}") if self.scheduler_enabled.get(): # Перепланируем, чтобы учесть новые пути/время self.start_scheduler() @@ -754,6 +810,9 @@ class BackgroundFileCopyApp: "source": source, "dest": dest, "full_copy": bool(full_copy), + "last_file": "", + "include_masks": "", + "exclude_masks": "", "status": "—", "status_tag": "idle" } @@ -767,12 +826,10 @@ class BackgroundFileCopyApp: pair["status_tag"] = status_tag self.copy_pairs.append(pair) - 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.render_pairs() self.pairs_tree.selection_set(pair_id) self.on_pair_select() + self.request_autosave() def remove_selected_pair(self): pair_id = self.selected_pair_id @@ -782,9 +839,13 @@ class BackgroundFileCopyApp: 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) + self.request_autosave() + + def toggle_compact_view(self): + compact = self.compact_view_var.get() + style = ttk.Style() + style.configure("Treeview", rowheight=18 if compact else 24) + self.render_pairs() def remove_path_pair(self, pair_id): """Совместимость: удалить пару по id.""" @@ -801,6 +862,7 @@ class BackgroundFileCopyApp: for item in self.pairs_tree.get_children(): self.pairs_tree.delete(item) self.selected_pair_id = None + self.request_autosave() def get_pair_by_id(self, pair_id: str): for pair in self.copy_pairs: @@ -808,10 +870,53 @@ class BackgroundFileCopyApp: return pair return None + def find_pair_by_paths(self, source: str, dest: str, full_copy: bool): + for pair in self.copy_pairs: + if pair.get("source") == source and pair.get("dest") == dest and bool(pair.get("full_copy", False)) == bool(full_copy): + 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"],)) + self.render_pairs() + + def shorten_path(self, path: str, max_len: int = 40) -> str: + if len(path) <= max_len: + return path + return path[:18] + "..." + path[-(max_len - 21):] + + def format_status(self, status_text: str) -> str: + if status_text == "OK": + return "✅ OK" + if status_text in ("Нет источника", "Нет назначения", "Нет доступа"): + return f"❌ {status_text}" + if status_text == "—": + return "—" + return status_text + + def render_pairs(self): + search = (self.search_var.get() or "").lower() + status_filter = self.filter_status_var.get() + for item in self.pairs_tree.get_children(): + self.pairs_tree.delete(item) + + for pair in self.copy_pairs: + status_text = pair.get("status", "—") + if status_filter != "Все" and status_text != status_filter: + continue + if search: + if search not in pair.get("source", "").lower() and search not in pair.get("dest", "").lower(): + continue + mode_text = "Вся папка" if pair.get("full_copy") else "Последний файл" + last_file = pair.get("last_file", "") + source_disp = self.shorten_path(pair.get("source", "")) + dest_disp = self.shorten_path(pair.get("dest", "")) + self.pairs_tree.insert( + "", + "end", + iid=pair["id"], + values=(source_disp, dest_disp, mode_text, last_file, self.format_status(status_text)), + tags=(pair.get("status_tag", "idle"),) + ) def on_pair_select(self): selection = self.pairs_tree.selection() @@ -819,15 +924,6 @@ class BackgroundFileCopyApp: 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 @@ -836,30 +932,143 @@ class BackgroundFileCopyApp: 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()) + pair["source"] = pair.get("source", "").strip() + pair["dest"] = pair.get("dest", "").strip() 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 on_tree_double_click(self, event): + item = self.pairs_tree.identify_row(event.y) + column = self.pairs_tree.identify_column(event.x) + if not item or not column: + return + self.selected_pair_id = item + if column == "#3": + self.toggle_selected_mode() + return + if column not in ("#1", "#2"): + return + if column == "#1": + self.pick_folder_for_item(item, "source", "Выберите папку источника") + return + if column == "#2": + self.pick_folder_for_item(item, "dest", "Выберите папку назначения") + return + + def on_tree_f2_edit(self, event): + selection = self.pairs_tree.selection() + if not selection: + return + item = selection[0] + column = self.pairs_tree.identify_column(event.x) + if not column or column not in ("#1", "#2"): + column = "#1" + self.inline_edit_cell(item, column) + + def on_tree_right_click(self, event): + item = self.pairs_tree.identify_row(event.y) + if item: + self.pairs_tree.selection_set(item) + self.selected_pair_id = item + menu = tk.Menu(self.root, tearoff=0) + menu.add_command(label="Выбрать источник…", command=lambda: self.pick_folder_for_item(self.selected_pair_id, "source", "Выберите папку источника")) + menu.add_command(label="Выбрать назначение…", command=lambda: self.pick_folder_for_item(self.selected_pair_id, "dest", "Выберите папку назначения")) + menu.add_command(label="Переключить режим", command=self.toggle_selected_mode) + menu.add_command(label="Открыть источник", command=self.open_selected_source) + menu.add_command(label="Открыть назначение", command=self.open_selected_destination) + menu.add_command(label="Показать лог пары", command=self.show_pair_log) + menu.add_command(label="Маски пары…", command=self.edit_pair_masks) + menu.add_separator() + menu.add_command(label="Удалить", command=self.remove_selected_pair) + menu.tk_popup(event.x_root, event.y_root) + menu.grab_release() + + def on_tree_motion(self, event): + item = self.pairs_tree.identify_row(event.y) + column = self.pairs_tree.identify_column(event.x) + if not item: + return + pair = self.get_pair_by_id(item) + if not pair: + return + if column == "#1": + self.set_status_text(pair.get("source", "") or "Готов к работе") + elif column == "#2": + self.set_status_text(pair.get("dest", "") or "Готов к работе") + elif column == "#4": + self.set_status_text(pair.get("last_file", "") or "Готов к работе") + + def pick_folder_for_item(self, item_id, field: str, title: str): + if not item_id: + return + folder = filedialog.askdirectory(title=title) + if folder: + pair = self.get_pair_by_id(item_id) + if pair: + pair[field] = folder + 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) + self.request_autosave() + + def inline_edit_cell(self, item, column): + bbox = self.pairs_tree.bbox(item, column) + if not bbox: + return + x, y, w, h = bbox + value = self.pairs_tree.set(item, column) + if self.inline_editor is not None: + self.inline_editor.destroy() + self.inline_editor = None + entry = ttk.Entry(self.pairs_tree) + entry.insert(0, value) + entry.select_range(0, tk.END) + entry.place(x=x, y=y, width=w, height=h) + entry.focus_set() + self.inline_editor = entry + + def commit(_evt=None): + new_val = entry.get().strip() + entry.destroy() + self.inline_editor = None + pair = self.get_pair_by_id(item) + if not pair: + return + if column == "#1": + pair["source"] = new_val + elif column == "#2": + pair["dest"] = new_val + 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) + self.request_autosave() + + def cancel(_evt=None): + entry.destroy() + self.inline_editor = None + + entry.bind("", commit) + entry.bind("", commit) + entry.bind("", cancel) + def validate_pair(self, source: str, dest: str): if not source or not dest: - return "?", "idle" + return "—", "idle" if not os.path.exists(source): return "Нет источника", "error" + if not os.access(source, os.R_OK): + 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: + if not os.access(dest, os.W_OK): return "Нет доступа", "error" return "OK", "ok" @@ -873,6 +1082,67 @@ class BackgroundFileCopyApp: return self.open_destination(pair["dest"]) + def open_selected_source(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["source"]) + + def show_pair_log(self): + pair_id = self.selected_pair_id + if not pair_id: + return + pair = self.get_pair_by_id(pair_id) + if not pair: + return + src = pair.get("source", "") + dst = pair.get("dest", "") + content = self.log_text.get("1.0", tk.END).splitlines() + filtered = [line for line in content if (src and src in line) or (dst and dst in line)] + win = tk.Toplevel(self.root) + win.title("Лог пары") + win.geometry("700x400") + text = scrolledtext.ScrolledText(win, wrap=tk.WORD) + text.pack(fill=tk.BOTH, expand=True) + text.insert(tk.END, "\n".join(filtered) if filtered else "Нет записей для этой пары.") + + def edit_pair_masks(self): + pair_id = self.selected_pair_id + if not pair_id: + return + pair = self.get_pair_by_id(pair_id) + if not pair: + return + + win = tk.Toplevel(self.root) + win.title("Маски пары") + win.geometry("420x180") + win.transient(self.root) + win.grab_set() + + include_var = tk.StringVar(value=pair.get("include_masks", "")) + exclude_var = tk.StringVar(value=pair.get("exclude_masks", "")) + + ttk.Label(win, text="Включать маски (через ;):").pack(anchor=tk.W, padx=10, pady=4) + include_entry = ttk.Entry(win, textvariable=include_var) + include_entry.pack(fill=tk.X, padx=10, pady=2) + ttk.Label(win, text="Исключать маски (через ;):").pack(anchor=tk.W, padx=10, pady=4) + exclude_entry = ttk.Entry(win, textvariable=exclude_var) + exclude_entry.pack(fill=tk.X, padx=10, pady=2) + + def apply(): + pair["include_masks"] = include_var.get().strip() + pair["exclude_masks"] = exclude_var.get().strip() + self.request_autosave() + win.destroy() + + btns = ttk.Frame(win) + btns.pack(pady=8) + ttk.Button(btns, text="Сохранить", command=apply).pack(side=tk.LEFT, padx=6) + ttk.Button(btns, text="Отмена", command=win.destroy).pack(side=tk.LEFT, padx=6) def toggle_selected_mode(self): pair_id = self.selected_pair_id if not pair_id: @@ -881,8 +1151,8 @@ class BackgroundFileCopyApp: if not pair: return pair["full_copy"] = not pair["full_copy"] - self.edit_full_copy.set(pair["full_copy"]) self.update_pair_row(pair) + self.request_autosave() def browse_folder(self, entry): """Открывает диалог выбора папки""" @@ -890,6 +1160,7 @@ class BackgroundFileCopyApp: if folder: entry.delete(0, tk.END) entry.insert(0, folder) + self.update_selected_pair_from_edit() def open_destination(self, path: str): if not path or path.startswith("Например: "): @@ -913,7 +1184,7 @@ class BackgroundFileCopyApp: def open_schedule_dialog(self): dlg = tk.Toplevel(self.root) dlg.title("Расписание") - dlg.geometry("420x360") + dlg.geometry("520x360") dlg.transient(self.root) dlg.grab_set() @@ -924,12 +1195,47 @@ class BackgroundFileCopyApp: 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) + type_frame = ttk.Frame(dlg) + type_frame.pack(fill=tk.X, pady=5, padx=10) + ttk.Label(type_frame, text="Тип:").pack(side=tk.LEFT) + ttk.Radiobutton(type_frame, text="Ежедневно", variable=self.schedule_type_var, value="daily").pack(side=tk.LEFT, padx=4) + ttk.Radiobutton(type_frame, text="Еженедельно", variable=self.schedule_type_var, value="weekly").pack(side=tk.LEFT, padx=4) + ttk.Radiobutton(type_frame, text="Ежемесячно", variable=self.schedule_type_var, value="monthly").pack(side=tk.LEFT, padx=4) + days_frame = ttk.Frame(dlg) days_frame.pack(fill=tk.X, pady=5, padx=10) - ttk.Label(days_frame, text="Дни:").pack(side=tk.LEFT) + ttk.Label(days_frame, text="Дни недели:").pack(side=tk.LEFT) day_labels = [("Mon", "Пн"), ("Tue", "Вт"), ("Wed", "Ср"), ("Thu", "Чт"), ("Fri", "Пт"), ("Sat", "Сб"), ("Sun", "Вс")] + day_checks = [] for key, label in day_labels: - ttk.Checkbutton(days_frame, text=label, variable=self.schedule_days_vars[key]).pack(side=tk.LEFT, padx=2) + cb = ttk.Checkbutton(days_frame, text=label, variable=self.schedule_days_vars[key]) + cb.pack(side=tk.LEFT, padx=2) + day_checks.append(cb) + + month_frame = ttk.Frame(dlg) + month_frame.pack(fill=tk.X, pady=5, padx=10) + ttk.Label(month_frame, text="День месяца:").pack(side=tk.LEFT) + month_spin = ttk.Spinbox(month_frame, from_=1, to=31, width=4, textvariable=self.schedule_monthday_var) + month_spin.pack(side=tk.LEFT, padx=4) + ttk.Label(month_frame, text="(Если день > кол-ва дней, запуск в последний день месяца)").pack(side=tk.LEFT, padx=6) + + def update_schedule_controls(): + mode = self.schedule_type_var.get() + if mode == "daily": + for cb in day_checks: + cb.config(state=tk.DISABLED) + month_spin.config(state=tk.DISABLED) + elif mode == "weekly": + for cb in day_checks: + cb.config(state=tk.NORMAL) + month_spin.config(state=tk.DISABLED) + else: + for cb in day_checks: + cb.config(state=tk.DISABLED) + month_spin.config(state=tk.NORMAL) + + self.schedule_type_var.trace_add("write", lambda *_: update_schedule_controls()) + update_schedule_controls() list_frame = ttk.Frame(dlg) list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) @@ -947,14 +1253,26 @@ class BackgroundFileCopyApp: 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 "Ежедневно" + sched_type = entry.get("type", "daily") + if sched_type == "monthly": + days_text = f"ежемесячно, день {entry.get('day', 1)}" + elif sched_type == "weekly": + days = entry.get("days", []) + days_text = ",".join(days) if days else "еженедельно" + else: + days_text = "ежедневно" 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}) + sched_type = self.schedule_type_var.get() + entry = {"time": time_str, "type": sched_type} + if sched_type == "weekly": + entry["days"] = days + elif sched_type == "monthly": + entry["day"] = int(self.schedule_monthday_var.get() or 1) + self.schedules.append(entry) refresh_tree() self.update_schedule_summary_label() @@ -980,9 +1298,13 @@ class BackgroundFileCopyApp: 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]) + if self.profile_combo is not None: + self.profile_combo["values"] = names + if self.active_profile.get() not in names: + self.active_profile.set(names[0]) + else: + if self.active_profile.get() not in names: + self.active_profile.set(names[0]) def default_profile(self) -> dict: return { @@ -993,6 +1315,7 @@ class BackgroundFileCopyApp: "min_free_gb": DEFAULT_MIN_FREE_GB, "max_workers": DEFAULT_MAX_WORKERS, "verify_only": False, + "cleanup_old": False, "last_success_time": None, "last_result_summary": None, "last_hashes": {}, @@ -1006,6 +1329,8 @@ class BackgroundFileCopyApp: "source": p.get("source", ""), "dest": p.get("dest", ""), "full_copy": bool(p.get("full_copy", False)), + "include_masks": p.get("include_masks", ""), + "exclude_masks": p.get("exclude_masks", ""), } for p in self.copy_pairs ] @@ -1015,12 +1340,16 @@ class BackgroundFileCopyApp: 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["cleanup_old"] = bool(self.cleanup_old_var.get()) + profile["skip_cleanup_confirm"] = bool(self.skip_cleanup_confirm) 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.loading_settings = True + self.autosave_enabled = False self.save_active_profile() profile = self.profiles.get(name) or self.default_profile() self.active_profile.set(name) @@ -1029,6 +1358,8 @@ class BackgroundFileCopyApp: 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.cleanup_old_var.set(profile.get("cleanup_old", False)) + self.skip_cleanup_confirm = bool(profile.get("skip_cleanup_confirm", 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", {}) @@ -1038,8 +1369,12 @@ class BackgroundFileCopyApp: 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)) + last = self.copy_pairs[-1] + last["include_masks"] = item.get("include_masks", "") + last["exclude_masks"] = item.get("exclude_masks", "") self.schedules = profile.get("schedules", []) self.update_schedule_summary_label() + self.render_pairs() if self.schedules: first_time = self.schedules[0].get("time", "03:00") if ":" in first_time: @@ -1048,6 +1383,8 @@ class BackgroundFileCopyApp: self.minute_var.set(minute) if self.scheduler_enabled.get(): self.start_scheduler() + self.autosave_enabled = True + self.loading_settings = False def add_profile(self): name = simpledialog.askstring("Новый профиль", "Введите имя профиля:") @@ -1118,9 +1455,15 @@ class BackgroundFileCopyApp: 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} + sched_type = self.schedule_type_var.get() + entry = {"time": time_str, "type": sched_type} + if sched_type == "weekly": + entry["days"] = days + elif sched_type == "monthly": + entry["day"] = int(self.schedule_monthday_var.get() or 1) self.schedules.append(entry) self.update_schedule_summary_label() + self.request_autosave() if self.scheduler_enabled.get(): self.start_scheduler() @@ -1129,12 +1472,76 @@ class BackgroundFileCopyApp: return self.schedules.pop() self.update_schedule_summary_label() + self.request_autosave() if self.scheduler_enabled.get(): self.start_scheduler() def show_hint(self, title: str, message: str): messagebox.showinfo(title, message) + def request_autosave(self): + if not self.autosave_enabled or self.loading_settings: + return + if self.autosave_job is not None: + self.root.after_cancel(self.autosave_job) + self.autosave_job = self.root.after(1000, lambda: self.save_settings(show_message=False)) + + def show_mask_templates(self): + choices = [ + ("SQL/BAK", "*.bak;*.sql;*.backup"), + ("Все файлы", "*.*"), + ("Только BAK", "*.bak"), + ("Архивы", "*.zip;*.7z;*.rar"), + ] + win = tk.Toplevel(self.root) + win.title("Шаблоны масок") + win.geometry("320x220") + win.transient(self.root) + win.grab_set() + + for label, value in choices: + def apply(val=value): + self.include_masks_var.set(val) + self.request_autosave() + win.destroy() + ttk.Button(win, text=f"{label}: {value}", command=apply).pack(fill=tk.X, padx=10, pady=4) + + def on_cleanup_toggle(self): + if not self.cleanup_old_var.get(): + self.request_autosave() + return + if self.skip_cleanup_confirm: + self.request_autosave() + return + dlg = tk.Toplevel(self.root) + dlg.title("Подтверждение") + dlg.geometry("360x170") + dlg.transient(self.root) + dlg.grab_set() + + ttk.Label(dlg, text="Удалять старые файлы в назначении?\nЭто необратимое действие.").pack(pady=10) + skip_var = tk.BooleanVar(value=False) + ttk.Checkbutton(dlg, text="Больше не спрашивать", variable=skip_var).pack() + + def confirm(): + self.skip_cleanup_confirm = skip_var.get() + dlg.destroy() + self.request_autosave() + + def cancel(): + self.cleanup_old_var.set(False) + dlg.destroy() + + btns = ttk.Frame(dlg) + btns.pack(pady=10) + ttk.Button(btns, text="Да", command=confirm).pack(side=tk.LEFT, padx=8) + ttk.Button(btns, text="Нет", command=cancel).pack(side=tk.LEFT, padx=8) + + def stop_copying(self): + if self.is_copying: + self.cancel_event.set() + self.log_message("⏹ Остановка копирования по запросу пользователя", "warning") + def open_wizard(self): if getattr(self, "wizard_window", None) is not None: try: @@ -1198,7 +1605,7 @@ class BackgroundFileCopyApp: 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="Включить расписание", 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) @@ -1281,11 +1688,11 @@ class BackgroundFileCopyApp: pair["status_tag"] = status_tag self.update_pair_row(pair) - def find_latest_file(self, folder_path: str) -> Optional[Path]: + def find_latest_file(self, folder_path: str, include_masks: Optional[List[str]] = None, exclude_masks: Optional[List[str]] = None) -> Optional[Path]: """Находит самый последний файл в папке""" try: - include_masks = parse_masks(self.include_masks_var.get()) - exclude_masks = parse_masks(self.exclude_masks_var.get()) + include_masks = include_masks or parse_masks(self.include_masks_var.get()) + exclude_masks = exclude_masks or 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(): @@ -1374,10 +1781,14 @@ class BackgroundFileCopyApp: skipped = 0 errors = 0 for root, _dirs, files in os.walk(source): + if self.cancel_event.is_set(): + break rel = os.path.relpath(root, source) dest_root = dest / rel if rel != "." else dest dest_root.mkdir(parents=True, exist_ok=True) for fname in files: + if self.cancel_event.is_set(): + break src_file = Path(root) / fname dst_file = dest_root / fname try: @@ -1412,10 +1823,14 @@ class BackgroundFileCopyApp: self.record_error_detail(msg) return copied, skipped, errors - def process_pair(self, source: str, dest: str, full_copy: bool) -> tuple: + def process_pair(self, source: str, dest: str, full_copy: bool, background: bool) -> tuple: copied_files = 0 skipped_files = 0 error_files = 0 + pair_ref = self.find_pair_by_paths(source, dest, full_copy) + + if self.cancel_event.is_set(): + return copied_files, skipped_files, error_files self.log_message(f"\n📁 Обработка папки: {source}", "info") src_path = Path(source) @@ -1435,11 +1850,31 @@ class BackgroundFileCopyApp: self.record_error_detail(msg) return copied_files, skipped_files, error_files + 1 + if not self.verify_only.get(): + required_size = get_total_copy_size(src_path, dest_path) + if required_size > 0 and not self.has_enough_space(dest_path, required_size): + return copied_files, skipped_files, error_files + 1 + + if pair_ref is not None: + pair_ref["last_file"] = "—" + self.update_pair_row(pair_ref) + c, s, e = self.copy_full_folder(src_path, dest_path) + if self.tray_icon is not None: + self.tray_notify(f"{source}", f"Готово: ✅ {c} / ⏭️ {s} / ❌ {e}") return copied_files + c, skipped_files + s, error_files + e - latest_file = self.find_latest_file(source) + include_masks = parse_masks(pair_ref.get("include_masks", "")) if pair_ref else [] + exclude_masks = parse_masks(pair_ref.get("exclude_masks", "")) if pair_ref else [] + if not include_masks and not exclude_masks: + include_masks = parse_masks(self.include_masks_var.get()) + exclude_masks = parse_masks(self.exclude_masks_var.get()) + + latest_file = self.find_latest_file(source, include_masks=include_masks, exclude_masks=exclude_masks) if latest_file: + if pair_ref is not None: + pair_ref["last_file"] = latest_file.name + self.update_pair_row(pair_ref) try: dest_path.mkdir(parents=True, exist_ok=True) except Exception as e: @@ -1473,6 +1908,24 @@ class BackgroundFileCopyApp: self.log_message(f"✅ Обновлен: {latest_file.name} (новее)", "success") else: self.log_message(f"✅ Скопирован: {latest_file.name}", "success") + if self.cleanup_old_var.get(): + include_masks = parse_masks(self.include_masks_var.get()) + exclude_masks = parse_masks(self.exclude_masks_var.get()) + for item in dest_path.iterdir(): + if not item.is_file(): + continue + if item.name == latest_file.name: + continue + if include_masks or exclude_masks: + if not matches_masks(item.name, include_masks, exclude_masks): + continue + try: + item.unlink() + self.log_message(f"🧹 Удален старый файл: {item.name}", "info") + except Exception as e: + msg = f"❌ Не удалось удалить файл {item.name}: {e}" + self.log_message(msg, "warning") + self.record_error_detail(msg) else: error_files += 1 msg = f"❌ Контрольная сумма не совпала: {latest_file.name}" @@ -1493,6 +1946,9 @@ class BackgroundFileCopyApp: error_files += 1 self.step_progress() + if self.tray_icon is not None and background: + self.tray_notify(f"{source}", f"Готово: ✅ {copied_files} / ⏭️ {skipped_files} / ❌ {error_files}") + return copied_files, skipped_files, error_files def copy_files_thread(self, pairs: List[tuple], background: bool = False): @@ -1502,6 +1958,7 @@ class BackgroundFileCopyApp: return self.is_copying = True + self.cancel_event.clear() self.root.after(0, lambda: self.status_bar.config(text="Идет копирование...")) total_units = 0 for source, dest, full_copy in pairs: @@ -1529,8 +1986,10 @@ class BackgroundFileCopyApp: 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] + futures = [executor.submit(self.process_pair, source, dest, full_copy, background) for source, dest, full_copy in pairs] for future in as_completed(futures): + if self.cancel_event.is_set(): + break try: c, s, e = future.result() copied_files += c @@ -1657,9 +2116,12 @@ class BackgroundFileCopyApp: parts = [] for entry in self.schedules: time_str = entry.get("time", "—") - days = entry.get("days", []) - if days: - days_text = ",".join(days) + sched_type = entry.get("type", "daily") + if sched_type == "monthly": + parts.append(f"{time_str} (ежемес., {entry.get('day', 1)})") + elif sched_type == "weekly": + days = entry.get("days", []) + days_text = ",".join(days) if days else "еженед." parts.append(f"{time_str} ({days_text})") else: parts.append(f"{time_str} (ежедневно)")