Каталогизатор документов: разбор папки, тезисы, поиск, GUI
Программа принимает папку с документами, извлекает из них текст и метаданные и строит каталог: краткое описание, тезисы и ключевые слова для каждого файла плюс поиск по всему собранному. Ядро: - extract: PDF, DJVU, DOC(X), RTF, ODT, CHM, EPUB, FB2, PPT(X), XLS(X), TXT; PDF передаётся MuPDF потоком в память (работают длинные пути) и ограничен по времени — повреждённый файл иначе чинится минутами; - summarize: экстрактивное реферирование по TF-IDF, посчитанному на самой библиотеке, с лёгким стеммером для русского и английского; - db: SQLite с полнотекстовым индексом FTS5, морфологический поиск с BM25; - scanner: инкрементальный многопоточный обход, счётчик попыток защищает от файла, обрывающего разбор; - report: автономный HTML с поиском, Markdown, JSON, CSV. Интерфейс: - окно PySide6 в тёмной теме: вкладки разбора и поиска, фоновый поток, карточка документа, правка своих ключевых слов; - командная строка: build, scan, analyze, report, search, show, tag, stats; - у каждой папки свой каталог, последняя обработанная запоминается. Сборка: scripts/build_exe.py даёт Catalogizer.exe в Windows и Catalogizer.app в macOS, иконки .ico и .icns рисуются кодом. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
5
.gitattributes
vendored
Normal file
5
.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.command text eol=lf
|
||||||
|
*.ico binary
|
||||||
|
*.icns binary
|
||||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -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/
|
||||||
51
BUILD_APP.command
Executable file
51
BUILD_APP.command
Executable file
@@ -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."
|
||||||
86
BUILD_EXE.bat
Normal file
86
BUILD_EXE.bat
Normal file
@@ -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
|
||||||
57
CATALOG.bat
Normal file
57
CATALOG.bat
Normal file
@@ -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" %*
|
||||||
30
CATALOG.command
Executable file
30
CATALOG.command
Executable file
@@ -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" "$@"
|
||||||
254
README.md
Normal file
254
README.md
Normal file
@@ -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), его драйвер может надолго придерживать открытые файлы. На время
|
||||||
|
первого разбора синхронизацию лучше приостановить.
|
||||||
12
catalog.py
Normal file
12
catalog.py
Normal file
@@ -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())
|
||||||
11
catalog_gui.py
Normal file
11
catalog_gui.py
Normal file
@@ -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())
|
||||||
2
catalogizer/__init__.py
Normal file
2
catalogizer/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Каталогизатор технической литературы."""
|
||||||
|
__version__ = "1.0.0"
|
||||||
75
catalogizer/analyze.py
Normal file
75
catalogizer/analyze.py
Normal file
@@ -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)}
|
||||||
58
catalogizer/app.py
Normal file
58
catalogizer/app.py
Normal file
@@ -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()
|
||||||
383
catalogizer/cli.py
Normal file
383
catalogizer/cli.py
Normal file
@@ -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)
|
||||||
119
catalogizer/config.py
Normal file
119
catalogizer/config.py
Normal file
@@ -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)
|
||||||
234
catalogizer/db.py
Normal file
234
catalogizer/db.py
Normal file
@@ -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,))}
|
||||||
349
catalogizer/extract.py
Normal file
349
catalogizer/extract.py
Normal file
@@ -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"<book-title>(.*?)</book-title>", raw, re.S)
|
||||||
|
author = re.findall(r"<(?:first|last)-name>(.*?)</(?: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"<dc:title[^>]*>(.*?)</dc:title>", meta, re.S)
|
||||||
|
a = re.search(r"<dc:creator[^>]*>(.*?)</dc:creator>", 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'<param name="Name" value="([^"]{4,120})"', text)
|
||||||
|
if not titles:
|
||||||
|
titles = re.findall(r"<title>([^<]{4,120})</title>", 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)[^>]*>.*?</\1>", " ", 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
|
||||||
301
catalogizer/report.py
Normal file
301
catalogizer/report.py
Normal file
@@ -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 = """<!doctype html>
|
||||||
|
<html lang="ru"><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Каталог библиотеки</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#fbfbfa;--fg:#1c1c1a;--mut:#6b6b66;--line:#e2e1dc;--card:#fff;--acc:#8a4b1f;--hl:#ffeaa7}
|
||||||
|
@media(prefers-color-scheme:dark){:root{--bg:#16171a;--fg:#e8e8e4;--mut:#9a9a94;--line:#2c2e33;--card:#1d1f23;--acc:#e0a06a;--hl:#5a4a12}}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif}
|
||||||
|
header{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--line);padding:14px 20px;z-index:5}
|
||||||
|
h1{margin:0 0 10px;font-size:19px;font-weight:650}
|
||||||
|
.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||||
|
input,select{padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);font-size:15px}
|
||||||
|
#q{flex:1;min-width:240px}
|
||||||
|
.stat{color:var(--mut);font-size:13px;margin-top:8px}
|
||||||
|
main{padding:16px 20px 60px;max-width:1080px;margin:0 auto}
|
||||||
|
.doc{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:12px}
|
||||||
|
.doc h2{margin:0 0 4px;font-size:16px;font-weight:620;line-height:1.35}
|
||||||
|
.meta{color:var(--mut);font-size:12.5px;margin-bottom:8px}
|
||||||
|
.desc{margin:0 0 8px}
|
||||||
|
ul.th{margin:6px 0 8px;padding-left:20px}
|
||||||
|
ul.th li{margin:3px 0;color:var(--fg)}
|
||||||
|
.kw{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
||||||
|
.kw span{background:var(--bg);border:1px solid var(--line);border-radius:20px;padding:2px 10px;font-size:12px;color:var(--mut);cursor:pointer}
|
||||||
|
.kw span:hover{color:var(--acc);border-color:var(--acc)}
|
||||||
|
.kw span.mine{color:var(--acc);border-color:var(--acc);font-weight:600}
|
||||||
|
.path{font:12px ui-monospace,Consolas,monospace;color:var(--mut);word-break:break-all;margin-top:8px}
|
||||||
|
mark{background:var(--hl);color:inherit;border-radius:2px}
|
||||||
|
.folder{margin:22px 0 10px;font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--acc);font-weight:650}
|
||||||
|
.empty{color:var(--mut);padding:40px 0;text-align:center}
|
||||||
|
button.more{background:none;border:1px solid var(--line);border-radius:8px;padding:8px 14px;color:var(--mut);cursor:pointer}
|
||||||
|
</style></head><body>
|
||||||
|
<header>
|
||||||
|
<h1>Каталог библиотеки</h1>
|
||||||
|
<div class="bar">
|
||||||
|
<input id="q" placeholder="Поиск по названию, тезисам, ключевым словам…" autofocus>
|
||||||
|
<select id="ext"><option value="">все форматы</option>__EXTS__</select>
|
||||||
|
<select id="fold"><option value="">все разделы</option>__FOLDERS__</select>
|
||||||
|
<select id="lang"><option value="">язык</option><option value="ru">рус</option><option value="en">англ</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="stat" id="stat"></div>
|
||||||
|
</header>
|
||||||
|
<main id="list"></main>
|
||||||
|
<script>
|
||||||
|
const DOCS = __DATA__;
|
||||||
|
const ROOT = __ROOT__;
|
||||||
|
|
||||||
|
// Ссылка на файл: путь приходит из каталога уже абсолютным, поэтому здесь
|
||||||
|
// достаточно привести разделители к виду file:// (работает и в Windows,
|
||||||
|
// и в macOS, и в Linux).
|
||||||
|
function fileUrl(p){
|
||||||
|
let s = String(p).replace(/\\\\/g,'/');
|
||||||
|
if(!s.startsWith('/')) s = '/' + s; // Windows: /E:/папка/файл.pdf
|
||||||
|
return 'file://' + encodeURI(s);
|
||||||
|
}
|
||||||
|
DOCS.forEach(d => { d.mk = d.mk || [];
|
||||||
|
d._s = (d.t+" "+d.a+" "+d.d+" "+d.k.join(" ")+" "+d.mk.join(" ")+" "
|
||||||
|
+d.th.join(" ")+" "+d.p).toLowerCase(); });
|
||||||
|
|
||||||
|
const q=document.getElementById('q'), ext=document.getElementById('ext'),
|
||||||
|
fold=document.getElementById('fold'), lang=document.getElementById('lang'),
|
||||||
|
list=document.getElementById('list'), stat=document.getElementById('stat');
|
||||||
|
let shown=60;
|
||||||
|
|
||||||
|
function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||||
|
function hl(s,terms){s=esc(s);for(const t of terms){if(t.length<2)continue;
|
||||||
|
s=s.replace(new RegExp('('+t.replace(/[.*+?^${}()|[\\]\\\\]/g,'\\\\$&')+')','gi'),'<mark>$1</mark>');}return s;}
|
||||||
|
|
||||||
|
function terms(){return q.value.toLowerCase().split(/[\\s,;]+/).filter(w=>w.length>1);}
|
||||||
|
|
||||||
|
function score(d,ts){
|
||||||
|
let sc=0;
|
||||||
|
for(const t of ts){
|
||||||
|
if(!d._s.includes(t)) return -1; // все слова обязательны
|
||||||
|
if(d.t.toLowerCase().includes(t)) sc+=10;
|
||||||
|
if(d.mk.some(k=>k.toLowerCase().includes(t))) sc+=12; // свои слова важнее
|
||||||
|
if(d.k.some(k=>k.includes(t))) sc+=6;
|
||||||
|
if(d.d.toLowerCase().includes(t)) sc+=3;
|
||||||
|
sc+=1;
|
||||||
|
}
|
||||||
|
return sc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
const ts=terms(), e=ext.value, f=fold.value, l=lang.value;
|
||||||
|
let res=[];
|
||||||
|
for(const d of DOCS){
|
||||||
|
if(e && d.e!==e) continue;
|
||||||
|
if(f && d.fold!==f) continue;
|
||||||
|
if(l && d.l!==l) continue;
|
||||||
|
const sc = ts.length? score(d,ts) : 0;
|
||||||
|
if(sc<0) continue;
|
||||||
|
res.push([sc,d]);
|
||||||
|
}
|
||||||
|
if(ts.length) res.sort((a,b)=>b[0]-a[0]);
|
||||||
|
stat.textContent = 'Найдено: '+res.length+' из '+DOCS.length;
|
||||||
|
const part=res.slice(0,shown);
|
||||||
|
let html='', lastF=null;
|
||||||
|
for(const [,d] of part){
|
||||||
|
if(!ts.length && d.fold!==lastF){ html+='<div class="folder">'+esc(d.fold)+'</div>'; lastF=d.fold; }
|
||||||
|
html+='<article class="doc"><h2>'+hl(d.t,ts)+'</h2><div class="meta">'+
|
||||||
|
esc([d.e.slice(1),d.kind,d.a,d.y||'',d.pg?d.pg+' с.':'',d.sz,d.sc?'скан':''].filter(Boolean).join(' · '))+
|
||||||
|
'</div><p class="desc">'+hl(d.d,ts)+'</p>';
|
||||||
|
if(d.th.length) html+='<ul class="th">'+d.th.map(t=>'<li>'+hl(t,ts)+'</li>').join('')+'</ul>';
|
||||||
|
if(d.mk.length || d.k.length){
|
||||||
|
html+='<div class="kw">'
|
||||||
|
+ d.mk.map(k=>'<span class="mine" onclick="q.value=this.textContent;render()">'+esc(k)+'</span>').join('')
|
||||||
|
+ d.k.map(k=>'<span onclick="q.value=this.textContent;render()">'+esc(k)+'</span>').join('')
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
html+='<div class="path"><a href="'+fileUrl(d.f)+'">'+esc(d.p)+'</a></div></article>';
|
||||||
|
}
|
||||||
|
if(res.length>shown) html+='<button class="more" onclick="shown+=100;render()">Показать ещё</button>';
|
||||||
|
if(!res.length) html='<div class="empty">Ничего не найдено</div>';
|
||||||
|
list.innerHTML=html;
|
||||||
|
}
|
||||||
|
let timer; q.oninput=()=>{clearTimeout(timer);shown=60;timer=setTimeout(render,120);};
|
||||||
|
ext.onchange=fold.onchange=lang.onchange=()=>{shown=60;render();};
|
||||||
|
render();
|
||||||
|
</script></body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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})
|
||||||
|
# "</" в данных закрыло бы <script> раньше времени.
|
||||||
|
payload = json.dumps(data, ensure_ascii=False).replace("</", "<\\/")
|
||||||
|
out = (HTML_TEMPLATE
|
||||||
|
.replace("__DATA__", payload)
|
||||||
|
.replace("__ROOT__", json.dumps(root))
|
||||||
|
.replace("__EXTS__", "".join(
|
||||||
|
'<option value="%s">%s</option>' % (e, html.escape(e.lstrip(".")))
|
||||||
|
for e in exts))
|
||||||
|
.replace("__FOLDERS__", "".join(
|
||||||
|
'<option value="%s">%s</option>' % (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"),
|
||||||
|
}
|
||||||
175
catalogizer/scanner.py
Normal file
175
catalogizer/scanner.py
Normal file
@@ -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)}
|
||||||
86
catalogizer/search.py
Normal file
86
catalogizer/search.py
Normal file
@@ -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
|
||||||
178
catalogizer/summarize.py
Normal file
178
catalogizer/summarize.py
Normal file
@@ -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)
|
||||||
156
catalogizer/textutil.py
Normal file
156
catalogizer/textutil.py
Normal file
@@ -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
|
||||||
3
catalogizer/ui/__init__.py
Normal file
3
catalogizer/ui/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""@file __init__.py
|
||||||
|
@brief Слой представления: тёмное окно PySide6 поверх ядра каталогизатора.
|
||||||
|
"""
|
||||||
378
catalogizer/ui/catalog_tab.py
Normal file
378
catalogizer/ui/catalog_tab.py
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
"""@file catalog_tab.py
|
||||||
|
@brief Вкладка каталога: поиск по ключевым словам и карточка документа.
|
||||||
|
|
||||||
|
Пустой запрос показывает весь каталог по разделам; непустой уходит в
|
||||||
|
полнотекстовый индекс с учётом морфологии и ранжированием BM25.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QKeySequence, QShortcut
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QCompleter,
|
||||||
|
QComboBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QSplitter,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QTextBrowser,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .. import config, db
|
||||||
|
from .. import search as search_mod
|
||||||
|
from .theme import CARD_CSS
|
||||||
|
|
||||||
|
LANGS = (("любой язык", None), ("русский", "ru"), ("английский", "en"))
|
||||||
|
COLUMNS = (("Название", 430), ("Тип", 100), ("Год", 60), ("Стр.", 60),
|
||||||
|
("Формат", 75), ("Раздел", 280))
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogTab(QWidget):
|
||||||
|
"""@brief Поиск по каталогу и просмотр карточки выбранного документа."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._rows: list[dict] = []
|
||||||
|
self._root = config.current_root()
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(12, 12, 12, 12)
|
||||||
|
layout.setSpacing(8)
|
||||||
|
layout.addLayout(self._build_query_row())
|
||||||
|
|
||||||
|
splitter = QSplitter(Qt.Orientation.Vertical)
|
||||||
|
splitter.addWidget(self._build_table())
|
||||||
|
splitter.addWidget(self._build_card())
|
||||||
|
splitter.setSizes([420, 260])
|
||||||
|
layout.addWidget(splitter, 1)
|
||||||
|
layout.addLayout(self._build_keyword_editor())
|
||||||
|
layout.addLayout(self._build_actions())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Построение интерфейса
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _build_query_row(self) -> QHBoxLayout:
|
||||||
|
row = QHBoxLayout()
|
||||||
|
self.query = QLineEdit()
|
||||||
|
self.query.setPlaceholderText(
|
||||||
|
"Поиск по названию, тезисам, ключевым словам и тексту… "
|
||||||
|
"(\"точная фраза\", -исключить)")
|
||||||
|
self.query.returnPressed.connect(self.refresh)
|
||||||
|
row.addWidget(self.query, 1)
|
||||||
|
|
||||||
|
find = QPushButton("Найти")
|
||||||
|
find.setProperty("primary", True)
|
||||||
|
find.clicked.connect(self.refresh)
|
||||||
|
row.addWidget(find)
|
||||||
|
|
||||||
|
self.ext_box = QComboBox()
|
||||||
|
self.ext_box.addItem("все форматы", None)
|
||||||
|
self.ext_box.currentIndexChanged.connect(self.refresh)
|
||||||
|
row.addWidget(self.ext_box)
|
||||||
|
|
||||||
|
self.lang_box = QComboBox()
|
||||||
|
for title, code in LANGS:
|
||||||
|
self.lang_box.addItem(title, code)
|
||||||
|
self.lang_box.currentIndexChanged.connect(self.refresh)
|
||||||
|
row.addWidget(self.lang_box)
|
||||||
|
|
||||||
|
self.section_box = QComboBox()
|
||||||
|
self.section_box.addItem("все разделы", None)
|
||||||
|
self.section_box.currentIndexChanged.connect(self.refresh)
|
||||||
|
self.section_box.setMinimumWidth(220)
|
||||||
|
row.addWidget(self.section_box)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _build_table(self) -> QTableWidget:
|
||||||
|
self.table = QTableWidget(0, len(COLUMNS))
|
||||||
|
self.table.setHorizontalHeaderLabels([c[0] for c in COLUMNS])
|
||||||
|
self.table.verticalHeader().setVisible(False)
|
||||||
|
self.table.setAlternatingRowColors(True)
|
||||||
|
self.table.setSelectionBehavior(
|
||||||
|
QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self.table.setSelectionMode(
|
||||||
|
QAbstractItemView.SelectionMode.SingleSelection)
|
||||||
|
self.table.setEditTriggers(
|
||||||
|
QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
for index, (_, width) in enumerate(COLUMNS):
|
||||||
|
self.table.setColumnWidth(index, width)
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(
|
||||||
|
0, QHeaderView.ResizeMode.Stretch)
|
||||||
|
self.table.itemSelectionChanged.connect(self._show_card)
|
||||||
|
self.table.doubleClicked.connect(self.open_document)
|
||||||
|
return self.table
|
||||||
|
|
||||||
|
def _build_card(self) -> QTextBrowser:
|
||||||
|
self.card = QTextBrowser()
|
||||||
|
self.card.setOpenExternalLinks(False)
|
||||||
|
self.card.document().setDefaultStyleSheet(CARD_CSS)
|
||||||
|
return self.card
|
||||||
|
|
||||||
|
def _build_keyword_editor(self) -> QHBoxLayout:
|
||||||
|
"""@brief Строка правки своих ключевых слов выбранного документа."""
|
||||||
|
row = QHBoxLayout()
|
||||||
|
label = QLabel("Свои ключевые слова:")
|
||||||
|
label.setProperty("muted", True)
|
||||||
|
row.addWidget(label)
|
||||||
|
|
||||||
|
self.my_keywords = QLineEdit()
|
||||||
|
self.my_keywords.setPlaceholderText(
|
||||||
|
"через запятую — сохраняются в каталоге и участвуют в поиске")
|
||||||
|
self.my_keywords.setEnabled(False)
|
||||||
|
self.my_keywords.returnPressed.connect(self.save_keywords)
|
||||||
|
row.addWidget(self.my_keywords, 1)
|
||||||
|
|
||||||
|
self.save_kw = QPushButton("Сохранить")
|
||||||
|
self.save_kw.setEnabled(False)
|
||||||
|
self.save_kw.clicked.connect(self.save_keywords)
|
||||||
|
row.addWidget(self.save_kw)
|
||||||
|
QShortcut(QKeySequence("Ctrl+S"), self, self.save_keywords)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _build_actions(self) -> QHBoxLayout:
|
||||||
|
row = QHBoxLayout()
|
||||||
|
open_doc = QPushButton("Открыть документ")
|
||||||
|
open_doc.clicked.connect(self.open_document)
|
||||||
|
row.addWidget(open_doc)
|
||||||
|
show_dir = QPushButton("Показать в папке")
|
||||||
|
show_dir.clicked.connect(self.open_containing_folder)
|
||||||
|
row.addWidget(show_dir)
|
||||||
|
row.addStretch(1)
|
||||||
|
self.found = QLabel("")
|
||||||
|
self.found.setProperty("muted", True)
|
||||||
|
row.addWidget(self.found)
|
||||||
|
return row
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Данные
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def set_root(self, root: Path) -> None:
|
||||||
|
"""@brief Переключает вкладку на каталог другой папки."""
|
||||||
|
self._root = Path(root)
|
||||||
|
self.reload_filters()
|
||||||
|
self._reload_completer()
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def _connect(self, create: bool = False):
|
||||||
|
db_path, _ = config.paths_for(self._root)
|
||||||
|
return db.connect(db_path, create=create)
|
||||||
|
|
||||||
|
def _reload_completer(self) -> None:
|
||||||
|
"""@brief Подсказки при вводе — из уже заведённых своих слов."""
|
||||||
|
try:
|
||||||
|
con = self._connect()
|
||||||
|
words = db.all_user_keywords(con)
|
||||||
|
con.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return
|
||||||
|
completer = QCompleter(words, self)
|
||||||
|
completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||||
|
completer.setFilterMode(Qt.MatchFlag.MatchContains)
|
||||||
|
self.my_keywords.setCompleter(completer)
|
||||||
|
|
||||||
|
def reload_filters(self) -> None:
|
||||||
|
"""@brief Обновляет списки форматов и разделов по содержимому базы."""
|
||||||
|
exts, folders = [], []
|
||||||
|
try:
|
||||||
|
con = self._connect()
|
||||||
|
exts = [r["ext"] for r in
|
||||||
|
con.execute("SELECT DISTINCT ext FROM docs ORDER BY ext")]
|
||||||
|
folders = [r["folder"] for r in con.execute(
|
||||||
|
"SELECT folder, COUNT(*) c FROM docs GROUP BY folder"
|
||||||
|
" ORDER BY c DESC LIMIT 200")]
|
||||||
|
con.close()
|
||||||
|
except Exception: # noqa: BLE001 — базы может ещё не быть
|
||||||
|
pass
|
||||||
|
|
||||||
|
for box, items, first in ((self.ext_box, exts, "все форматы"),
|
||||||
|
(self.section_box, folders, "все разделы")):
|
||||||
|
box.blockSignals(True)
|
||||||
|
box.clear()
|
||||||
|
box.addItem(first, None)
|
||||||
|
for item in items:
|
||||||
|
box.addItem(item, item)
|
||||||
|
box.blockSignals(False)
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
"""@brief Выполняет поиск (или показ всего каталога) и заполняет таблицу."""
|
||||||
|
text = self.query.text().strip()
|
||||||
|
ext = self.ext_box.currentData()
|
||||||
|
lang = self.lang_box.currentData()
|
||||||
|
section = self.section_box.currentData()
|
||||||
|
try:
|
||||||
|
con = self._connect()
|
||||||
|
if text:
|
||||||
|
self._rows = search_mod.search(con, text, limit=500, ext=ext,
|
||||||
|
lang=lang, folder=section)
|
||||||
|
else:
|
||||||
|
self._rows = self._browse(con, ext, lang, section)
|
||||||
|
con.close()
|
||||||
|
except FileNotFoundError:
|
||||||
|
self._rows = [] # каталог этой папки ещё не построен
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
self._rows = []
|
||||||
|
QMessageBox.warning(self, "Каталогизатор", str(e))
|
||||||
|
|
||||||
|
self.table.setRowCount(len(self._rows))
|
||||||
|
for row, doc in enumerate(self._rows):
|
||||||
|
values = (doc["title"], doc.get("kind") or "",
|
||||||
|
str(doc.get("year") or ""), str(doc.get("pages") or ""),
|
||||||
|
doc["ext"].lstrip("."), doc.get("folder") or "")
|
||||||
|
for col, value in enumerate(values):
|
||||||
|
item = QTableWidgetItem(value)
|
||||||
|
if col in (2, 3, 4):
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.table.setItem(row, col, item)
|
||||||
|
self.found.setText("Найдено: %d" % len(self._rows))
|
||||||
|
if self._rows:
|
||||||
|
self.table.selectRow(0)
|
||||||
|
else:
|
||||||
|
self.card.setHtml("<p class='facts'>Ничего не найдено.</p>")
|
||||||
|
self.my_keywords.clear()
|
||||||
|
self.my_keywords.setEnabled(False)
|
||||||
|
self.save_kw.setEnabled(False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _browse(con, ext, lang, section) -> list[dict]:
|
||||||
|
"""@brief Весь каталог по разделам — когда строка запроса пуста."""
|
||||||
|
sql = ["SELECT d.id, d.title, d.author, d.year, d.pages, d.ext, d.lang,",
|
||||||
|
" d.kind, d.folder, d.rel_path, d.path, d.description,",
|
||||||
|
" d.keywords, d.theses, d.scanned, d.error,",
|
||||||
|
" u.keywords AS user_keywords",
|
||||||
|
" FROM docs d LEFT JOIN user_keywords u ON u.path = d.path",
|
||||||
|
" WHERE 1 = 1"]
|
||||||
|
params: list = []
|
||||||
|
for column, value in (("ext", ext), ("lang", lang), ("folder", section)):
|
||||||
|
if value:
|
||||||
|
sql.append("AND d.%s = ?" % column)
|
||||||
|
params.append(value)
|
||||||
|
sql.append("ORDER BY d.folder, d.title LIMIT 3000")
|
||||||
|
rows = []
|
||||||
|
for r in con.execute("\n".join(sql), params):
|
||||||
|
doc = dict(r)
|
||||||
|
doc["keywords"] = json.loads(doc["keywords"] or "[]")
|
||||||
|
doc["theses"] = json.loads(doc["theses"] or "[]")
|
||||||
|
doc["user_keywords"] = json.loads(doc["user_keywords"] or "[]")
|
||||||
|
doc["snip"] = ""
|
||||||
|
rows.append(doc)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _current(self) -> dict | None:
|
||||||
|
row = self.table.currentRow()
|
||||||
|
if row < 0 or row >= len(self._rows):
|
||||||
|
return None
|
||||||
|
return self._rows[row]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Карточка и действия
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _show_card(self) -> None:
|
||||||
|
doc = self._current()
|
||||||
|
if doc is None:
|
||||||
|
return
|
||||||
|
esc = html.escape
|
||||||
|
facts = [doc["ext"].lstrip("."), doc.get("kind") or ""]
|
||||||
|
if doc.get("author"):
|
||||||
|
facts.append(doc["author"])
|
||||||
|
if doc.get("year"):
|
||||||
|
facts.append(str(doc["year"]))
|
||||||
|
if doc.get("pages"):
|
||||||
|
facts.append("%s с." % doc["pages"])
|
||||||
|
if doc.get("scanned"):
|
||||||
|
facts.append("скан без текстового слоя")
|
||||||
|
if doc.get("error"):
|
||||||
|
facts.append(doc["error"])
|
||||||
|
|
||||||
|
parts = ["<h2>%s</h2>" % esc(doc["title"]),
|
||||||
|
"<div class='facts'>%s</div>"
|
||||||
|
% esc(" · ".join(f for f in facts if f)),
|
||||||
|
"<div class='desc'>%s</div>" % esc(doc.get("description") or "")]
|
||||||
|
if doc.get("theses"):
|
||||||
|
parts.append("<div class='label'>Тезисы</div><ul>")
|
||||||
|
parts += ["<li>%s</li>" % esc(t) for t in doc["theses"]]
|
||||||
|
parts.append("</ul>")
|
||||||
|
if doc.get("user_keywords"):
|
||||||
|
parts.append("<div class='label'>Свои ключевые слова</div>"
|
||||||
|
"<div class='mine'>%s</div>"
|
||||||
|
% esc(", ".join(doc["user_keywords"])))
|
||||||
|
if doc.get("keywords"):
|
||||||
|
parts.append("<div class='label'>Ключевые слова</div>"
|
||||||
|
"<div class='kw'>%s</div>"
|
||||||
|
% esc(", ".join(doc["keywords"])))
|
||||||
|
if doc.get("snip"):
|
||||||
|
snippet = esc(" ".join(doc["snip"].split()))
|
||||||
|
snippet = snippet.replace("«", "<mark>").replace("»", "</mark>")
|
||||||
|
parts.append("<div class='label'>Из текста</div><div>%s</div>"
|
||||||
|
% snippet)
|
||||||
|
parts.append("<div class='path'>%s</div>" % esc(doc["path"]))
|
||||||
|
self.card.setHtml("".join(parts))
|
||||||
|
|
||||||
|
self.my_keywords.setText(", ".join(doc.get("user_keywords") or []))
|
||||||
|
self.my_keywords.setEnabled(True)
|
||||||
|
self.save_kw.setEnabled(True)
|
||||||
|
|
||||||
|
def save_keywords(self) -> None:
|
||||||
|
"""@brief Записывает свои ключевые слова выбранного документа."""
|
||||||
|
doc = self._current()
|
||||||
|
if doc is None:
|
||||||
|
return
|
||||||
|
words = [w.strip() for w in self.my_keywords.text().split(",")]
|
||||||
|
try:
|
||||||
|
con = self._connect(create=True)
|
||||||
|
saved = db.set_user_keywords(con, doc["path"], words)
|
||||||
|
con.close()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
QMessageBox.warning(self, "Каталогизатор", str(e))
|
||||||
|
return
|
||||||
|
doc["user_keywords"] = saved
|
||||||
|
self.my_keywords.setText(", ".join(saved))
|
||||||
|
self._show_card()
|
||||||
|
self._reload_completer()
|
||||||
|
self.found.setText("Ключевые слова сохранены")
|
||||||
|
|
||||||
|
def open_document(self) -> None:
|
||||||
|
"""@brief Открывает выбранный документ системным приложением."""
|
||||||
|
doc = self._current()
|
||||||
|
if doc:
|
||||||
|
self._open(Path(doc["path"]))
|
||||||
|
|
||||||
|
def open_containing_folder(self) -> None:
|
||||||
|
"""@brief Показывает документ в проводнике."""
|
||||||
|
doc = self._current()
|
||||||
|
if not doc:
|
||||||
|
return
|
||||||
|
path = Path(doc["path"])
|
||||||
|
if os.name == "nt":
|
||||||
|
subprocess.Popen(["explorer", "/select,", str(path)])
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
subprocess.Popen(["open", "-R", str(path)]) # показать в Finder
|
||||||
|
else:
|
||||||
|
self._open(path.parent)
|
||||||
|
|
||||||
|
def _open(self, path: Path) -> None:
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
os.startfile(str(path)) # noqa: S606 — открываем через оболочку
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
subprocess.Popen(["open", str(path)])
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["xdg-open", str(path)])
|
||||||
|
except OSError as e:
|
||||||
|
QMessageBox.warning(self, "Каталогизатор",
|
||||||
|
"Не удалось открыть:\n%s\n%s" % (path, e))
|
||||||
124
catalogizer/ui/main_window.py
Normal file
124
catalogizer/ui/main_window.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""@file main_window.py
|
||||||
|
@brief Главное окно каталогизатора: шапка, вкладки разбора и поиска.
|
||||||
|
|
||||||
|
Обе вкладки работают с одной и той же папкой: смена папки на вкладке
|
||||||
|
разбора сразу переключает каталог, по которому идёт поиск.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QMainWindow,
|
||||||
|
QTabWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .. import config, db
|
||||||
|
from .catalog_tab import CatalogTab
|
||||||
|
from .scan_tab import ScanTab
|
||||||
|
from .theme import APP_STYLESHEET
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
"""@brief Окно с вкладками «Каталог и поиск» и «Разбор папки»."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path | None = None,
|
||||||
|
parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Каталогизатор документов")
|
||||||
|
self.resize(1280, 820)
|
||||||
|
self.setMinimumSize(1000, 640)
|
||||||
|
self.setStyleSheet(APP_STYLESHEET)
|
||||||
|
|
||||||
|
self.catalog_tab = CatalogTab()
|
||||||
|
self.scan_tab = ScanTab()
|
||||||
|
|
||||||
|
central = QWidget()
|
||||||
|
layout = QVBoxLayout(central)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
layout.addWidget(self._build_header())
|
||||||
|
layout.addWidget(self._build_tabs(), 1)
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
|
||||||
|
self.scan_tab.catalog_ready.connect(self._on_catalog_ready)
|
||||||
|
self.scan_tab.root_changed.connect(self._on_root_changed)
|
||||||
|
|
||||||
|
self.set_root(Path(root) if root else config.current_root())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _build_header(self) -> QFrame:
|
||||||
|
header = QFrame()
|
||||||
|
header.setObjectName("header")
|
||||||
|
row = QHBoxLayout(header)
|
||||||
|
row.setContentsMargins(18, 12, 18, 12)
|
||||||
|
|
||||||
|
titles = QVBoxLayout()
|
||||||
|
title = QLabel("Каталогизатор")
|
||||||
|
title.setProperty("title", True)
|
||||||
|
subtitle = QLabel("Описания, тезисы и поиск по папке с документами")
|
||||||
|
subtitle.setProperty("muted", True)
|
||||||
|
titles.addWidget(title)
|
||||||
|
titles.addWidget(subtitle)
|
||||||
|
row.addLayout(titles)
|
||||||
|
row.addStretch(1)
|
||||||
|
|
||||||
|
self.summary = QLabel("")
|
||||||
|
self.summary.setProperty("muted", True)
|
||||||
|
row.addWidget(self.summary)
|
||||||
|
return header
|
||||||
|
|
||||||
|
def _build_tabs(self) -> QTabWidget:
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
self.tabs.addTab(self.catalog_tab, "Каталог и поиск")
|
||||||
|
self.tabs.addTab(self.scan_tab, "Разбор папки")
|
||||||
|
return self.tabs
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def set_root(self, root: Path) -> None:
|
||||||
|
"""@brief Переключает обе вкладки на указанную папку."""
|
||||||
|
self.scan_tab.set_root(root)
|
||||||
|
self.catalog_tab.set_root(root)
|
||||||
|
self._update_summary(root)
|
||||||
|
|
||||||
|
def _on_root_changed(self, text: str) -> None:
|
||||||
|
root = Path(text.strip('" '))
|
||||||
|
self.catalog_tab.set_root(root)
|
||||||
|
self._update_summary(root)
|
||||||
|
|
||||||
|
def _on_catalog_ready(self, text: str) -> None:
|
||||||
|
root = Path(text)
|
||||||
|
self.catalog_tab.set_root(root)
|
||||||
|
self._update_summary(root)
|
||||||
|
self.tabs.setCurrentWidget(self.catalog_tab)
|
||||||
|
|
||||||
|
def _update_summary(self, root: Path) -> None:
|
||||||
|
"""@brief Показывает в шапке, что уже есть в каталоге этой папки."""
|
||||||
|
try:
|
||||||
|
db_path, _ = config.paths_for(root)
|
||||||
|
con = db.connect(db_path, create=False)
|
||||||
|
total = con.execute("SELECT COUNT(*) c FROM docs").fetchone()["c"]
|
||||||
|
scans = con.execute(
|
||||||
|
"SELECT COUNT(*) c FROM docs WHERE scanned = 1").fetchone()["c"]
|
||||||
|
when = db.get_meta(con, "last_scan")
|
||||||
|
con.close()
|
||||||
|
except Exception: # noqa: BLE001 — база может быть ещё не создана
|
||||||
|
self.summary.setText("")
|
||||||
|
return
|
||||||
|
if not total:
|
||||||
|
self.summary.setText("Каталог пуст — постройте его на вкладке «Разбор папки»")
|
||||||
|
return
|
||||||
|
self.summary.setText(
|
||||||
|
"В каталоге %d документов, из них %d сканов · обновлён %s"
|
||||||
|
% (total, scans, when or "—"))
|
||||||
|
|
||||||
|
def closeEvent(self, event) -> None: # noqa: N802 — имя из Qt
|
||||||
|
if self.scan_tab.busy():
|
||||||
|
self.scan_tab.stop_build()
|
||||||
|
event.accept()
|
||||||
244
catalogizer/ui/scan_tab.py
Normal file
244
catalogizer/ui/scan_tab.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""@file scan_tab.py
|
||||||
|
@brief Вкладка разбора: выбор папки, параметры, ход работы и журнал.
|
||||||
|
|
||||||
|
Здесь запускается полный цикл — обход папки, извлечение текста, тезисы и
|
||||||
|
выгрузка отчётов. Работа идёт в BuildWorker, окно остаётся живым.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QFileDialog,
|
||||||
|
QFrame,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .. import config, db, report
|
||||||
|
from .worker import BuildWorker
|
||||||
|
|
||||||
|
|
||||||
|
class ScanTab(QWidget):
|
||||||
|
"""@brief Управление разбором папки и показ хода работы."""
|
||||||
|
|
||||||
|
#: каталог перестроен — вкладке поиска пора перечитать данные
|
||||||
|
catalog_ready = Signal(str)
|
||||||
|
#: пользователь выбрал другую папку
|
||||||
|
root_changed = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._worker: BuildWorker | None = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(12, 12, 12, 12)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
layout.addWidget(self._build_source_panel())
|
||||||
|
layout.addWidget(self._build_progress_panel())
|
||||||
|
|
||||||
|
journal = QLabel("Журнал")
|
||||||
|
journal.setProperty("section", True)
|
||||||
|
layout.addWidget(journal)
|
||||||
|
self.log = QPlainTextEdit()
|
||||||
|
self.log.setReadOnly(True)
|
||||||
|
layout.addWidget(self.log, 1)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _build_source_panel(self) -> QFrame:
|
||||||
|
panel = QFrame()
|
||||||
|
panel.setProperty("panel", True)
|
||||||
|
grid = QGridLayout(panel)
|
||||||
|
grid.setContentsMargins(14, 12, 14, 12)
|
||||||
|
grid.setHorizontalSpacing(8)
|
||||||
|
|
||||||
|
title = QLabel("Папка с документами")
|
||||||
|
title.setProperty("section", True)
|
||||||
|
grid.addWidget(title, 0, 0, 1, 4)
|
||||||
|
|
||||||
|
self.folder = QLineEdit(str(config.current_root()))
|
||||||
|
self.folder.editingFinished.connect(
|
||||||
|
lambda: self.root_changed.emit(self.folder.text()))
|
||||||
|
grid.addWidget(self.folder, 1, 0, 1, 3)
|
||||||
|
|
||||||
|
browse = QPushButton("Обзор…")
|
||||||
|
browse.clicked.connect(self.pick_folder)
|
||||||
|
grid.addWidget(browse, 1, 3)
|
||||||
|
|
||||||
|
options = QHBoxLayout()
|
||||||
|
options.addWidget(QLabel("потоков чтения:"))
|
||||||
|
self.jobs = QSpinBox()
|
||||||
|
self.jobs.setRange(1, 32)
|
||||||
|
self.jobs.setValue(4)
|
||||||
|
self.jobs.setToolTip("Жёсткий диск — 2–4, SSD — 8–16.")
|
||||||
|
options.addWidget(self.jobs)
|
||||||
|
options.addSpacing(16)
|
||||||
|
options.addWidget(QLabel("страниц на документ:"))
|
||||||
|
self.max_pages = QSpinBox()
|
||||||
|
self.max_pages.setRange(3, 200)
|
||||||
|
self.max_pages.setValue(config.MAX_PDF_PAGES)
|
||||||
|
self.max_pages.setToolTip(
|
||||||
|
"Сколько первых страниц читать: больше — точнее тезисы, но дольше.")
|
||||||
|
options.addWidget(self.max_pages)
|
||||||
|
options.addSpacing(16)
|
||||||
|
self.force = QCheckBox("перечитать всё заново")
|
||||||
|
self.force.setToolTip(
|
||||||
|
"Обычно читаются только новые и изменившиеся файлы.")
|
||||||
|
options.addWidget(self.force)
|
||||||
|
options.addStretch(1)
|
||||||
|
grid.addLayout(options, 2, 0, 1, 4)
|
||||||
|
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
self.start = QPushButton("Построить каталог")
|
||||||
|
self.start.setProperty("primary", True)
|
||||||
|
self.start.clicked.connect(self.start_build)
|
||||||
|
buttons.addWidget(self.start)
|
||||||
|
self.stop = QPushButton("Остановить")
|
||||||
|
self.stop.setEnabled(False)
|
||||||
|
self.stop.clicked.connect(self.stop_build)
|
||||||
|
buttons.addWidget(self.stop)
|
||||||
|
buttons.addStretch(1)
|
||||||
|
reports = QPushButton("Пересобрать отчёты")
|
||||||
|
reports.clicked.connect(self.rebuild_reports)
|
||||||
|
buttons.addWidget(reports)
|
||||||
|
html_button = QPushButton("Открыть HTML-каталог")
|
||||||
|
html_button.clicked.connect(self.open_html)
|
||||||
|
buttons.addWidget(html_button)
|
||||||
|
grid.addLayout(buttons, 3, 0, 1, 4)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def _build_progress_panel(self) -> QFrame:
|
||||||
|
panel = QFrame()
|
||||||
|
panel.setProperty("panel", True)
|
||||||
|
box = QVBoxLayout(panel)
|
||||||
|
box.setContentsMargins(14, 12, 14, 12)
|
||||||
|
self.bar = QProgressBar()
|
||||||
|
self.bar.setRange(0, 100)
|
||||||
|
self.bar.setValue(0)
|
||||||
|
box.addWidget(self.bar)
|
||||||
|
self.state = QLabel("Готово. Выберите папку и нажмите «Построить каталог».")
|
||||||
|
self.state.setProperty("muted", True)
|
||||||
|
box.addWidget(self.state)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def root(self) -> Path:
|
||||||
|
return Path(self.folder.text().strip('" '))
|
||||||
|
|
||||||
|
def set_root(self, root: Path) -> None:
|
||||||
|
self.folder.setText(str(root))
|
||||||
|
|
||||||
|
def say(self, text: str) -> None:
|
||||||
|
"""@brief Добавляет строку в журнал."""
|
||||||
|
self.log.appendPlainText(text)
|
||||||
|
|
||||||
|
def pick_folder(self) -> None:
|
||||||
|
chosen = QFileDialog.getExistingDirectory(
|
||||||
|
self, "Папка с документами", str(self.root()))
|
||||||
|
if chosen:
|
||||||
|
self.folder.setText(str(Path(chosen)))
|
||||||
|
self.root_changed.emit(self.folder.text())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def start_build(self) -> None:
|
||||||
|
"""@brief Запускает полный цикл в фоновом потоке."""
|
||||||
|
root = self.root()
|
||||||
|
if not root.is_dir():
|
||||||
|
QMessageBox.warning(self, "Каталогизатор",
|
||||||
|
"Папка не найдена:\n%s" % root)
|
||||||
|
return
|
||||||
|
self.start.setEnabled(False)
|
||||||
|
self.stop.setEnabled(True)
|
||||||
|
self.bar.setRange(0, 0) # неопределённый ход: идёт обход папки
|
||||||
|
self.state.setText("Обход папки…")
|
||||||
|
|
||||||
|
self._worker = BuildWorker(root, self.jobs.value(),
|
||||||
|
self.max_pages.value(), self.force.isChecked())
|
||||||
|
self._worker.progress.connect(self._on_progress)
|
||||||
|
self._worker.message.connect(self.say)
|
||||||
|
self._worker.finished_ok.connect(self._on_done)
|
||||||
|
self._worker.failed.connect(self._on_failed)
|
||||||
|
self._worker.start()
|
||||||
|
|
||||||
|
def stop_build(self) -> None:
|
||||||
|
if self._worker:
|
||||||
|
self._worker.request_stop()
|
||||||
|
self.state.setText("Останавливаюсь после текущей порции…")
|
||||||
|
|
||||||
|
def _on_progress(self, done: int, total: int, text: str) -> None:
|
||||||
|
if total > 0:
|
||||||
|
self.bar.setRange(0, total)
|
||||||
|
self.bar.setValue(done)
|
||||||
|
else:
|
||||||
|
self.bar.setRange(0, 0)
|
||||||
|
self.state.setText(text)
|
||||||
|
|
||||||
|
def _on_done(self, text: str) -> None:
|
||||||
|
self.bar.setRange(0, 100)
|
||||||
|
self.bar.setValue(100)
|
||||||
|
self.state.setText(text)
|
||||||
|
self.say(text)
|
||||||
|
self.start.setEnabled(True)
|
||||||
|
self.stop.setEnabled(False)
|
||||||
|
self.catalog_ready.emit(str(self.root()))
|
||||||
|
|
||||||
|
def _on_failed(self, text: str) -> None:
|
||||||
|
self.bar.setRange(0, 100)
|
||||||
|
self.bar.setValue(0)
|
||||||
|
self.state.setText("Ошибка разбора")
|
||||||
|
self.say(text)
|
||||||
|
self.start.setEnabled(True)
|
||||||
|
self.stop.setEnabled(False)
|
||||||
|
QMessageBox.critical(self, "Каталогизатор",
|
||||||
|
text.strip().splitlines()[-1])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def out_dir(self) -> Path:
|
||||||
|
return config.paths_for(self.root())[1]
|
||||||
|
|
||||||
|
def rebuild_reports(self) -> None:
|
||||||
|
"""@brief Пересобирает HTML, Markdown, JSON и CSV из готовой базы."""
|
||||||
|
try:
|
||||||
|
db_path, out_dir = config.paths_for(self.root())
|
||||||
|
con = db.connect(db_path, create=False)
|
||||||
|
paths = report.build_all(con, out_dir)
|
||||||
|
con.close()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
QMessageBox.critical(self, "Каталогизатор", str(e))
|
||||||
|
return
|
||||||
|
for kind, path in paths.items():
|
||||||
|
self.say("%-5s %s" % (kind, path))
|
||||||
|
self.state.setText("Отчёты сохранены в %s" % out_dir)
|
||||||
|
|
||||||
|
def open_html(self) -> None:
|
||||||
|
"""@brief Открывает автономную HTML-страницу каталога."""
|
||||||
|
path = self.out_dir() / "catalog.html"
|
||||||
|
if not path.exists():
|
||||||
|
QMessageBox.information(
|
||||||
|
self, "Каталогизатор",
|
||||||
|
"HTML-каталог ещё не собран.\nНажмите «Построить каталог».")
|
||||||
|
return
|
||||||
|
if os.name == "nt":
|
||||||
|
os.startfile(str(path)) # noqa: S606
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
subprocess.Popen(["open", str(path)])
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["xdg-open", str(path)])
|
||||||
|
|
||||||
|
def busy(self) -> bool:
|
||||||
|
return bool(self._worker and self._worker.isRunning())
|
||||||
140
catalogizer/ui/theme.py
Normal file
140
catalogizer/ui/theme.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""@file theme.py
|
||||||
|
@brief Единая спокойная тёмная тема каталогизатора без внешних ресурсов.
|
||||||
|
|
||||||
|
Палитра и правила оформления совпадают со стилем SETGUI, чтобы обе
|
||||||
|
программы выглядели частями одного инструментария.
|
||||||
|
"""
|
||||||
|
|
||||||
|
APP_STYLESHEET = """
|
||||||
|
QWidget {
|
||||||
|
background: #10151d;
|
||||||
|
color: #dce6f2;
|
||||||
|
font-family: "Segoe UI", "SF Pro Text", "Helvetica Neue", "Noto Sans",
|
||||||
|
sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
QMainWindow { background: #0d1219; }
|
||||||
|
QFrame#header {
|
||||||
|
background: #151d28;
|
||||||
|
border-bottom: 1px solid #263345;
|
||||||
|
}
|
||||||
|
QFrame[panel="true"] {
|
||||||
|
background: #151d28;
|
||||||
|
border: 1px solid #263345;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
QLabel[muted="true"] { color: #8291a5; }
|
||||||
|
QLabel[title="true"] {
|
||||||
|
color: #f4f8fc;
|
||||||
|
font-size: 15pt;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
QLabel[section="true"] {
|
||||||
|
color: #f4f8fc;
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
QPushButton {
|
||||||
|
background: #243247;
|
||||||
|
border: 1px solid #344762;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background: #2d405b; }
|
||||||
|
QPushButton:pressed { background: #1c293b; }
|
||||||
|
QPushButton:disabled { color: #627086; background: #192230; }
|
||||||
|
QPushButton[primary="true"] {
|
||||||
|
background: #1570d8;
|
||||||
|
border-color: #2584ec;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
QPushButton[primary="true"]:hover { background: #2584ec; }
|
||||||
|
QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {
|
||||||
|
background: #0d131c;
|
||||||
|
border: 1px solid #34445a;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 6px;
|
||||||
|
selection-background-color: #1570d8;
|
||||||
|
}
|
||||||
|
QComboBox::drop-down { border: 0; width: 24px; }
|
||||||
|
QComboBox QAbstractItemView {
|
||||||
|
background: #0d131c;
|
||||||
|
border: 1px solid #34445a;
|
||||||
|
selection-background-color: #1570d8;
|
||||||
|
}
|
||||||
|
QTabWidget::pane {
|
||||||
|
border: 1px solid #263345;
|
||||||
|
background: #10151d;
|
||||||
|
}
|
||||||
|
QTabBar::tab {
|
||||||
|
background: #151d28;
|
||||||
|
color: #91a0b4;
|
||||||
|
border: 1px solid #263345;
|
||||||
|
padding: 9px 15px;
|
||||||
|
}
|
||||||
|
QTabBar::tab:selected {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #1c2838;
|
||||||
|
border-bottom: 2px solid #2f91ff;
|
||||||
|
}
|
||||||
|
QHeaderView::section {
|
||||||
|
background: #1b2635;
|
||||||
|
color: #b9c5d5;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid #2b394c;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
QTableWidget, QTreeWidget {
|
||||||
|
background: #0d131c;
|
||||||
|
alternate-background-color: #111a25;
|
||||||
|
gridline-color: #263345;
|
||||||
|
border: 1px solid #263345;
|
||||||
|
selection-background-color: #1570d8;
|
||||||
|
selection-color: #ffffff;
|
||||||
|
}
|
||||||
|
QTextBrowser, QPlainTextEdit {
|
||||||
|
background: #090e14;
|
||||||
|
color: #aabbd0;
|
||||||
|
border: 1px solid #263345;
|
||||||
|
font-family: "Cascadia Mono", "SF Mono", Menlo, "DejaVu Sans Mono",
|
||||||
|
monospace;
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
QProgressBar {
|
||||||
|
background: #0d131c;
|
||||||
|
border: 1px solid #34445a;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
QProgressBar::chunk { background: #1570d8; border-radius: 4px; }
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background: #10151d;
|
||||||
|
width: 11px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background: #34445a;
|
||||||
|
min-height: 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QSplitter::handle { background: #1b2635; }
|
||||||
|
QStatusBar { background: #151d28; color: #8291a5; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Цвета карточки документа: HTML-разметка QTextBrowser темы не наследует.
|
||||||
|
CARD_CSS = """
|
||||||
|
body { color: #dce6f2;
|
||||||
|
font-family: "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||||
|
font-size: 10pt; }
|
||||||
|
h2 { color: #f4f8fc; font-size: 12pt; margin: 0 0 4px 0; }
|
||||||
|
.facts { color: #8291a5; font-size: 9pt; margin-bottom: 10px; }
|
||||||
|
.desc { margin-bottom: 10px; }
|
||||||
|
.label { color: #2f91ff; font-weight: 600; }
|
||||||
|
li { margin-bottom: 4px; }
|
||||||
|
.kw { color: #b9c5d5; }
|
||||||
|
.mine { color: #2f91ff; font-weight: 600; }
|
||||||
|
.path { color: #627086;
|
||||||
|
font-family: "Cascadia Mono", "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 8pt; }
|
||||||
|
mark { background: #1570d8; color: #ffffff; }
|
||||||
|
"""
|
||||||
78
catalogizer/ui/worker.py
Normal file
78
catalogizer/ui/worker.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""@file worker.py
|
||||||
|
@brief Фоновый поток построения каталога.
|
||||||
|
|
||||||
|
Разбор библиотеки идёт минутами и часами, поэтому выполняется в отдельном
|
||||||
|
потоке QThread: окно остаётся отзывчивым, а ход работы приходит сигналами.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QThread, Signal
|
||||||
|
|
||||||
|
from .. import analyze as analyze_mod
|
||||||
|
from .. import config, db, report, scanner
|
||||||
|
|
||||||
|
|
||||||
|
class BuildWorker(QThread):
|
||||||
|
"""@brief Полный цикл: обход папки, анализ, выгрузка отчётов."""
|
||||||
|
|
||||||
|
#: сделано, всего, поясняющая строка
|
||||||
|
progress = Signal(int, int, str)
|
||||||
|
#: строка в журнал
|
||||||
|
message = Signal(str)
|
||||||
|
#: итоговая строка состояния
|
||||||
|
finished_ok = Signal(str)
|
||||||
|
#: текст ошибки с трассировкой
|
||||||
|
failed = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, root: Path, jobs: int, max_pages: int,
|
||||||
|
force: bool = False, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._root = Path(root)
|
||||||
|
self._jobs = jobs
|
||||||
|
self._max_pages = max_pages
|
||||||
|
self._force = force
|
||||||
|
self._stop = False
|
||||||
|
|
||||||
|
def request_stop(self) -> None:
|
||||||
|
"""@brief Просит остановиться после текущей порции файлов.
|
||||||
|
|
||||||
|
Разобранное уже записано в базу, поэтому следующий запуск продолжит
|
||||||
|
с того же места.
|
||||||
|
"""
|
||||||
|
self._stop = True
|
||||||
|
|
||||||
|
def run(self) -> None: # noqa: D102 — контракт QThread
|
||||||
|
try:
|
||||||
|
config.remember(self._root)
|
||||||
|
db_path, out_dir = config.paths_for(self._root)
|
||||||
|
con = db.connect(db_path)
|
||||||
|
self.message.emit("Папка: %s" % self._root)
|
||||||
|
|
||||||
|
stats = scanner.scan(
|
||||||
|
con, self._root, max_pages=self._max_pages, jobs=self._jobs,
|
||||||
|
force=self._force, quiet=True,
|
||||||
|
progress=lambda d, t, m: self.progress.emit(d, t, m),
|
||||||
|
stop=lambda: self._stop)
|
||||||
|
self.message.emit(
|
||||||
|
"Разобрано %(processed)d, с ошибками %(errors)d, "
|
||||||
|
"пропущено %(skipped)d, за %(seconds)s с" % stats)
|
||||||
|
|
||||||
|
self.progress.emit(0, 0, "Анализ текстов…")
|
||||||
|
analyze_mod.analyze(
|
||||||
|
con, quiet=True,
|
||||||
|
progress=lambda d, t, m: self.progress.emit(d, t, m))
|
||||||
|
|
||||||
|
self.progress.emit(0, 0, "Выгрузка отчётов…")
|
||||||
|
for kind, path in report.build_all(con, out_dir).items():
|
||||||
|
self.message.emit("%-5s %s" % (kind, path))
|
||||||
|
total = con.execute("SELECT COUNT(*) c FROM docs").fetchone()["c"]
|
||||||
|
con.close()
|
||||||
|
self.finished_ok.emit(
|
||||||
|
"Каталог готов: %d документов%s"
|
||||||
|
% (total, " (остановлено)" if self._stop else ""))
|
||||||
|
except Exception: # noqa: BLE001 — окно должно показать причину
|
||||||
|
self.failed.emit(traceback.format_exc())
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pymupdf>=1.24
|
||||||
|
PySide6>=6.7,<7
|
||||||
155
scripts/build_exe.py
Normal file
155
scripts/build_exe.py
Normal file
@@ -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())
|
||||||
167
scripts/make_icon.py
Normal file
167
scripts/make_icon.py
Normal file
@@ -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("<IiiHHIIiiII", 40, size, size * 2, 1, 32, 0,
|
||||||
|
size * size * 4, 0, 0, 0, 0)
|
||||||
|
mask_row = ((size + 31) // 32) * 4
|
||||||
|
return header + _bgra_bottom_up(size) + bytes(mask_row * size)
|
||||||
|
|
||||||
|
|
||||||
|
def build_ico(target: Path = TARGET_ICO) -> Path:
|
||||||
|
"""@brief Пишет многоразмерный .ico и возвращает путь к нему."""
|
||||||
|
images = [_ico_entry(size) for size in SIZES]
|
||||||
|
offset = 6 + 16 * len(images)
|
||||||
|
out = bytearray(struct.pack("<HHH", 0, 1, len(images)))
|
||||||
|
for size, data in zip(SIZES, images):
|
||||||
|
out += struct.pack("<BBBBHHII", size % 256, size % 256, 0, 0, 1, 32,
|
||||||
|
len(data), offset)
|
||||||
|
offset += len(data)
|
||||||
|
for data in images:
|
||||||
|
out += data
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(bytes(out))
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# macOS: .icns
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _rgba_top_down(size: int) -> 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())
|
||||||
Reference in New Issue
Block a user