Каталогизатор документов: разбор папки, тезисы, поиск, 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:
3
catalogizer/ui/__init__.py
Normal file
3
catalogizer/ui/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""@file __init__.py
|
||||
@brief Слой представления: тёмное окно PySide6 поверх ядра каталогизатора.
|
||||
"""
|
||||
378
catalogizer/ui/catalog_tab.py
Normal file
378
catalogizer/ui/catalog_tab.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""@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))
|
||||
124
catalogizer/ui/main_window.py
Normal file
124
catalogizer/ui/main_window.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""@file main_window.py
|
||||
@brief Главное окно каталогизатора: шапка, вкладки разбора и поиска.
|
||||
|
||||
Обе вкладки работают с одной и той же папкой: смена папки на вкладке
|
||||
разбора сразу переключает каталог, по которому идёт поиск.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import config, db
|
||||
from .catalog_tab import CatalogTab
|
||||
from .scan_tab import ScanTab
|
||||
from .theme import APP_STYLESHEET
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""@brief Окно с вкладками «Каталог и поиск» и «Разбор папки»."""
|
||||
|
||||
def __init__(self, root: Path | None = None,
|
||||
parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Каталогизатор документов")
|
||||
self.resize(1280, 820)
|
||||
self.setMinimumSize(1000, 640)
|
||||
self.setStyleSheet(APP_STYLESHEET)
|
||||
|
||||
self.catalog_tab = CatalogTab()
|
||||
self.scan_tab = ScanTab()
|
||||
|
||||
central = QWidget()
|
||||
layout = QVBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self._build_header())
|
||||
layout.addWidget(self._build_tabs(), 1)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
self.scan_tab.catalog_ready.connect(self._on_catalog_ready)
|
||||
self.scan_tab.root_changed.connect(self._on_root_changed)
|
||||
|
||||
self.set_root(Path(root) if root else config.current_root())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _build_header(self) -> QFrame:
|
||||
header = QFrame()
|
||||
header.setObjectName("header")
|
||||
row = QHBoxLayout(header)
|
||||
row.setContentsMargins(18, 12, 18, 12)
|
||||
|
||||
titles = QVBoxLayout()
|
||||
title = QLabel("Каталогизатор")
|
||||
title.setProperty("title", True)
|
||||
subtitle = QLabel("Описания, тезисы и поиск по папке с документами")
|
||||
subtitle.setProperty("muted", True)
|
||||
titles.addWidget(title)
|
||||
titles.addWidget(subtitle)
|
||||
row.addLayout(titles)
|
||||
row.addStretch(1)
|
||||
|
||||
self.summary = QLabel("")
|
||||
self.summary.setProperty("muted", True)
|
||||
row.addWidget(self.summary)
|
||||
return header
|
||||
|
||||
def _build_tabs(self) -> QTabWidget:
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.addTab(self.catalog_tab, "Каталог и поиск")
|
||||
self.tabs.addTab(self.scan_tab, "Разбор папки")
|
||||
return self.tabs
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def set_root(self, root: Path) -> None:
|
||||
"""@brief Переключает обе вкладки на указанную папку."""
|
||||
self.scan_tab.set_root(root)
|
||||
self.catalog_tab.set_root(root)
|
||||
self._update_summary(root)
|
||||
|
||||
def _on_root_changed(self, text: str) -> None:
|
||||
root = Path(text.strip('" '))
|
||||
self.catalog_tab.set_root(root)
|
||||
self._update_summary(root)
|
||||
|
||||
def _on_catalog_ready(self, text: str) -> None:
|
||||
root = Path(text)
|
||||
self.catalog_tab.set_root(root)
|
||||
self._update_summary(root)
|
||||
self.tabs.setCurrentWidget(self.catalog_tab)
|
||||
|
||||
def _update_summary(self, root: Path) -> None:
|
||||
"""@brief Показывает в шапке, что уже есть в каталоге этой папки."""
|
||||
try:
|
||||
db_path, _ = config.paths_for(root)
|
||||
con = db.connect(db_path, create=False)
|
||||
total = con.execute("SELECT COUNT(*) c FROM docs").fetchone()["c"]
|
||||
scans = con.execute(
|
||||
"SELECT COUNT(*) c FROM docs WHERE scanned = 1").fetchone()["c"]
|
||||
when = db.get_meta(con, "last_scan")
|
||||
con.close()
|
||||
except Exception: # noqa: BLE001 — база может быть ещё не создана
|
||||
self.summary.setText("")
|
||||
return
|
||||
if not total:
|
||||
self.summary.setText("Каталог пуст — постройте его на вкладке «Разбор папки»")
|
||||
return
|
||||
self.summary.setText(
|
||||
"В каталоге %d документов, из них %d сканов · обновлён %s"
|
||||
% (total, scans, when or "—"))
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802 — имя из Qt
|
||||
if self.scan_tab.busy():
|
||||
self.scan_tab.stop_build()
|
||||
event.accept()
|
||||
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())
|
||||
140
catalogizer/ui/theme.py
Normal file
140
catalogizer/ui/theme.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""@file theme.py
|
||||
@brief Единая спокойная тёмная тема каталогизатора без внешних ресурсов.
|
||||
|
||||
Палитра и правила оформления совпадают со стилем SETGUI, чтобы обе
|
||||
программы выглядели частями одного инструментария.
|
||||
"""
|
||||
|
||||
APP_STYLESHEET = """
|
||||
QWidget {
|
||||
background: #10151d;
|
||||
color: #dce6f2;
|
||||
font-family: "Segoe UI", "SF Pro Text", "Helvetica Neue", "Noto Sans",
|
||||
sans-serif;
|
||||
font-size: 10pt;
|
||||
}
|
||||
QMainWindow { background: #0d1219; }
|
||||
QFrame#header {
|
||||
background: #151d28;
|
||||
border-bottom: 1px solid #263345;
|
||||
}
|
||||
QFrame[panel="true"] {
|
||||
background: #151d28;
|
||||
border: 1px solid #263345;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QLabel[muted="true"] { color: #8291a5; }
|
||||
QLabel[title="true"] {
|
||||
color: #f4f8fc;
|
||||
font-size: 15pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
QLabel[section="true"] {
|
||||
color: #f4f8fc;
|
||||
font-size: 11pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton {
|
||||
background: #243247;
|
||||
border: 1px solid #344762;
|
||||
border-radius: 5px;
|
||||
padding: 7px 14px;
|
||||
}
|
||||
QPushButton:hover { background: #2d405b; }
|
||||
QPushButton:pressed { background: #1c293b; }
|
||||
QPushButton:disabled { color: #627086; background: #192230; }
|
||||
QPushButton[primary="true"] {
|
||||
background: #1570d8;
|
||||
border-color: #2584ec;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton[primary="true"]:hover { background: #2584ec; }
|
||||
QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {
|
||||
background: #0d131c;
|
||||
border: 1px solid #34445a;
|
||||
border-radius: 5px;
|
||||
padding: 6px;
|
||||
selection-background-color: #1570d8;
|
||||
}
|
||||
QComboBox::drop-down { border: 0; width: 24px; }
|
||||
QComboBox QAbstractItemView {
|
||||
background: #0d131c;
|
||||
border: 1px solid #34445a;
|
||||
selection-background-color: #1570d8;
|
||||
}
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #263345;
|
||||
background: #10151d;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background: #151d28;
|
||||
color: #91a0b4;
|
||||
border: 1px solid #263345;
|
||||
padding: 9px 15px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
color: #ffffff;
|
||||
background: #1c2838;
|
||||
border-bottom: 2px solid #2f91ff;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background: #1b2635;
|
||||
color: #b9c5d5;
|
||||
border: 0;
|
||||
border-right: 1px solid #2b394c;
|
||||
padding: 6px;
|
||||
}
|
||||
QTableWidget, QTreeWidget {
|
||||
background: #0d131c;
|
||||
alternate-background-color: #111a25;
|
||||
gridline-color: #263345;
|
||||
border: 1px solid #263345;
|
||||
selection-background-color: #1570d8;
|
||||
selection-color: #ffffff;
|
||||
}
|
||||
QTextBrowser, QPlainTextEdit {
|
||||
background: #090e14;
|
||||
color: #aabbd0;
|
||||
border: 1px solid #263345;
|
||||
font-family: "Cascadia Mono", "SF Mono", Menlo, "DejaVu Sans Mono",
|
||||
monospace;
|
||||
font-size: 9pt;
|
||||
}
|
||||
QProgressBar {
|
||||
background: #0d131c;
|
||||
border: 1px solid #34445a;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
QProgressBar::chunk { background: #1570d8; border-radius: 4px; }
|
||||
QScrollBar:vertical {
|
||||
background: #10151d;
|
||||
width: 11px;
|
||||
margin: 0;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #34445a;
|
||||
min-height: 24px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
QSplitter::handle { background: #1b2635; }
|
||||
QStatusBar { background: #151d28; color: #8291a5; }
|
||||
"""
|
||||
|
||||
#: Цвета карточки документа: HTML-разметка QTextBrowser темы не наследует.
|
||||
CARD_CSS = """
|
||||
body { color: #dce6f2;
|
||||
font-family: "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||
font-size: 10pt; }
|
||||
h2 { color: #f4f8fc; font-size: 12pt; margin: 0 0 4px 0; }
|
||||
.facts { color: #8291a5; font-size: 9pt; margin-bottom: 10px; }
|
||||
.desc { margin-bottom: 10px; }
|
||||
.label { color: #2f91ff; font-weight: 600; }
|
||||
li { margin-bottom: 4px; }
|
||||
.kw { color: #b9c5d5; }
|
||||
.mine { color: #2f91ff; font-weight: 600; }
|
||||
.path { color: #627086;
|
||||
font-family: "Cascadia Mono", "SF Mono", Menlo, monospace;
|
||||
font-size: 8pt; }
|
||||
mark { background: #1570d8; color: #ffffff; }
|
||||
"""
|
||||
78
catalogizer/ui/worker.py
Normal file
78
catalogizer/ui/worker.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""@file worker.py
|
||||
@brief Фоновый поток построения каталога.
|
||||
|
||||
Разбор библиотеки идёт минутами и часами, поэтому выполняется в отдельном
|
||||
потоке QThread: окно остаётся отзывчивым, а ход работы приходит сигналами.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from .. import analyze as analyze_mod
|
||||
from .. import config, db, report, scanner
|
||||
|
||||
|
||||
class BuildWorker(QThread):
|
||||
"""@brief Полный цикл: обход папки, анализ, выгрузка отчётов."""
|
||||
|
||||
#: сделано, всего, поясняющая строка
|
||||
progress = Signal(int, int, str)
|
||||
#: строка в журнал
|
||||
message = Signal(str)
|
||||
#: итоговая строка состояния
|
||||
finished_ok = Signal(str)
|
||||
#: текст ошибки с трассировкой
|
||||
failed = Signal(str)
|
||||
|
||||
def __init__(self, root: Path, jobs: int, max_pages: int,
|
||||
force: bool = False, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._root = Path(root)
|
||||
self._jobs = jobs
|
||||
self._max_pages = max_pages
|
||||
self._force = force
|
||||
self._stop = False
|
||||
|
||||
def request_stop(self) -> None:
|
||||
"""@brief Просит остановиться после текущей порции файлов.
|
||||
|
||||
Разобранное уже записано в базу, поэтому следующий запуск продолжит
|
||||
с того же места.
|
||||
"""
|
||||
self._stop = True
|
||||
|
||||
def run(self) -> None: # noqa: D102 — контракт QThread
|
||||
try:
|
||||
config.remember(self._root)
|
||||
db_path, out_dir = config.paths_for(self._root)
|
||||
con = db.connect(db_path)
|
||||
self.message.emit("Папка: %s" % self._root)
|
||||
|
||||
stats = scanner.scan(
|
||||
con, self._root, max_pages=self._max_pages, jobs=self._jobs,
|
||||
force=self._force, quiet=True,
|
||||
progress=lambda d, t, m: self.progress.emit(d, t, m),
|
||||
stop=lambda: self._stop)
|
||||
self.message.emit(
|
||||
"Разобрано %(processed)d, с ошибками %(errors)d, "
|
||||
"пропущено %(skipped)d, за %(seconds)s с" % stats)
|
||||
|
||||
self.progress.emit(0, 0, "Анализ текстов…")
|
||||
analyze_mod.analyze(
|
||||
con, quiet=True,
|
||||
progress=lambda d, t, m: self.progress.emit(d, t, m))
|
||||
|
||||
self.progress.emit(0, 0, "Выгрузка отчётов…")
|
||||
for kind, path in report.build_all(con, out_dir).items():
|
||||
self.message.emit("%-5s %s" % (kind, path))
|
||||
total = con.execute("SELECT COUNT(*) c FROM docs").fetchone()["c"]
|
||||
con.close()
|
||||
self.finished_ok.emit(
|
||||
"Каталог готов: %d документов%s"
|
||||
% (total, " (остановлено)" if self._stop else ""))
|
||||
except Exception: # noqa: BLE001 — окно должно показать причину
|
||||
self.failed.emit(traceback.format_exc())
|
||||
Reference in New Issue
Block a user