"""@file scan_tab.py @brief Вкладка разбора: выбор папки, параметры, ход работы и журнал. Здесь запускается полный цикл — обход папки, извлечение текста, тезисы и выгрузка отчётов. Работа идёт в BuildWorker, окно остаётся живым. """ from __future__ import annotations import os import subprocess import sys from pathlib import Path from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QCheckBox, QFileDialog, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, QSpinBox, QVBoxLayout, QWidget, ) from .. import config, db, report from .worker import BuildWorker class ScanTab(QWidget): """@brief Управление разбором папки и показ хода работы.""" #: каталог перестроен — вкладке поиска пора перечитать данные catalog_ready = Signal(str) #: пользователь выбрал другую папку root_changed = Signal(str) def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._worker: BuildWorker | None = None layout = QVBoxLayout(self) layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(10) layout.addWidget(self._build_source_panel()) layout.addWidget(self._build_progress_panel()) journal = QLabel("Журнал") journal.setProperty("section", True) layout.addWidget(journal) self.log = QPlainTextEdit() self.log.setReadOnly(True) layout.addWidget(self.log, 1) # ------------------------------------------------------------------ def _build_source_panel(self) -> QFrame: panel = QFrame() panel.setProperty("panel", True) grid = QGridLayout(panel) grid.setContentsMargins(14, 12, 14, 12) grid.setHorizontalSpacing(8) title = QLabel("Папка с документами") title.setProperty("section", True) grid.addWidget(title, 0, 0, 1, 4) self.folder = QLineEdit(str(config.current_root())) self.folder.editingFinished.connect( lambda: self.root_changed.emit(self.folder.text())) grid.addWidget(self.folder, 1, 0, 1, 3) browse = QPushButton("Обзор…") browse.clicked.connect(self.pick_folder) grid.addWidget(browse, 1, 3) options = QHBoxLayout() options.addWidget(QLabel("потоков чтения:")) self.jobs = QSpinBox() self.jobs.setRange(1, 32) self.jobs.setValue(4) self.jobs.setToolTip("Жёсткий диск — 2–4, SSD — 8–16.") options.addWidget(self.jobs) options.addSpacing(16) options.addWidget(QLabel("страниц на документ:")) self.max_pages = QSpinBox() self.max_pages.setRange(3, 200) self.max_pages.setValue(config.MAX_PDF_PAGES) self.max_pages.setToolTip( "Сколько первых страниц читать: больше — точнее тезисы, но дольше.") options.addWidget(self.max_pages) options.addSpacing(16) self.force = QCheckBox("перечитать всё заново") self.force.setToolTip( "Обычно читаются только новые и изменившиеся файлы.") options.addWidget(self.force) options.addStretch(1) grid.addLayout(options, 2, 0, 1, 4) buttons = QHBoxLayout() self.start = QPushButton("Построить каталог") self.start.setProperty("primary", True) self.start.clicked.connect(self.start_build) buttons.addWidget(self.start) self.stop = QPushButton("Остановить") self.stop.setEnabled(False) self.stop.clicked.connect(self.stop_build) buttons.addWidget(self.stop) buttons.addStretch(1) reports = QPushButton("Пересобрать отчёты") reports.clicked.connect(self.rebuild_reports) buttons.addWidget(reports) html_button = QPushButton("Открыть HTML-каталог") html_button.clicked.connect(self.open_html) buttons.addWidget(html_button) grid.addLayout(buttons, 3, 0, 1, 4) return panel def _build_progress_panel(self) -> QFrame: panel = QFrame() panel.setProperty("panel", True) box = QVBoxLayout(panel) box.setContentsMargins(14, 12, 14, 12) self.bar = QProgressBar() self.bar.setRange(0, 100) self.bar.setValue(0) box.addWidget(self.bar) self.state = QLabel("Готово. Выберите папку и нажмите «Построить каталог».") self.state.setProperty("muted", True) box.addWidget(self.state) return panel # ------------------------------------------------------------------ def root(self) -> Path: return Path(self.folder.text().strip('" ')) def set_root(self, root: Path) -> None: self.folder.setText(str(root)) def say(self, text: str) -> None: """@brief Добавляет строку в журнал.""" self.log.appendPlainText(text) def pick_folder(self) -> None: chosen = QFileDialog.getExistingDirectory( self, "Папка с документами", str(self.root())) if chosen: self.folder.setText(str(Path(chosen))) self.root_changed.emit(self.folder.text()) # ------------------------------------------------------------------ def start_build(self) -> None: """@brief Запускает полный цикл в фоновом потоке.""" root = self.root() if not root.is_dir(): QMessageBox.warning(self, "Каталогизатор", "Папка не найдена:\n%s" % root) return self.start.setEnabled(False) self.stop.setEnabled(True) self.bar.setRange(0, 0) # неопределённый ход: идёт обход папки self.state.setText("Обход папки…") self._worker = BuildWorker(root, self.jobs.value(), self.max_pages.value(), self.force.isChecked()) self._worker.progress.connect(self._on_progress) self._worker.message.connect(self.say) self._worker.finished_ok.connect(self._on_done) self._worker.failed.connect(self._on_failed) self._worker.start() def stop_build(self) -> None: if self._worker: self._worker.request_stop() self.state.setText("Останавливаюсь после текущей порции…") def _on_progress(self, done: int, total: int, text: str) -> None: if total > 0: self.bar.setRange(0, total) self.bar.setValue(done) else: self.bar.setRange(0, 0) self.state.setText(text) def _on_done(self, text: str) -> None: self.bar.setRange(0, 100) self.bar.setValue(100) self.state.setText(text) self.say(text) self.start.setEnabled(True) self.stop.setEnabled(False) self.catalog_ready.emit(str(self.root())) def _on_failed(self, text: str) -> None: self.bar.setRange(0, 100) self.bar.setValue(0) self.state.setText("Ошибка разбора") self.say(text) self.start.setEnabled(True) self.stop.setEnabled(False) QMessageBox.critical(self, "Каталогизатор", text.strip().splitlines()[-1]) # ------------------------------------------------------------------ def out_dir(self) -> Path: return config.paths_for(self.root())[1] def rebuild_reports(self) -> None: """@brief Пересобирает HTML, Markdown, JSON и CSV из готовой базы.""" try: db_path, out_dir = config.paths_for(self.root()) con = db.connect(db_path, create=False) paths = report.build_all(con, out_dir) con.close() except Exception as e: # noqa: BLE001 QMessageBox.critical(self, "Каталогизатор", str(e)) return for kind, path in paths.items(): self.say("%-5s %s" % (kind, path)) self.state.setText("Отчёты сохранены в %s" % out_dir) def open_html(self) -> None: """@brief Открывает автономную HTML-страницу каталога.""" path = self.out_dir() / "catalog.html" if not path.exists(): QMessageBox.information( self, "Каталогизатор", "HTML-каталог ещё не собран.\nНажмите «Построить каталог».") return if os.name == "nt": os.startfile(str(path)) # noqa: S606 elif sys.platform == "darwin": subprocess.Popen(["open", str(path)]) else: subprocess.Popen(["xdg-open", str(path)]) def busy(self) -> bool: return bool(self._worker and self._worker.isRunning())