/
FunZick
/
ExcelCleaner
Обзор
Документация
Войти
/
FunZick
/
ExcelCleaner
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
main.py
1 405 строк
57 KB
FunZick
Create: main.py
03 авг 2026, 19:38
Верифицирован
03 авг 2026, 19:38
117e1eb
Код
Авторство
О чём код?
""" Excel Cleaner — приложение для очистки Excel файлов. Возможности: - Загрузка и предпросмотр данных - Удаление полных дубликатов (с настройкой столбцов) - Удаление пустых строк и столбцов - Удаление по критерию (пустой конкретный столбец) - Предпросмотр изменений с подсветкой - Undo (отмена последнего действия) - Выбор активного листа - Экспорт в CSV - Авто-backup перед изменениями - Пакетная обработка нескольких файлов - Сравнение двух файлов """ import os import shutil import time import copy import csv import tkinter as tk from tkinter import ttk, filedialog, messagebox import openpyxl from openpyxl.utils.exceptions import InvalidFileException # ============================================================ # Вспомогательные функции # ============================================================ def _cell_value(cell): """Приводим значение ячейки к строке для сравнения.""" if cell is None: return "" return cell def _is_row_empty(row): """Проверяет, пустая ли строка (все ячейки None или пустые).""" return all(v is None or str(v).strip() == "" for v in row) def _make_backup(filepath): """Создаёт резервную копию файла с уникальным именем.""" if not os.path.exists(filepath): return None # Проверяем, что это Excel-файл if not filepath.endswith((".xlsx", ".xls")): return None # Создаём уникальное имя с timestamp timestamp = int(time.time()) base, ext = os.path.splitext(filepath) backup_path = f"{base}_backup_{timestamp}{ext}" # Если файл уже существует, добавляем счётчик counter = 1 while os.path.exists(backup_path): backup_path = f"{base}_backup_{timestamp}_{counter}{ext}" counter += 1 shutil.copy2(filepath, backup_path) return backup_path def _count_cells(ws): """Общее количество ячеек на листе.""" return ws.max_row * ws.max_column def _is_large_file(ws, threshold=50000): """Определяет, является ли файл «большим».""" return _count_cells(ws) > threshold # ============================================================ # Вкладка: Очистка # ============================================================ class CleanTab: """Вкладка очистки одного файла.""" def __init__(self, parent, app): self.parent = parent # ttk.Frame внутри Notebook self.app = app self.ws = None self.wb = None self.file_path = None self.current_sheet_name = None self.headers = None self.data = None # список списков значений (без заголовка) self.undo_stack = [] # история: [(old_headers, old_data)] # Цвета для подсветки self.highlight_bg = "#ffcccc" # красный для удаления self.preview_mode = False # True если включён предпросмотр self._build_ui() # ---- UI ---- def _build_ui(self): # --- Панель 1: Загрузка --- f_load = ttk.LabelFrame(self.parent, text="Загрузка файла", padding=5) f_load.pack(fill=tk.X, padx=5, pady=2) ttk.Label(f_load, text="Файл:").pack(side=tk.LEFT) self.lbl_file = ttk.Label(f_load, text="Не выбран", foreground="gray") self.lbl_file.pack(side=tk.LEFT, padx=5) ttk.Button(f_load, text="Открыть", command=self._open_file).pack(side=tk.LEFT, padx=3) # --- Панель 2: Настройки --- f_set = ttk.LabelFrame(self.parent, text="Настройки", padding=5) f_set.pack(fill=tk.X, padx=5, pady=2) # Выбор листа ttk.Label(f_set, text="Лист:").pack(side=tk.LEFT) self.var_sheet = tk.StringVar() self.cmb_sheet = ttk.Combobox(f_set, textvariable=self.var_sheet, width=25, state="readonly") self.cmb_sheet.pack(side=tk.LEFT, padx=3) self.cmb_sheet.bind("<<ComboboxSelected>>", self._on_sheet_change) # Чекбоксы self.chk_keep_header = tk.BooleanVar(value=True) ttk.Checkbutton(f_set, text="Сохранить заголовок", variable=self.chk_keep_header).pack(side=tk.LEFT, padx=10) self.chk_backup = tk.BooleanVar(value=True) ttk.Checkbutton(f_set, text="Авто-backup", variable=self.chk_backup).pack(side=tk.LEFT, padx=10) # --- Панель 3: Настройки дубликатов --- f_dup = ttk.LabelFrame(self.parent, text="Дубликаты", padding=5) f_dup.pack(fill=tk.X, padx=5, pady=2) ttk.Label(f_dup, text="Столбцы для проверки:").pack(side=tk.LEFT) self.lbl_dup_cols = ttk.Label(f_dup, text="(все столбцы)", foreground="gray") self.lbl_dup_cols.pack(side=tk.LEFT, padx=3) self.chk_dup_all = tk.BooleanVar(value=True) ttk.Checkbutton(f_dup, text="Все столбцы", variable=self.chk_dup_all, command=self._on_dup_mode_change).pack(side=tk.LEFT, padx=10) self.chk_dup_list_var = tk.BooleanVar(value=False) self.chk_dup_list = ttk.Checkbutton(f_dup, text="Выборочно", variable=self.chk_dup_list_var, command=self._on_dup_mode_change) self.chk_dup_list.pack(side=tk.LEFT, padx=10) self.btn_sel_dup_cols = ttk.Button(f_dup, text="Выбрать столбцы", command=self._select_dup_columns, state=tk.DISABLED) self.btn_sel_dup_cols.pack(side=tk.LEFT, padx=3) # --- Панель 4: Действия --- f_action = ttk.LabelFrame(self.parent, text="Действия", padding=5) f_action.pack(fill=tk.X, padx=5, pady=2) self.btn_preview = ttk.Button(f_action, text="🔍 Предпросмотр", command=self._preview_changes) self.btn_preview.pack(side=tk.LEFT, padx=3) self.btn_undo = ttk.Button(f_action, text="↩ Отменить (Undo)", command=self._undo, state=tk.DISABLED) self.btn_undo.pack(side=tk.LEFT, padx=3) ttk.Separator(f_action, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10) self.btn_rm_dup = ttk.Button(f_action, text="Удалить дубликаты", command=self._remove_duplicates) self.btn_rm_dup.pack(side=tk.LEFT, padx=3) self.btn_rm_empty = ttk.Button(f_action, text="Удалить пустые", command=self._remove_empty) self.btn_rm_empty.pack(side=tk.LEFT, padx=3) self.btn_rm_by_col = ttk.Button(f_action, text="Удалить по критерию", command=self._remove_by_criterion) self.btn_rm_by_col.pack(side=tk.LEFT, padx=3) ttk.Separator(f_action, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10) self.btn_apply_preview = ttk.Button(f_action, text="✅ Применить", command=self._apply_preview, state=tk.DISABLED) self.btn_apply_preview.pack(side=tk.LEFT, padx=3) self.btn_cancel_preview = ttk.Button(f_action, text="❌ Отмена", command=self._cancel_preview, state=tk.DISABLED) self.btn_cancel_preview.pack(side=tk.LEFT, padx=3) ttk.Separator(f_action, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10) self.btn_save = ttk.Button(f_action, text="💾 Сохранить (.xlsx)", command=self._save_xlsx) self.btn_save.pack(side=tk.LEFT, padx=3) self.btn_export_csv = ttk.Button(f_action, text="📄 Экспорт CSV", command=self._export_csv) self.btn_export_csv.pack(side=tk.LEFT, padx=3) # --- Прогресс-бар --- self.progress = ttk.Progressbar(self.parent, mode='determinate') self.progress.pack(fill=tk.X, padx=5, pady=2) # --- Таблица --- f_table = ttk.Frame(self.parent) f_table.pack(fill=tk.BOTH, expand=True, padx=5, pady=2) self.tree = ttk.Treeview(f_table, show="headings", height=20) self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.v_scroll = ttk.Scrollbar(f_table, orient=tk.VERTICAL, command=self.tree.yview) self.h_scroll = ttk.Scrollbar(f_table, orient=tk.HORIZONTAL, command=self.tree.xview) self.tree.configure(yscrollcommand=self.v_scroll.set, xscrollcommand=self.h_scroll.set) self.v_scroll.pack(side=tk.RIGHT, fill=tk.Y) self.h_scroll.pack(side=tk.BOTTOM, fill=tk.X) # --- Статус --- self.status = ttk.Label(self.parent, text="Готов к работе. Перетащите файл или нажмите «Открыть».", foreground="green") self.status.pack(fill=tk.X, padx=5, pady=2) # ---- Drag & Drop ---- def _on_drop_raw(self, event): data = event.data import re matches = re.findall(r"\{(.+?)\}", data) if matches: self._on_drop_clean(matches[0]) elif data: self._on_drop_clean(data) def _on_drop_clean(self, filepath): """Обработка перетаскивания файла.""" filepath = filepath.strip().strip("{}") if os.path.isfile(filepath): self._load_workbook(filepath) # ---- Загрузка ---- def _open_file(self): filepath = filedialog.askopenfilename( title="Выберите Excel файл", filetypes=[("Excel files", "*.xlsx *.xls"), ("All files", "*.*")], ) if filepath: self._load_workbook(filepath) def _load_workbook(self, filepath): """Загружает файл и показывает данные.""" self._reset_undo() self._clear_preview() try: self.wb = openpyxl.load_workbook(filepath, data_only=True) except InvalidFileException: messagebox.showerror("Ошибка", "Файл не является корректным Excel файлом.") return except Exception as e: messagebox.showerror("Ошибка", f"Не удалось открыть файл:\n{e}") return self.file_path = filepath self.lbl_file.config(text=filepath, foreground="black") # Заполняем список листов sheet_names = self.wb.sheetnames self.cmb_sheet["values"] = sheet_names if sheet_names: self.var_sheet.set(sheet_names[0]) self._load_sheet(sheet_names[0]) else: self.status.config(text="В файле нет листов.", foreground="red") def _load_sheet(self, sheet_name): """Загружает конкретный лист.""" self.ws = self.wb[sheet_name] self.current_sheet_name = sheet_name rows = list(self.ws.iter_rows(values_only=True)) if not rows: self.status.config(text=f"Лист «{sheet_name}» пуст.", foreground="orange") self._clear_tree() return self.headers = ["" if h is None else str(h) for h in rows[0]] self.data = [ ["" if cell is None else cell for cell in row] for row in rows[1:] ] self._display_tree() large = _is_large_file(self.ws) self.status.config( text=f"Лист: {sheet_name} | Строк: {len(self.data)} | Столбцов: {len(self.headers)}" + (" | ⚠ Большой файл!" if large else ""), foreground="green" ) # Обновляем состояние кнопок self.btn_rm_dup["state"] = tk.NORMAL self.btn_rm_empty["state"] = tk.NORMAL self.btn_rm_by_col["state"] = tk.NORMAL self.btn_preview["state"] = tk.NORMAL self.btn_save["state"] = tk.NORMAL self.btn_export_csv["state"] = tk.NORMAL def _on_sheet_change(self, event=None): sheet_name = self.var_sheet.get() if sheet_name: self._load_sheet(sheet_name) # ---- Отображение ---- def _display_tree(self): """Заполняет Treeview данными.""" self._clear_tree() num_cols = len(self.headers) self.tree["columns"] = self.headers self.tree["show"] = "headings" for i, header in enumerate(self.headers): self.tree.heading(header, text=header) self.tree.column(header, width=120, minwidth=50) for row in self.data: # Создаём копию строки, чтобы не мутировать self.data vals = [str(v) for v in row[:num_cols]] # Дополняем до нужной длины while len(vals) < num_cols: vals.append("") self.tree.insert("", tk.END, values=vals) def _clear_tree(self): for col in self.tree["columns"]: self.tree.heading(col, text="") self.tree.column(col, width=0) self.tree["columns"] = [] self.tree.delete(*self.tree.get_children()) # ---- Undo ---- def _save_state(self): """Сохраняет текущее состояние в стек undo.""" state = (copy.deepcopy(self.headers), copy.deepcopy(self.data)) self.undo_stack.append(state) self.btn_undo["state"] = tk.NORMAL def _reset_undo(self): self.undo_stack = [] self.btn_undo["state"] = tk.DISABLED def _undo(self): if not self.undo_stack: return old_headers, old_data = self.undo_stack.pop() self.headers = old_headers self.data = old_data self._clear_preview() self._display_tree() self.status.config(text="Отменено последнее действие.", foreground="blue") if not self.undo_stack: self.btn_undo["state"] = tk.DISABLED # ---- Выбор столбцов для дубликатов ---- def _on_dup_mode_change(self): if self.chk_dup_all.get(): self.chk_dup_list_var.set(False) self.lbl_dup_cols.config(text="(все столбцы)", foreground="gray") self.btn_sel_dup_cols["state"] = tk.DISABLED else: if self.chk_dup_list_var.get(): self.btn_sel_dup_cols["state"] = tk.NORMAL if self.headers else tk.DISABLED else: self.lbl_dup_cols.config(text="(все столбцы)", foreground="gray") self.btn_sel_dup_cols["state"] = tk.DISABLED def _select_dup_columns(self): if not self.headers: return dlg = DupColumnSelector(self.parent, self.headers) self.parent.wait_window(dlg.top) if dlg.selected: names = ", ".join(dlg.selected) self.lbl_dup_cols.config(text=names, foreground="black") # ---- Предпросмотр ---- def _preview_changes(self): """Подсвечивает строки, которые будут удалены (без применения).""" if self.data is None or not self.data: messagebox.showwarning("Внимание", "Нет данных для предпросмотра.") return # Определяем какие строки будут удалены to_remove = set() # 1. Дубликаты if self.chk_dup_all.get(): seen = set() for idx, row in enumerate(self.data): key = tuple(_cell_value(c) for c in row) if key in seen: to_remove.add(idx) else: seen.add(key) else: # Выборочные столбцы selected = self._get_selected_dup_columns() if selected: seen = set() for idx, row in enumerate(self.data): key = tuple(_cell_value(row[c]) for c in selected) if key in seen: to_remove.add(idx) else: seen.add(key) # 2. Пустые строки (если заголовок сохраняется) keep_header = self.chk_keep_header.get() start_idx = 1 if keep_header else 0 for idx in range(start_idx, len(self.data)): if _is_row_empty(self.data[idx]): to_remove.add(idx) if not to_remove: messagebox.showinfo("Информация", "Строк для удаления не найдено.") return # Подсвечиваем self._highlight_rows(to_remove) self.preview_mode = True self.btn_apply_preview["state"] = tk.NORMAL self.btn_cancel_preview["state"] = tk.NORMAL self.status.config( text=f"Предпросмотр: будет удалено {len(to_remove)} строк. " f"Нажмите «Применить» или «Отмена».", foreground="orange" ) def _highlight_rows(self, indices): """Подсвечивает строки в дереве.""" items = self.tree.get_children() # items могут не совпадать с self.data если есть несколько листов, # но мы перерисовываем дерево при смене листа, так что совпадают. for i, item in enumerate(items): if i in indices: self.tree.item(item, tags=("delete",)) self.tree.tag_configure("delete", background=self.highlight_bg) def _apply_preview(self): """Применяет изменения из предпросмотра.""" if not self.preview_mode: return # Собираем индексы подсвеченных строк to_remove = set() for i, item in enumerate(self.tree.get_children()): tags = self.tree.item(item, "tags") if "delete" in tags: to_remove.add(i) # Перезаписываем данные self._save_state() self.data = [row for idx, row in enumerate(self.data) if idx not in to_remove] # Пересчитываем столбцы (удаление пустых столбцов тоже можно учесть) self._update_ws() self._clear_preview() self._display_tree() self.status.config( text=f"Применено: удалено {len(to_remove)} строк.", foreground="blue" ) self.btn_apply_preview["state"] = tk.DISABLED self.btn_cancel_preview["state"] = tk.DISABLED def _cancel_preview(self): self._clear_preview() self.preview_mode = False self.btn_apply_preview["state"] = tk.DISABLED self.btn_cancel_preview["state"] = tk.DISABLED self.status.config(text="Предпросмотр отменён.", foreground="green") def _clear_preview(self): for item in self.tree.get_children(): tags = self.tree.item(item, "tags") if "delete" in tags: current_tags = [t for t in tags if t != "delete"] self.tree.item(item, tags=tuple(current_tags)) def _get_selected_dup_columns(self): """Возвращает индексы выбранных столбцов для проверки дубликатов.""" if not self.headers: return [] dlg = DupColumnSelector(self.parent, self.headers) self.parent.wait_window(dlg.top) if dlg.selected_indices: return dlg.selected_indices return [] # ---- Обновление ws из self.data ---- def _update_ws(self): """Синхронизирует данные из self.headers/self.data обратно в ws.""" if not self.ws: return # Удаляем все строки начиная со второй (заголовок оставляем) if self.ws.max_row >= 2: self.ws.delete_rows(2) # Удаляем все столбцы if self.ws.max_column >= 1: self.ws.delete_cols(1) # Записываем заголовок for col_idx, h in enumerate(self.headers, start=1): self.ws.cell(row=1, column=col_idx, value=h) # Записываем данные for row_idx, row in enumerate(self.data, start=2): for col_idx, val in enumerate(row, start=1): self.ws.cell(row=row_idx, column=col_idx, value=val) # ---- Удаление дубликатов ---- def _remove_duplicates(self): if not self.data: messagebox.showwarning("Внимание", "Нет данных.") return self._save_state() if self.chk_backup.get() and self.file_path: _make_backup(self.file_path) if self.chk_dup_all.get(): seen = set() unique = [] for row in self.data: key = tuple(_cell_value(c) for c in row) if key not in seen: seen.add(key) unique.append(row) else: selected = self._get_selected_dup_columns() if not selected: messagebox.showwarning("Внимание", "Выберите столбцы для проверки.") return seen = set() unique = [] for row in self.data: key = tuple(_cell_value(row[c]) for c in selected) if key not in seen: seen.add(key) unique.append(row) removed = len(self.data) - len(unique) self.data = unique self._update_ws() self._display_tree() self.status.config( text=f"Удалено дубликатов: {removed}. Осталось строк: {len(self.data)}", foreground="blue" ) # ---- Удаление пустых ---- def _remove_empty(self): if not self.data: messagebox.showwarning("Внимание", "Нет данных.") return self._save_state() if self.chk_backup.get() and self.file_path: _make_backup(self.file_path) # Удаляем пустые строки keep_header = self.chk_keep_header.get() start_idx = 1 if keep_header else 0 new_data = self.data[:start_idx] + [ row for row in self.data[start_idx:] if not _is_row_empty(row) ] # Удаляем пустые столбцы new_headers, new_data = self._remove_empty_columns(new_headers=self.headers, new_data=new_data) self.headers = new_headers self.data = new_data self._update_ws() self._display_tree() self.status.config( text=f"Удалено пустых столбцов: {len(self.headers) - len(new_headers)}, " f"пустых строк: {len(self.data) - len(new_data)}", foreground="blue" ) def _remove_empty_columns(self, new_headers, new_data): """Удаляет полностью пустые столбцы.""" if not new_data: return new_headers, new_data non_empty_cols = [] for col_idx in range(len(new_headers)): is_empty = True for row in new_data: if col_idx < len(row) and row[col_idx] is not None and str(row[col_idx]).strip() != "": is_empty = False break if not is_empty: non_empty_cols.append(col_idx) new_headers = [new_headers[i] for i in non_empty_cols] # Создаём новые строки с правильными значениями new_data = [ [row[i] for i in non_empty_cols if i < len(row)] for row in new_data ] # Дополняем строки до единой длины num_cols = len(new_headers) new_data = [row + [""] * (num_cols - len(row)) for row in new_data] return new_headers, new_data # ---- Удаление по критерию ---- def _remove_by_criterion(self): if not self.headers: messagebox.showwarning("Внимание", "Нет данных.") return dlg = CriterionDialog(self.parent, self.headers) self.parent.wait_window(dlg.top) if dlg.result: col_idx = dlg.result["col_idx"] condition = dlg.result["condition"] self._save_state() if self.chk_backup.get() and self.file_path: _make_backup(self.file_path) removed = 0 if condition == "empty": new_data = [] for row in self.data: val = row[col_idx] if col_idx < len(row) else None if val is not None and str(val).strip() != "": new_data.append(row) else: removed += 1 self.data = new_data elif condition == "not_empty": new_data = [] for row in self.data: val = row[col_idx] if col_idx < len(row) else None if val is None or str(val).strip() == "": removed += 1 else: new_data.append(row) self.data = new_data else: messagebox.showerror("Ошибка", f"Неизвестное условие: {condition}") self._display_tree() return self._update_ws() self._display_tree() self.status.config( text=f"Удалено строк по критерию: {removed}. Осталось: {len(self.data)}", foreground="blue" ) # ---- Экспорт ---- def _save_xlsx(self): if not self.wb or not self.file_path: messagebox.showwarning("Внимание", "Сначала откройте файл.") return filepath = filedialog.asksaveasfilename( title="Сохранить как", defaultextension=".xlsx", filetypes=[("Excel files", "*.xlsx"), ("All files", "*.*")], ) if not filepath: return try: self._update_ws() self.wb.save(filepath) self.status.config(text=f"Файл сохранён: {filepath}", foreground="green") messagebox.showinfo("Успех", "Файл успешно сохранён!") except Exception as e: messagebox.showerror("Ошибка", f"Не удалось сохранить:\n{e}") def _export_csv(self): if not self.data or not self.headers: messagebox.showwarning("Внимание", "Нет данных для экспорта.") return filepath = filedialog.asksaveasfilename( title="Экспорт в CSV", defaultextension=".csv", filetypes=[("CSV files", "*.csv"), ("All files", "*.*")], ) if not filepath: return try: with open(filepath, "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f, delimiter=";") writer.writerow(self.headers) for row in self.data: writer.writerow([str(v) for v in row]) self.status.config(text=f"CSV сохранён: {filepath}", foreground="green") messagebox.showinfo("Успех", "Файл CSV успешно экспортирован!") except Exception as e: messagebox.showerror("Ошибка", f"Не удалось экспортировать:\n{e}") # ============================================================ # Вкладка: Сравнение файлов # ============================================================ class CompareTab: """Вкладка сравнения двух файлов.""" def __init__(self, parent, app): self.parent = parent self.app = app self.file1_path = None self.file2_path = None self.data1 = None self.data2 = None self.headers1 = None self.headers2 = None self._build_ui() def _build_ui(self): # --- Выбор файлов --- f_select = ttk.LabelFrame(self.parent, text="Выбор файлов для сравнения", padding=5) f_select.pack(fill=tk.X, padx=5, pady=2) ttk.Label(f_select, text="Файл 1:").pack(side=tk.LEFT) self.lbl_f1 = ttk.Label(f_select, text="Не выбран", foreground="gray") self.lbl_f1.pack(side=tk.LEFT, padx=5) ttk.Button(f_select, text="Выбрать", command=self._open_file1).pack(side=tk.LEFT, padx=3) ttk.Label(f_select, text="Файл 2:").pack(side=tk.LEFT, padx=(20, 0)) self.lbl_f2 = ttk.Label(f_select, text="Не выбран", foreground="gray") self.lbl_f2.pack(side=tk.LEFT, padx=5) ttk.Button(f_select, text="Выбрать", command=self._open_file2).pack(side=tk.LEFT, padx=3) # --- Настройки --- f_set = ttk.LabelFrame(self.parent, text="Настройки сравнения", padding=5) f_set.pack(fill=tk.X, padx=5, pady=2) self.var_sheet1 = tk.StringVar() self.var_sheet2 = tk.StringVar() ttk.Label(f_set, text="Лист 1:").pack(side=tk.LEFT) self.cmb_sheet1 = ttk.Combobox(f_set, textvariable=self.var_sheet1, width=20, state="readonly") self.cmb_sheet1.pack(side=tk.LEFT, padx=3) ttk.Label(f_set, text="Лист 2:").pack(side=tk.LEFT, padx=(10, 0)) self.cmb_sheet2 = ttk.Combobox(f_set, textvariable=self.var_sheet2, width=20, state="readonly") self.cmb_sheet2.pack(side=tk.LEFT, padx=3) self.chk_exact = tk.BooleanVar(value=True) ttk.Checkbutton(f_set, text="Точное совпадение (все столбцы)", variable=self.chk_exact).pack(side=tk.LEFT, padx=10) # --- Кнопка --- f_action = ttk.Frame(self.parent) f_action.pack(fill=tk.X, padx=5, pady=2) self.btn_compare = ttk.Button(f_action, text="🔍 Сравнить", command=self._compare) self.btn_compare.pack(side=tk.LEFT, padx=5) self.btn_export_diff = ttk.Button(f_action, text="📄 Экспорт различий", command=self._export_diff, state=tk.DISABLED) self.btn_export_diff.pack(side=tk.LEFT, padx=5) # --- Прогресс --- self.progress = ttk.Progressbar(self.parent, mode='determinate') self.progress.pack(fill=tk.X, padx=5, pady=2) # --- Таблица результатов --- f_table = ttk.Frame(self.parent) f_table.pack(fill=tk.BOTH, expand=True, padx=5, pady=2) self.tree = ttk.Treeview(f_table, show="headings", height=20) self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.v_scroll = ttk.Scrollbar(f_table, orient=tk.VERTICAL, command=self.tree.yview) self.h_scroll = ttk.Scrollbar(f_table, orient=tk.HORIZONTAL, command=self.tree.xview) self.tree.configure(yscrollcommand=self.v_scroll.set, xscrollcommand=self.h_scroll.set) self.v_scroll.pack(side=tk.RIGHT, fill=tk.Y) self.h_scroll.pack(side=tk.BOTTOM, fill=tk.X) # --- Статус --- self.status = ttk.Label(self.parent, text="Выберите два файла для сравнения.", foreground="green") self.status.pack(fill=tk.X, padx=5, pady=2) # Текущие результаты self.diff_results = [] self.diff_headers = [] def _open_file1(self): filepath = filedialog.askopenfilename( title="Выберите первый файл", filetypes=[("Excel files", "*.xlsx *.xls"), ("All files", "*.*")], ) if filepath: self.file1_path = filepath self.lbl_f1.config(text=filepath, foreground="black") self._load_sheet_list(filepath, self.cmb_sheet1, self.var_sheet1) def _open_file2(self): filepath = filedialog.askopenfilename( title="Выберите второй файл", filetypes=[("Excel files", "*.xlsx *.xls"), ("All files", "*.*")], ) if filepath: self.file2_path = filepath self.lbl_f2.config(text=filepath, foreground="black") self._load_sheet_list(filepath, self.cmb_sheet2, self.var_sheet2) def _load_sheet_list(self, filepath, cmb, var): try: wb = openpyxl.load_workbook(filepath, data_only=True) names = wb.sheetnames cmb["values"] = names if names: var.set(names[0]) except Exception: pass def _compare(self): if not self.file1_path or not self.file2_path: messagebox.showwarning("Внимание", "Выберите оба файла.") return try: wb1 = openpyxl.load_workbook(self.file1_path, data_only=True) wb2 = openpyxl.load_workbook(self.file2_path, data_only=True) s1 = self.var_sheet1.get() or wb1.sheetnames[0] s2 = self.var_sheet2.get() or wb2.sheetnames[0] ws1 = wb1[s1] ws2 = wb2[s2] rows1 = list(ws1.iter_rows(values_only=True)) rows2 = list(ws2.iter_rows(values_only=True)) if not rows1 or not rows2: messagebox.showinfo("Информация", "Один из листов пуст.") return self.headers1 = ["" if h is None else str(h) for h in rows1[0]] self.headers2 = ["" if h is None else str(h) for h in rows2[0]] data1 = rows1[1:] data2 = rows2[1:] # Определяем общую ширину max_cols = max(len(self.headers1), len(self.headers2)) # Приводим строки к единой длине def pad(row, n): r = list(row) while len(r) < n: r.append("") return r[:n] data1 = [pad(r, max_cols) for r in data1] data2 = [pad(r, max_cols) for r in data2] # Преобразуем в кортежи для сравнения, сохраняя порядок через dict def to_key(row): return tuple(_cell_value(c) for c in row) # order_preserving unique seen1 = dict.fromkeys(to_key(r) for r in data1) seen2 = dict.fromkeys(to_key(r) for r in data2) only_in_1 = [k for k in seen1 if k not in seen2] only_in_2 = [k for k in seen2 if k not in seen1] common = [k for k in seen1 if k in seen2] results = [] for key in only_in_1: results.append(("Только в файле 1", list(key))) for key in only_in_2: results.append(("Только в файле 2", list(key))) for key in common: results.append(("Оба файла", list(key))) self.diff_results = results self.diff_headers = [f"Столбец {i+1}" for i in range(max_cols)] self._display_diff() self.status.config( text=f"Найдено: только в файле 1 — {len(only_in_1)}, " f"только в файле 2 — {len(only_in_2)}, " f"общих — {len(common)}", foreground="blue" ) self.btn_export_diff["state"] = tk.NORMAL except Exception as e: messagebox.showerror("Ошибка", f"Не удалось сравнить:\n{e}") def _display_diff(self): self.tree["columns"] = self.diff_headers self.tree["show"] = "headings" for i, h in enumerate(self.diff_headers): self.tree.heading(h, text=h) self.tree.column(h, width=120, minwidth=50) for label, row in self.diff_results: while len(row) < len(self.diff_headers): row.append("") vals = [str(v) for v in row[:len(self.diff_headers)]] if label == "Только в файле 1": tag = ("only1",) elif label == "Только в файле 2": tag = ("only2",) else: tag = ("common",) self.tree.insert("", tk.END, values=vals, tags=tag) self.tree.tag_configure("only1", background="#ffcccc") self.tree.tag_configure("only2", background="#ccddff") self.tree.tag_configure("common", background="#ccffcc") def _export_diff(self): if not self.diff_results: return filepath = filedialog.asksaveasfilename( title="Экспорт различий", defaultextension=".xlsx", filetypes=[("Excel files", "*.xlsx"), ("CSV files", "*.csv"), ("All files", "*.*")], ) if not filepath: return try: if filepath.endswith(".csv"): with open(filepath, "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f, delimiter=";") writer.writerow(["Тип"] + self.diff_headers) for label, row in self.diff_results: while len(row) < len(self.diff_headers): row.append("") writer.writerow([label] + [str(v) for v in row[:len(self.diff_headers)]]) else: wb = openpyxl.Workbook() ws = wb.active ws.append(["Тип"] + self.diff_headers) for label, row in self.diff_results: while len(row) < len(self.diff_headers): row.append("") ws.append([label] + [str(v) for v in row[:len(self.diff_headers)]]) wb.save(filepath) self.status.config(text=f"Результат сохранён: {filepath}", foreground="green") messagebox.showinfo("Успех", "Файл с различиями сохранён!") except Exception as e: messagebox.showerror("Ошибка", f"Не удалось сохранить:\n{e}") # ============================================================ # Вкладка: Пакетная обработка # ============================================================ class BatchTab: """Вкладка пакетной обработки нескольких файлов.""" def __init__(self, parent, app): self.parent = parent self.app = app self.file_list = [] # список путей self.processing = False self._build_ui() def _build_ui(self): # --- Выбор файлов --- f_select = ttk.LabelFrame(self.parent, text="Файлы для обработки", padding=5) f_select.pack(fill=tk.X, padx=5, pady=2) ttk.Button(f_select, text="Добавить файлы", command=self._add_files).pack(side=tk.LEFT, padx=3) ttk.Button(f_select, text="Добавить папку", command=self._add_folder).pack(side=tk.LEFT, padx=3) # --- Список файлов --- f_list = ttk.LabelFrame(self.parent, text="Список файлов", padding=5) f_list.pack(fill=tk.BOTH, expand=True, padx=5, pady=2) self.tree = ttk.Treeview(f_list, columns=("file",), show="headings", height=15) self.tree.heading("file", text="Путь к файлу") self.tree.column("file", width=800) self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.v_scroll = ttk.Scrollbar(f_list, orient=tk.VERTICAL, command=self.tree.yview) self.tree.configure(yscrollcommand=self.v_scroll.set) self.v_scroll.pack(side=tk.RIGHT, fill=tk.Y) # Кнопки управления списком f_list_btn = ttk.Frame(f_list) f_list_btn.pack(fill=tk.X, pady=2) ttk.Button(f_list_btn, text="Удалить выбранный", command=self._remove_selected).pack(side=tk.LEFT, padx=3) ttk.Button(f_list_btn, text="Очистить список", command=self._clear_list).pack(side=tk.LEFT, padx=3) # --- Настройки --- f_set = ttk.LabelFrame(self.parent, text="Настройки обработки", padding=5) f_set.pack(fill=tk.X, padx=5, pady=2) self.chk_dup = tk.BooleanVar(value=True) ttk.Checkbutton(f_set, text="Удалить дубликаты", variable=self.chk_dup).pack(side=tk.LEFT, padx=10) self.chk_empty = tk.BooleanVar(value=True) ttk.Checkbutton(f_set, text="Удалить пустые строки/столбцы", variable=self.chk_empty).pack(side=tk.LEFT, padx=10) self.chk_backup = tk.BooleanVar(value=True) ttk.Checkbutton(f_set, text="Авто-backup", variable=self.chk_backup).pack(side=tk.LEFT, padx=10) # --- Кнопка запуска --- f_action = ttk.Frame(self.parent) f_action.pack(fill=tk.X, padx=5, pady=2) self.btn_start = ttk.Button(f_action, text="▶ Начать обработку", command=self._start_batch) self.btn_start.pack(side=tk.LEFT, padx=3) self.btn_stop = ttk.Button(f_action, text="⏹ Стоп", command=self._stop_batch, state=tk.DISABLED) self.btn_stop.pack(side=tk.LEFT, padx=3) # --- Прогресс --- self.progress = ttk.Progressbar(self.parent, mode='determinate') self.progress.pack(fill=tk.X, padx=5, pady=2) # --- Результат --- f_log = ttk.LabelFrame(self.parent, text="Журнал", padding=5) f_log.pack(fill=tk.BOTH, expand=True, padx=5, pady=2) self.log_text = tk.Text(f_log, height=8, state=tk.DISABLED, font=("Consolas", 9)) self.log_vscroll = ttk.Scrollbar(f_log, orient=tk.VERTICAL, command=self.log_text.yview) self.log_text.configure(yscrollcommand=self.log_vscroll.set) self.log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.log_vscroll.pack(side=tk.RIGHT, fill=tk.Y) # --- Статус --- self.status = ttk.Label(self.parent, text="Добавьте файлы для пакетной обработки.", foreground="green") self.status.pack(fill=tk.X, padx=5, pady=2) def _add_files(self): files = filedialog.askopenfilenames( title="Выберите файлы", filetypes=[("Excel files", "*.xlsx *.xls"), ("All files", "*.*")], ) for f in files: if f not in self.file_list: self.file_list.append(f) self._refresh_list() def _add_folder(self): folder = filedialog.askdirectory(title="Выберите папку") if not folder: return for root_dir, _, filenames in os.walk(folder): for fn in filenames: if fn.endswith((".xlsx", ".xls")): fp = os.path.join(root_dir, fn) if fp not in self.file_list: self.file_list.append(fp) self._refresh_list() def _remove_selected(self): sel = self.tree.selection() for item in sel: idx = self.tree.index(item) if idx < len(self.file_list): self.file_list.pop(idx) self._refresh_list() def _clear_list(self): self.file_list.clear() self._refresh_list() def _refresh_list(self): for item in self.tree.get_children(): self.tree.delete(item) for f in self.file_list: self.tree.insert("", tk.END, values=f) self.status.config(text=f"В списке: {len(self.file_list)} файлов.", foreground="green") def _log(self, msg): self.log_text.config(state=tk.NORMAL) self.log_text.insert(tk.END, msg + "\n") self.log_text.see(tk.END) self.log_text.config(state=tk.DISABLED) def _stop_batch(self): """Останавливает пакетную обработку.""" self.processing = False self.btn_start["state"] = tk.NORMAL self.btn_stop["state"] = tk.DISABLED self._log("\n⛔ Обработка остановлена пользователем.") self.status.config(text="Обработка остановлена.", foreground="orange") def _start_batch(self): if not self.file_list: messagebox.showwarning("Внимание", "Список файлов пуст.") return # Предупреждение о большом количестве файлов if len(self.file_list) > 50: confirmed = messagebox.askyesno( "Подтверждение", f"В списке {len(self.file_list)} файлов. " f"Обработка может занять много времени.\n\n" f"Продолжить?" ) if not confirmed: return self.processing = True self.btn_start["state"] = tk.DISABLED self.btn_stop["state"] = tk.NORMAL self._log("=" * 60) self._log("Начало пакетной обработки") self._log("=" * 60) total = len(self.file_list) success = 0 errors = 0 stopped = False for i, filepath in enumerate(self.file_list): if not self.processing: stopped = True break self.progress["value"] = (i / total) * 100 self._log(f"\n[{i+1}/{total}] Обработка: {filepath}") try: self._process_single(filepath) success += 1 self._log(f" ✅ Успешно обработано") except Exception as e: errors += 1 self._log(f" ❌ Ошибка: {e}") self.progress["value"] = 100 self._log(f"\n{'=' * 60}") if stopped: self._log(f"Обработка прервана пользователем.") self._log(f"Обработано: всего {total}, успешно {success}, ошибок {errors}") else: self._log(f"Завершено: всего {total}, успешно {success}, ошибок {errors}") self._log(f"{'=' * 60}") self.processing = False self.btn_start["state"] = tk.NORMAL self.btn_stop["state"] = tk.DISABLED self.status.config( text=f"Обработка завершена. Успешно: {success}, Ошибок: {errors}" + (" (прервана)" if stopped else ""), foreground="blue" if errors == 0 and not stopped else "orange" if stopped else "red" ) def _process_single(self, filepath): """Обработка одного файла: удаление дубликатов и пустых.""" # Валидация файла if not os.path.isfile(filepath): raise FileNotFoundError(f"Файл не найден: {filepath}") if not filepath.endswith((".xlsx", ".xls")): raise ValueError(f"Неподдерживаемый формат: {filepath}") # Проверка доступа к файлу if not os.access(filepath, os.W_OK): raise PermissionError(f"Нет записи в файл: {filepath}") wb = openpyxl.load_workbook(filepath, data_only=True) try: ws = wb.active # Backup if self.chk_backup.get(): _make_backup(filepath) # Удаление дубликатов if self.chk_dup.get(): rows = list(ws.iter_rows(values_only=True)) if len(rows) > 1: headers = rows[0] data = rows[1:] seen = set() unique = [] for row in data: key = tuple(_cell_value(c) for c in row) if key not in seen: seen.add(key) unique.append(row) if len(unique) < len(data): ws.delete_rows(2) for idx, row in enumerate(unique, start=2): for ci, val in enumerate(row, start=1): ws.cell(row=idx, column=ci, value=val) self._log(f" Удалено дубликатов: {len(data) - len(unique)}") # Удаление пустых if self.chk_empty.get(): # Пустые столбцы empty_cols = [] for ci in range(1, ws.max_column + 1): if all( ws.cell(row=ri, column=ci).value is None or str(ws.cell(row=ri, column=ci).value).strip() == "" for ri in range(1, ws.max_row + 1) ): empty_cols.append(ci) for ci in reversed(empty_cols): ws.delete_cols(ci) # Пустые строки empty_rows = [] for ri in range(ws.max_row, 0, -1): if all( ws.cell(row=ri, column=ci).value is None or str(ws.cell(row=ri, column=ci).value).strip() == "" for ci in range(1, ws.max_column + 1) ): empty_rows.append(ri) for ri in empty_rows: ws.delete_rows(ri) if empty_rows: self._log(f" Удалено пустых строк: {len(empty_rows)}, столбцов: {len(empty_cols)}") wb.save(filepath) finally: wb.close() # ============================================================ # Диалоги # ============================================================ class DupColumnSelector: """Диалог выбора столбцов для проверки дубликатов.""" def __init__(self, parent, headers): self.top = tk.Toplevel(parent) self.top.title("Выбор столбцов") self.top.geometry("400x300") self.top.transient(parent) self.top.grab_set() self.headers = headers self.selected_indices = [] # индексы выбранных столбцов self.chk_vars = {} f = ttk.Frame(self.top, padding=10) f.pack(fill=tk.BOTH, expand=True) ttk.Label(f, text="Выберите столбцы для проверки дубликатов:").pack(fill=tk.X, pady=(0, 10)) # Чекбоксы в фрейме со скроллом canvas = tk.Canvas(f) vsb = ttk.Scrollbar(f, orient=tk.VERTICAL, command=canvas.yview) inner = ttk.Frame(canvas) inner.bind( "<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")), ) canvas.create_window((0, 0), window=inner, anchor="nw") canvas.configure(yscrollcommand=vsb.set) for i, h in enumerate(headers): var = tk.BooleanVar() self.chk_vars[i] = var ttk.Checkbutton(inner, text=str(h), variable=var).pack(fill=tk.X, padx=5, pady=1) canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) vsb.pack(side=tk.RIGHT, fill=tk.Y) # Кнопки btn_frame = ttk.Frame(self.top, padding=10) btn_frame.pack(fill=tk.X) ttk.Button(btn_frame, text="OK", command=self._ok).pack(side=tk.RIGHT, padx=5) ttk.Button(btn_frame, text="Отмена", command=self._cancel).pack(side=tk.RIGHT, padx=5) # По умолчанию выбраны все for var in self.chk_vars.values(): var.set(True) def _ok(self): self.selected_indices = [i for i, var in self.chk_vars.items() if var.get()] self.top.destroy() def _cancel(self): self.selected_indices = [] self.top.destroy() class CriterionDialog: """Диалог удаления по критерию.""" def __init__(self, parent, headers): self.top = tk.Toplevel(parent) self.top.title("Удаление по критерию") self.top.geometry("400x200") self.top.transient(parent) self.top.grab_set() self.headers = headers self.result = None ttk.Label(self.top, text="Удалить строки, где столбец:").pack(pady=(10, 5)) f1 = ttk.Frame(self.top, padding=5) f1.pack(fill=tk.X, padx=10) self.var_col = tk.StringVar() ttk.Label(f1, text="Столбец:").pack(side=tk.LEFT) self.cmb_col = ttk.Combobox(f1, textvariable=self.var_col, values=headers, width=30, state="readonly") self.cmb_col.pack(side=tk.LEFT, padx=5) f2 = ttk.Frame(self.top, padding=5) f2.pack(fill=tk.X, padx=10) self.var_cond = tk.StringVar(value="empty") ttk.Radiobutton(f2, text="Пустой", variable=self.var_cond, value="empty").pack(side=tk.LEFT, padx=10) ttk.Radiobutton(f2, text="Не пустой", variable=self.var_cond, value="not_empty").pack(side=tk.LEFT, padx=10) btn_frame = ttk.Frame(self.top, padding=10) btn_frame.pack(fill=tk.X) ttk.Button(btn_frame, text="OK", command=self._ok).pack(side=tk.RIGHT, padx=5) ttk.Button(btn_frame, text="Отмена", command=self.top.destroy).pack(side=tk.RIGHT, padx=5) def _ok(self): col_name = self.var_col.get() if not col_name: messagebox.showwarning("Внимание", "Выберите столбец.") return col_idx = self.headers.index(col_name) self.result = { "col_idx": col_idx, "condition": self.var_cond.get(), } self.top.destroy() # ============================================================ # Основное приложение # ============================================================ class ExcelCleanerApp: """Главное окно приложения.""" def __init__(self, root): self.root = root self.root.title("Excel Cleaner — Очистка и обработка файлов") self.root.geometry("1100x750") # --- Меню --- menubar = tk.Menu(root) root.config(menu=menubar) file_menu = tk.Menu(menubar, tearoff=0) file_menu.add_command(label="Выход", command=root.quit) menubar.add_cascade(label="Файл", menu=file_menu) help_menu = tk.Menu(menubar, tearoff=0) help_menu.add_command(label="О программе", command=self._about) menubar.add_cascade(label="Справка", menu=help_menu) # --- Notebook (вкладки) --- self.notebook = ttk.Notebook(root) self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) tab_clean = ttk.Frame(self.notebook) tab_compare = ttk.Frame(self.notebook) tab_batch = ttk.Frame(self.notebook) self.notebook.add(tab_clean, text="🧹 Очистка") self.notebook.add(tab_compare, text="🔀 Сравнение") self.notebook.add(tab_batch, text="📦 Пакетная обработка") # Создаём вкладки self.clean_tab = CleanTab(tab_clean, self) self.compare_tab = CompareTab(tab_compare, self) self.batch_tab = BatchTab(tab_batch, self) def _about(self): messagebox.showinfo( "О программе", "Excel Cleaner v2.0\n\n" "Возможности:\n" "• Загрузка и предпросмотр Excel файлов\n" "• Удаление дубликатов (с настройкой столбцов)\n" "• Удаление пустых строк и столбцов\n" "• Удаление по критерию (пустой столбец)\n" "• Предпросмотр изменений с подсветкой\n" "• Undo (отмена последнего действия)\n" "• Выбор активного листа\n" "• Экспорт в CSV\n" "• Авто-backup перед изменениями\n" "• Пакетная обработка\n" "• Сравнение двух файлов\n" "\nБиблиотеки: tkinter, openpyxl" ) def main(): root = tk.Tk() app = ExcelCleanerApp(root) root.mainloop() if __name__ == "__main__": main()