Files
catalog_doc/catalogizer/ui/catalog_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

379 lines
16 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 catalog_tab.py
@brief Вкладка каталога: поиск по ключевым словам и карточка документа.
Пустой запрос показывает весь каталог по разделам; непустой уходит в
полнотекстовый индекс с учётом морфологии и ранжированием BM25.
"""
from __future__ import annotations
import html
import json
import os
import subprocess
import sys
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import (
QAbstractItemView,
QCompleter,
QComboBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSplitter,
QTableWidget,
QTableWidgetItem,
QTextBrowser,
QVBoxLayout,
QWidget,
)
from .. import config, db
from .. import search as search_mod
from .theme import CARD_CSS
LANGS = (("любой язык", None), ("русский", "ru"), ("английский", "en"))
COLUMNS = (("Название", 430), ("Тип", 100), ("Год", 60), ("Стр.", 60),
("Формат", 75), ("Раздел", 280))
class CatalogTab(QWidget):
"""@brief Поиск по каталогу и просмотр карточки выбранного документа."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._rows: list[dict] = []
self._root = config.current_root()
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
layout.addLayout(self._build_query_row())
splitter = QSplitter(Qt.Orientation.Vertical)
splitter.addWidget(self._build_table())
splitter.addWidget(self._build_card())
splitter.setSizes([420, 260])
layout.addWidget(splitter, 1)
layout.addLayout(self._build_keyword_editor())
layout.addLayout(self._build_actions())
# ------------------------------------------------------------------
# Построение интерфейса
# ------------------------------------------------------------------
def _build_query_row(self) -> QHBoxLayout:
row = QHBoxLayout()
self.query = QLineEdit()
self.query.setPlaceholderText(
"Поиск по названию, тезисам, ключевым словам и тексту… "
"(\"точная фраза\", -исключить)")
self.query.returnPressed.connect(self.refresh)
row.addWidget(self.query, 1)
find = QPushButton("Найти")
find.setProperty("primary", True)
find.clicked.connect(self.refresh)
row.addWidget(find)
self.ext_box = QComboBox()
self.ext_box.addItem("все форматы", None)
self.ext_box.currentIndexChanged.connect(self.refresh)
row.addWidget(self.ext_box)
self.lang_box = QComboBox()
for title, code in LANGS:
self.lang_box.addItem(title, code)
self.lang_box.currentIndexChanged.connect(self.refresh)
row.addWidget(self.lang_box)
self.section_box = QComboBox()
self.section_box.addItem("все разделы", None)
self.section_box.currentIndexChanged.connect(self.refresh)
self.section_box.setMinimumWidth(220)
row.addWidget(self.section_box)
return row
def _build_table(self) -> QTableWidget:
self.table = QTableWidget(0, len(COLUMNS))
self.table.setHorizontalHeaderLabels([c[0] for c in COLUMNS])
self.table.verticalHeader().setVisible(False)
self.table.setAlternatingRowColors(True)
self.table.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(
QAbstractItemView.SelectionMode.SingleSelection)
self.table.setEditTriggers(
QAbstractItemView.EditTrigger.NoEditTriggers)
for index, (_, width) in enumerate(COLUMNS):
self.table.setColumnWidth(index, width)
self.table.horizontalHeader().setSectionResizeMode(
0, QHeaderView.ResizeMode.Stretch)
self.table.itemSelectionChanged.connect(self._show_card)
self.table.doubleClicked.connect(self.open_document)
return self.table
def _build_card(self) -> QTextBrowser:
self.card = QTextBrowser()
self.card.setOpenExternalLinks(False)
self.card.document().setDefaultStyleSheet(CARD_CSS)
return self.card
def _build_keyword_editor(self) -> QHBoxLayout:
"""@brief Строка правки своих ключевых слов выбранного документа."""
row = QHBoxLayout()
label = QLabel("Свои ключевые слова:")
label.setProperty("muted", True)
row.addWidget(label)
self.my_keywords = QLineEdit()
self.my_keywords.setPlaceholderText(
"через запятую — сохраняются в каталоге и участвуют в поиске")
self.my_keywords.setEnabled(False)
self.my_keywords.returnPressed.connect(self.save_keywords)
row.addWidget(self.my_keywords, 1)
self.save_kw = QPushButton("Сохранить")
self.save_kw.setEnabled(False)
self.save_kw.clicked.connect(self.save_keywords)
row.addWidget(self.save_kw)
QShortcut(QKeySequence("Ctrl+S"), self, self.save_keywords)
return row
def _build_actions(self) -> QHBoxLayout:
row = QHBoxLayout()
open_doc = QPushButton("Открыть документ")
open_doc.clicked.connect(self.open_document)
row.addWidget(open_doc)
show_dir = QPushButton("Показать в папке")
show_dir.clicked.connect(self.open_containing_folder)
row.addWidget(show_dir)
row.addStretch(1)
self.found = QLabel("")
self.found.setProperty("muted", True)
row.addWidget(self.found)
return row
# ------------------------------------------------------------------
# Данные
# ------------------------------------------------------------------
def set_root(self, root: Path) -> None:
"""@brief Переключает вкладку на каталог другой папки."""
self._root = Path(root)
self.reload_filters()
self._reload_completer()
self.refresh()
def _connect(self, create: bool = False):
db_path, _ = config.paths_for(self._root)
return db.connect(db_path, create=create)
def _reload_completer(self) -> None:
"""@brief Подсказки при вводе — из уже заведённых своих слов."""
try:
con = self._connect()
words = db.all_user_keywords(con)
con.close()
except Exception: # noqa: BLE001
return
completer = QCompleter(words, self)
completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
completer.setFilterMode(Qt.MatchFlag.MatchContains)
self.my_keywords.setCompleter(completer)
def reload_filters(self) -> None:
"""@brief Обновляет списки форматов и разделов по содержимому базы."""
exts, folders = [], []
try:
con = self._connect()
exts = [r["ext"] for r in
con.execute("SELECT DISTINCT ext FROM docs ORDER BY ext")]
folders = [r["folder"] for r in con.execute(
"SELECT folder, COUNT(*) c FROM docs GROUP BY folder"
" ORDER BY c DESC LIMIT 200")]
con.close()
except Exception: # noqa: BLE001 — базы может ещё не быть
pass
for box, items, first in ((self.ext_box, exts, "все форматы"),
(self.section_box, folders, "все разделы")):
box.blockSignals(True)
box.clear()
box.addItem(first, None)
for item in items:
box.addItem(item, item)
box.blockSignals(False)
def refresh(self) -> None:
"""@brief Выполняет поиск (или показ всего каталога) и заполняет таблицу."""
text = self.query.text().strip()
ext = self.ext_box.currentData()
lang = self.lang_box.currentData()
section = self.section_box.currentData()
try:
con = self._connect()
if text:
self._rows = search_mod.search(con, text, limit=500, ext=ext,
lang=lang, folder=section)
else:
self._rows = self._browse(con, ext, lang, section)
con.close()
except FileNotFoundError:
self._rows = [] # каталог этой папки ещё не построен
except Exception as e: # noqa: BLE001
self._rows = []
QMessageBox.warning(self, "Каталогизатор", str(e))
self.table.setRowCount(len(self._rows))
for row, doc in enumerate(self._rows):
values = (doc["title"], doc.get("kind") or "",
str(doc.get("year") or ""), str(doc.get("pages") or ""),
doc["ext"].lstrip("."), doc.get("folder") or "")
for col, value in enumerate(values):
item = QTableWidgetItem(value)
if col in (2, 3, 4):
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, col, item)
self.found.setText("Найдено: %d" % len(self._rows))
if self._rows:
self.table.selectRow(0)
else:
self.card.setHtml("<p class='facts'>Ничего не найдено.</p>")
self.my_keywords.clear()
self.my_keywords.setEnabled(False)
self.save_kw.setEnabled(False)
@staticmethod
def _browse(con, ext, lang, section) -> list[dict]:
"""@brief Весь каталог по разделам — когда строка запроса пуста."""
sql = ["SELECT d.id, d.title, d.author, d.year, d.pages, d.ext, d.lang,",
" d.kind, d.folder, d.rel_path, d.path, d.description,",
" d.keywords, d.theses, d.scanned, d.error,",
" u.keywords AS user_keywords",
" FROM docs d LEFT JOIN user_keywords u ON u.path = d.path",
" WHERE 1 = 1"]
params: list = []
for column, value in (("ext", ext), ("lang", lang), ("folder", section)):
if value:
sql.append("AND d.%s = ?" % column)
params.append(value)
sql.append("ORDER BY d.folder, d.title LIMIT 3000")
rows = []
for r in con.execute("\n".join(sql), params):
doc = dict(r)
doc["keywords"] = json.loads(doc["keywords"] or "[]")
doc["theses"] = json.loads(doc["theses"] or "[]")
doc["user_keywords"] = json.loads(doc["user_keywords"] or "[]")
doc["snip"] = ""
rows.append(doc)
return rows
def _current(self) -> dict | None:
row = self.table.currentRow()
if row < 0 or row >= len(self._rows):
return None
return self._rows[row]
# ------------------------------------------------------------------
# Карточка и действия
# ------------------------------------------------------------------
def _show_card(self) -> None:
doc = self._current()
if doc is None:
return
esc = html.escape
facts = [doc["ext"].lstrip("."), doc.get("kind") or ""]
if doc.get("author"):
facts.append(doc["author"])
if doc.get("year"):
facts.append(str(doc["year"]))
if doc.get("pages"):
facts.append("%s с." % doc["pages"])
if doc.get("scanned"):
facts.append("скан без текстового слоя")
if doc.get("error"):
facts.append(doc["error"])
parts = ["<h2>%s</h2>" % esc(doc["title"]),
"<div class='facts'>%s</div>"
% esc(" · ".join(f for f in facts if f)),
"<div class='desc'>%s</div>" % esc(doc.get("description") or "")]
if doc.get("theses"):
parts.append("<div class='label'>Тезисы</div><ul>")
parts += ["<li>%s</li>" % esc(t) for t in doc["theses"]]
parts.append("</ul>")
if doc.get("user_keywords"):
parts.append("<div class='label'>Свои ключевые слова</div>"
"<div class='mine'>%s</div>"
% esc(", ".join(doc["user_keywords"])))
if doc.get("keywords"):
parts.append("<div class='label'>Ключевые слова</div>"
"<div class='kw'>%s</div>"
% esc(", ".join(doc["keywords"])))
if doc.get("snip"):
snippet = esc(" ".join(doc["snip"].split()))
snippet = snippet.replace("«", "<mark>").replace("»", "</mark>")
parts.append("<div class='label'>Из текста</div><div>%s</div>"
% snippet)
parts.append("<div class='path'>%s</div>" % esc(doc["path"]))
self.card.setHtml("".join(parts))
self.my_keywords.setText(", ".join(doc.get("user_keywords") or []))
self.my_keywords.setEnabled(True)
self.save_kw.setEnabled(True)
def save_keywords(self) -> None:
"""@brief Записывает свои ключевые слова выбранного документа."""
doc = self._current()
if doc is None:
return
words = [w.strip() for w in self.my_keywords.text().split(",")]
try:
con = self._connect(create=True)
saved = db.set_user_keywords(con, doc["path"], words)
con.close()
except Exception as e: # noqa: BLE001
QMessageBox.warning(self, "Каталогизатор", str(e))
return
doc["user_keywords"] = saved
self.my_keywords.setText(", ".join(saved))
self._show_card()
self._reload_completer()
self.found.setText("Ключевые слова сохранены")
def open_document(self) -> None:
"""@brief Открывает выбранный документ системным приложением."""
doc = self._current()
if doc:
self._open(Path(doc["path"]))
def open_containing_folder(self) -> None:
"""@brief Показывает документ в проводнике."""
doc = self._current()
if not doc:
return
path = Path(doc["path"])
if os.name == "nt":
subprocess.Popen(["explorer", "/select,", str(path)])
elif sys.platform == "darwin":
subprocess.Popen(["open", "-R", str(path)]) # показать в Finder
else:
self._open(path.parent)
def _open(self, path: Path) -> None:
try:
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)])
except OSError as e:
QMessageBox.warning(self, "Каталогизатор",
"Не удалось открыть:\n%s\n%s" % (path, e))