From 49f91247c70d2b754094da0f9c41fc74f631afa4 Mon Sep 17 00:00:00 2001 From: Andrey Kruchinkin Date: Sun, 30 Aug 2026 19:20:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=82=D0=BE=D1=80=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2:=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8,=20=D1=82?= =?UTF-8?q?=D0=B5=D0=B7=D0=B8=D1=81=D1=8B,=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA,=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Программа принимает папку с документами, извлекает из них текст и метаданные и строит каталог: краткое описание, тезисы и ключевые слова для каждого файла плюс поиск по всему собранному. Ядро: - 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 --- .gitattributes | 5 + .gitignore | 21 ++ BUILD_APP.command | 51 +++++ BUILD_EXE.bat | 86 ++++++++ CATALOG.bat | 57 +++++ CATALOG.command | 30 +++ README.md | 254 ++++++++++++++++++++++ catalog.py | 12 ++ catalog_gui.py | 11 + catalogizer/__init__.py | 2 + catalogizer/analyze.py | 75 +++++++ catalogizer/app.py | 58 +++++ catalogizer/cli.py | 383 ++++++++++++++++++++++++++++++++++ catalogizer/config.py | 119 +++++++++++ catalogizer/db.py | 234 +++++++++++++++++++++ catalogizer/extract.py | 349 +++++++++++++++++++++++++++++++ catalogizer/report.py | 301 ++++++++++++++++++++++++++ catalogizer/scanner.py | 175 ++++++++++++++++ catalogizer/search.py | 86 ++++++++ catalogizer/summarize.py | 178 ++++++++++++++++ catalogizer/textutil.py | 156 ++++++++++++++ catalogizer/ui/__init__.py | 3 + catalogizer/ui/catalog_tab.py | 378 +++++++++++++++++++++++++++++++++ catalogizer/ui/main_window.py | 124 +++++++++++ catalogizer/ui/scan_tab.py | 244 ++++++++++++++++++++++ catalogizer/ui/theme.py | 140 +++++++++++++ catalogizer/ui/worker.py | 78 +++++++ requirements.txt | 2 + scripts/build_exe.py | 155 ++++++++++++++ scripts/make_icon.py | 167 +++++++++++++++ 30 files changed, 3934 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100755 BUILD_APP.command create mode 100644 BUILD_EXE.bat create mode 100644 CATALOG.bat create mode 100755 CATALOG.command create mode 100644 README.md create mode 100644 catalog.py create mode 100644 catalog_gui.py create mode 100644 catalogizer/__init__.py create mode 100644 catalogizer/analyze.py create mode 100644 catalogizer/app.py create mode 100644 catalogizer/cli.py create mode 100644 catalogizer/config.py create mode 100644 catalogizer/db.py create mode 100644 catalogizer/extract.py create mode 100644 catalogizer/report.py create mode 100644 catalogizer/scanner.py create mode 100644 catalogizer/search.py create mode 100644 catalogizer/summarize.py create mode 100644 catalogizer/textutil.py create mode 100644 catalogizer/ui/__init__.py create mode 100644 catalogizer/ui/catalog_tab.py create mode 100644 catalogizer/ui/main_window.py create mode 100644 catalogizer/ui/scan_tab.py create mode 100644 catalogizer/ui/theme.py create mode 100644 catalogizer/ui/worker.py create mode 100644 requirements.txt create mode 100644 scripts/build_exe.py create mode 100644 scripts/make_icon.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..38f23e9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.bat text eol=crlf +*.command text eol=lf +*.ico binary +*.icns binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e1f8ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Сборка +build/ +dist/ +.venv/ +*.spec + +# Иконки рисуются кодом: python scripts/make_icon.py +ico/ + +# Данные каталогов: свои у каждого пользователя +catalog.db +catalog.db-shm +catalog.db-wal +catalogs/ +out/ +.current + +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ diff --git a/BUILD_APP.command b/BUILD_APP.command new file mode 100755 index 0000000..6a9e89a --- /dev/null +++ b/BUILD_APP.command @@ -0,0 +1,51 @@ +#!/bin/bash +# Build a standalone Catalogizer.app on macOS: double-click this file in Finder. +# Any arguments are passed to scripts/build_exe.py (--console, --cli). +# --setup create .venv and install PySide6 + pymupdf + PyInstaller first +# +# PyInstaller cannot cross-compile: run this on the Mac itself. +set -u + +MODULE_ROOT="$(cd "$(dirname "$0")" && pwd)" +VENV_PYTHON="$MODULE_ROOT/.venv/bin/python3" +BUILD_SCRIPT="$MODULE_ROOT/scripts/build_exe.py" +PACKED_APP="$MODULE_ROOT/dist/Catalogizer.app" +PACKAGES="PySide6 pymupdf pyinstaller" + +setup() { + echo "Preparing the build environment in .venv - this takes a few minutes." + if [ ! -x "$VENV_PYTHON" ]; then + python3 -m venv "$MODULE_ROOT/.venv" || { + echo "Failed to create .venv. Is python3 installed?"; exit 1; } + fi + "$VENV_PYTHON" -m pip install --upgrade pip + "$VENV_PYTHON" -m pip install $PACKAGES || { + echo "Failed to install $PACKAGES."; exit 1; } +} + +if [ "${1:-}" = "--setup" ]; then + setup + shift +elif [ ! -x "$VENV_PYTHON" ]; then + echo "Local .venv is missing. Starting the one-time setup." + setup +fi + +# A running build keeps its own files locked and PyInstaller fails halfway. +if pgrep -x Catalogizer >/dev/null 2>&1; then + echo "Catalogizer is running and cannot be overwritten." + echo "Quit the application and start the build again." + exit 1 +fi + +"$VENV_PYTHON" "$BUILD_SCRIPT" "$@" || { + echo; echo "Build failed. See README.md, section about the app."; exit 1; } + +# Self-check: the packed build has no console to show a traceback. +if [ -d "$PACKED_APP" ]; then + "$PACKED_APP/Contents/MacOS/Catalogizer" --check || { + echo; echo "The build starts but the main window is broken."; exit 1; } +fi + +echo +echo "Done. Start it with CATALOG.command or open dist/Catalogizer.app." diff --git a/BUILD_EXE.bat b/BUILD_EXE.bat new file mode 100644 index 0000000..5b38a7a --- /dev/null +++ b/BUILD_EXE.bat @@ -0,0 +1,86 @@ +@echo off +setlocal + +rem Build a standalone Catalogizer.exe: double-click this file in Explorer. +rem Any arguments are passed to scripts\build_exe.py (--onedir, --console, --cli). +rem --setup create .venv and install PySide6 + pymupdf + PyInstaller first + +set "MODULE_ROOT=%~dp0." +set "VENV_PYTHON=%MODULE_ROOT%\.venv\Scripts\python.exe" +set "BUILD_SCRIPT=%MODULE_ROOT%\scripts\build_exe.py" +set "PACKED_EXE=%MODULE_ROOT%\dist\Catalogizer.exe" +set "EXE_NAME=Catalogizer.exe" +set "PACKAGES=PySide6 pymupdf pyinstaller" +set "BUILD_ARGS=%*" + +if /i "%~1"=="--setup" ( + set "BUILD_ARGS=" + goto setup +) +if exist "%VENV_PYTHON%" goto have_python + +echo Local .venv is missing. +echo Starting the one-time build environment setup. +echo. + +:setup +echo Preparing the build environment in .venv - this takes a few minutes. +echo. +if not exist "%VENV_PYTHON%" ( + python -m venv "%MODULE_ROOT%\.venv" + if errorlevel 1 ( + echo Failed to create .venv. Is Python installed and in PATH? + pause + exit /b 1 + ) +) +"%VENV_PYTHON%" -m pip install --upgrade pip +"%VENV_PYTHON%" -m pip install %PACKAGES% +if errorlevel 1 ( + echo Failed to install %PACKAGES%. + pause + exit /b 1 +) +echo. + +:have_python +if not exist "%VENV_PYTHON%" ( + echo Build environment is missing: "%VENV_PYTHON%" + echo Create it with: BUILD_EXE.bat --setup + pause + exit /b 1 +) + +rem A running build keeps its own file locked and PyInstaller fails halfway. +"%SystemRoot%\System32\tasklist.exe" /FI "IMAGENAME eq %EXE_NAME%" 2>nul | "%SystemRoot%\System32\find.exe" /I "%EXE_NAME%" >nul +if not errorlevel 1 ( + echo %EXE_NAME% is running and cannot be overwritten. + echo Close the application and start the build again. + echo. + pause + exit /b 1 +) + +"%VENV_PYTHON%" "%BUILD_SCRIPT%" %BUILD_ARGS% +if errorlevel 1 ( + echo. + echo Build failed. See README.md, section about the exe. + pause + exit /b 1 +) + +rem Self-check: the packed build has no console to show a traceback. +if exist "%PACKED_EXE%" ( + "%PACKED_EXE%" --check + if errorlevel 1 ( + echo. + echo The build starts but the main window is broken. + pause + exit /b 1 + ) +) + +echo. +echo Done. Start it with CATALOG.bat or run dist\%EXE_NAME% directly. +pause +exit /b 0 diff --git a/CATALOG.bat b/CATALOG.bat new file mode 100644 index 0000000..1d37c3e --- /dev/null +++ b/CATALOG.bat @@ -0,0 +1,57 @@ +@echo off +setlocal + +rem GUI launcher: double-click this file in Windows Explorer. +set "MODULE_ROOT=%~dp0." +set "PACKED_EXE=%MODULE_ROOT%\dist\Catalogizer.exe" +set "LOCAL_PYTHON=%MODULE_ROOT%\.venv\Scripts\python.exe" +set "LOCAL_PYTHONW=%MODULE_ROOT%\.venv\Scripts\pythonw.exe" + +rem A packed build needs neither Python nor PySide6, so it wins when present. +if exist "%PACKED_EXE%" ( + if /i "%~1"=="--check" ( + "%PACKED_EXE%" --check + if errorlevel 1 ( + echo Catalogizer.exe is broken. Rebuild it with BUILD_EXE.bat. + pause + exit /b 1 + ) + echo Catalogizer: packed build is ready. + exit /b 0 + ) + start "Catalogizer" /D "%MODULE_ROOT%" "%PACKED_EXE%" %* + exit /b 0 +) + +if exist "%LOCAL_PYTHON%" ( + set "CHECK_PYTHON=%LOCAL_PYTHON%" + set "GUI_PYTHON=%LOCAL_PYTHONW%" +) else ( + set "CHECK_PYTHON=python" + where pythonw >nul 2>&1 + if errorlevel 1 ( + set "GUI_PYTHON=python" + ) else ( + set "GUI_PYTHON=pythonw" + ) +) + +"%CHECK_PYTHON%" -c "import PySide6, pymupdf" >nul 2>&1 +if errorlevel 1 ( + echo Python with PySide6 and pymupdf is not available. + echo. + echo Either build a standalone Catalogizer.exe with BUILD_EXE.bat, + echo or install the dependencies: + echo python -m pip install -r "%MODULE_ROOT%\requirements.txt" + echo. + pause + exit /b 1 +) + +if /i "%~1"=="--check" ( + "%CHECK_PYTHON%" "%MODULE_ROOT%\catalog_gui.py" --check + exit /b %errorlevel% +) + +rem pythonw starts the desktop application without an extra console window. +start "Catalogizer" /D "%MODULE_ROOT%" "%GUI_PYTHON%" "%MODULE_ROOT%\catalog_gui.py" %* diff --git a/CATALOG.command b/CATALOG.command new file mode 100755 index 0000000..0046e6f --- /dev/null +++ b/CATALOG.command @@ -0,0 +1,30 @@ +#!/bin/bash +# GUI launcher for macOS: double-click this file in Finder. +set -u + +MODULE_ROOT="$(cd "$(dirname "$0")" && pwd)" +PACKED_APP="$MODULE_ROOT/dist/Catalogizer.app" +VENV_PYTHON="$MODULE_ROOT/.venv/bin/python3" + +# A packed build needs neither Python nor PySide6, so it wins when present. +if [ -d "$PACKED_APP" ]; then + if [ "${1:-}" = "--check" ]; then + "$PACKED_APP/Contents/MacOS/Catalogizer" --check + exit $? + fi + open -a "$PACKED_APP" --args "$@" + exit 0 +fi + +PYTHON="$VENV_PYTHON" +[ -x "$PYTHON" ] || PYTHON="$(command -v python3 || true)" +if [ -z "$PYTHON" ] || ! "$PYTHON" -c "import PySide6, pymupdf" >/dev/null 2>&1; then + echo "Python with PySide6 and pymupdf is not available." + echo + echo "Either build a standalone Catalogizer.app with BUILD_APP.command," + echo "or install the dependencies:" + echo " python3 -m pip install -r \"$MODULE_ROOT/requirements.txt\"" + exit 1 +fi + +exec "$PYTHON" "$MODULE_ROOT/catalog_gui.py" "$@" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2dcb76 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# Каталогизатор документов + +Программа принимает папку с документами, разбирает её и строит каталог: +для каждого файла — **краткое описание**, **тезисы** и **ключевые слова**, +плюс **поиск по каталогу** — в окне программы, в командной строке и в +автономной HTML-странице. + +Всё работает локально: файлы только читаются, никаких обращений в сеть. + +Программа работает в Windows, macOS и Linux — исходники одни и те же. + +## Запуск + +**Windows** — двойной щелчок по `CATALOG.bat` или `dist\Catalogizer.exe`. +**macOS** — двойной щелчок по `CATALOG.command` или `dist/Catalogizer.app`. +Ни Python, ни библиотек при этом не требуется. + +**Из исходников** (любая система): + +```bash +pip install -r requirements.txt +python catalog_gui.py +``` + +## Окно программы + +Две вкладки: + +* **Разбор папки** — поле пути и «Обзор…», параметры (потоков чтения, страниц + на документ, «перечитать всё заново»), кнопки «Построить каталог» и + «Остановить», индикатор хода работы и журнал. Разбор идёт в фоновом потоке, + окно не подвисает; остановка безопасна — разобранное уже в базе, следующий + запуск продолжит с того же места. +* **Каталог и поиск** — строка поиска, фильтры по формату, языку и разделу, + таблица найденного и карточка документа с описанием, тезисами, ключевыми + словами и фрагментом текста с подсветкой. Двойной щелчок открывает документ, + «Показать в папке» — проводник на нём. + +Кнопки «Пересобрать отчёты» и «Открыть HTML-каталог» — на вкладке разбора. + +## Командная строка + +Папка — обычный аргумент: + +```bash +python catalog.py build "E:\Yandex.Disk\литература" +``` + +| Команда | Что делает | +|---|---| +| `gui [ПАПКА]` | графическое окно (то же, что запуск без аргументов) | +| `scan ПАПКА` | обход папки, извлечение текста и метаданных в базу | +| `analyze` | ключевые слова, тезисы и описания по всей библиотеке | +| `report` | выгрузка `catalog.html`, `catalog.md`, `catalog.json`, `catalog.csv` | +| `build ПАПКА` | полный цикл: `scan` + `analyze` + `report` | +| `search ЗАПРОС` | поиск по каталогу | +| `show ID\|ИМЯ` | карточка одного документа | +| `tag ДОКУМЕНТ СЛОВА` | свои ключевые слова документа | +| `stats` | сводка: форматы, разделы, частые темы | + +Последняя обработанная папка запоминается, поэтому дальше путь можно не +повторять: `python catalog.py search "инвертор"`. Обратиться к другому +каталогу — ключ `-c`: `python catalog.py -c "D:\books" stats`. + +У каждой папки своя база (`catalogs/<имя>-<хеш>.db`) и своя папка отчётов +(`out/<имя>-<хеш>/`), каталоги разных папок не смешиваются. + +Полезные ключи `scan`/`build`: + +```bash +python catalog.py scan --ext pdf djvu # только выбранные форматы +python catalog.py scan --max-pages 40 # читать больше страниц из каждого файла +python catalog.py scan --jobs 8 # потоков чтения: HDD 2-4, SSD 8-16 +python catalog.py scan --limit 50 # пробный прогон на 50 файлах +python catalog.py scan --force # перечитать всё заново +python catalog.py scan --retry-broken # снова попробовать «битые» файлы +``` + +## Поиск + +```bash +python catalog.py search "инвертор" +python catalog.py search "цифровая обработка сигналов" -n 30 +python catalog.py search stm32 dma --ext pdf +python catalog.py search "операционный усилитель" --lang ru --year-from 2010 +python catalog.py search "\"частотное преобразование\"" # точная фраза +python catalog.py search fpga -verilog # минус — исключить слово +python catalog.py search can --section datasheet # только в разделе +``` + +Поиск морфологический: слова приводятся к основе, поэтому «инвертор» находит +«инверторы» и «инвертора». Ранжирование — BM25 с повышенным весом названия и +ключевых слов; в выдаче показывается фрагмент текста с подсветкой. + +## Свои ключевые слова + +Кроме слов, вычисленных из текста, к любому документу можно добавить свои — +рубрику, проект, пометку «нужное». Они полноправно участвуют в поиске и даже +весят в нём больше вычисленных. + +В окне: выбрать документ, вписать слова через запятую в строку «Свои +ключевые слова» под карточкой и нажать «Сохранить» (или Enter, или Ctrl+S). +При вводе подсказываются слова, уже заведённые в этом каталоге. + +В командной строке: + +```bash +python catalog.py tag opamp "силовая электроника, к проекту А" # добавить +python catalog.py tag 137 --remove "к проекту А" # убрать +python catalog.py tag 137 --set "только это" # заменить список +python catalog.py tag 137 --set # очистить +python catalog.py tag 137 # показать +python catalog.py tag --all # все слова каталога +python catalog.py search "к проекту А" # найти помеченные +``` + +Документ указывается номером `id` или частью имени, пути либо названия. + +Слова хранятся отдельно от результатов разбора и привязаны к пути файла, +поэтому переживают повторный разбор папки и перестроение каталога. В отчёты +они попадают отдельной строкой («Свои ключевые слова»), в HTML-каталоге +выделены цветом, в CSV — отдельной колонкой. + +## Отчёты + +После `report` в папке `out/`: + +* **`catalog.html`** — автономная страница: поиск по мере ввода, фильтры, + тезисы и ключевые слова у каждого документа, ссылки на файлы. Открывается + двойным щелчком, без сервера; +* `catalog.md` — каталог по разделам для чтения и печати; +* `catalog.json`, `catalog.csv` — выгрузка для других программ (CSV с BOM, + открывается в Excel). + +## Как строятся описания и тезисы + +Реферирование экстрактивное — фразы берутся из самого документа, ничего не +досочиняется: + +1. из файла читаются первые `--max-pages` страниц, оглавление и метаданные; +2. слова приводятся к основе (лёгкий стеммер для русского и английского), + считаются частоты; слова из названия и оглавления получают больший вес; +3. по всей библиотеке считается IDF — так отсеиваются слова, общие для всей + техлитературы, и остаются характерные именно для этого документа; +4. **ключевые слова** — верхние основы по TF-IDF, показанные самой короткой + частой словоформой; +5. **тезисы** — предложения с наибольшей плотностью ключевых слов, с бонусом + началу документа (аннотация, введение) и оборотам вида «рассматривается», + «предназначено», `this book describes`; близкие по составу фразы отбрасываются; +6. **описание** — тип издания, язык, объём, темы и первый тезис. + +Для сканов без текстового слоя тезисы берутся из оглавления PDF, а если его +нет — документ попадает в каталог с пометкой «скан» и описанием по имени файла. +Найти такие файлы: `python catalog.py stats`. + +## Форматы и внешние утилиты + +Разбираются PDF, DJVU, DOC, DOCX, RTF, ODT, CHM, EPUB, FB2, PPT, PPTX, XLS, +XLSX, TXT, MD. Обязательна только `pymupdf` (PDF); дополнительно, если есть +в PATH: + +* `antiword` — качественный разбор старых `.doc` (иначе грубая выборка строк); +* `djvutxt` из DjVuLibre — текстовый слой `.djvu` (иначе для DJVU остаются + только название, число страниц и раздел). + +## Сборка исполняемого файла + +Сборщик один — `scripts/build_exe.py`; что получится, зависит от системы, +**на которой идёт сборка**: PyInstaller не умеет собирать под чужую +платформу. Программу для macOS собирают на Mac, для Windows — на Windows. + +**Windows** → `dist\Catalogizer.exe`: + +```bash +BUILD_EXE.bat --setup # один раз: .venv с PySide6, pymupdf, PyInstaller +BUILD_EXE.bat # сборка +``` + +**macOS** → `dist/Catalogizer.app`: + +```bash +chmod +x BUILD_APP.command CATALOG.command # один раз, после переноса файлов +./BUILD_APP.command --setup # один раз: .venv и зависимости +./BUILD_APP.command # сборка +``` + +Оба сценария можно просто запустить двойным щелчком. Ключи передаются в +`scripts/build_exe.py`: `--onedir` (папка вместо одного файла, запускается +быстрее; в macOS так всегда — `.app` и есть папка), `--console` (оставить +консоль для traceback), `--cli` (собрать ещё и консольную программу +`catalog`). После сборки автоматически выполняется самопроверка `--check`: +окно строится целиком и программа выходит. + +Иконки генерируются кодом — `python scripts/make_icon.py` создаёт +`ico/catalog.ico` для Windows и `ico/catalog.icns` для macOS. + +Куда программа кладёт базы и отчёты: в Windows и Linux — **рядом с +исполняемым файлом**, в macOS — в `~/Library/Application Support/Catalogizer`, +потому что содержимое `.app` считается неизменяемым. + +### Что учтено для macOS + +* пути и `\?\`-префикс применяются только в Windows; +* «Показать в папке» открывает Finder на файле (`open -R`), «Открыть + документ» — `open`; +* ссылки на файлы в HTML-каталоге строятся из абсолютного пути и работают + и с `/Users/...`, и с `E:\...`; +* в теме заданы системные шрифты (`SF Pro Text`, `SF Mono`) как замена + windows-овским; +* папка по умолчанию — `~/Documents`, если жёстко прописанной библиотеки нет; +* `.command`-файлы сохранены с переводами строк LF, иначе bash их не примет. + +Первый запуск неподписанного `.app` macOS блокирует: правой кнопкой по +`Catalogizer.app` → «Открыть» → «Открыть» в диалоге. Либо снять карантин: +`xattr -dr com.apple.quarantine dist/Catalogizer.app`. + +## Устройство + +``` +CATALOG.bat запуск окна в Windows +CATALOG.command запуск окна в macOS +BUILD_EXE.bat сборка exe в Windows +BUILD_APP.command сборка .app в macOS +catalog_gui.py точка входа окна (её упаковывает PyInstaller) +catalog.py точка входа командной строки +catalogizer/ + app.py сборка QApplication, ключ --check + config.py папка по умолчанию, форматы, ограничения разбора + extract.py чтение PDF, DJVU, DOC(X), RTF, CHM, EPUB, FB2, XLS(X), TXT + textutil.py язык, токенизация, стемминг, разбиение на предложения + summarize.py TF-IDF, ключевые слова, тезисы, описание + scanner.py обход папки, многопоточное извлечение + analyze.py проход анализа по всей базе + search.py запросы к полнотекстовому индексу + report.py HTML, Markdown, JSON, CSV + db.py SQLite + FTS5 + ui/ тёмная тема, главное окно, вкладки, фоновый поток +scripts/build_exe.py сборка exe/.app — под ту систему, где запущен +scripts/make_icon.py генерация иконок .ico и .icns +``` + +## Замечания по работе + +* Файлы длиннее 260 символов пути читаются через префикс `\\?\`; PDF + передаются MuPDF потоком в память, поэтому нестандартные пути не мешают. +* Кодировка русских `.txt` определяется перебором (utf-8, cp1251, koi8-r, cp866). +* Разбор одного PDF ограничен по времени (`PDF_TIME_BUDGET`, 25 с): + повреждённый файл MuPDF чинит страницу за страницей и может занять минуты. +* Сбой на файле не останавливает обход: причина сохраняется в поле `error` + и видна в `stats`. Файл, трижды оборвавший разбор, дальше пропускается — + вернуть его в очередь можно ключом `--retry-broken`. +* Если папка лежит под управлением клиента синхронизации (Яндекс.Диск, + OneDrive), его драйвер может надолго придерживать открытые файлы. На время + первого разбора синхронизацию лучше приостановить. diff --git a/catalog.py b/catalog.py new file mode 100644 index 0000000..c3b17e7 --- /dev/null +++ b/catalog.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +"""Каталогизатор библиотеки — точка входа. + + python catalog.py build полный цикл: обход, анализ, отчёты + python catalog.py search "запрос" поиск по каталогу +""" +import sys + +from catalogizer.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/catalog_gui.py b/catalog_gui.py new file mode 100644 index 0000000..165ab9c --- /dev/null +++ b/catalog_gui.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +"""@file catalog_gui.py +@brief Точка входа оконного каталогизатора (её же упаковывает PyInstaller). +""" + +import sys + +from catalogizer.app import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/catalogizer/__init__.py b/catalogizer/__init__.py new file mode 100644 index 0000000..3c9279a --- /dev/null +++ b/catalogizer/__init__.py @@ -0,0 +1,2 @@ +"""Каталогизатор технической литературы.""" +__version__ = "1.0.0" diff --git a/catalogizer/analyze.py b/catalogizer/analyze.py new file mode 100644 index 0000000..f9af282 --- /dev/null +++ b/catalogizer/analyze.py @@ -0,0 +1,75 @@ +"""Второй проход: тезисы, ключевые слова, описания (этап «analyze»). + +IDF считается по всей библиотеке, поэтому проход выполняется после того, +как тексты всех документов уже лежат в базе. +""" +from __future__ import annotations + +import json +import time +from collections import Counter + +from . import db +from .summarize import Analyzer, describe +from .textutil import stem, tokens + + +def build_df(con) -> tuple[dict[str, int], int]: + """Документная частота каждой основы слова по всей библиотеке.""" + df: Counter = Counter() + n = 0 + for row in con.execute("SELECT terms FROM docs WHERE terms IS NOT NULL"): + try: + terms = json.loads(row["terms"]) + except (TypeError, ValueError): + continue + n += 1 + df.update(terms.keys()) + return dict(df), n + + +def analyze(con, *, force: bool = False, quiet: bool = False, + progress=None) -> dict: + df, n_docs = build_df(con) + an = Analyzer(df, n_docs) + + where = "" if force else " WHERE analyzed = 0" + ids = [r["id"] for r in con.execute("SELECT id FROM docs" + where)] + total = len(ids) + if not quiet: + print("Документов в базе: %d, к анализу: %d" % (n_docs, total), flush=True) + if progress: + progress(0, total, "Анализ: %d документов" % total) + + started = time.time() + for i, doc_id in enumerate(ids, 1): + row = con.execute( + "SELECT title, body, toc, terms, lang, kind, pages, scanned, filename" + " FROM docs WHERE id = ?", (doc_id,)).fetchone() + body = row["body"] or "" + try: + terms = Counter(json.loads(row["terms"] or "{}")) + toc = json.loads(row["toc"] or "[]") + except ValueError: + terms, toc = Counter(), [] + + keywords = an.keywords(terms, body, row["title"] or "", toc) + theses = an.theses(body, terms) if not row["scanned"] else [] + if not theses and toc: + # Для сканов и справок без связного текста тезисы берём из оглавления. + theses = [t for t in toc[:8] if len(t) > 8] + description = describe(row["title"] or row["filename"], row["kind"] or "", + row["lang"] or "?", row["pages"], keywords, + theses, not row["scanned"]) + stems = " ".join(stem(t) for t in tokens(body[:20_000])) + db.save_summary(con, doc_id, description, theses, keywords, stems) + + if i % 100 == 0: + con.commit() + if not quiet: + print(" %d/%d" % (i, total), flush=True) + if progress: + progress(i, total, "Анализ %d/%d" % (i, total)) + con.commit() + return {"analyzed": total, "corpus": n_docs, + "seconds": round(time.time() - started, 1)} diff --git a/catalogizer/app.py b/catalogizer/app.py new file mode 100644 index 0000000..f78683b --- /dev/null +++ b/catalogizer/app.py @@ -0,0 +1,58 @@ +"""@file app.py +@brief Точка входа графического приложения каталогизатора. + +Собирает QApplication и главное окно. С ключом ``--check`` окно строится, +но не показывается: так проверяется собранный exe, где traceback показать +некому. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication + +from .ui.main_window import MainWindow + + +def _set_windows_app_id() -> None: + """@brief Закрепляет собственную группу приложения на панели задач Windows.""" + if sys.platform != "win32": + return + try: + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + "SET.Catalogizer") + except (AttributeError, OSError): + pass + + +def main(argv: list[str] | None = None) -> int: + """@brief Запускает окно каталогизатора. + + @param argv Аргументы без имени программы: путь к папке и/или ``--check``. + @return Код выхода приложения. + """ + argv = list(sys.argv[1:] if argv is None else argv) + check = "--check" in argv + folders = [a for a in argv if not a.startswith("-")] + + _set_windows_app_id() + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) + application = QApplication(sys.argv[:1]) + application.setApplicationName("Каталогизатор") + application.setOrganizationName("SET") + + window = MainWindow(Path(folders[0]) if folders else None) + if check: + # Самопроверка сборки: окно строится целиком и приложение выходит. + application.processEvents() + titles = [window.tabs.tabText(i) for i in range(window.tabs.count())] + print("Каталогизатор: вкладок", len(titles), "-", ", ".join(titles)) + return 0 + window.show() + return application.exec() diff --git a/catalogizer/cli.py b/catalogizer/cli.py new file mode 100644 index 0000000..e777e29 --- /dev/null +++ b/catalogizer/cli.py @@ -0,0 +1,383 @@ +"""Командный интерфейс каталогизатора.""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from . import analyze as analyze_mod +from . import config, db, report, scanner, search as search_mod + + +def _utf8_console() -> None: + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + +def _fmt_size(n: int) -> str: + return report._size(n) + + +def resolve_root(args) -> Path: + """Папка с документами: позиционный аргумент, --root или прошлый запуск.""" + given = (getattr(args, "catalog", None) or getattr(args, "folder", None) + or getattr(args, "root", None)) + return Path(given).expanduser() if given else config.current_root() + + +def open_catalog(args, root: Path | None = None): + """Подключение к базе того каталога, о котором идёт речь.""" + if root is None: + root = resolve_root(args) + db_path, out_dir = config.paths_for(root) + if args.db: + db_path = Path(args.db) + if not getattr(args, "out", None): + args.out = str(out_dir) + return db.connect(db_path), root + + +# -------------------------------------------------------------------------- +# Команды +# -------------------------------------------------------------------------- + +def cmd_scan(args) -> int: + root = resolve_root(args) + if not root.is_dir(): + print("Папка не найдена: %s" % root) + return 2 + con, root = open_catalog(args, root) + config.remember(root) + if args.retry_broken: + con.execute("DELETE FROM attempts") + con.commit() + print("Папка: %s" % root) + exts = None + if args.ext: + exts = {"." + e.lower().lstrip(".") for e in args.ext} + stats = scanner.scan(con, root, max_pages=args.max_pages, + jobs=args.jobs, force=args.force, limit=args.limit, + extensions=exts) + print("Разбор завершён: обработано %(processed)d, с ошибками %(errors)d, " + "пропущено %(skipped)d, за %(seconds)s с" % stats) + con.close() + return 0 + + +def cmd_analyze(args) -> int: + con, _ = open_catalog(args) + stats = analyze_mod.analyze(con, force=args.force) + print("Анализ завершён: %(analyzed)d документов за %(seconds)s с" % stats) + con.close() + return 0 + + +def cmd_report(args) -> int: + con, _ = open_catalog(args) + paths = report.build_all(con, args.out) + for kind, p in paths.items(): + print("%-5s %s" % (kind, p)) + con.close() + return 0 + + +def cmd_build(args) -> int: + rc = cmd_scan(args) or cmd_analyze(args) or cmd_report(args) + return rc + + +def cmd_search(args) -> int: + con, _ = open_catalog(args) + rows = search_mod.search(con, " ".join(args.query), limit=args.limit, + ext=args.ext_filter, folder=args.section, + lang=args.lang, year_from=args.year_from, + kind=args.kind) + if args.json: + print(json.dumps(rows, ensure_ascii=False, indent=1)) + con.close() + return 0 + if not rows: + print("Ничего не найдено.") + con.close() + return 1 + for i, r in enumerate(rows, 1): + facts = [r["ext"].lstrip("."), r["kind"] or ""] + if r["author"]: + facts.append(r["author"]) + if r["year"]: + facts.append(str(r["year"])) + if r["pages"]: + facts.append("%d с." % r["pages"]) + print("\n%2d. %s" % (i, r["title"])) + print(" %s" % " · ".join(f for f in facts if f)) + if r["description"]: + print(" %s" % r["description"][:300]) + if r["snip"]: + print(" … %s" % " ".join(r["snip"].split())[:280]) + if r["user_keywords"]: + print(" свои слова: %s" % ", ".join(r["user_keywords"])) + if r["keywords"]: + print(" ключевые слова: %s" % ", ".join(r["keywords"][:8])) + print(" %s" % r["path"]) + con.close() + return 0 + + +def cmd_show(args) -> int: + con, _ = open_catalog(args) + if args.target.isdigit(): + row = con.execute("SELECT * FROM docs WHERE id = ?", + (int(args.target),)).fetchone() + else: + row = con.execute( + "SELECT * FROM docs WHERE path LIKE ? OR rel_path LIKE ?" + " OR filename LIKE ? LIMIT 1", + ("%" + args.target + "%",) * 3).fetchone() + if row is None: + print("Документ не найден.") + return 1 + print(row["title"]) + print("=" * min(len(row["title"]), 80)) + for label, key in (("Автор", "author"), ("Год", "year"), ("Тип", "kind"), + ("Язык", "lang"), ("Страниц", "pages"), ("Путь", "path")): + if row[key]: + print("%-9s %s" % (label + ":", row[key])) + print("%-9s %s" % ("Размер:", _fmt_size(row["size"]))) + if row["error"]: + print("%-9s %s" % ("Ошибка:", row["error"])) + print("\nОписание:\n %s" % (row["description"] or "—")) + theses = json.loads(row["theses"] or "[]") + if theses: + print("\nТезисы:") + for t in theses: + print(" • %s" % t) + kws = json.loads(row["keywords"] or "[]") + if kws: + print("\nКлючевые слова: %s" % ", ".join(kws)) + toc = json.loads(row["toc"] or "[]") + if toc and args.toc: + print("\nОглавление:") + for t in toc[:60]: + print(" - %s" % t) + con.close() + return 0 + + +def _find_doc(con, target: str): + """Документ по числовому id или по части имени/пути.""" + if target.isdigit(): + return con.execute("SELECT * FROM docs WHERE id = ?", + (int(target),)).fetchone() + return con.execute( + "SELECT * FROM docs WHERE path LIKE ? OR rel_path LIKE ?" + " OR filename LIKE ? OR title LIKE ? LIMIT 1", + ("%" + target + "%",) * 4).fetchone() + + +def cmd_tag(args) -> int: + """Свои ключевые слова документа: показать, добавить, убрать, заменить.""" + con, _ = open_catalog(args) + if args.all: + words = db.all_user_keywords(con) + print("\n".join(words) if words else "Своих ключевых слов пока нет.") + con.close() + return 0 + + row = _find_doc(con, args.target) + if row is None: + print("Документ не найден: %s" % args.target) + con.close() + return 1 + + current = db.get_user_keywords(con, row["path"]) + words = [w for chunk in args.words for w in chunk.split(",") if w.strip()] + if args.remove: + drop = {w.strip().lower() for w in words} + new = [w for w in current if w.lower() not in drop] + elif args.set: + new = words + elif words: + new = current + words + else: + new = current # без слов — просто показать + + if new != current: + new = db.set_user_keywords(con, row["path"], new) + print("%s\n свои ключевые слова: %s" + % (row["title"], ", ".join(new) if new else "—")) + con.close() + return 0 + + +def cmd_stats(args) -> int: + con, _ = open_catalog(args) + total = con.execute("SELECT COUNT(*) c, SUM(size) s FROM docs").fetchone() + if not total["c"]: + print("База пуста — выполните: python catalog.py build") + return 1 + print("Корень: %s" % db.get_meta(con, "root")) + print("Обновлено: %s" % db.get_meta(con, "last_scan")) + print("Документов: %d (%s)" % (total["c"], _fmt_size(total["s"] or 0))) + print("С тезисами: %d" % con.execute( + "SELECT COUNT(*) c FROM docs WHERE analyzed = 1 AND scanned = 0").fetchone()["c"]) + print("Сканов без текста: %d" % con.execute( + "SELECT COUNT(*) c FROM docs WHERE scanned = 1").fetchone()["c"]) + print("С ошибками: %d" % con.execute( + "SELECT COUNT(*) c FROM docs WHERE error <> ''").fetchone()["c"]) + + print("\nПо форматам:") + for r in con.execute("SELECT ext, COUNT(*) c, SUM(size) s FROM docs" + " GROUP BY ext ORDER BY c DESC"): + print(" %-6s %5d %s" % (r["ext"], r["c"], _fmt_size(r["s"] or 0))) + + print("\nКрупнейшие разделы:") + for r in con.execute("SELECT folder, COUNT(*) c FROM docs GROUP BY folder" + " ORDER BY c DESC LIMIT 15"): + print(" %5d %s" % (r["c"], r["folder"][:70])) + + print("\nЧастые темы библиотеки:") + from collections import Counter + cnt: Counter = Counter() + for r in con.execute("SELECT keywords FROM docs WHERE keywords IS NOT NULL"): + cnt.update(json.loads(r["keywords"])[:8]) + print(" " + ", ".join("%s (%d)" % (w, n) for w, n in cnt.most_common(25))) + con.close() + return 0 + + +# -------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="catalog", + description="Каталогизатор технической библиотеки: разбор файлов, " + "краткие описания, тезисы и поиск по ключевым словам.") + p.add_argument("-c", "--catalog", default=None, metavar="ПАПКА", + help="папка каталога для search/report/stats/show " + "(по умолчанию — обработанная последней)") + p.add_argument("--db", default=None, + help="файл базы каталога (по умолчанию свой для каждой папки)") + sub = p.add_subparsers(dest="cmd", required=False) + + def add_scan_args(sp): + sp.add_argument("folder", nargs="?", default=None, + help="папка с документами (по умолчанию из config.py)") + sp.add_argument("--root", default=None, + help="то же самое, что и позиционная папка") + sp.add_argument("--max-pages", type=int, default=config.MAX_PDF_PAGES, + help="сколько страниц читать из документа") + sp.add_argument("--jobs", type=int, default=4, + help="потоков разбора (HDD — 2-4, SSD — 8-16)") + sp.add_argument("--limit", type=int, default=None, + help="разобрать не более N новых файлов (для пробы)") + sp.add_argument("--ext", nargs="*", default=None, + help="ограничить форматы, например: pdf djvu") + sp.add_argument("--force", action="store_true", + help="перечитать все файлы, а не только изменившиеся") + sp.add_argument("--retry-broken", action="store_true", + help="снова попробовать файлы, помеченные как битые") + + sp = sub.add_parser("scan", help="обойти библиотеку и извлечь тексты") + add_scan_args(sp) + sp.set_defaults(func=cmd_scan) + + sp = sub.add_parser("analyze", help="построить описания, тезисы, ключевые слова") + sp.add_argument("--force", action="store_true", help="пересчитать всё заново") + sp.set_defaults(func=cmd_analyze) + + sp = sub.add_parser("report", help="выгрузить каталог (HTML, MD, JSON, CSV)") + sp.add_argument("folder", nargs="?", default=None, + help="папка каталога (по умолчанию — обработанная последней)") + sp.add_argument("--out", default=None, + help="папка выгрузки (по умолчанию out/ этого каталога)") + sp.set_defaults(func=cmd_report) + + sp = sub.add_parser("build", help="полный цикл: scan + analyze + report") + add_scan_args(sp) + sp.add_argument("--out", default=None, + help="папка выгрузки (по умолчанию out/ этого каталога)") + sp.set_defaults(func=cmd_build) + + sp = sub.add_parser("search", help="поиск по каталогу") + sp.add_argument("query", nargs="+", help="слова запроса; \"фраза\" — точная") + sp.add_argument("-n", "--limit", type=int, default=20) + sp.add_argument("--ext", dest="ext_filter", default=None, help="формат: pdf") + sp.add_argument("--section", "--folder", dest="section", default=None, + help="искать только в разделе (подстрока пути)") + sp.add_argument("--lang", default=None, choices=["ru", "en"]) + sp.add_argument("--kind", default=None, help="тип: книга, даташит, учебник…") + sp.add_argument("--year-from", type=int, default=None) + sp.add_argument("--json", action="store_true", help="вывод в JSON") + sp.set_defaults(func=cmd_search) + + sp = sub.add_parser("show", help="карточка документа по id или имени") + sp.add_argument("target") + sp.add_argument("--toc", action="store_true", help="показать оглавление") + sp.set_defaults(func=cmd_show) + + sp = sub.add_parser("tag", help="свои ключевые слова документа") + sp.add_argument("target", nargs="?", default="", + help="id документа или часть имени/названия") + sp.add_argument("words", nargs="*", default=[], + help="слова через пробел или запятую") + sp.add_argument("--remove", action="store_true", help="убрать эти слова") + sp.add_argument("--set", action="store_true", + help="заменить список целиком (без слов — очистить)") + sp.add_argument("--all", action="store_true", + help="показать все свои ключевые слова каталога") + sp.set_defaults(func=cmd_tag) + + sp = sub.add_parser("gui", help="графическое окно (по умолчанию)") + sp.add_argument("folder", nargs="?", default=None, + help="папка, открытая при запуске") + sp.set_defaults(func=cmd_gui) + + sp = sub.add_parser("stats", help="сводка по каталогу") + sp.add_argument("folder", nargs="?", default=None, + help="папка каталога (по умолчанию — обработанная последней)") + sp.set_defaults(func=cmd_stats) + return p + + +def cmd_gui(args) -> int: + """Графическое окно каталогизатора.""" + try: + from .app import main as gui_main + except ImportError: + print("Не установлен PySide6: python -m pip install PySide6") + return 2 + folder = getattr(args, "folder", None) + return gui_main([folder] if folder else []) + + +def interactive() -> list[str]: + """Запуск без аргументов: открываем окно, а без PySide6 — спрашиваем папку.""" + try: + import PySide6 # noqa: F401 — проверка наличия графической библиотеки + except ImportError: + pass + else: + return ["gui"] + print("Каталогизатор документов.") + print("Укажите папку с документами (Enter — %s):" % config.current_root()) + try: + folder = input("> ").strip().strip('"') + except (EOFError, KeyboardInterrupt): + return ["--help"] + return ["build", folder] if folder else ["build"] + + +def main(argv: list[str] | None = None) -> int: + _utf8_console() + argv = list(sys.argv[1:] if argv is None else argv) + if not argv: + argv = interactive() + args = build_parser().parse_args(argv) + if not getattr(args, "func", None): + build_parser().print_help() + return 2 + return args.func(args) diff --git a/catalogizer/config.py b/catalogizer/config.py new file mode 100644 index 0000000..425923c --- /dev/null +++ b/catalogizer/config.py @@ -0,0 +1,119 @@ +"""Настройки каталогизатора.""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def _default_root() -> Path: + """Папка, предлагаемая при первом запуске. + + Переопределяется переменной окружения CATALOG_ROOT или аргументом + команды. Жёсткий путь к библиотеке действует только там, где он есть, + иначе берётся домашняя папка «Документы» — так программа осмысленно + стартует и на macOS, и на Linux. + """ + given = os.environ.get("CATALOG_ROOT") + if given: + return Path(given).expanduser() + library = Path(r"E:\Yandex.Disk\литература") + if library.is_dir(): + return library + for name in ("Documents", "Документы"): + candidate = Path.home() / name + if candidate.is_dir(): + return candidate + return Path.home() + + +DEFAULT_ROOT = _default_root() + +def _app_dir() -> Path: + """Папка, где программа держит базы каталогов и отчёты. + + Из исходников — корень проекта. В собранной программе — рядом с самим + файлом, кроме macOS: там ``.app`` считается неизменяемым (и может + запускаться из карантина), поэтому данные уходят в + ``~/Library/Application Support``. + """ + if not getattr(sys, "frozen", False): + return Path(__file__).resolve().parent.parent + if sys.platform == "darwin": + target = Path.home() / "Library" / "Application Support" / "Catalogizer" + target.mkdir(parents=True, exist_ok=True) + return target + return Path(sys.executable).resolve().parent + + +APP_DIR = _app_dir() +DB_PATH = APP_DIR / "catalog.db" # база «главной» библиотеки +OUT_DIR = APP_DIR / "out" # отчёты по ней +CATALOGS_DIR = APP_DIR / "catalogs" # базы остальных обработанных папок +CURRENT_FILE = APP_DIR / ".current" # последняя обработанная папка + +# Расширения, которые считаем «документами» библиотеки. +DOC_EXTENSIONS = { + ".pdf", ".djvu", ".djv", + ".doc", ".docx", ".rtf", ".odt", + ".chm", ".epub", ".fb2", + ".ppt", ".pptx", ".xls", ".xlsx", + ".txt", ".md", +} + +# Каталоги, которые не имеет смысла разбирать (SDK, репозитории, сборки). +SKIP_DIR_NAMES = { + ".git", ".svn", "node_modules", "__pycache__", ".idea", ".vscode", + "Debug", "Release", "obj", "build", "cmake-build-debug", +} + +# Мусорные txt/md из состава SDK и репозиториев — не тащим в каталог. +NOISE_FILENAMES = { + "readme.txt", "readme.md", "license.txt", "license.md", "licence.txt", + "changelog.md", "changelog.txt", "contributing.md", "code_of_conduct.md", + "manifest.txt", "makefile.txt", "notice.txt", "authors.md", +} + +# Ограничения разбора. +MAX_PDF_PAGES = 20 # сколько страниц читаем из начала документа +MAX_TEXT_CHARS = 30_000 # сколько символов текста храним в базе +MIN_TEXT_CHARS = 200 # меньше — считаем, что текстового слоя нет (скан) +MIN_FILE_SIZE = 2_048 # файлы мельче игнорируем +MAX_PDF_BYTES = 400_000_000 # PDF крупнее не разбираем +PDF_TIME_BUDGET = 25 # секунд на один PDF: повреждённые чинятся вечно + +# Сколько тезисов и ключевых слов делать на документ. +N_THESES = 5 +N_KEYWORDS = 12 + + +def _slug(root: Path) -> str: + """Короткое имя каталога, однозначно привязанное к пути папки.""" + import hashlib + import re + name = re.sub(r"[^\w\-]+", "_", root.name, flags=re.U).strip("_") or "catalog" + tail = hashlib.sha1(str(root).lower().encode("utf-8")).hexdigest()[:6] + return "%s-%s" % (name[:40], tail) + + +def paths_for(root: Path) -> tuple[Path, Path]: + """База и папка отчётов для указанной папки с документами.""" + root = Path(root).resolve() + if root == Path(DEFAULT_ROOT).resolve(): + return DB_PATH, OUT_DIR + slug = _slug(root) + return CATALOGS_DIR / (slug + ".db"), OUT_DIR / slug + + +def remember(root: Path) -> None: + """Запоминает последнюю обработанную папку — её и берут search/report.""" + CURRENT_FILE.write_text(str(Path(root).resolve()), encoding="utf-8") + + +def current_root() -> Path: + """Папка, с которой работали в последний раз.""" + try: + value = CURRENT_FILE.read_text(encoding="utf-8").strip() + except OSError: + return Path(DEFAULT_ROOT) + return Path(value) if value else Path(DEFAULT_ROOT) diff --git a/catalogizer/db.py b/catalogizer/db.py new file mode 100644 index 0000000..6a1127c --- /dev/null +++ b/catalogizer/db.py @@ -0,0 +1,234 @@ +"""Хранилище каталога: SQLite + полнотекстовый индекс FTS5.""" +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +from . import config + +SCHEMA = """ +PRAGMA journal_mode = WAL; + +CREATE TABLE IF NOT EXISTS docs ( + id INTEGER PRIMARY KEY, + path TEXT UNIQUE NOT NULL, -- абсолютный путь + rel_path TEXT NOT NULL, -- путь от корня библиотеки + folder TEXT NOT NULL, -- раздел (папка) библиотеки + filename TEXT NOT NULL, + ext TEXT NOT NULL, + size INTEGER NOT NULL, + mtime REAL NOT NULL, + title TEXT, + author TEXT, + year INTEGER, + pages INTEGER, + lang TEXT, + kind TEXT, + text_chars INTEGER DEFAULT 0, + scanned INTEGER DEFAULT 0, -- 1 = нет текстового слоя + error TEXT, + body TEXT, -- извлечённый текст (обрезанный) + toc TEXT, -- оглавление, JSON-список + terms TEXT, -- частоты основ, JSON + description TEXT, + theses TEXT, -- JSON-список тезисов + keywords TEXT, -- JSON-список ключевых слов + stems TEXT, -- основы слов текста для поиска + indexed_at TEXT, + analyzed INTEGER DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_docs_folder ON docs(folder); +CREATE INDEX IF NOT EXISTS idx_docs_ext ON docs(ext); +CREATE INDEX IF NOT EXISTS idx_docs_analyzed ON docs(analyzed); + +CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5( + title, keywords, description, body, stems, path, + tokenize = 'unicode61 remove_diacritics 2' +); + +CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT); + +-- Ключевые слова, добавленные вручную. Хранятся по пути файла, а не по id: +-- так они переживают перестроение каталога и повторный разбор папки. +CREATE TABLE IF NOT EXISTS user_keywords ( + path TEXT PRIMARY KEY, + keywords TEXT NOT NULL, -- JSON-список + changed TEXT +); + +-- Счётчик попыток разбора. Нужен, чтобы файл, на котором парсер аварийно +-- завершает весь процесс, не блокировал обход при следующем запуске. +CREATE TABLE IF NOT EXISTS attempts (path TEXT PRIMARY KEY, n INTEGER NOT NULL); +""" + + +#: Колонки, добавленные после первых версий базы. Старые каталоги +#: дополняются на месте, чтобы их не пришлось строить заново. +MIGRATIONS = (("docs", "stems", "TEXT"),) + + +def connect(db_path: Path | None = None, + create: bool = True) -> sqlite3.Connection: + """Подключение к базе каталога. + + @param create Создавать базу, если её ещё нет. Окно читает каталоги + с ``create=False``: просмотр не должен плодить пустые базы рядом + с программой. + """ + path = Path(db_path or config.DB_PATH) + if not create and not path.exists(): + raise FileNotFoundError("каталог ещё не построен: %s" % path) + path.parent.mkdir(parents=True, exist_ok=True) + con = sqlite3.connect(path, timeout=60) + con.row_factory = sqlite3.Row + con.executescript(SCHEMA) + for table, column, decl in MIGRATIONS: + have = {r["name"] for r in con.execute("PRAGMA table_info(%s)" % table)} + if column not in have: + con.execute("ALTER TABLE %s ADD COLUMN %s %s" % (table, column, decl)) + con.commit() + return con + + +def get_state(con: sqlite3.Connection) -> dict[str, tuple[float, int]]: + """Соответствие путь -> (mtime, size) для инкрементального обхода.""" + return {r["path"]: (r["mtime"], r["size"]) + for r in con.execute("SELECT path, mtime, size FROM docs")} + + +def upsert_doc(con: sqlite3.Connection, rec: dict) -> None: + cols = ("path", "rel_path", "folder", "filename", "ext", "size", "mtime", + "title", "author", "year", "pages", "lang", "kind", "text_chars", + "scanned", "error", "body", "toc", "terms", "indexed_at") + values = [rec.get(c) for c in cols] + con.execute( + "INSERT INTO docs (%s, analyzed) VALUES (%s, 0) " + "ON CONFLICT(path) DO UPDATE SET %s, analyzed = 0" + % (", ".join(cols), + ", ".join("?" * len(cols)), + ", ".join("%s = excluded.%s" % (c, c) for c in cols[1:])), + values, + ) + + +def save_summary(con: sqlite3.Connection, doc_id: int, description: str, + theses: list[str], keywords: list[str], stems: str) -> None: + con.execute( + "UPDATE docs SET description = ?, theses = ?, keywords = ?, stems = ?, " + "analyzed = 1 WHERE id = ?", + (description, json.dumps(theses, ensure_ascii=False), + json.dumps(keywords, ensure_ascii=False), stems, doc_id)) + reindex_doc(con, doc_id) + + +def reindex_doc(con: sqlite3.Connection, doc_id: int) -> None: + """Перестраивает строку полнотекстового индекса для одного документа. + + Вызывается и после анализа, и после правки ключевых слов вручную — + иначе добавленное слово не находилось бы поиском. + """ + row = con.execute( + "SELECT d.title, d.body, d.path, d.description, d.keywords, d.stems," + " u.keywords AS mine" + " FROM docs d LEFT JOIN user_keywords u ON u.path = d.path" + " WHERE d.id = ?", (doc_id,)).fetchone() + if row is None: + return + words = _as_list(row["keywords"]) + _as_list(row["mine"]) + con.execute("DELETE FROM docs_fts WHERE rowid = ?", (doc_id,)) + con.execute( + "INSERT INTO docs_fts (rowid, title, keywords, description, body, stems," + " path) VALUES (?, ?, ?, ?, ?, ?, ?)", + (doc_id, row["title"] or "", " ".join(words), row["description"] or "", + (row["body"] or "")[:20_000], row["stems"] or "", row["path"])) + + +def _as_list(value: str | None) -> list[str]: + try: + return list(json.loads(value or "[]")) + except (TypeError, ValueError): + return [] + + +def get_user_keywords(con: sqlite3.Connection, path: str) -> list[str]: + """Ключевые слова, добавленные к документу вручную.""" + row = con.execute("SELECT keywords FROM user_keywords WHERE path = ?", + (path,)).fetchone() + return _as_list(row["keywords"]) if row else [] + + +def set_user_keywords(con: sqlite3.Connection, path: str, + keywords: list[str]) -> list[str]: + """Заменяет свои ключевые слова документа и обновляет поисковый индекс. + + @param keywords Новый список; пустой список удаляет запись. + @return Сохранённый список без повторов и пустых значений. + """ + from datetime import datetime + + clean: list[str] = [] + for word in keywords: + word = " ".join(str(word).split()) + if word and word.lower() not in {w.lower() for w in clean}: + clean.append(word) + if clean: + con.execute( + "INSERT INTO user_keywords (path, keywords, changed)" + " VALUES (?, ?, ?) ON CONFLICT(path) DO UPDATE SET" + " keywords = excluded.keywords, changed = excluded.changed", + (path, json.dumps(clean, ensure_ascii=False), + datetime.now().isoformat(timespec="seconds"))) + else: + con.execute("DELETE FROM user_keywords WHERE path = ?", (path,)) + row = con.execute("SELECT id FROM docs WHERE path = ?", (path,)).fetchone() + if row: + reindex_doc(con, row["id"]) + con.commit() + return clean + + +def all_user_keywords(con: sqlite3.Connection) -> list[str]: + """Все свои ключевые слова каталога — для подсказок при вводе.""" + seen: dict[str, None] = {} + for row in con.execute("SELECT keywords FROM user_keywords"): + for word in _as_list(row["keywords"]): + seen.setdefault(word, None) + return sorted(seen, key=str.lower) + + +def purge_missing(con: sqlite3.Connection, alive: set[str]) -> int: + """Удаляет из базы записи о файлах, которых больше нет на диске.""" + gone = [r["id"] for r in con.execute("SELECT id, path FROM docs") + if r["path"] not in alive] + for i in gone: + con.execute("DELETE FROM docs WHERE id = ?", (i,)) + con.execute("DELETE FROM docs_fts WHERE rowid = ?", (i,)) + return len(gone) + + +def set_meta(con: sqlite3.Connection, key: str, value: str) -> None: + con.execute("INSERT INTO meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value)) + + +def get_meta(con: sqlite3.Connection, key: str, default: str = "") -> str: + row = con.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone() + return row["value"] if row else default + + +def bump_attempt(con: sqlite3.Connection, path: str) -> None: + con.execute("INSERT INTO attempts (path, n) VALUES (?, 1) " + "ON CONFLICT(path) DO UPDATE SET n = n + 1", (path,)) + + +def clear_attempt(con: sqlite3.Connection, path: str) -> None: + con.execute("DELETE FROM attempts WHERE path = ?", (path,)) + + +def failed_paths(con: sqlite3.Connection, max_tries: int = 3) -> set[str]: + """Файлы, разбор которых уже несколько раз обрывал процесс.""" + return {r["path"] for r in + con.execute("SELECT path FROM attempts WHERE n >= ?", (max_tries,))} diff --git a/catalogizer/extract.py b/catalogizer/extract.py new file mode 100644 index 0000000..d12f466 --- /dev/null +++ b/catalogizer/extract.py @@ -0,0 +1,349 @@ +"""Извлечение текста и метаданных из файлов библиотеки.""" +from __future__ import annotations + +import os +import re +import shutil +import struct +import subprocess +import time +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + +from . import config +from .textutil import clean_text, pretty_name + +try: # PyMuPDF ставится как pymupdf, исторический импорт — fitz + import pymupdf as fitz +except ImportError: # pragma: no cover + try: + import fitz + except ImportError: + fitz = None + +if fitz is not None: + # Повреждённые PDF иначе засыпают консоль сообщениями MuPDF о починке. + try: + fitz.TOOLS.mupdf_display_errors(False) + except AttributeError: # pragma: no cover — другая версия PyMuPDF + pass + +DJVUTXT = shutil.which("djvutxt") +ANTIWORD = shutil.which("antiword") + + +def long_path(p: str | Path) -> str: + """Путь в форме, которую Windows принимает при длине > 260 символов.""" + s = os.fspath(p) + if os.name == "nt" and not s.startswith("\\\\?\\"): + s = "\\\\?\\" + os.path.abspath(s) + return s + + +def _read_bytes(path: Path, limit: int | None = None) -> bytes: + with open(long_path(path), "rb") as f: + return f.read(limit) if limit else f.read() + + +def _decode(data: bytes) -> str: + """Декодирование текста без chardet: пробуем типовые кодировки.""" + for enc in ("utf-8-sig", "utf-8"): + try: + return data.decode(enc) + except UnicodeDecodeError: + pass + if data[:2] in (b"\xff\xfe", b"\xfe\xff"): + try: + return data.decode("utf-16") + except UnicodeDecodeError: + pass + best, best_score = "", -1.0 + for enc in ("cp1251", "koi8-r", "cp866", "cp1252", "latin-1"): + try: + t = data.decode(enc) + except UnicodeDecodeError: + continue + good = sum(ch.isalpha() or ch.isspace() or ch.isdigit() for ch in t) + score = good / max(len(t), 1) + if score > best_score: + best, best_score = t, score + return best + + +# -------------------------------------------------------------------------- +# Форматы +# -------------------------------------------------------------------------- + +def _from_pdf(path: Path, max_pages: int) -> dict: + """Текст и метаданные PDF. + + Файл читается средствами Python и передаётся MuPDF потоком: так работают + длинные и нестандартные пути, а сам разбор ограничен по времени — + повреждённый PDF MuPDF пытается чинить страницу за страницей и может + занять минуты. + """ + if fitz is None: + return {"error": "PyMuPDF не установлен (pip install pymupdf)"} + if path.stat().st_size > config.MAX_PDF_BYTES: + return {"error": "PDF больше %d МБ — пропущен" + % (config.MAX_PDF_BYTES // 1_000_000)} + started = time.time() + out: dict = {} + data = _read_bytes(path) + with fitz.open(stream=data, filetype="pdf") as doc: + if doc.needs_pass: + return {"pages": doc.page_count, "error": "PDF защищён паролем"} + out["pages"] = doc.page_count + meta = doc.metadata or {} + out["title"] = (meta.get("title") or "").strip() + out["author"] = (meta.get("author") or "").strip() + # Оглавление — концентрированный источник о содержании книги. + try: + toc = doc.get_toc() + except Exception: + toc = [] + if toc: + out["toc"] = [t[1].strip() for t in toc[:120] if t[1].strip()] + parts = [] + for i in range(min(max_pages, doc.page_count)): + try: + parts.append(doc[i].get_text("text")) + except Exception: + continue + if sum(len(p) for p in parts) > config.MAX_TEXT_CHARS * 1.5: + break + out["text"] = "\n".join(parts) + return out + + +def _djvu_pages(path: Path) -> int | None: + """Число страниц из заголовка DJVU (чанк DIRM) без внешних утилит.""" + try: + head = _read_bytes(path, 4096) + except OSError: + return None + idx = head.find(b"DIRM") + if idx > 0 and len(head) > idx + 11: + try: + n = struct.unpack(">H", head[idx + 9: idx + 11])[0] + except struct.error: + return None + # Каталог DIRM содержит и служебные компоненты — берём только + # правдоподобные значения. + return n if 1 <= n <= 5000 else None + return 1 if b"INFO" in head else None + + +def _from_djvu(path: Path, max_pages: int) -> dict: + out: dict = {"pages": _djvu_pages(path)} + if DJVUTXT: + try: + r = subprocess.run( + [DJVUTXT, "--page=1-%d" % max_pages, str(path)], + capture_output=True, timeout=120, + ) + out["text"] = _decode(r.stdout) + except (subprocess.SubprocessError, OSError) as e: + out["error"] = "djvutxt: %s" % e + else: + out["error"] = "нет djvutxt (DjVuLibre) — текст DJVU не извлечён" + return out + + +def _zip_xml_text(path: Path, members: list[str], tags: tuple[str, ...]) -> str: + parts: list[str] = [] + with zipfile.ZipFile(long_path(path)) as z: + names = z.namelist() + wanted = [n for n in names if any(re.fullmatch(m, n) for m in members)] + for n in sorted(wanted): + try: + root = ET.fromstring(z.read(n)) + except (ET.ParseError, KeyError): + continue + for el in root.iter(): + tag = el.tag.rsplit("}", 1)[-1] + if tag in tags and el.text: + parts.append(el.text) + if tag in ("p", "br", "tab"): + parts.append("\n") + if sum(len(p) for p in parts) > config.MAX_TEXT_CHARS * 1.5: + break + return "".join(parts) + + +def _ooxml_meta(path: Path) -> dict: + try: + with zipfile.ZipFile(long_path(path)) as z: + root = ET.fromstring(z.read("docProps/core.xml")) + except (KeyError, zipfile.BadZipFile, ET.ParseError, OSError): + return {} + vals = {} + for el in root: + tag = el.tag.rsplit("}", 1)[-1] + if el.text: + vals[tag] = el.text.strip() + return {"title": vals.get("title", ""), "author": vals.get("creator", "")} + + +def _from_docx(path: Path) -> dict: + out = _ooxml_meta(path) + out["text"] = _zip_xml_text(path, [r"word/document\.xml"], ("t",)) + return out + + +def _from_pptx(path: Path) -> dict: + out = _ooxml_meta(path) + out["text"] = _zip_xml_text(path, [r"ppt/slides/slide\d+\.xml"], ("t",)) + return out + + +def _from_xlsx(path: Path) -> dict: + out = _ooxml_meta(path) + out["text"] = _zip_xml_text(path, [r"xl/sharedStrings\.xml"], ("t",)) + return out + + +def _from_binary_office(path: Path) -> dict: + """Word 97 / Excel 97 / PowerPoint 97: antiword, иначе выборка строк.""" + if ANTIWORD and path.suffix.lower() == ".doc": + try: + r = subprocess.run([ANTIWORD, "-m", "UTF-8.txt", str(path)], + capture_output=True, timeout=90) + if r.stdout: + return {"text": _decode(r.stdout)} + except (subprocess.SubprocessError, OSError): + pass + data = _read_bytes(path, 2_000_000) + text = _decode(data) + chunks = re.findall( + r"[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё0-9 ,.;:()\-«»\"']{25,}", text) + return {"text": "\n".join(chunks[:2000])} + + +def _from_rtf(path: Path) -> dict: + raw = _decode(_read_bytes(path, 4_000_000)) + raw = re.sub(r"\\'([0-9a-fA-F]{2})", + lambda m: bytes([int(m.group(1), 16)]).decode("cp1251", "ignore"), + raw) + raw = re.sub(r"\\u(-?\d+)\s?\??", lambda m: chr(int(m.group(1)) % 65536), raw) + raw = re.sub(r"\{\\\*.*?\}", " ", raw, flags=re.S) + raw = re.sub(r"\\[a-zA-Z]+-?\d*\s?", " ", raw) + return {"text": raw.replace("{", " ").replace("}", " ")} + + +def _from_fb2(path: Path) -> dict: + raw = _decode(_read_bytes(path, 8_000_000)) + title = re.search(r"(.*?)", raw, re.S) + author = re.findall(r"<(?:first|last)-name>(.*?)", raw) + return {"title": title.group(1).strip() if title else "", + "author": " ".join(author[:2]), + "text": re.sub(r"<[^>]+>", " ", raw)} + + +def _from_epub(path: Path) -> dict: + parts: list[str] = [] + title = author = "" + with zipfile.ZipFile(long_path(path)) as z: + for n in z.namelist(): + if n.endswith(".opf"): + meta = _decode(z.read(n)) + m = re.search(r"]*>(.*?)", meta, re.S) + a = re.search(r"]*>(.*?)", meta, re.S) + title = m.group(1).strip() if m else "" + author = a.group(1).strip() if a else "" + break + for n in sorted(z.namelist()): + if n.lower().endswith((".xhtml", ".html", ".htm")): + parts.append(re.sub(r"<[^>]+>", " ", _decode(z.read(n)))) + if sum(len(p) for p in parts) > config.MAX_TEXT_CHARS * 1.5: + break + return {"title": title, "author": author, "text": "\n".join(parts)} + + +def _from_chm(path: Path) -> dict: + """Из CHM без chmlib берём читаемые заголовки разделов справки.""" + text = _decode(_read_bytes(path, 1_500_000)) + titles = re.findall(r'([^<]{4,120})", text, re.I) + return {"text": "\n".join(dict.fromkeys(titles))[: config.MAX_TEXT_CHARS], + "error": "" if titles else "CHM: текст не извлекается без chmlib"} + + +def _from_plain(path: Path) -> dict: + raw = _decode(_read_bytes(path, 4_000_000)) + if path.suffix.lower() in (".html", ".htm"): + raw = re.sub(r"<(script|style)[^>]*>.*?", " ", raw, flags=re.S | re.I) + raw = re.sub(r"<[^>]+>", " ", raw) + return {"text": raw} + + +_HANDLERS = { + ".pdf": lambda p, mp: _from_pdf(p, mp), + ".djvu": lambda p, mp: _from_djvu(p, mp), + ".djv": lambda p, mp: _from_djvu(p, mp), + ".docx": lambda p, mp: _from_docx(p), + ".doc": lambda p, mp: _from_binary_office(p), + ".rtf": lambda p, mp: _from_rtf(p), + ".odt": lambda p, mp: {"text": _zip_xml_text(p, [r"content\.xml"], + ("p", "h", "span"))}, + ".pptx": lambda p, mp: _from_pptx(p), + ".ppt": lambda p, mp: _from_binary_office(p), + ".xlsx": lambda p, mp: _from_xlsx(p), + ".xls": lambda p, mp: _from_binary_office(p), + ".epub": lambda p, mp: _from_epub(p), + ".fb2": lambda p, mp: _from_fb2(p), + ".chm": lambda p, mp: _from_chm(p), + ".txt": lambda p, mp: _from_plain(p), + ".md": lambda p, mp: _from_plain(p), +} + +YEAR_RE = re.compile(r"\b(19[5-9]\d|20[0-4]\d)\b") + +BAD_TITLE_MARKS = ("untitled", "microsoft word", "pdfcreator", "документ", + "unknown", "print", "no title", "titul") + + +def guess_year(*sources: str) -> int | None: + for s in sources: + if not s: + continue + years = [int(y) for y in YEAR_RE.findall(s)] + if years: + return max(years) + return None + + +def extract(path: Path, max_pages: int = config.MAX_PDF_PAGES) -> dict: + """Единая точка: текст и метаданные файла. + + Никогда не бросает исключение — ошибка возвращается в поле ``error``. + """ + ext = path.suffix.lower() + res: dict = {"title": "", "author": "", "pages": None, "text": "", + "error": "", "toc": []} + handler = _HANDLERS.get(ext) + if handler is None: + res["error"] = "формат %s не поддерживается" % ext + return res + try: + got = handler(path, max_pages) + res.update({k: v for k, v in got.items() if v is not None}) + except Exception as e: # noqa: BLE001 — устойчивость важнее диагностики + res["error"] = ("%s: %s" % (type(e).__name__, e))[:300] + + res["text"] = clean_text(res.get("text") or "")[: config.MAX_TEXT_CHARS] + + title = (res.get("title") or "").strip() + low = title.lower() + # Метаданные часто содержат мусор от конвертеров и имена исходников вёрстки — + # в таких случаях берём название из имени файла. + if (len(title) < 4 or any(b in low for b in BAD_TITLE_MARKS) + or re.search(r"\.(indb|indd|doc|docx|pdf|qxd|tex|fm|cdr|pmd|vp|rtf|" + r"ps|sla|odt)$", low)): + title = pretty_name(path.name) + res["title"] = title[:300] + res["author"] = (res.get("author") or "")[:200] + res["year"] = guess_year(path.name, res["title"], res["text"][:1500]) + return res diff --git a/catalogizer/report.py b/catalogizer/report.py new file mode 100644 index 0000000..3ee29fa --- /dev/null +++ b/catalogizer/report.py @@ -0,0 +1,301 @@ +"""Выгрузка каталога: Markdown, HTML с поиском, JSON, CSV.""" +from __future__ import annotations + +import csv +import html +import json +from datetime import datetime +from pathlib import Path + +from . import config, db + + +def fetch_all(con) -> list[dict]: + rows = con.execute( + "SELECT d.id, d.title, d.author, d.year, d.pages, d.ext, d.size, d.lang," + " d.kind, d.folder, d.rel_path, d.path, d.description, d.theses," + " d.keywords, d.scanned, d.error, u.keywords AS user_keywords" + " FROM docs d LEFT JOIN user_keywords u ON u.path = d.path" + " ORDER BY d.folder, d.title").fetchall() + out = [] + for r in rows: + d = dict(r) + d["theses"] = json.loads(d["theses"] or "[]") + d["keywords"] = json.loads(d["keywords"] or "[]") + d["user_keywords"] = json.loads(d["user_keywords"] or "[]") + out.append(d) + return out + + +def _size(n: int) -> str: + for unit in ("Б", "КБ", "МБ", "ГБ"): + if n < 1024: + return "%.0f %s" % (n, unit) + n /= 1024.0 + return "%.1f ТБ" % n + + +# -------------------------------------------------------------------------- +# Markdown +# -------------------------------------------------------------------------- + +def to_markdown(docs: list[dict], root: str, path: Path) -> Path: + by_folder: dict[str, list[dict]] = {} + for d in docs: + by_folder.setdefault(d["folder"], []).append(d) + + lines = [ + "# Каталог библиотеки", + "", + "Источник: `%s` " % root, + "Документов: **%d**, разделов: **%d** " % (len(docs), len(by_folder)), + "Составлен: %s" % datetime.now().strftime("%d.%m.%Y %H:%M"), + "", + "## Разделы", + "", + ] + for folder in sorted(by_folder): + anchor = folder.lower().replace(" ", "-").replace("\\", "-").replace(".", "") + lines.append("- [%s](#%s) — %d" % (folder, anchor, len(by_folder[folder]))) + lines.append("") + + for folder in sorted(by_folder): + lines += ["", "## %s" % folder, ""] + for d in sorted(by_folder[folder], key=lambda x: (x["title"] or "").lower()): + head = "### %s" % (d["title"] or d["rel_path"]) + lines.append(head) + facts = [d["ext"].lstrip("."), d["kind"] or ""] + if d["author"]: + facts.append(d["author"]) + if d["year"]: + facts.append(str(d["year"])) + if d["pages"]: + facts.append("%d с." % d["pages"]) + facts.append(_size(d["size"])) + if d["scanned"]: + facts.append("скан") + lines.append("*%s*" % " · ".join(f for f in facts if f)) + lines.append("") + lines.append(d["description"] or "") + if d["theses"]: + lines.append("") + lines.append("**Тезисы:**") + for t in d["theses"]: + lines.append("- %s" % t) + if d["keywords"]: + lines.append("") + lines.append("**Ключевые слова:** %s" % ", ".join(d["keywords"])) + if d["user_keywords"]: + lines.append("") + lines.append("**Свои ключевые слова:** %s" + % ", ".join(d["user_keywords"])) + lines.append("") + lines.append("`%s`" % d["rel_path"]) + lines.append("") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines), encoding="utf-8") + return path + + +# -------------------------------------------------------------------------- +# HTML с поиском +# -------------------------------------------------------------------------- + +HTML_TEMPLATE = """ + + + +Каталог библиотеки + +
+

