Files
catalog_doc/catalogizer/ui/scan_tab.py
Andrey Kruchinkin 49f91247c7 Каталогизатор документов: разбор папки, тезисы, поиск, GUI
Программа принимает папку с документами, извлекает из них текст и
метаданные и строит каталог: краткое описание, тезисы и ключевые слова
для каждого файла плюс поиск по всему собранному.

Ядро:
- extract: PDF, DJVU, DOC(X), RTF, ODT, CHM, EPUB, FB2, PPT(X), XLS(X), TXT;
  PDF передаётся MuPDF потоком в память (работают длинные пути) и ограничен
  по времени — повреждённый файл иначе чинится минутами;
- summarize: экстрактивное реферирование по TF-IDF, посчитанному на самой
  библиотеке, с лёгким стеммером для русского и английского;
- db: SQLite с полнотекстовым индексом FTS5, морфологический поиск с BM25;
- scanner: инкрементальный многопоточный обход, счётчик попыток защищает
  от файла, обрывающего разбор;
- report: автономный HTML с поиском, Markdown, JSON, CSV.

Интерфейс:
- окно PySide6 в тёмной теме: вкладки разбора и поиска, фоновый поток,
  карточка документа, правка своих ключевых слов;
- командная строка: build, scan, analyze, report, search, show, tag, stats;
- у каждой папки свой каталог, последняя обработанная запоминается.

Сборка: scripts/build_exe.py даёт Catalogizer.exe в Windows и
Catalogizer.app в macOS, иконки .ico и .icns рисуются кодом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 19:20:29 +03:00

245 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""@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("Жёсткий диск — 24, SSD — 816.")
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())