"""@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("

Ничего не найдено.

") 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 = ["

%s

" % esc(doc["title"]), "
%s
" % esc(" · ".join(f for f in facts if f)), "
%s
" % esc(doc.get("description") or "")] if doc.get("theses"): parts.append("
Тезисы
") if doc.get("user_keywords"): parts.append("
Свои ключевые слова
" "
%s
" % esc(", ".join(doc["user_keywords"]))) if doc.get("keywords"): parts.append("
Ключевые слова
" "
%s
" % esc(", ".join(doc["keywords"]))) if doc.get("snip"): snippet = esc(" ".join(doc["snip"].split())) snippet = snippet.replace("«", "").replace("»", "") parts.append("
Из текста
%s
" % snippet) parts.append("
%s
" % 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))