Каталог библиотеки

+
+ + + + +
+
+
+
+ +""" + + +def to_html(docs: list[dict], root: str, path: Path) -> Path: + data = [{ + "t": d["title"] or d["rel_path"], + "a": d["author"] or "", + "y": d["year"] or "", + "pg": d["pages"] or 0, + "e": d["ext"], + "l": d["lang"] or "", + "kind": d["kind"] or "", + "f": d["path"], + "p": d["rel_path"], + "fold": d["folder"], + "d": d["description"] or "", + "th": d["theses"], + "k": d["keywords"], + "mk": d["user_keywords"], + "sz": _size(d["size"]), + "sc": 1 if d["scanned"] else 0, + } for d in docs] + + exts = sorted({d["ext"] for d in docs}) + folders = sorted({d["folder"] for d in docs}) + # " раньше времени. + payload = json.dumps(data, ensure_ascii=False).replace("%s' % (e, html.escape(e.lstrip("."))) + for e in exts)) + .replace("__FOLDERS__", "".join( + '' % (html.escape(f), html.escape(f[:70])) + for f in folders))) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(out, encoding="utf-8") + return path + + +# -------------------------------------------------------------------------- +# JSON / CSV +# -------------------------------------------------------------------------- + +def to_json(docs: list[dict], path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(docs, ensure_ascii=False, indent=1), encoding="utf-8") + return path + + +def to_csv(docs: list[dict], path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + cols = ("title", "author", "year", "pages", "kind", "lang", "ext", "size", + "folder", "rel_path", "description") + with open(path, "w", encoding="utf-8-sig", newline="") as f: + w = csv.writer(f, delimiter=";") + w.writerow(["Название", "Автор", "Год", "Страниц", "Тип", "Язык", + "Формат", "Размер", "Раздел", "Путь", "Описание", + "Ключевые слова", "Свои ключевые слова"]) + for d in docs: + w.writerow([d.get(c, "") for c in cols] + + [", ".join(d["keywords"]), + ", ".join(d["user_keywords"])]) + return path + + +def build_all(con, out_dir: Path | None = None) -> dict[str, Path]: + out_dir = Path(out_dir or config.OUT_DIR) + docs = fetch_all(con) + root = db.get_meta(con, "root", str(config.DEFAULT_ROOT)) + return { + "html": to_html(docs, root, out_dir / "catalog.html"), + "md": to_markdown(docs, root, out_dir / "catalog.md"), + "json": to_json(docs, out_dir / "catalog.json"), + "csv": to_csv(docs, out_dir / "catalog.csv"), + } diff --git a/catalogizer/scanner.py b/catalogizer/scanner.py new file mode 100644 index 0000000..8e921f1 --- /dev/null +++ b/catalogizer/scanner.py @@ -0,0 +1,175 @@ +"""Обход библиотеки и наполнение базы (этап «scan»).""" +from __future__ import annotations + +import json +import os +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path + +from . import config, db +from .extract import extract, long_path +from .summarize import analyze_language, doc_terms, guess_kind +from .textutil import pretty_name + +MAX_TRIES = 3 # после стольких обрывов файл помечается как непригодный + + +def iter_files(root: Path, extensions: set[str] | None = None): + """Все файлы-документы библиотеки (с фильтрацией мусора и SDK-папок).""" + exts = extensions or config.DOC_EXTENSIONS + for dirpath, dirnames, filenames in os.walk(long_path(root)): + dirnames[:] = [d for d in dirnames + if d not in config.SKIP_DIR_NAMES and not d.startswith(".")] + for name in filenames: + ext = os.path.splitext(name)[1].lower() + if ext not in exts: + continue + if name.lower() in config.NOISE_FILENAMES: + continue + full = os.path.join(dirpath, name) + try: + st = os.stat(full) + except OSError: + continue + if st.st_size < config.MIN_FILE_SIZE: + continue + clean = full[4:] if full.startswith("\\\\?\\") else full + yield Path(clean), st + + +def _base_record(path: Path, st, root: Path) -> dict: + rel = os.path.relpath(str(path), str(root)) + return { + "path": str(path), + "rel_path": rel, + "folder": os.path.dirname(rel) or ".", + "filename": path.name, + "ext": path.suffix.lower(), + "size": st.st_size, + "mtime": st.st_mtime, + "indexed_at": datetime.now().isoformat(timespec="seconds"), + } + + +def _build_record(path: Path, st, root: Path, max_pages: int) -> dict: + res = extract(path, max_pages) + text = res["text"] + terms = doc_terms(text, res["title"], res["toc"]) + rec = _base_record(path, st, root) + rec.update({ + "title": res["title"], + "author": res["author"], + "year": res["year"], + "pages": res["pages"], + "lang": analyze_language(text), + "kind": guess_kind(res["title"], text, res["pages"]), + "text_chars": len(text), + "scanned": int(len(text) < config.MIN_TEXT_CHARS), + "error": res["error"], + "body": text, + "toc": json.dumps(res["toc"][:120], ensure_ascii=False), + "terms": json.dumps(dict(terms.most_common(400)), ensure_ascii=False), + }) + return rec + + +def _stub_record(path: Path, st, root: Path, reason: str) -> dict: + """Запись о файле, который разобрать не удалось: он остаётся в каталоге.""" + rec = _base_record(path, st, root) + rec.update({ + "title": pretty_name(path.name), "author": "", "year": None, + "pages": None, "lang": "?", "kind": "документ", "text_chars": 0, + "scanned": 1, "error": reason, "body": "", "toc": "[]", "terms": "{}", + }) + return rec + + +def scan(con, root: Path, *, max_pages: int = config.MAX_PDF_PAGES, + jobs: int = 4, force: bool = False, limit: int | None = None, + extensions: set[str] | None = None, quiet: bool = False, + progress=None, stop=None) -> dict: + """Обходит библиотеку и обновляет записи изменившихся файлов. + + Обход инкрементальный: повторный запуск читает только новые и + изменившиеся файлы, поэтому прерванный разбор можно продолжить. + """ + known = db.get_state(con) + broken = db.failed_paths(con, MAX_TRIES) + alive: set[str] = set() + todo: list[tuple[Path, os.stat_result]] = [] + skipped = 0 + + for path, st in iter_files(root, extensions): + sp = str(path) + alive.add(sp) + prev = known.get(sp) + if not force and prev and abs(prev[0] - st.st_mtime) < 1 \ + and prev[1] == st.st_size: + continue + if sp in broken: + # Файл уже несколько раз обрывал разбор — заносим без текста. + db.upsert_doc(con, _stub_record( + path, st, root, + "разбор аварийно прерывался %d раз(а)" % MAX_TRIES)) + skipped += 1 + continue + todo.append((path, st)) + if limit and len(todo) >= limit: + break + + removed = 0 if limit else db.purge_missing(con, alive) + con.commit() + total = len(todo) + msg = ("Найдено файлов: %d, к разбору: %d, пропущено битых: %d, " + "удалено из базы: %d" % (len(alive), total, skipped, removed)) + if not quiet: + print(msg, flush=True) + if progress: + progress(0, total, msg) + + done = errors = 0 + started = time.time() + chunk = max(jobs, 10) + for offset in range(0, total, chunk): + batch = todo[offset: offset + chunk] + # Попытка отмечается до разбора: если процесс погибнет внутри + # библиотеки-парсера, при следующем запуске файл будет опознан. + for p, _ in batch: + db.bump_attempt(con, str(p)) + con.commit() + + with ThreadPoolExecutor(max_workers=jobs) as pool: + futures = {pool.submit(_build_record, p, s, root, max_pages): (p, s) + for p, s in batch} + for fut in as_completed(futures): + path, st = futures[fut] + try: + rec = fut.result() + except Exception as e: # noqa: BLE001 + rec = _stub_record(path, st, root, + "%s: %s" % (type(e).__name__, e)) + db.upsert_doc(con, rec) + db.clear_attempt(con, str(path)) + done += 1 + if rec["error"]: + errors += 1 + con.commit() + speed = done / max(time.time() - started, 0.1) + eta = (total - done) / max(speed, 0.001) + line = ("%d/%d (%.1f файл/с, осталось ~%d мин)" + % (done, total, speed, eta / 60)) + if not quiet: + print(" " + line, flush=True) + if progress: + progress(done, total, line) + if stop and stop(): + break + + db.set_meta(con, "root", str(root)) + db.set_meta(con, "last_scan", datetime.now().isoformat(timespec="seconds")) + con.commit() + return {"total_files": len(alive), "processed": done, "errors": errors, + "removed": removed, "skipped": skipped, + "seconds": round(time.time() - started, 1)} diff --git a/catalogizer/search.py b/catalogizer/search.py new file mode 100644 index 0000000..ab1251c --- /dev/null +++ b/catalogizer/search.py @@ -0,0 +1,86 @@ +"""Поиск по каталогу: полнотекстовый запрос с учётом морфологии.""" +from __future__ import annotations + +import json +import re + +from .textutil import stem + +SAFE = re.compile(r"[^\w\-Ѐ-ӿ]+", re.U) + +# Веса колонок для bm25: заголовок и ключевые слова важнее тела документа. +BM25_WEIGHTS = (10.0, 8.0, 4.0, 1.0, 0.8, 2.0) + + +def build_query(text: str) -> str: + """Превращает пользовательский запрос в выражение FTS5. + + Каждое слово ищется и как есть, и по основе с префиксом — чтобы + «инвертор» находил «инверторы» и «инвертора». + """ + phrases = re.findall(r'"([^"]+)"', text) + rest = re.sub(r'"[^"]*"', " ", text) + + parts = ['"%s"' % SAFE.sub(" ", p).strip() for p in phrases if p.strip()] + for raw in rest.split(): + neg = raw.startswith("-") + word = SAFE.sub("", raw.lstrip("-+")).strip() + if len(word) < 2: + continue + st = stem(word) + variants = {'"%s"' % word.lower()} + if st and st != word.lower(): + variants.add('"%s"*' % st) + else: + variants.add('"%s"*' % word.lower()) + clause = "(" + " OR ".join(sorted(variants)) + ")" + parts.append(("NOT " + clause) if neg else clause) + return " AND ".join(p for p in parts if p).replace("AND NOT", "NOT") + + +def search(con, query: str, *, limit: int = 20, ext: str | None = None, + folder: str | None = None, lang: str | None = None, + year_from: int | None = None, kind: str | None = None) -> list[dict]: + fts_query = build_query(query) + if not fts_query: + return [] + + sql = [ + "SELECT d.id, d.title, d.author, d.year, d.pages, d.ext, d.lang, d.kind,", + " d.rel_path, d.path, d.folder, d.description, d.keywords, d.theses,", + " d.scanned, u.keywords AS user_keywords,", + " snippet(docs_fts, 3, '«', '»', ' … ', 14) AS snip,", + " bm25(docs_fts, %s) AS rank" % ", ".join(str(w) for w in BM25_WEIGHTS), + "FROM docs_fts JOIN docs d ON d.id = docs_fts.rowid", + " LEFT JOIN user_keywords u ON u.path = d.path", + "WHERE docs_fts MATCH ?", + ] + params: list = [fts_query] + if ext: + sql.append("AND d.ext = ?") + params.append("." + ext.lower().lstrip(".")) + if folder: + sql.append("AND d.folder LIKE ?") + params.append("%" + folder + "%") + if lang: + sql.append("AND d.lang = ?") + params.append(lang) + if kind: + sql.append("AND d.kind = ?") + params.append(kind) + if year_from: + sql.append("AND d.year >= ?") + params.append(year_from) + sql.append("ORDER BY rank LIMIT ?") + params.append(limit) + + rows = con.execute("\n".join(sql), params).fetchall() + out = [] + for r in rows: + d = dict(r) + d["keywords"] = json.loads(d.get("keywords") or "[]") + d["theses"] = json.loads(d.get("theses") or "[]") + d["user_keywords"] = json.loads(d.get("user_keywords") or "[]") + d["score"] = round(-d.pop("rank"), 2) + out.append(d) + return out diff --git a/catalogizer/summarize.py b/catalogizer/summarize.py new file mode 100644 index 0000000..884ce26 --- /dev/null +++ b/catalogizer/summarize.py @@ -0,0 +1,178 @@ +"""Ключевые слова, тезисы и краткое описание документа. + +Без обращения к внешним сервисам: экстрактивное реферирование по TF-IDF, +рассчитанному на самой библиотеке. +""" +from __future__ import annotations + +import math +import re +from collections import Counter + +from . import config +from .textutil import (STOP, detect_lang, sentences, similar, stem, tokens) + +# Общие для любой техлитературы слова: сами по себе ничего не говорят. +GENERIC = { + "устройство", "система", "систем", "работа", "работы", "значение", "число", + "рисунок", "таблица", "формула", "пример", "случай", "результат", "метод", + "задача", "часть", "время", "уровень", "величина", "процесс", "качество", + "device", "system", "value", "number", "example", "case", "result", + "method", "task", "part", "time", "level", "process", "quality", "user", + "chapter", "figure", "section", "information", "application", "data", +} +GENERIC_STEMS = {stem(w) for w in GENERIC} + +WORD_OK = re.compile(r"^[a-zа-я][a-zа-я0-9\-]{2,}$") + + +def doc_terms(text: str, title: str = "", toc: list[str] | None = None) -> Counter: + """Частоты основ слов документа с усилением заголовка и оглавления.""" + c: Counter = Counter() + for w in tokens(text): + if WORD_OK.match(w): + c[stem(w)] += 1 + for w in tokens(title): + if WORD_OK.match(w): + c[stem(w)] += 6 + for line in (toc or [])[:120]: + for w in tokens(line): + if WORD_OK.match(w): + c[stem(w)] += 3 + return c + + +def _surface_forms(text: str, title: str, toc: list[str] | None) -> dict[str, str]: + """Для каждой основы — самая частая словоформа (её и показываем).""" + forms: dict[str, Counter] = {} + for src in (title, "\n".join(toc or []), text[:15_000]): + for w in tokens(src): + if WORD_OK.match(w): + forms.setdefault(stem(w), Counter())[w] += 1 + best = {} + for st, c in forms.items(): + top = c.most_common(1)[0][1] + # Среди частых словоформ берём самую короткую: «вход», а не «входами». + good = [w for w, n in c.items() if n >= top * 0.5] + best[st] = min(good, key=lambda w: (len(w), w)) + return best + + +class Analyzer: + """Считает ключевые слова и тезисы с учётом IDF по всей библиотеке.""" + + def __init__(self, df: dict[str, int], n_docs: int): + self.df = df + self.n_docs = max(n_docs, 1) + + def idf(self, term: str) -> float: + return math.log((self.n_docs + 1) / (self.df.get(term, 0) + 1)) + 1.0 + + def weights(self, terms: Counter) -> dict[str, float]: + """Вес основы = сглаженный TF × IDF, без слишком общих слов.""" + total = sum(terms.values()) or 1 + w = {} + for t, n in terms.items(): + if t in GENERIC_STEMS or t in STOP or len(t) < 3: + continue + if n < 2 and total > 400: + continue + w[t] = (1 + math.log(n)) * self.idf(t) / math.log(total + 10) + return w + + def keywords(self, terms: Counter, text: str, title: str, + toc: list[str] | None, limit: int = config.N_KEYWORDS) -> list[str]: + w = self.weights(terms) + forms = _surface_forms(text, title, toc) + best = sorted(w.items(), key=lambda kv: -kv[1]) + out: list[str] = [] + for st, _ in best: + word = forms.get(st, st) + if any(word in o or o in word for o in out): + continue + out.append(word) + if len(out) >= limit: + break + return out + + def theses(self, text: str, terms: Counter, + limit: int = config.N_THESES) -> list[str]: + """Экстрактивные тезисы: самые «плотные» по ключевым словам фразы.""" + w = self.weights(terms) + cands = sentences(text) + if not cands: + return [] + scored = [] + for pos, s in enumerate(cands): + ws = [w.get(stem(t), 0.0) for t in tokens(s)] + if len(ws) < 5: + continue + score = sum(ws) / math.sqrt(len(ws)) + # Начало документа (аннотация, введение) — обычно самое ценное. + score *= 1.0 + 0.4 * max(0.0, 1.0 - pos / 40.0) + if re.search(r"(предназначен|рассматрива|описыва|посвящ|позволяет" + r"|книга|пособие|руководство|describes|presents" + r"|provides|introduces|this (book|guide|manual))", + s, re.I): + score *= 1.35 + scored.append((score, pos, s)) + scored.sort(key=lambda x: -x[0]) + + picked: list[tuple[int, str]] = [] + for _, pos, s in scored: + if any(similar(s, p) > 0.55 for _, p in picked): + continue + picked.append((pos, s)) + if len(picked) >= limit: + break + picked.sort(key=lambda x: x[0]) + return [s for _, s in picked] + + +TYPE_HINTS = ( + (r"datasheet|data sheet|техническое описание", "даташит"), + (r"reference manual|user manual|руководство пользователя", "руководство"), + (r"application note|прикладн", "апноут"), + (r"учебн|пособие|курс лекций|лекци", "учебник"), + (r"диссертац|автореферат", "диссертация"), + (r"гост|стандарт|iec |ieee std", "стандарт"), + (r"статья|journal|proceedings", "статья"), + (r"errata|release notes", "заметки к выпуску"), +) + + +def guess_kind(title: str, text: str, pages: int | None) -> str: + """Тип издания — по названию, первым страницам и объёму.""" + probe = (title + " " + text[:3000]).lower() + for pat, kind in TYPE_HINTS: + if re.search(pat, probe): + return kind + if pages and pages > 120: + return "книга" + if pages and pages <= 20: + return "заметка" + return "документ" + + +def describe(title: str, kind: str, lang: str, pages: int | None, + keywords: list[str], theses: list[str], has_text: bool) -> str: + """Краткое описание в одну-две фразы.""" + head = kind.capitalize() + if lang in ("ru", "en"): + head += " на %s языке" % ("русском" if lang == "ru" else "английском") + if pages: + head += ", %d с" % pages + if keywords: + head += ". Темы: " + ", ".join(keywords[:6]) + if not has_text: + return head + ". Текстовый слой отсутствует (скан) — описание по имени файла." + if theses: + first = theses[0] + if len(first) > 220: + first = first[:217].rsplit(" ", 1)[0] + "…" + head += ". " + first + return head + ("" if head.endswith((".", "…")) else ".") + + +def analyze_language(text: str) -> str: + return detect_lang(text) diff --git a/catalogizer/textutil.py b/catalogizer/textutil.py new file mode 100644 index 0000000..1d8b00c --- /dev/null +++ b/catalogizer/textutil.py @@ -0,0 +1,156 @@ +"""Работа с текстом: язык, токены, стемминг, предложения.""" +from __future__ import annotations + +import re +import unicodedata + +CYR = re.compile(r"[а-яёА-ЯЁ]") +LAT = re.compile(r"[a-zA-Z]") + +TOKEN_RE = re.compile(r"[a-zA-Zа-яёА-ЯЁ][a-zA-Zа-яёА-ЯЁ0-9\-]{1,30}") + +RU_STOP = set(""" +а без более больше будет будто бы был была были было быть в вам вас ведь весь +вдруг вот впрочем все всегда всего всех всю вы где да даже два для до другой +его ее её ей ему если есть еще ещё же за зачем здесь и из или им иметь их к +как какой когда конечно кто куда ли лишь между меня мне может можно мой моя мы +на над надо наш не него неё нее ней нельзя нет ни нибудь никогда ним них ничего +но ну о об один он она они оно от очень перед по под после потом потому почти +при про просто раз разве с сам свое своей свои свой себе себя сейчас сказать +со совсем так такой там те тебя тем теперь то тогда того тоже той только том +тот тут ты у уж уже хорошо хоть чего чей чем через что чтобы чуть эта эти это +этой этом этот эту я является является является том числе также т е т д т п +рис таблица глава раздел стр страница пример примера данной данного данные +можно должен должна должны следует таким образом однако если этом при этом +""".split()) + +EN_STOP = set(""" +a about above after again against all am an and any are as at be because been +before being below between both but by can cannot could did do does doing down +during each few for from further had has have having he her here hers him his +how i if in into is it its itself just me more most must my no nor not of off +on once only or other our out over own same she should so some such than that +the their them then there these they this those through to too under until up +use used using very was we were what when where which while who whom why will +with would you your figure table chapter section page fig eq ref see note +""".split()) + +STOP = RU_STOP | EN_STOP + +# Мусорные токены, характерные для технической документации. +JUNK = set(""" +www http https com ru org net pdf doc docx page pages copyright reserved rights +inc ltd corp all datasheet rev revision version document documents note notes +""".split()) + + +def detect_lang(text: str) -> str: + """Грубое определение языка по соотношению кириллицы и латиницы.""" + sample = text[:20_000] + cyr = len(CYR.findall(sample)) + lat = len(LAT.findall(sample)) + if cyr + lat < 30: + return "?" + return "ru" if cyr > lat * 0.5 else "en" + + +_RU_ENDINGS = ( + "ированием", "ированию", "ирования", "ированный", "ирование", + "ами", "ями", "ого", "ему", "ому", "ыми", "ими", "ая", "ое", "ые", "ый", + "ой", "ий", "ин", "ов", "ев", "ам", "ям", "ах", "ях", "ею", "ью", "ия", + "ии", "ие", "ем", "ом", "ах", "ешь", "ете", "ает", "ают", "ить", "ать", + "ешь", "ся", "сь", "а", "е", "и", "й", "о", "у", "ы", "ь", "ю", "я", +) + + +def stem(word: str) -> str: + """Лёгкий стеммер: RU — отсечение окончаний, EN — отсечение суффиксов. + + Нужен не для лингвистической точности, а чтобы «инвертор», «инвертора» + и «инверторы» попадали в один индексный ключ. + """ + w = word.lower().replace("ё", "е") + if CYR.search(w): + for end in _RU_ENDINGS: + if len(w) - len(end) >= 4 and w.endswith(end): + return w[: -len(end)] + return w + for end in ("ingly", "ations", "ation", "ings", "edly", "ies", "ing", + "ers", "er", "es", "ed", "s"): + if len(w) - len(end) >= 4 and w.endswith(end): + base = w[: -len(end)] + if end == "ies": + base += "y" + return base + return w + + +def tokens(text: str) -> list[str]: + """Значимые слова текста в нижнем регистре.""" + out = [] + for m in TOKEN_RE.finditer(text): + w = m.group(0).lower().replace("ё", "е") + if len(w) < 3 or w in STOP or w in JUNK: + continue + if w.strip("-") != w: + w = w.strip("-") + if len(w) < 3: + continue + out.append(w) + return out + + +def clean_text(raw: str) -> str: + """Убирает переносы строк внутри слов, колонтитулы и лишние пробелы.""" + t = unicodedata.normalize("NFKC", raw) + t = t.replace("­", "") + t = re.sub(r"-\n(?=[a-zа-яё])", "", t) # перенос по слогам + t = re.sub(r"(?<=[^\n\.\!\?:;])\n(?=[a-zа-яё])", " ", t) # разрыв строки + t = re.sub(r"[ \t ]+", " ", t) + t = re.sub(r"\n{3,}", "\n\n", t) + return t.strip() + + +SENT_SPLIT = re.compile(r"(?<=[.!?…])\s+(?=[«\"'(\[]?[A-ZА-ЯЁ0-9])|\n{2,}") + + +def sentences(text: str) -> list[str]: + """Разбивает текст на предложения, отбрасывая заведомый мусор.""" + out = [] + for chunk in SENT_SPLIT.split(text): + if not chunk: + continue + s = " ".join(chunk.split()) + if not (40 <= len(s) <= 400): + continue + letters = sum(ch.isalpha() for ch in s) + if letters < len(s) * 0.55: # формулы, таблицы, номера страниц + continue + if s.count("|") > 2 or s.count("...") > 1 or s.count("__") > 0: + continue + words = s.split() + if len(words) < 6: + continue + upper = sum(w.isupper() for w in words) + if upper > len(words) * 0.6: # заголовок капсом + continue + out.append(s) + return out + + +def similar(a: str, b: str) -> float: + """Мера пересечения двух предложений по множеству основ слов.""" + sa = {stem(w) for w in tokens(a)} + sb = {stem(w) for w in tokens(b)} + if not sa or not sb: + return 0.0 + return len(sa & sb) / len(sa | sb) + + +def pretty_name(filename: str) -> str: + """Читаемое название из имени файла.""" + name = re.sub(r"\.[A-Za-z0-9]{2,5}$", "", filename) + name = name.replace("_", " ").replace("%20", " ") + name = re.sub(r"[.\-]{2,}", " ", name) + name = re.sub(r"\s{2,}", " ", name).strip(" -.") + return name or filename diff --git a/catalogizer/ui/__init__.py b/catalogizer/ui/__init__.py new file mode 100644 index 0000000..cff42ac --- /dev/null +++ b/catalogizer/ui/__init__.py @@ -0,0 +1,3 @@ +"""@file __init__.py +@brief Слой представления: тёмное окно PySide6 поверх ядра каталогизатора. +""" diff --git a/catalogizer/ui/catalog_tab.py b/catalogizer/ui/catalog_tab.py new file mode 100644 index 0000000..55082ec --- /dev/null +++ b/catalogizer/ui/catalog_tab.py @@ -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("

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

") + 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("
Тезисы
    ") + parts += ["
  • %s
  • " % esc(t) for t in doc["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)) diff --git a/catalogizer/ui/main_window.py b/catalogizer/ui/main_window.py new file mode 100644 index 0000000..afecb80 --- /dev/null +++ b/catalogizer/ui/main_window.py @@ -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() diff --git a/catalogizer/ui/scan_tab.py b/catalogizer/ui/scan_tab.py new file mode 100644 index 0000000..33e4137 --- /dev/null +++ b/catalogizer/ui/scan_tab.py @@ -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()) diff --git a/catalogizer/ui/theme.py b/catalogizer/ui/theme.py new file mode 100644 index 0000000..a216849 --- /dev/null +++ b/catalogizer/ui/theme.py @@ -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; } +""" diff --git a/catalogizer/ui/worker.py b/catalogizer/ui/worker.py new file mode 100644 index 0000000..74208a7 --- /dev/null +++ b/catalogizer/ui/worker.py @@ -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()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9a369e7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pymupdf>=1.24 +PySide6>=6.7,<7 diff --git a/scripts/build_exe.py b/scripts/build_exe.py new file mode 100644 index 0000000..f244ec6 --- /dev/null +++ b/scripts/build_exe.py @@ -0,0 +1,155 @@ +"""@file build_exe.py +@brief Сборка автономной программы: Python, Qt и PyMuPDF внутри. + +Запуск (из корня проекта, интерпретатором с PySide6 и PyInstaller): + + python scripts/build_exe.py + +Что получается, зависит от системы, на которой идёт сборка: + +* Windows — ``dist/Catalogizer.exe``; +* macOS — ``dist/Catalogizer.app``; +* Linux — ``dist/Catalogizer``. + +PyInstaller не умеет собирать под чужую систему: программу для macOS +собирают на macOS, для Windows — на Windows. Исходники при этом одни. + +Готовая программа не требует ни Python, ни PySide6, ни PyMuPDF. + +Ключи разобраны в ``--help``: + +* ``--onedir`` — папка вместо одного файла: запускается заметно быстрее + (в macOS так всегда: ``.app`` по устройству и есть папка); +* ``--console`` — оставить консоль, чтобы видеть traceback и сообщения Qt; +* ``--cli`` — дополнительно собрать консольную программу ``catalog``. +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +MACOS = sys.platform == "darwin" +WINDOWS = sys.platform == "win32" + +#: Идентификатор пакета macOS: по нему система различает приложения. +BUNDLE_ID = "ru.set.catalogizer" + +#: Корень проекта: скрипт лежит в scripts/. +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent)) # для make_icon +DIST = ROOT / "dist" +BUILD = ROOT / "build" +ICON = ROOT / "ico" / ("catalog.icns" if MACOS else "catalog.ico") + +#: Модули Qt, которые приложению не нужны. Без этого списка в сборку тянутся +#: QtWebEngine и 3D-стек — сотни мегабайт мимо цели. +UNUSED_QT_MODULES = ( + "QtWebEngineCore", "QtWebEngineWidgets", "QtWebEngineQuick", + "QtQuick", "QtQuick3D", "QtQml", "Qt3DCore", "Qt3DRender", + "QtMultimedia", "QtBluetooth", "QtCharts", "QtDataVisualization", + "QtGraphs", "QtPdf", "QtSql", "QtTest", "QtNetwork", "QtSerialPort", +) + +#: Что исключается всегда: tkinter в программе не используется, а PySide2 +#: рядом с PySide6 ломает сборку. +COMMON_EXCLUDES = ("tkinter", "PySide2", "shiboken2", "matplotlib", + "numpy", "PIL", "pytest") + + +def check_environment() -> None: + """@brief Проверяет, что в интерпретаторе есть всё нужное для сборки.""" + missing = [] + for module, package in (("PyInstaller", "pyinstaller"), + ("PySide6", "PySide6"), + ("pymupdf", "pymupdf")): + try: + __import__(module) + except ImportError: + missing.append(package) + if missing: + raise SystemExit( + "Не хватает пакетов: %s\nУстановите их:\n %s -m pip install %s" + % (", ".join(missing), sys.executable, " ".join(missing))) + + +def build_command(name: str, entry: Path, args: argparse.Namespace, + windowed: bool) -> list[str]: + """@brief Собирает командную строку PyInstaller для одной цели.""" + command = [ + sys.executable, "-m", "PyInstaller", + "--noconfirm", + "--clean", + "--name", name, + "--distpath", str(DIST), + "--workpath", str(BUILD), + "--specpath", str(BUILD), + # Пакет catalogizer лежит в корне проекта и не обязан быть установлен. + "--paths", str(ROOT), + ] + # На macOS .app — это всегда папка, поэтому onefile там смысла не имеет. + command.append("--onedir" if (args.onedir or (MACOS and windowed)) + else "--onefile") + # Консоль скрыта: приложение оконное. С --console видны traceback и + # сообщения Qt — так ищут причину, если программа молча не стартует. + command.append("--console" if (args.console or not windowed) else "--windowed") + if ICON.exists(): + command += ["--icon", str(ICON)] + if MACOS and windowed: + command += ["--osx-bundle-identifier", BUNDLE_ID] + for module in UNUSED_QT_MODULES: + command += ["--exclude-module", "PySide6.%s" % module] + for module in COMMON_EXCLUDES: + command += ["--exclude-module", module] + command.append(str(entry)) + return command + + +def run(command: list[str]) -> None: + print(" ".join('"%s"' % c if " " in c else c for c in command), flush=True) + result = subprocess.run(command, cwd=str(ROOT)) + if result.returncode: + raise SystemExit("PyInstaller завершился с кодом %d" % result.returncode) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Сборка автономного Каталогизатора " + "(exe в Windows, .app в macOS)") + parser.add_argument("--onedir", action="store_true", + help="папка вместо одного файла: запуск быстрее") + parser.add_argument("--console", action="store_true", + help="оставить консоль у оконной сборки (для отладки)") + parser.add_argument("--cli", action="store_true", + help="дополнительно собрать консольную программу catalog") + args = parser.parse_args() + + check_environment() + if not ICON.exists(): + # Иконки не хранятся в репозитории — они целиком рисуются кодом. + import make_icon + + print("Рисую иконки…", flush=True) + make_icon.build_ico() + make_icon.build_icns() + if BUILD.exists(): + shutil.rmtree(BUILD, ignore_errors=True) + + run(build_command("Catalogizer", ROOT / "catalog_gui.py", args, windowed=True)) + if args.cli: + run(build_command("catalog", ROOT / "catalog.py", args, windowed=False)) + + if MACOS: + # Загрузчик .app обязан быть исполняемым, даже если права потерялись. + binary = DIST / "Catalogizer.app" / "Contents" / "MacOS" / "Catalogizer" + if binary.exists(): + binary.chmod(0o755) + print("\nГотово. Файлы в %s" % DIST) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/make_icon.py b/scripts/make_icon.py new file mode 100644 index 0000000..2d0d7a6 --- /dev/null +++ b/scripts/make_icon.py @@ -0,0 +1,167 @@ +"""@file make_icon.py +@brief Генерация иконок программы без внешних библиотек. + +Иконка рисуется кодом (стопка страниц с корешком и лупой), чтобы в +репозитории не заводить бинарный файл неизвестного происхождения. +Делаются оба формата: ``ico/catalog.ico`` для Windows и +``ico/catalog.icns`` для macOS. + +Запуск: ``python scripts/make_icon.py``. +""" + +from __future__ import annotations + +import struct +import zlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +TARGET_ICO = ROOT / "ico" / "catalog.ico" +TARGET_ICNS = ROOT / "ico" / "catalog.icns" + +#: Размеры изображений внутри .ico. +SIZES = (16, 24, 32, 48, 64, 128, 256) + +#: Типы изображений ICNS и их размеры: macOS берёт подходящий сам. +ICNS_TYPES = ((b"ic11", 32), (b"ic12", 64), (b"ic07", 128), + (b"ic08", 256), (b"ic09", 512)) + +#: Служебные байты растровых форматов. +TRANSPARENT = bytes(4) # прозрачный пиксель +FILTER_NONE = bytes(1) # PNG: строка без фильтра +PNG_MAGIC = bytes((137, 80, 78, 71, 13, 10, 26, 10)) + +BG = (0x1d, 0x28, 0x38) # тёмная плашка в цветах темы +PAGE = (0xdc, 0xe6, 0xf2) # страница +PAGE_DIM = (0x91, 0xa0, 0xb4) # страницы за первой +SPINE = (0x15, 0x70, 0xd8) # синий корешок +GLASS = (0x2f, 0x91, 0xff) # лупа + + +def _round_corner(x: float, y: float, size: float, radius: float) -> bool: + """@brief Точка внутри скруглённого квадрата 0..size?""" + cx = min(max(x, radius), size - radius) + cy = min(max(y, radius), size - radius) + return (x - cx) ** 2 + (y - cy) ** 2 <= radius ** 2 + + +def _pixel(u: float, v: float) -> tuple[int, int, int] | None: + """@brief Цвет точки в относительных координатах 0..1 (None — прозрачно).""" + if not _round_corner(u, v, 1.0, 0.22): + return None + colour = BG + + # Три страницы уступом: задние приглушены. + for shift, page in ((0.10, PAGE_DIM), (0.05, PAGE_DIM), (0.0, PAGE)): + left, right = 0.22 + shift, 0.72 + shift + top, bottom = 0.16 + shift, 0.80 + shift + if left <= u <= right and top <= v <= bottom: + colour = page + if u <= left + 0.06 and page is PAGE: + colour = SPINE # корешок у передней страницы + # Лупа: кольцо и ручка. + cx, cy, r = 0.66, 0.64, 0.19 + d = ((u - cx) ** 2 + (v - cy) ** 2) ** 0.5 + if r - 0.055 <= d <= r: + colour = GLASS + if 0.0 <= u - cx - 0.10 <= 0.055 and 0.0 <= v - cy - 0.10 <= 0.20: + if abs((u - cx - 0.135) - (v - cy - 0.135)) < 0.035: + colour = GLASS + return colour + + +# -------------------------------------------------------------------------- +# Windows: .ico +# -------------------------------------------------------------------------- + +def _bgra_bottom_up(size: int) -> bytes: + """@brief Растр BGRA снизу вверх — так его хранит формат ICO.""" + rows = [] + for y in range(size - 1, -1, -1): + row = bytearray() + for x in range(size): + px = _pixel((x + 0.5) / size, (y + 0.5) / size) + if px: + r, g, b = px + row += bytes((b, g, r, 255)) + else: + row += TRANSPARENT + rows.append(bytes(row)) + return b"".join(rows) + + +def _ico_entry(size: int) -> bytes: + """@brief Одно изображение в формате BMP-в-ICO вместе с пустой маской.""" + header = struct.pack(" Path: + """@brief Пишет многоразмерный .ico и возвращает путь к нему.""" + images = [_ico_entry(size) for size in SIZES] + offset = 6 + 16 * len(images) + out = bytearray(struct.pack(" bytes: + """@brief Растр RGBA сверху вниз, каждая строка с байтом фильтра PNG.""" + rows = [] + for y in range(size): + row = bytearray(FILTER_NONE) + for x in range(size): + px = _pixel((x + 0.5) / size, (y + 0.5) / size) + if px: + r, g, b = px + row += bytes((r, g, b, 255)) + else: + row += TRANSPARENT + rows.append(bytes(row)) + return b"".join(rows) + + +def _png(size: int) -> bytes: + """@brief PNG без внешних библиотек: zlib и CRC из стандартной поставки.""" + + def chunk(tag: bytes, data: bytes) -> bytes: + body = tag + data + return (struct.pack(">I", len(data)) + body + + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)) + + # Ширина, высота, 8 бит на канал, тип 6 (RGBA), без чересстрочности. + header = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0) + return (PNG_MAGIC + + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(_rgba_top_down(size), 9)) + + chunk(b"IEND", b"")) + + +def build_icns(target: Path = TARGET_ICNS) -> Path: + """@brief Пишет .icns из PNG-изображений нескольких размеров.""" + parts = [] + for tag, size in ICNS_TYPES: + data = _png(size) + parts.append(tag + struct.pack(">I", len(data) + 8) + data) + body = b"".join(parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"icns" + struct.pack(">I", len(body) + 8) + body) + return target + + +if __name__ == "__main__": + print(build_ico()) + print(build_icns())