Каталогизатор документов: разбор папки, тезисы, поиск, 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>
This commit is contained in:
244
catalogizer/ui/scan_tab.py
Normal file
244
catalogizer/ui/scan_tab.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""@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())
|
||||
Reference in New Issue
Block a user