Программа принимает папку с документами, извлекает из них текст и метаданные и строит каталог: краткое описание, тезисы и ключевые слова для каждого файла плюс поиск по всему собранному. Ядро: - 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>
379 lines
16 KiB
Python
379 lines
16 KiB
Python
"""@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))
|