/
AntiManager
/
table_structure_analyzer
Обзор
Документация
Войти
/
AntiManager
/
table_structure_analyzer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
main.py
1 193 строки
47 KB
Eugene
fix gui bugs
28 дек 2025, 19:13
28 дек 2025, 19:13
97257de
Код
Авторство
О чём код?
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ GUI для анализа Excel-файлов. Оптимизировано для работы с большими файлами. """ import json import logging import threading import tkinter as tk from datetime import datetime from pathlib import Path from tkinter import filedialog, messagebox, scrolledtext, ttk from typing import Dict, List, Optional import yaml from core.config import ( GUI_DEFAULT_SIZE, GUI_MIN_HEIGHT, GUI_MIN_WIDTH, LOG_FORMAT, LOG_TIME_FMT, PREVIEW_ROWS, TYPE_ANALYSIS_ROWS, ) from core.file_processor import FileProcessor from core.structure_exporter import StructureExporter class StructureAnalyzerGUI: def __init__(self, root: tk.Tk): self.root = root self.root.title( f"Excel-Structure Analyzer v1.4 (Оптимизировано для больших файлов)" ) self.root.geometry(GUI_DEFAULT_SIZE) self.root.minsize(GUI_MIN_WIDTH, GUI_MIN_HEIGHT) self.processor = FileProcessor() self.exporter = StructureExporter() self.input_path: Optional[str] = None self.input_type = "file" self.sheet_info: Dict[str, List[str]] = {} self.selected_sheets: Dict[str, List[str]] = {} self.current_structure: Optional[Dict] = None self.folder_stats: Optional[Dict] = None self.sample_file_path: Optional[str] = None self._build_ui() self._setup_logging() logging.info("=" * 60) logging.info("Excel Structure Analyzer v1.4 (Оптимизированная версия)") logging.info(f"Быстрый анализ: первые {TYPE_ANALYSIS_ROWS} строк") logging.info("=" * 60) # ---------- UI ---------- def _build_ui(self): # верхняя панель выбора top = ttk.Frame(self.root) top.pack(fill=tk.X, padx=5, pady=5) ttk.Label(top, text="Источник:").pack(side=tk.LEFT, padx=(0, 5)) self.input_type_var = tk.StringVar(value="file") ttk.Radiobutton( top, text="Файл", variable=self.input_type_var, value="file", command=self._on_type_change, ).pack(side=tk.LEFT) ttk.Radiobutton( top, text="Папка", variable=self.input_type_var, value="folder", command=self._on_type_change, ).pack(side=tk.LEFT, padx=(5, 15)) self.path_var = tk.StringVar() ttk.Entry(top, textvariable=self.path_var, state="readonly", width=60).pack( side=tk.LEFT, fill=tk.X, expand=True ) ttk.Button(top, text="Обзор…", command=self._select_input).pack( side=tk.LEFT, padx=(5, 0) ) # Кнопки действий с улучшенными подсказками btn_bar = ttk.Frame(self.root) btn_bar.pack(fill=tk.X, padx=5, pady=(0, 5)) self.analyze_btn = ttk.Button( btn_bar, text="➜ Быстрый анализ", command=self._analyze ) self.analyze_btn.pack(side=tk.LEFT) # Добавляем меню для выбора типа анализа self.analysis_type_var = tk.StringVar(value="quick") analysis_menu = tk.Menubutton(btn_bar, text="⚙ Режим анализа", relief=tk.RAISED) analysis_menu.pack(side=tk.LEFT, padx=5) analysis_menu.menu = tk.Menu(analysis_menu, tearoff=0) analysis_menu["menu"] = analysis_menu.menu analysis_menu.menu.add_radiobutton( label="Быстрый (первые 100 строк)", variable=self.analysis_type_var, value="quick", ) analysis_menu.menu.add_radiobutton( label="Полный (все данные)", variable=self.analysis_type_var, value="deep" ) ttk.Button(btn_bar, text="✓ Проверить структуру", command=self._validate).pack( side=tk.LEFT, padx=5 ) ttk.Button(btn_bar, text="💾 Экспорт", command=self._open_export_dialog).pack( side=tk.RIGHT ) # центральная часть – Notebook nb = ttk.Notebook(self.root) nb.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # 1) Листы и предпросмотр tab1 = ttk.Frame(nb) nb.add(tab1, text="Листы") self._build_sheets_tab(tab1) # 2) Статистика self.stats_text = scrolledtext.ScrolledText(nb, height=8, state=tk.DISABLED) nb.add(self.stats_text, text="📊 Статистика") # 3) Лог self.log_txt = scrolledtext.ScrolledText(nb, height=8, state=tk.DISABLED) nb.add(self.log_txt, text="Лог") # прогресс-бар self.progress = ttk.Progressbar(self.root, mode="indeterminate") def _build_sheets_tab(self, parent): paned = ttk.PanedWindow(parent, orient=tk.HORIZONTAL) paned.pack(fill=tk.BOTH, expand=True) # левая часть – treeview листов с кнопками управления left = ttk.Labelframe(paned, text="Листы файла") paned.add(left, weight=1) # Создаем фрейм для кнопок и treeview left_container = ttk.Frame(left) left_container.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # Вертикальная панель кнопок слева btn_frame = ttk.Frame(left_container) btn_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 5)) # Кнопки управления выбором листов (вертикально) ttk.Label(btn_frame, text="Управление выбором:").pack(pady=(0, 5)) ttk.Button(btn_frame, text="Выбрать все", command=self._sel_all).pack( fill=tk.X, pady=2 ) ttk.Button(btn_frame, text="Снять выбор", command=self._desel_all).pack( fill=tk.X, pady=2 ) ttk.Button( btn_frame, text="Инвертировать", command=self._invert_selection ).pack(fill=tk.X, pady=2) ttk.Button(btn_frame, text="Просмотреть данные", command=self._view_sheet).pack( fill=tk.X, pady=(10, 2) ) # Информация о выборе self.selection_info = tk.StringVar(value="Выбрано: 0 из 0") ttk.Label(btn_frame, textvariable=self.selection_info, font=("Arial", 9)).pack( pady=(10, 0) ) # Фрейм для treeview с прокруткой tree_frame = ttk.Frame(left_container) tree_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) # Treeview с чекбоксами для выбора листов # Колонки: Выбрать, Колонки, Строки, Статус self.sheets_tree = ttk.Treeview( tree_frame, columns=("selected", "columns", "rows", "status"), show="tree headings", height=12, ) # Настраиваем заголовки и ширину колонок self.sheets_tree.heading("#0", text="Название листа") self.sheets_tree.column("#0", width=200, stretch=True) self.sheets_tree.heading("selected", text="✓") self.sheets_tree.column("selected", width=50, anchor="center") self.sheets_tree.heading("columns", text="Колонки") self.sheets_tree.column("columns", width=80, anchor="center") self.sheets_tree.heading("rows", text="Строки") self.sheets_tree.column("rows", width=100, anchor="center") self.sheets_tree.heading("status", text="Статус") self.sheets_tree.column("status", width=80, anchor="center") # Прокрутка для treeview vsb = ttk.Scrollbar( tree_frame, orient=tk.VERTICAL, command=self.sheets_tree.yview ) hsb = ttk.Scrollbar( tree_frame, orient=tk.HORIZONTAL, command=self.sheets_tree.xview ) self.sheets_tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) # Размещаем treeview и скроллбары self.sheets_tree.grid(row=0, column=0, sticky="nsew") vsb.grid(row=0, column=1, sticky="ns") hsb.grid(row=1, column=0, sticky="ew") tree_frame.grid_rowconfigure(0, weight=1) tree_frame.grid_columnconfigure(0, weight=1) # Обработка кликов по чекбоксам self.sheets_tree.bind("<Button-1>", self._on_tree_click) # правая часть – предпросмотр структуры right = ttk.Labelframe(paned, text="Предпросмотр структуры") paned.add(right, weight=2) self.preview_txt = scrolledtext.ScrolledText( right, wrap=tk.WORD, state=tk.DISABLED ) self.preview_txt.pack(fill=tk.BOTH, expand=True) fmt_frame = ttk.Frame(right) fmt_frame.pack(fill=tk.X, pady=2) # Информация о режиме анализа self.analysis_info = tk.StringVar(value="Режим: Быстрый (первые 100 строк)") ttk.Label(fmt_frame, textvariable=self.analysis_info, font=("Arial", 9)).pack( side=tk.LEFT ) self.preview_fmt = tk.StringVar(value="yaml") ttk.Radiobutton( fmt_frame, text="YAML", variable=self.preview_fmt, value="yaml", command=self._update_preview, ).pack(side=tk.LEFT, padx=10) ttk.Radiobutton( fmt_frame, text="JSON", variable=self.preview_fmt, value="json", command=self._update_preview, ).pack(side=tk.LEFT) ttk.Button(fmt_frame, text="↻ Обновить", command=self._update_preview).pack( side=tk.RIGHT ) # ---------- логика ---------- def _setup_logging(self): class _Handler(logging.Handler): def __init__(self, widget): super().__init__() self.w = widget def emit(self, record): self.w.configure(state=tk.NORMAL) self.w.insert(tk.END, self.format(record) + "\n") self.w.see(tk.END) self.w.configure(state=tk.DISABLED) h = _Handler(self.log_txt) h.setFormatter(logging.Formatter(LOG_FORMAT, LOG_TIME_FMT)) logging.getLogger().addHandler(h) logging.getLogger().setLevel(logging.INFO) class StatsHandler(logging.Handler): def __init__(self, widget): super().__init__() self.w = widget def emit(self, record): if "📊" in record.getMessage(): self.w.configure(state=tk.NORMAL) self.w.insert(tk.END, record.getMessage() + "\n") self.w.see(tk.END) self.w.configure(state=tk.DISABLED) stats_handler = StatsHandler(self.stats_text) stats_handler.setFormatter(logging.Formatter("%(message)s")) stats_logger = logging.getLogger("stats") stats_logger.addHandler(stats_handler) stats_logger.setLevel(logging.INFO) def _on_type_change(self): self.path_var.set("") self.input_path = None self._clear_tree() def _select_input(self): if self.input_type_var.get() == "file": p = filedialog.askopenfilename( title="Выберите Excel файл", filetypes=[ ("Excel файлы", "*.xlsx *.xls *.xlsm"), ("Все файлы", "*.*"), ], ) else: p = filedialog.askdirectory(title="Выберите папку с Excel файлами") if p: self.input_path = p self.path_var.set(p) logging.info(f"Выбрано: {p}") if self.input_type_var.get() == "file": self._load_file_info() def _load_file_info(self): if not self.input_path: return try: logging.info(f"Попытка загрузки файла: {self.input_path}") logging.info(f"Размер файла: {Path(self.input_path).stat().st_size / 1024 / 1024:.2f} MB") analyzer = self.processor.get_analyzer(self.input_path) if not analyzer: logging.error(f"Не удалось создать анализатор для файла") messagebox.showerror("Ошибка", f"Неподдерживаемый формат файла: {Path(self.input_path).suffix}") return sheet_info = analyzer.get_sheet_info(self.input_path) # Диагностика logging.info(f"Получена информация о листах: {len(sheet_info)}") for sheet_name, columns in sheet_info.items(): logging.info(f" Лист: '{sheet_name}', колонок: {len(columns)}") if columns: logging.info(f" Первые 3 колонки: {columns[:3]}") # Сохраняем информацию о листах self.sheet_info = sheet_info # Заполняем дерево self._populate_tree() logging.info(f"Интерфейс обновлен: {len(self.sheet_info)} листов") except Exception as e: logging.error(f"Ошибка загрузки файла: {e}", exc_info=True) messagebox.showerror("Ошибка", f"Не удалось загрузить файл:\n{e}") def _populate_tree(self): # Не очищаем tree при заполнении, только обновляем # Очищаем только старые данные в treeview for i in self.sheets_tree.get_children(): self.sheets_tree.delete(i) # Очищаем selected_sheets для новых данных self.selected_sheets.clear() if not self.sheet_info: logging.warning("Нет информации о листах для отображения") self.selection_info.set("Выбрано: 0 из 0") return try: analyzer = self.processor.get_analyzer(self.input_path) for sh, cols in self.sheet_info.items(): # По умолчанию все листы выбраны self.selected_sheets[sh] = cols.copy() try: rows, _ = analyzer.get_sheet_dimensions(self.input_path, sh) row_text = f"{rows:,}" if rows > 0 else "?" except Exception as e: logging.debug(f"Не удалось получить размеры листа {sh}: {e}") row_text = "?" # Добавляем лист с чекбоксом (✓ по умолчанию) item_id = self.sheets_tree.insert("", tk.END, text=sh, values=("✓", len(cols), row_text, "Активен")) # Сохраняем состояние выбора в тегах self.sheets_tree.item(item_id, tags=("selected",)) # Обновляем информацию о выборе self._update_selection_info() logging.info(f"Дерево листов заполнено: {len(self.sheet_info)} листов") except Exception as e: logging.warning(f"Не удалось получить размеры листов: {e}") for sh, cols in self.sheet_info.items(): self.selected_sheets[sh] = cols.copy() item_id = self.sheets_tree.insert("", tk.END, text=sh, values=("✓", len(cols), "?", "Активен")) self.sheets_tree.item(item_id, tags=("selected",)) self._update_selection_info() logging.info(f"Дерево листов заполнено (без размеров): {len(self.sheet_info)} листов") def _populate_tree_from_structure(self, structure: Dict): """Заполняет treeview информацией о листах из структуры""" self._clear_tree() if not structure or "sheets" not in structure: return self.sheet_info = {} try: # Сохраняем путь к файлу-образцу из структуры if "file_info" in structure: self.sample_file_path = structure["file_info"].get("file_path", "") logging.info( f"Файл-образец установлен: {Path(self.sample_file_path).name}" ) for sh_name, sh_data in structure.get("sheets", {}).items(): # Получаем колонки из структуры columns = list(sh_data.get("columns", {}).keys()) self.sheet_info[sh_name] = columns self.selected_sheets[sh_name] = columns.copy() # Получаем количество строк из структуры dimensions = sh_data.get("dimensions", {}) rows = dimensions.get("total_rows", 0) row_text = f"{rows:,}" if rows > 0 else "?" # Определяем статус if "error" in sh_data: status = "Ошибка" else: status = ( "Образец" if self.input_type_var.get() == "folder" else "Активен" ) # Добавляем лист item_id = self.sheets_tree.insert( "", tk.END, text=sh_name, values=("✓", len(columns), row_text, status), ) # Сохраняем состояние выбора в тегах self.sheets_tree.item(item_id, tags=("selected",)) # Обновляем информацию о выборе self._update_selection_info() logging.info(f"Загружено из структуры: {len(self.sheet_info)} листов") except Exception as e: logging.error(f"Ошибка загрузки структуры в treeview: {e}") def _clear_tree(self): """Очищает treeview и сбрасывает данные о листах""" for i in self.sheets_tree.get_children(): self.sheets_tree.delete(i) self.selected_sheets.clear() self.sheet_info.clear() # Очищаем только когда нужно полностью сбросить self.selection_info.set("Выбрано: 0 из 0") logging.debug("Дерево листов очищено") def _on_tree_click(self, event): """Обработка кликов по treeview для переключения чекбоксов""" # Определяем область клика region = self.sheets_tree.identify("region", event.x, event.y) if region not in ("cell", "tree"): return # Определяем строку и колонку item = self.sheets_tree.identify_row(event.y) column = self.sheets_tree.identify_column(event.x) if not item: return # Проверяем, что клик был в колонке "selected" (через heading) tree_column_id = int(column.replace("#", "")) # Если это колонка "selected" (✓) или это tree column (#0) if column == "#1": # Колонка "selected" (индекс #1) # Получаем текущее значение current_values = self.sheets_tree.item(item, "values") if not current_values: return # Переключаем состояние чекбокса current_value = current_values[0] # Первое значение в списке values new_value = "☐" if current_value == "✓" else "✓" # Обновляем значения values = list(current_values) values[0] = new_value self.sheets_tree.item(item, values=tuple(values)) # Обновляем теги и selected_sheets sh = self.sheets_tree.item(item, "text") if new_value == "✓": self.sheets_tree.item(item, tags=("selected",)) self.selected_sheets[sh] = self.sheet_info.get(sh, []).copy() else: self.sheets_tree.item(item, tags=()) self.selected_sheets[sh] = [] # Обновляем информацию о выборе self._update_selection_info() elif column == "#0": # Клик в названии листа - можно выделить строку для просмотра # Это улучшает UX self.sheets_tree.selection_set(item) def _update_selection_info(self): """Обновляет информацию о количестве выбранных листов""" total = len(self.sheet_info) selected = sum(1 for sh in self.selected_sheets.values() if sh) self.selection_info.set(f"Выбрано: {selected} из {total}") def _sel_all(self): for item in self.sheets_tree.get_children(): sh = self.sheets_tree.item(item, "text") self.selected_sheets[sh] = self.sheet_info.get(sh, []).copy() values = list(self.sheets_tree.item(item, "values")) values[0] = "✓" self.sheets_tree.item(item, values=tuple(values)) self.sheets_tree.item(item, tags=("selected",)) self._update_selection_info() def _desel_all(self): for item in self.sheets_tree.get_children(): sh = self.sheets_tree.item(item, "text") self.selected_sheets[sh] = [] values = list(self.sheets_tree.item(item, "values")) values[0] = "☐" self.sheets_tree.item(item, values=tuple(values)) self.sheets_tree.item(item, tags=()) self._update_selection_info() def _invert_selection(self): """Инвертирует выбор всех листов""" for item in self.sheets_tree.get_children(): sh = self.sheets_tree.item(item, "text") current_values = self.sheets_tree.item(item, "values") if not current_values: continue current_value = current_values[0] new_value = "☐" if current_value == "✓" else "✓" values = list(current_values) values[0] = new_value self.sheets_tree.item(item, values=tuple(values)) if new_value == "✓": self.sheets_tree.item(item, tags=("selected",)) self.selected_sheets[sh] = self.sheet_info.get(sh, []).copy() else: self.sheets_tree.item(item, tags=()) self.selected_sheets[sh] = [] self._update_selection_info() def _view_sheet(self): sel = self.sheets_tree.selection() if not sel: messagebox.showinfo("Просмотр", "Выберите лист для просмотра") return sh = self.sheets_tree.item(sel[0], "text") # Определяем путь к файлу в зависимости от режима if self.input_type_var.get() == "file": file_path = self.input_path elif self.input_type_var.get() == "folder": # Пытаемся получить путь из разных источников if self.sample_file_path and Path(self.sample_file_path).exists(): file_path = self.sample_file_path elif self.current_structure and "file_info" in self.current_structure: file_path = self.current_structure["file_info"].get("file_path", "") else: messagebox.showwarning( "Просмотр", "Не найден файл для просмотра. Сначала выполните анализ папки.", ) return else: messagebox.showwarning("Просмотр", "Не выбран режим файла или папки") return if not file_path or not Path(file_path).exists(): messagebox.showwarning("Просмотр", f"Файл не найден: {file_path}") return try: analyzer = self.processor.get_analyzer(file_path) if not analyzer: messagebox.showerror( "Ошибка", "Не удалось создать анализатор для файла" ) return data = analyzer.get_sheet_data(file_path, sh, PREVIEW_ROWS) dlg = tk.Toplevel(self.root) dlg.title(f"Данные: {sh}") dlg.geometry("900x500") dlg.transient(self.root) if not data: ttk.Label(dlg, text="Нет данных для отображения").pack(pady=20) ttk.Button(dlg, text="Закрыть", command=dlg.destroy).pack(pady=5) return cols = list(data[0].keys()) frame = ttk.Frame(dlg) frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) tree = ttk.Treeview(frame, columns=cols, show="headings", height=15) for c in cols: tree.heading(c, text=c) tree.column(c, width=120, minwidth=80) for row in data: tree.insert("", tk.END, values=[row.get(c, "") for c in cols]) vsb = ttk.Scrollbar(frame, orient="vertical", command=tree.yview) hsb = ttk.Scrollbar(frame, orient="horizontal", command=tree.xview) tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) tree.grid(row=0, column=0, sticky="nsew") vsb.grid(row=0, column=1, sticky="ns") hsb.grid(row=1, column=0, sticky="ew") frame.grid_rowconfigure(0, weight=1) frame.grid_columnconfigure(0, weight=1) info_label = ttk.Label( dlg, text=f"Показано {len(data)} строк (первые {PREVIEW_ROWS} из файла)" ) info_label.pack(pady=5) ttk.Button(dlg, text="Закрыть", command=dlg.destroy).pack(pady=5) except Exception as e: logging.error(f"Ошибка просмотра листа: {e}") messagebox.showerror("Ошибка", f"Не удалось отобразить данные:\n{e}") # ---------- анализ ---------- def _analyze(self): if not self.input_path: messagebox.showwarning("Анализ", "Сначала выберите файл/папку") return # Проверяем, выбраны ли листы (только для режима файла) if self.input_type_var.get() == "file": selected_count = sum(1 for sh in self.selected_sheets.values() if sh) if selected_count == 0: messagebox.showwarning( "Анализ", "Выберите хотя бы один лист для анализа" ) return analysis_mode = self.analysis_type_var.get() if analysis_mode == "quick": self.analysis_info.set( f"Режим: Быстрый (первые {TYPE_ANALYSIS_ROWS} строк)" ) else: self.analysis_info.set("Режим: Полный анализ (все данные)") logging.info(f"Начало анализа в режиме: {analysis_mode}") if self.input_type_var.get() == "file": self._thread_run(self._analyze_single) else: self._thread_run(self._analyze_folder) def _thread_run(self, target): self.analyze_btn.config(state=tk.DISABLED) self.progress.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=2) self.progress.start(10) th = threading.Thread(target=target, daemon=True) th.start() def _analyze_single(self): try: # Собираем только выбранные листы want = [sh for sh, cols in self.selected_sheets.items() if cols] if not want: self.root.after( 0, lambda: messagebox.showwarning( "Анализ", "Не выбрано ни одного листа" ), ) return analysis_mode = self.analysis_type_var.get() deep = analysis_mode == "deep" struct = self.processor.process_single_file( self.input_path, want, deep=deep ) self.root.after(0, self._store_struct, struct, None) except Exception as e: self.root.after(0, lambda: messagebox.showerror("Ошибка анализа", str(e))) logging.exception("Ошибка анализа файла") finally: self.root.after(0, self._progress_stop) def _analyze_folder(self): try: analysis_mode = self.analysis_type_var.get() deep = analysis_mode == "deep" ok, res = self.processor.validate_files_structure( self.input_path, deep=deep ) # Сохраняем путь к файлу-образцу sample_structure = res.get("sample_structure") if sample_structure and "file_info" in sample_structure: self.sample_file_path = sample_structure["file_info"].get( "file_path", "" ) logging.info( f"Файл-образец установлен: {Path(self.sample_file_path).name}" ) if res.get("folder_stats"): folder_stats = res["folder_stats"] self.folder_stats = folder_stats self._show_folder_stats(folder_stats) if sample_structure and self.folder_stats: sample_structure["folder_stats"] = self.folder_stats # Заполняем treeview информацией из структуры-образца if sample_structure: self.root.after(0, self._populate_tree_from_structure, sample_structure) self.root.after(0, self._store_struct, sample_structure, self.folder_stats) if not ok and res.get("differences"): msg = "Файлы в папке имеют различия в структуре.\n" msg += f"Проверено файлов: {res.get('analyzed_files', 0)}\n" msg += f"Всего файлов: {res.get('total_files', 0)}\n\n" msg += "Рекомендуется проверить файлы вручную." messagebox.showwarning("Различия в структуре", msg) except Exception as e: self.root.after( 0, lambda: messagebox.showerror("Ошибка анализа папки", str(e)) ) logging.exception("Ошибка анализа папки") finally: self.root.after(0, self._progress_stop) def _show_folder_stats(self, stats: Dict): """Отображает статистику папки""" if not stats: return self.stats_text.configure(state=tk.NORMAL) self.stats_text.delete("1.0", tk.END) stats_text = "=" * 60 + "\n" stats_text += "📊 СТАТИСТИКА ПАПКИ\n" stats_text += "=" * 60 + "\n\n" stats_text += f"📁 Папка: {Path(self.input_path).name}\n" stats_text += f"📄 Всего файлов: {stats.get('total_files', 0)}\n" stats_text += f"🔍 Обработано: {stats.get('processed_files', 0)}\n" stats_text += ( f"📊 Всего строк (оценка): {stats.get('total_estimated_rows', 0):,}\n" ) stats_text += f"📊 Всего столбцов: {stats.get('total_columns', 0)}\n" stats_text += f"💾 Примечание: {stats.get('analysis_note', '')}\n" stats_text += f"📝 {stats.get('estimation_note', '')}\n\n" if stats.get("file_list"): stats_text += "📋 Самые большие файлы:\n" stats_text += "-" * 40 + "\n" for i, file_info in enumerate(stats["file_list"][:5], 1): name = file_info.get("name", "?") size_mb = file_info.get("size_mb", 0) rows = file_info.get("estimated_rows", 0) sheets = file_info.get("total_sheets", 1) stats_text += f"{i}. {name}\n" stats_text += f" Размер: {size_mb:.1f} MB | " stats_text += f"Строк: {rows:,} | " stats_text += f"Листов: {sheets}\n" self.stats_text.insert("1.0", stats_text) self.stats_text.configure(state=tk.DISABLED) logging.info( f"📊 Статистика папки: {stats.get('total_files')} файлов, ~{stats.get('total_estimated_rows', 0):,} строк" ) def _store_struct(self, struct, folder_stats=None): self.current_structure = struct self.folder_stats = folder_stats if struct: # Добавляем информацию о режиме анализа analysis_mode = self.analysis_type_var.get() if analysis_mode == "quick": struct["analysis_info"] = { "mode": "quick", "note": f"Быстрый анализ (первые {TYPE_ANALYSIS_ROWS} строк)", "analyzed_rows": TYPE_ANALYSIS_ROWS, } else: struct["analysis_info"] = { "mode": "full", "note": "Полный анализ", "analyzed_rows": "all", } self._update_preview() def _progress_stop(self): self.progress.stop() self.progress.pack_forget() self.analyze_btn.config(state=tk.NORMAL) def _update_preview(self): if not self.current_structure: return fmt = self.preview_fmt.get() try: if fmt == "yaml": txt = yaml.dump( self.current_structure, allow_unicode=True, default_flow_style=False ) else: txt = json.dumps(self.current_structure, ensure_ascii=False, indent=2) self.preview_txt.configure(state=tk.NORMAL) self.preview_txt.delete("1.0", tk.END) if "analysis_info" in self.current_structure: header = f"# {self.current_structure['analysis_info']['note']}\n" header += f"# Время анализа: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" self.preview_txt.insert("1.0", header) self.preview_txt.insert("end", txt) else: self.preview_txt.insert("1.0", txt) self.preview_txt.configure(state=tk.DISABLED) except Exception as e: logging.error(f"Ошибка обновления предпросмотра: {e}") self.preview_txt.configure(state=tk.NORMAL) self.preview_txt.delete("1.0", tk.END) self.preview_txt.insert("1.0", f"Ошибка форматирования: {e}") self.preview_txt.configure(state=tk.DISABLED) # ---------- проверка ---------- def _validate(self): if not self.input_path or self.input_type_var.get() != "folder": messagebox.showwarning( "Проверка", "Выберите режим «Папка» для проверки структуры" ) return self.progress.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=2) self.progress.start(10) def validate_thread(): try: ok, res = self.processor.validate_files_structure( self.input_path, deep=False ) # Сохраняем путь к файлу-образцу sample_structure = res.get("sample_structure") if sample_structure and "file_info" in sample_structure: self.root.after( 0, lambda: setattr( self, "sample_file_path", sample_structure["file_info"].get("file_path", ""), ), ) # Заполняем treeview информацией из структуры-образца if sample_structure: self.root.after( 0, self._populate_tree_from_structure, sample_structure ) msg = ( "✅ Все файлы имеют одинаковую структуру\n\n" if ok else "⚠️ Файлы имеют различия в структуре\n\n" ) msg += f"📊 Статистика:\n" msg += f"• Всего файлов: {res.get('total_files', 0)}\n" msg += f"• Проверено файлов: {res.get('analyzed_files', 0)}\n" msg += f"• {res.get('analysis_note', '')}\n" if not ok and res.get("differences"): msg += "\n🔍 Различия:\n" for diff in res["differences"][:3]: msg += f"• {diff.get('file')}: {diff.get('differences')[0]}\n" if len(res["differences"]) > 3: msg += f"\n... и еще {len(res['differences']) - 3} различий\n" self.root.after( 0, lambda: messagebox.showinfo("Результат проверки", msg) ) except Exception as e: self.root.after( 0, lambda: messagebox.showerror("Ошибка проверки", str(e)) ) finally: self.root.after(0, self._progress_stop) threading.Thread(target=validate_thread, daemon=True).start() # ---------- экспорт ---------- def _open_export_dialog(self): if not self.current_structure: messagebox.showwarning("Экспорт", "Сначала выполните анализ") return ExportDialog(self.root, self.current_structure, self.exporter, self.input_path) def run(self): self.root.mainloop() # ---------- дочернее окно экспорта ---------- class ExportDialog(tk.Toplevel): def __init__(self, parent, struct, exporter, source_path=None): super().__init__(parent) self.struct, self.exporter = struct, exporter self.source_path = source_path self.title("Экспорт структуры") self.geometry("700x600") self.transient(parent) self.grab_set() # формат f1 = ttk.LabelFrame(self, text="Формат") f1.pack(fill=tk.X, padx=5, pady=5) self.fmt = tk.StringVar(value="json") ttk.Radiobutton( f1, text="JSON", variable=self.fmt, value="json", command=self._update_default_filename, ).pack(side=tk.LEFT) ttk.Radiobutton( f1, text="YAML", variable=self.fmt, value="yaml", command=self._update_default_filename, ).pack(side=tk.LEFT) # путь f2 = ttk.LabelFrame(self, text="Куда сохранить") f2.pack(fill=tk.X, padx=5, pady=5) self.path_var = tk.StringVar() ttk.Entry(f2, textvariable=self.path_var).pack( side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5) ) ttk.Button(f2, text="Обзор…", command=self._ask_save).pack(side=tk.RIGHT) self._update_default_filename() # опции f3 = ttk.LabelFrame(self, text="Опции") f3.pack(fill=tk.X, padx=5, pady=5) self.inc_prev = tk.BooleanVar(value=True) self.inc_meta = tk.BooleanVar(value=True) self.compact = tk.BooleanVar(value=False) ttk.Checkbutton( f3, text="С предпросмотром данных", variable=self.inc_prev, command=self._fill_preview, ).pack(side=tk.LEFT) ttk.Checkbutton( f3, text="С метаданными", variable=self.inc_meta, command=self._fill_preview ).pack(side=tk.LEFT, padx=10) ttk.Checkbutton( f3, text="Компактный JSON", variable=self.compact, command=self._fill_preview, ).pack(side=tk.LEFT) # кнопки btn_bar = ttk.Frame(self) btn_bar.pack(fill=tk.X, padx=5, pady=5) ttk.Button(btn_bar, text="💾 Сохранить", command=self._save).pack(side=tk.LEFT) ttk.Button(btn_bar, text="❌ Отмена", command=self.destroy).pack(side=tk.RIGHT) # прогресс self.progress = ttk.Progressbar(self, mode="indeterminate") # предпросмотр preview_frame = ttk.LabelFrame(self, text="Предпросмотр экспорта") preview_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.preview = scrolledtext.ScrolledText( preview_frame, height=12, state=tk.DISABLED ) self.preview.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self._fill_preview() def _update_default_filename(self): """Обновляет имя файла по умолчанию""" if self.source_path: path = Path(self.source_path) if path.is_file(): base_name = path.stem else: base_name = path.name ext = self.fmt.get() # Добавляем информацию о режиме анализа analysis_mode = self.struct.get("analysis_info", {}).get("mode", "quick") if analysis_mode == "quick": mode_suffix = "_quick" else: mode_suffix = "_full" default_name = f"{base_name}_structure{mode_suffix}.{ext}" self.path_var.set(str(path.parent / default_name)) else: ext = self.fmt.get() self.path_var.set(f"structure.{ext}") def _ask_save(self): ext = self.fmt.get() initial_file = ( Path(self.path_var.get()).name if self.path_var.get() else f"structure.{ext}" ) f = filedialog.asksaveasfilename( defaultextension=f".{ext}", filetypes=[(ext.upper(), f"*.{ext}"), ("Все файлы", "*.*")], initialfile=initial_file, ) if f: self.path_var.set(f) def _fill_preview(self): data = self._prepare() fmt = self.fmt.get() try: if fmt == "json": txt = self.exporter.export_to_json(data, compact=self.compact.get()) else: txt = self.exporter.export_to_yaml(data) self.preview.configure(state=tk.NORMAL) self.preview.delete("1.0", tk.END) header = f"# Экспорт структуры\n" header += f"# Формат: {fmt.upper()}\n" header += ( f"# Включать предпросмотр: {'Да' if self.inc_prev.get() else 'Нет'}\n" ) header += ( f"# Включать метаданные: {'Да' if self.inc_meta.get() else 'Нет'}\n" ) header += f"# Время: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" self.preview.insert("1.0", header) self.preview.insert("end", txt) self.preview.configure(state=tk.DISABLED) except Exception as e: logging.error(f"Ошибка предпросмотра: {e}") self.preview.configure(state=tk.NORMAL) self.preview.delete("1.0", tk.END) self.preview.insert("1.0", f"Ошибка форматирования: {e}") self.preview.configure(state=tk.DISABLED) def _prepare(self): import copy d = copy.deepcopy(self.struct) if not self.inc_prev.get(): for sh in d.get("sheets", {}).values(): sh.pop("data_preview", None) if not self.inc_meta.get(): for sh in d.get("sheets", {}).values(): sh.pop("metadata", None) return d def _save(self): p = self.path_var.get() if not p: messagebox.showwarning("Сохранение", "Укажите путь для сохранения") return self.progress.pack(fill=tk.X, padx=5, pady=5) self.progress.start() threading.Thread(target=self._do_save, args=(p,), daemon=True).start() def _do_save(self, p): try: data = self._prepare() Path(p).parent.mkdir(parents=True, exist_ok=True) if self.fmt.get() == "json": self.exporter.export_to_json(data, p, compact=self.compact.get()) else: self.exporter.export_to_yaml(data, p) file_size = Path(p).stat().st_size / 1024 msg = f"✅ Структура успешно сохранена!\n\n" msg += f"📁 Файл: {Path(p).name}\n" msg += f"📊 Размер: {file_size:.1f} KB\n" msg += f"📍 Путь: {p}" self.after(0, lambda: messagebox.showinfo("Экспорт завершен", msg)) except Exception as e: self.after( 0, lambda: messagebox.showerror( "Ошибка экспорта", f"Не удалось сохранить файл:\n{e}" ), ) logging.exception("Ошибка экспорта") finally: self.after(0, self._progress_stop) def _progress_stop(self): self.progress.stop() self.progress.pack_forget() # ---------- запуск ---------- def main(): logging.basicConfig( level=logging.INFO, format=LOG_FORMAT, datefmt=LOG_TIME_FMT, handlers=[ logging.StreamHandler(), logging.FileHandler( f"excel_analyzer_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" ), ], ) root = tk.Tk() StructureAnalyzerGUI(root).run() if __name__ == "__main__": main()