From 24200b401a0382e8a6d86d9f3974bf5f1e93219d Mon Sep 17 00:00:00 2001 From: mihailkudravcev Date: Thu, 16 Jul 2026 15:38:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B5=D1=82=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F.=20?= =?UTF-8?q?=D0=9D=D0=B5=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B8=D0=B5=20=D0=B8?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 185 +++++-- config.example.json | 4 + index1c/__init__.py | 3 + index1c/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 365 bytes index1c/__pycache__/build.cpython-311.pyc | Bin 0 -> 17033 bytes index1c/__pycache__/cli.cpython-311.pyc | Bin 0 -> 14521 bytes index1c/__pycache__/config.cpython-311.pyc | Bin 0 -> 9876 bytes index1c/__pycache__/db.cpython-311.pyc | Bin 0 -> 21383 bytes .../__pycache__/parse_meta.cpython-311.pyc | Bin 0 -> 16444 bytes .../__pycache__/parse_modules.cpython-311.pyc | Bin 0 -> 4413 bytes index1c/__pycache__/search.cpython-311.pyc | Bin 0 -> 16144 bytes index1c/__pycache__/writers.cpython-311.pyc | Bin 0 -> 19696 bytes index1c/build.py | 349 ++++++++++++ index1c/cli.py | 242 ++++++++ index1c/config.py | 231 ++++++++ index1c/db.py | 520 ++++++++++++++++++ index1c/parse_meta.py | 351 ++++++++++++ index1c/parse_modules.py | 89 +++ index1c/search.py | 287 ++++++++++ index1c/writers.py | 334 +++++++++++ index_1c.py | 218 ++++++++ query_1c.py | 222 ++++++++ 22 files changed, 2986 insertions(+), 49 deletions(-) create mode 100644 config.example.json create mode 100644 index1c/__init__.py create mode 100644 index1c/__pycache__/__init__.cpython-311.pyc create mode 100644 index1c/__pycache__/build.cpython-311.pyc create mode 100644 index1c/__pycache__/cli.cpython-311.pyc create mode 100644 index1c/__pycache__/config.cpython-311.pyc create mode 100644 index1c/__pycache__/db.cpython-311.pyc create mode 100644 index1c/__pycache__/parse_meta.cpython-311.pyc create mode 100644 index1c/__pycache__/parse_modules.cpython-311.pyc create mode 100644 index1c/__pycache__/search.cpython-311.pyc create mode 100644 index1c/__pycache__/writers.cpython-311.pyc create mode 100644 index1c/build.py create mode 100644 index1c/cli.py create mode 100644 index1c/config.py create mode 100644 index1c/db.py create mode 100644 index1c/parse_meta.py create mode 100644 index1c/parse_modules.py create mode 100644 index1c/search.py create mode 100644 index1c/writers.py create mode 100755 index_1c.py create mode 100755 query_1c.py diff --git a/README.md b/README.md index 7a5bc19..961bd18 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,155 @@ -# Индекс метаданных конфигураций 1С +# Индекс метаданных и модулей конфигураций 1С -Скрипты в `tools/index/` строят кэш для быстрого поиска имён, синонимов, полей и ссылок по XML-выгрузкам EDT — без полного обхода тысяч `.xml` в Cursor. +Единый кэш: **`cache/1c_meta/index.sqlite`** — все выгрузки 1С проекта в одной БД (FTS5). -## Быстрый старт +| Скрипт | Назначение | +|--------|------------| +| [`index_1c.py`](index_1c.py) | построение и обновление индекса | +| [`query_1c.py`](query_1c.py) | поиск по готовому индексу | + +Документация для Cursor: [`.cursor/rules/1c-meta-index.mdc`](../.cursor/rules/1c-meta-index.mdc) + +--- + +## Параметры командной строки + +| Параметр | Короткий | Что это | +|----------|----------|---------| +| `--config` | `-c` | **Файл настроек утилиты** (JSON), см. [`config.example.json`](config.example.json). Не конфигурация 1С. | +| `--root` | `-r` | Корень проекта CRM3-26 (если скрипт запущен не из дерева проекта). | +| `--baseconf` | `-b` | **Выгрузка конфигурации 1С** в репозитории — папка `/src/`. Можно указывать несколько раз. | + +### Алиасы `--baseconf` + +| Алиас | Папка в репозитории | +|-------|---------------------| +| `target`, `crm3_26` | `crm3-26` | +| `source`, `crm3_old`, `crm3-old`, `crm3_dev` | `crm3-dev` | + +Список выгрузок и алиасов: `python tools/index/index_1c.py list` + +--- + +## Зачем это нужно + +При работе в Cursor поиск по XML/BSL всего дерева `crm3-26/src` (десятки тысяч файлов) медленный. Индекс один раз разбирает метаданные и модули и даёт: + +- поиск объектов по имени, синониму, полям; +- обратный индекс ссылок (`refs`); +- поиск по BSL (процедуры, фрагменты кода); +- фильтр по конкретной выгрузке (`-b crm3-26`). + +**Типичный workflow агента:** `query_1c.py search` → `show` / `refs` → открыть конкретный `.bsl` или `.xml`, без полного Grep по конфигурации. + +--- + +## `index_1c.py` — переиндексация + +### Команды + +| Команда | Описание | +|---------|----------| +| `reindex` | обновить индекс (по умолчанию только изменившиеся файлы) | +| `status` | размер БД, метаданные, список проиндексированных baseconf | +| `list` | какие `/src/` есть в репозитории и что уже в индексе | + +### Режимы `reindex` + +| Вызов | Поведение | +|-------|-----------| +| `reindex -j 12` | инкремент всех известных выгрузок с `src/` | +| `reindex --full -j 12` | удалить `index.sqlite` и собрать заново | +| `reindex --full -b crm3-26` | пересобрать только `crm3-26` (остальные в БД сохраняются) | +| `reindex -b target -t Documents,Catalogs` | только документы и справочники целевой конфигурации | +| `reindex -b ws-rhana --no-modules` | только XML метаданных, без `.bsl` | + +Дополнительно: `--md` — экспорт Markdown в `cache/1c_meta/md//` для `@` в чате. + +### Что попадает в индекс + +- **Метаданные:** `src/<ТипObjects>/*.xml` — имя, синоним, реквизиты, измерения, типы, ссылки. +- **Модули:** все `**/*.bsl` — путь, владелец (`Document.ЗаказКлиента`), процедуры/функции, тело (до 200 КБ в FTS). + +Пропускаются по умолчанию: картинки, стили, XDTO, шаблоны, языки. + +--- + +## `query_1c.py` — поиск + +Требует готовый `cache/1c_meta/index.sqlite` (см. `index_1c.py reindex`). + +### Команды + +| Команда | Аргумент | Результат | +|---------|----------|-----------| +| `search` | строка запроса | объекты метаданных (FTS) | +| `show` | имя объекта | полная карточка JSON | +| `refs` | `Catalog.Партнеры` | кто ссылается полем-ссылкой | +| `modules` | строка | поиск по BSL | +| `module` | путь или owner | метаданные модуля (символы, строки) | + +### Примеры ```bash -source .venv-nt-041/bin/activate # опционально (используется stdlib) -python tools/index/index_1c.py reindex -python tools/index/index_1c.py search "ЗаказКлиента" -python tools/index/index_1c.py refs Catalog.Партнеры --config crm3-26 -python tools/index/index_1c.py show Document.ЗаказКлиента --config crm3-26 -python tools/index/index_1c.py status +# все конфигурации в индексе +python tools/index/query_1c.py search "ЗаказКлиента" + +# только целевая (алиас target) +python tools/index/query_1c.py show ЗаказКлиента -b target +python tools/index/query_1c.py show Document.ЗаказКлиента -b crm3-26 + +# исходная + расширение +python tools/index/query_1c.py search "Партнер" -b source -b crm3-dev.rhana + +# модули ws-rhana +python tools/index/query_1c.py modules "ОбработатьHealth" -b ws-rhana + +# обратные ссылки в целевой +python tools/index/query_1c.py refs Catalog.Партнеры -b target --limit 20 ``` -## Что индексируется +### Ошибки -Для каждого объекта метаданных верхнего уровня (`src/<Тип>/*.xml`): +- **Нет индекса** — подсказка запустить `index_1c.py reindex`. +- **`-b unknown`** — список папок в репозитории и алиасы. +- **`-b crm3-26`, но не проиндексировано** — список того, что есть в БД, и команда переиндексации. +- **`show` не нашёл объект** — похожие имена из индекса. -- имя, синоним, комментарий, подсказка; -- реквизиты / измерения / ресурсы / колонки ТЧ / значения перечислений; -- типы полей и ссылки (`cfg:CatalogRef.*`, `cfg:DocumentRef.*`, …); -- владельцы, движения регистров, «ввод на основании» (если есть в Properties). +--- -По умолчанию пропускаются шумные типы: `CommonPicture`, `StyleItem`, `XDTOPackage`, `CommonTemplate`, `Language`, `Bot`. +## Файл настроек утилиты (`--config`) -## Выборочная переиндексация +Для нестандартного расположения проекта: ```bash -# только целевая конфигурация -python tools/index/index_1c.py reindex --config crm3-26 - -# только документы и справочники -python tools/index/index_1c.py reindex -c crm3-26 -t Documents,Catalogs - -# регистры (русские/короткие алиасы) -python tools/index/index_1c.py reindex -c crm3-26 -t рс,рн - -# расширения -python tools/index/index_1c.py reindex -c crm3-26.rhana -c ws-rhana - -# параллельный разбор XML -python tools/index/index_1c.py reindex -c crm3-26 -j 8 +cp tools/index/config.example.json tools/index/config.local.json +# отредактировать project_root +python tools/index/query_1c.py --config tools/index/config.local.json search "Заказ" ``` -## Куда пишется кэш +Приоритет корня: `--root` > `project_root` в JSON > автоопределение от `tools/index/`. -`cache/1c_meta/` (в `.gitignore`): +--- -| Путь | Назначение | -|------|------------| -| `INDEX.md` | сводка по конфигурациям | -| `/objects.jsonl` | объекты (машиночитаемо) | -| `/fields.jsonl` | плоский список полей | -| `/refs.jsonl` | связи по ссылочным типам | -| `/search.sqlite` | FTS5 полнотекстовый поиск | -| `/objects.md` | человекочитаемый перечень | -| `/refs.md` | обратный индекс ссылок | -| `/by_type/*.md` | краткие списки для `@` в Cursor | -| `/manifest.json` | статистика прогона | +## Применение в Cursor -Конфигурации по умолчанию: `crm3-26`, `crm3-26.rhana`, `crm3-dev`, `crm3-dev.rhana`, `crm3-dev.docs`, `ws-rhana`, `bu-corp`, `bu-corp.rhana`, `diadoc.ext.rhana` (если есть `src/`). +1. После выгрузки из EDT/конфигуратора: + `python tools/index/index_1c.py reindex -j 12` + или `reindex -b target` если менялась только целевая. +2. Агент ищет через `query_1c.py`, не через Grep по `src/`. +3. Уточнение контекста: `-b target` / `-b source` чтобы не смешивать старую и новую конфигурацию. +4. Для кода: `modules` → открыть найденный `rel_path` в репозитории. -## Использование в Cursor +--- -1. Переиндексировать после обновления выгрузки из EDT/конфигуратора. -2. Для уточнения метаданных — `@cache/1c_meta//by_type/Document.md` или CLI `search` / `show` / `refs`. -3. Не Grep'ить сырой XML по всей конфигурации, пока нет основания, что индекса недостаточно. +## Артефакты + +``` +cache/1c_meta/ + index.sqlite # единая БД + INDEX.md # сводка последнего reindex + manifest.json + md// # опционально (--md) +``` + +Старые per-config `search.sqlite` в подкаталогах не используются. diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..1b7f9b9 --- /dev/null +++ b/config.example.json @@ -0,0 +1,4 @@ +{ + "project_root": "/path/to/crm3-26", + "comment": "Файл настроек утилиты tools/index (НЕ конфигурация 1С). Копируйте в config.local.json и укажите --config config.local.json" +} diff --git a/index1c/__init__.py b/index1c/__init__.py new file mode 100644 index 0000000..e9c3efc --- /dev/null +++ b/index1c/__init__.py @@ -0,0 +1,3 @@ +"""Индексация метаданных конфигураций 1С (выгрузки EDT/XML) для быстрого поиска.""" + +__version__ = "0.1.0" diff --git a/index1c/__pycache__/__init__.cpython-311.pyc b/index1c/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..98477e0708d99632d0392cf1d2a288214a3c68cd GIT binary patch literal 365 zcmXv~Jx>Bb5ZygOFcRo&thSWGThPSHSZQpOMokFKhTLA_A;*zBAZo>zT2NSOYhjGt zDH2Z<{0paB&1Z7%})n5a6u?@b&X(TF>a| zY)Tu)gpiIQ#5gWQ-E=+6DGTvPrr}d&yZp#Qj@#C8!7829q+6&PW}R1Dr)U~gj~lMN zzPh%-tBzChxK)AYqPE`(LKszT_Mv)RI??Z{fm+;U^+8~&5GjAas`luk54v)Tmd PtoEH;YQ)2IqbUCZ+NqVL literal 0 HcmV?d00001 diff --git a/index1c/__pycache__/build.cpython-311.pyc b/index1c/__pycache__/build.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9322bdef4e6377a103a24c70603640d8b28405ee GIT binary patch literal 17033 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 E7pw;EQUCw| literal 0 HcmV?d00001 diff --git a/index1c/__pycache__/cli.cpython-311.pyc b/index1c/__pycache__/cli.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ed06b6671b19ee0bcdbaa70327ddbd9d0058d08 GIT binary patch literal 14521 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 literal 0 HcmV?d00001 diff --git a/index1c/__pycache__/config.cpython-311.pyc b/index1c/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..baa0ae8402b4b913ad2db48baa08d2e0adb89b40 GIT binary patch literal 9876 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 literal 0 HcmV?d00001 diff --git a/index1c/__pycache__/parse_modules.cpython-311.pyc b/index1c/__pycache__/parse_modules.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5fa62b2ed9eaa4e79fbdf6d0627da05ac6edfa26 GIT binary patch literal 4413 zcma(U{cjWJ`MnQ+UTovo5S)}IYeE~7CI*TsK+z~6fdTnw2~@q{ zbI&-?X!`?o%y7eR||cbaHiwN+50(L0L;{K|1U4T0USK6KN#61+Ek;t62b|PyE?%NbXci^>KoD)3p7STbYLrCym zK>~N1hH-f9b{jr-ssxJrgmz)W6*lgNZxFr#p+ndN-wnc3LIl1+ftf_nExG+LMO)JD zYF}tyYG3J>wPpQ1ZAHJ{}*ingNtNq<+*+q~M! z!1rX|C}nbqY*tbdN=nMg<3M|agpx?66S52h`*K2g<9l)+AVL#EM@9}D=Z{alJi;HG z7#$utHktbcjP>qIWumXYCU5KAxA)moTCrdIqqf|yU)$dwPxR06r~9JY>_P3% zuwRlBlEbLlCveoea0}}O>dRP4ib4*H>cLz#Ne&f#P44UcFN;3WwY`52e|lRq^^%W4 zNpiVrV{C+apa8OnL=>k$<@5lR9I%v6$v(LCB^%F)iDeL%NU^vB_nuDg3!q zR+#goQ?hajW}Y@(@}rJs@&>G#Wc0i&&0jQg0+u>2o;&C*Dko zZ^mY^bXH6%ax956FZAzxF{Vgxs2ChpylBH6$=GZH%VK3md0_Su_P~-B*aV0?3n1U{ zTVn(Hqxqwkj~Z++Kb9Z6Jl25t^GEVWE*~-2ux33v8#dTYn)T@HCWH0m59beGK5Vd| z{6v1@@f_xzbcFLs1Y?Q%UU>Dp&z*Yb<@v*M1F8maH!LTYeMobs?LKk7IXxrfY3%m2f zhKnnx_go#CtHTI}Z?@m;EI17=SbX&!*P(G8MyRtmNazz+_Z9Z7g*uA6jX+!BB20UK z{+;>aYr5;1vg?_3L^*nlK&Uu=OI~{M`dc^ODyEC$Y_C@|-&!0u4=&H^g__3)2Cc@+@-xKhN8o*BYg5n*Zaz)0(%1q{rsNmIGi6bxD7;ktwtbNz=p52 zxMSgD{@`j$XSt=jbWm^UE4TCkm#c+vZM_vLp19tAvwi9Ojm~1H(b}d3df-{wTB1Jc zwQ6USl$C9Kx+F*+af^e7GH7;evnqd$Tgy6Ld0ij!HGxZ{#L5#pg_O#-C@i!EU4wkyTm`!YqZRpbD8p7tlp!3SFRJ#Rh|` zav}&G%?4L$Sf|eQ*!dg^5ksXZzAA>0;;-W+cO9=|D%-r@|Dxud3Xx!?Jw8(jS4R*! zXGsgwDx3AIECjTMvBvN zviLSuDZxDl;idV8x51L`)U4@q&uz$YcSxSI8iX3}_?#!F%=GW>m!TkWVR1H{NQzOK z#QdZrq_SsBr--qHFgaP_t3}2cvY7boxBH`B%js|n(3sd7CPsz>1Q2my zA_q(i1rx1fa`fmXs9B%_H9c5N2)rUfHgZi&j*vY~4nhbNr*De9B0-G=C6XkkE}BkR z!ip$BF(tyhVr|j+d z&f9;_+pl|LWp6BhVAUVKHTiMmyU4bCk!_#t(j(i;k?s1%9p#NXbpOt>e`o&CYG_kY zxxVM-o=^SvLIYZ8ps=g3Yk7F3^X|Y(XL;A*{O~FlHUb+Vf3F6^#pBl_HzTY5EguV| z7k-)gD5d**%l_WgaM!2dQt~&EPa=A_uN>|(HnhP7jZoX--Wz+D1U=MU4t0MvtUWim z68`&^ueSU{(f5p&_l!bE>3r$Dc5G4)9XEV|h4^Z4^HS^5>E9n%roZ4;lKS@j`tw8O z=ZEy*fpYM``bMtBc^?7zfq-?yIh-;v!*}`y&>y^m%-7T)1NMTGkDywHVw(K5IbF*D zz*sN#Fc73PD3RVLT627r=`#?y8w&)ONAk%N?eTK1*-ya}>G3(oHmm$%OlL~wg%rjw z!7`=;pGiy8Q3{U&C6n-3HK&v!V!mokGJXYyUL`yv>E;pCTG#eo-TT4d!eA}gdRC#T z>RycAh%Oz{TYJi_Jvz6w%x%@!tyZxVWo;`abqao(0OBxJqqll3%eUN!n7{2t7UwR) z$y%pu=p7@yt)`3T1u1F&2bsx1xefnEFbPa1jYX5I{p}QGOwyX^A}KtbnzmQ6_Oxme zS5nH%65kmDE<8+t^{;}|nYLdOr?NzW+X-eB{G>2_OYkoTHC3z`wZ}1iP)U&Efkbb&ERPdS)v(i4pZO>&0v6`o?S;j I0xV(u2Vd$iYybcN literal 0 HcmV?d00001 diff --git a/index1c/__pycache__/search.cpython-311.pyc b/index1c/__pycache__/search.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..87a731008bc620f6c6d6035c9cc422cfc2d4c871 GIT binary patch literal 16144 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 literal 0 HcmV?d00001 diff --git a/index1c/build.py b/index1c/build.py new file mode 100644 index 0000000..4bf0b72 --- /dev/null +++ b/index1c/build.py @@ -0,0 +1,349 @@ +"""Построение единого индекса проекта (метаданные + модули, full/incremental).""" + +from __future__ import annotations + +import json +import os +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Any + +from .config import DEFAULT_SKIP_TYPES, OBJECT_TYPE_FOLDERS, ConfigPaths, cache_dir +from . import db as dbmod +from .parse_meta import FieldRec, ObjectRec, iter_object_xml_files, parse_metadata_xml +from .parse_modules import ModuleRec, iter_bsl_files, parse_bsl_file +from . import writers + + +def default_workers() -> int: + return max(4, min(16, (os.cpu_count() or 4))) + + +def _file_stamp(path: Path) -> tuple[int, int]: + st = path.stat() + mtime_ns = getattr(st, "st_mtime_ns", int(st.st_mtime * 1e9)) + return mtime_ns, st.st_size + + +def _parse_meta_job(args: tuple[str, str, str]) -> tuple[str, ObjectRec | None, tuple[int, int]]: + config, otype, path_s = args + path = Path(path_s) + stamp = _file_stamp(path) + rec = parse_metadata_xml(path, config=config, object_type=otype) + return path_s, rec, stamp + + +def _parse_module_job(args: tuple[str, str, str]) -> ModuleRec | None: + config, src_s, path_s = args + return parse_bsl_file(Path(path_s), config=config, src_root=Path(src_s)) + + +def _fields_text(obj: ObjectRec) -> str: + return " ".join( + " ".join( + filter( + None, + [ + f.kind, + f.name, + f.synonym, + f.comment, + f.tabular_section, + " ".join(f.types), + ], + ) + ) + for f in obj.fields + ) + + +def _run_pool(jobs: list, worker, workers: int) -> list: + if not jobs: + return [] + if workers <= 1 or len(jobs) < 8: + return [worker(j) for j in jobs] + out: list = [] + with ThreadPoolExecutor(max_workers=workers) as pool: + futs = [pool.submit(worker, j) for j in jobs] + for fut in as_completed(futs): + try: + out.append(fut.result()) + except Exception: + out.append(None) + return out + + +def build_project_index( + configs: list[ConfigPaths], + project_root: Path, + *, + full: bool = False, + wipe_db: bool = False, + types: set[str] | None = None, + skip_types: set[str] | None = None, + index_modules: bool = True, + workers: int | None = None, + write_md: bool = False, +) -> dict[str, Any]: + """Индексирует конфигурации в единый cache/1c_meta/index.sqlite. + + full=True — для каждой указанной конфигурации удалить её данные и пересобрать. + wipe_db=True — удалить весь index.sqlite и создать заново (обычно вместе с full + по всем конфигам). + Иначе — инкремент по mtime/size файлов. + """ + workers = workers if workers is not None else default_workers() + skip = set(skip_types if skip_types is not None else DEFAULT_SKIP_TYPES) + cache_root = cache_dir(project_root) + cache_root.mkdir(parents=True, exist_ok=True) + db_path = dbmod.global_db_path(cache_root) + + t0 = time.perf_counter() + + if wipe_db or not db_path.exists(): + conn = dbmod.reset_db(db_path) + mode = "full" + force_config_full = True + else: + try: + conn = dbmod.ensure_db(db_path) + mode = "full" if full else "incremental" + force_config_full = full + except RuntimeError: + conn = dbmod.reset_db(db_path) + mode = "full" + force_config_full = True + + summary: dict[str, Any] = { + "mode": mode, + "db": str(db_path), + "workers": workers, + "configs": [], + "meta_parsed": 0, + "meta_skipped": 0, + "modules_parsed": 0, + "modules_skipped": 0, + "files_removed": 0, + "errors": 0, + } + + try: + for cfg in configs: + cfg_stats = _index_one_config( + conn, + cfg, + full=force_config_full, + types=types, + skip_types=skip, + index_modules=index_modules, + workers=workers, + summary=summary, + ) + summary["configs"].append(cfg_stats) + if write_md: + _export_md_for_config(conn, cfg, cache_root) + + dbmod.set_meta(conn, "last_reindex_at", dbmod.utcnow()) + dbmod.set_meta(conn, "last_reindex_mode", mode) + conn.commit() + finally: + conn.close() + + summary["elapsed_sec"] = round(time.perf_counter() - t0, 2) + writers.write_global_index_md(cache_root, summary) + return summary + + +def _index_one_config( + conn, + cfg: ConfigPaths, + *, + full: bool, + types: set[str] | None, + skip_types: set[str], + index_modules: bool, + workers: int, + summary: dict[str, Any], +) -> dict[str, Any]: + if full: + dbmod.delete_config(conn, cfg.name) + + existing = dbmod.get_file_index(conn, cfg.name) if not full else {} + seen_paths: set[str] = set() + + meta_files = iter_object_xml_files(cfg.src, types=types, skip_types=skip_types) + meta_jobs: list[tuple[str, str, str]] = [] + meta_skipped = 0 + for otype, path in meta_files: + rel = path.relative_to(cfg.src).as_posix() + seen_paths.add(rel) + mtime_ns, size = _file_stamp(path) + prev = existing.get(rel) + if ( + prev + and prev["mtime_ns"] == mtime_ns + and prev["size"] == size + and prev["kind"] == "meta_xml" + ): + meta_skipped += 1 + continue + if prev: + dbmod.delete_file_cascade(conn, prev["id"]) + meta_jobs.append((cfg.name, otype, str(path))) + + meta_results = _run_pool(meta_jobs, _parse_meta_job, workers) + meta_parsed = 0 + for item in meta_results: + if not item: + summary["errors"] += 1 + continue + path_s, rec, stamp = item + if rec is None: + summary["errors"] += 1 + continue + path = Path(path_s) + rel = path.relative_to(cfg.src).as_posix() + file_id = dbmod.upsert_file( + conn, + config=cfg.name, + rel_path=rel, + kind="meta_xml", + mtime_ns=stamp[0], + size=stamp[1], + object_type=rec.object_type, + ) + dbmod.upsert_object( + conn, rec.to_dict(), file_id=file_id, fields_text=_fields_text(rec) + ) + meta_parsed += 1 + + mod_parsed = 0 + mod_skipped = 0 + if index_modules: + mod_jobs: list[tuple[str, str, str]] = [] + for path in iter_bsl_files(cfg.src): + rel = path.relative_to(cfg.src).as_posix() + seen_paths.add(rel) + mtime_ns, size = _file_stamp(path) + prev = existing.get(rel) + if ( + prev + and prev["mtime_ns"] == mtime_ns + and prev["size"] == size + and prev["kind"] == "module_bsl" + ): + mod_skipped += 1 + continue + if prev: + dbmod.delete_file_cascade(conn, prev["id"]) + mod_jobs.append((cfg.name, str(cfg.src), str(path))) + + mod_results = _run_pool(mod_jobs, _parse_module_job, workers) + for rec in mod_results: + if rec is None: + summary["errors"] += 1 + continue + file_id = dbmod.upsert_file( + conn, + config=cfg.name, + rel_path=rec.rel_path, + kind="module_bsl", + mtime_ns=rec.mtime_ns, + size=rec.size, + ) + dbmod.upsert_module( + conn, + config=cfg.name, + rel_path=rec.rel_path, + owner=rec.owner, + module_kind=rec.module_kind, + symbols=rec.symbols, + body=rec.body, + line_count=rec.line_count, + size=rec.size, + file_id=file_id, + ) + mod_parsed += 1 + + removed = 0 + if not full: + for rel, info in existing.items(): + if rel in seen_paths: + continue + if types is not None and info["kind"] == "meta_xml": + folder = rel.split("/", 1)[0] + otype = OBJECT_TYPE_FOLDERS.get(folder) + if otype and otype not in types: + continue + if not index_modules and info["kind"] == "module_bsl": + continue + dbmod.delete_file_cascade(conn, info["id"]) + removed += 1 + + stats = dbmod.refresh_config_stats(conn, cfg.name, str(cfg.src)) + summary["meta_parsed"] += meta_parsed + summary["meta_skipped"] += meta_skipped + summary["modules_parsed"] += mod_parsed + summary["modules_skipped"] += mod_skipped + summary["files_removed"] += removed + + print( + f"→ {cfg.name}: meta +{meta_parsed}/skip {meta_skipped}, " + f"modules +{mod_parsed}/skip {mod_skipped}, removed {removed}, " + f"total objects={stats['objects']} modules={stats['modules']}" + ) + return stats + + +def _export_md_for_config(conn, cfg: ConfigPaths, cache_root: Path) -> None: + rows = conn.execute( + "SELECT json FROM objects WHERE config=? ORDER BY object_type, name", + (cfg.name,), + ).fetchall() + objects: list[ObjectRec] = [] + for r in rows: + d = json.loads(r["json"]) + fields = [FieldRec(**f) for f in d.get("fields") or []] + objects.append( + ObjectRec( + config=d["config"], + object_type=d["object_type"], + name=d["name"], + synonym=d.get("synonym") or "", + comment=d.get("comment") or "", + tooltip=d.get("tooltip") or "", + explanation=d.get("explanation") or "", + uuid=d.get("uuid") or "", + path=d.get("path") or "", + fields=fields, + owners=d.get("owners") or [], + register_records=d.get("register_records") or [], + based_on=d.get("based_on") or [], + input_by_string=d.get("input_by_string") or [], + ) + ) + out = cache_root / "md" / cfg.name + out.mkdir(parents=True, exist_ok=True) + writers.write_objects_markdown(out / "objects.md", objects, title=f"Метаданные: {cfg.name}") + writers.write_refs_markdown(out / "refs.md", objects, title=f"Ссылки: {cfg.name}") + writers.write_type_summaries(out, objects) + + +def build_config_index(*args, **kwargs): + raise RuntimeError( + "Устарело: используйте build_project_index() и единый cache/1c_meta/index.sqlite" + ) + + +def write_global_index(project_root: Path, manifests: list[dict[str, Any]]) -> Path: + cache_root = cache_dir(project_root) + summary = { + "mode": "legacy", + "db": str(dbmod.global_db_path(cache_root)), + "configs": manifests, + "elapsed_sec": 0, + "meta_parsed": 0, + "modules_parsed": 0, + } + return writers.write_global_index_md(cache_root, summary) diff --git a/index1c/cli.py b/index1c/cli.py new file mode 100644 index 0000000..1fd14e7 --- /dev/null +++ b/index1c/cli.py @@ -0,0 +1,242 @@ +"""Общие аргументы CLI: --config (скрипт), --root, --baseconf (конфигурация 1С).""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from . import db as dbmod +from .config import cache_dir, discover_configs, project_root_from_here, resolve_config_name + + +class BaseconfError(Exception): + """Ошибка выбора конфигурации 1С (--baseconf).""" + + def __init__(self, message: str, *, exit_code: int = 2): + super().__init__(message) + self.exit_code = exit_code + + +def load_tool_config(path: Path) -> dict[str, Any]: + """Файл настроек утилиты index (JSON). Не путать с конфигурацией 1С.""" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except OSError as e: + raise BaseconfError(f"Не удалось прочитать --config {path}: {e}", exit_code=1) from e + except json.JSONDecodeError as e: + raise BaseconfError(f"Некорректный JSON в --config {path}: {e}", exit_code=1) from e + if not isinstance(data, dict): + raise BaseconfError(f"--config {path}: ожидается JSON-объект", exit_code=1) + return data + + +def resolve_project_root(args: argparse.Namespace) -> Path: + """Корень проекта: --root → project_root из --config → авто.""" + if getattr(args, "root", None): + return Path(args.root).resolve() + cfg_path = getattr(args, "config", None) + if cfg_path: + data = load_tool_config(Path(cfg_path)) + pr = data.get("project_root") + if pr: + return Path(pr).resolve() + return project_root_from_here() + + +def add_tool_args(parser: argparse.ArgumentParser) -> None: + """Параметры утилиты (не конфигурация 1С).""" + parser.add_argument( + "--config", + "-c", + metavar="FILE", + help="JSON с настройками утилиты (project_root и др.), см. config.example.json", + ) + parser.add_argument( + "--root", + "-r", + metavar="DIR", + help="Корень проекта CRM3-26 (перекрывает project_root из --config)", + ) + + +def add_baseconf_args(parser: argparse.ArgumentParser) -> None: + """Фильтр по выгрузке конфигурации 1С в репозитории.""" + parser.add_argument( + "--baseconf", + "-b", + action="append", + metavar="NAME", + dest="baseconf", + help=( + "Конфигурация 1С в репозитории (папка /src/). " + "Можно несколько раз. Алиасы: target→crm3-26, source→crm3-dev. " + "Без параметра — поиск по всем проиндексированным." + ), + ) + + +def resolve_baseconf_names(raw: list[str] | None) -> list[str]: + """Разрешить алиасы имён конфигураций 1С (без проверки индекса).""" + if not raw: + return [] + out: list[str] = [] + seen: set[str] = set() + for item in raw: + for part in item.replace(";", ",").split(","): + part = part.strip() + if not part: + continue + name = resolve_config_name(part) + if name not in seen: + seen.add(name) + out.append(name) + return out + + +def validate_baseconfs( + project_root: Path, + names: list[str], + *, + conn=None, + require_indexed: bool = True, +) -> list[str]: + """ + Проверить --baseconf: папка src/, наличие в индексе. + Возвращает канонические имена. Иначе BaseconfError с подсказками. + """ + if not names: + return [] + + resolved = resolve_baseconf_names(names) + missing_src: list[str] = [] + for name in resolved: + if not (project_root / name / "src").is_dir(): + missing_src.append(name) + + if missing_src: + available = [c.name for c in discover_configs(project_root, None)] + lines = [ + "Конфигурация 1С не найдена в репозитории (нет каталога /src/):", + *(f" - {n}" for n in missing_src), + "", + "Доступные выгрузки в проекте:", + ] + if available: + lines.extend(f" - {n}" for n in available) + else: + lines.append(" (нет)") + lines.extend( + [ + "", + "Алиасы: target→crm3-26, source→crm3-dev, crm3_old→crm3-dev", + ] + ) + raise BaseconfError("\n".join(lines)) + + if not require_indexed or conn is None: + return resolved + + indexed = {c["name"] for c in dbmod.list_configs(conn)} + not_indexed = [n for n in resolved if n not in indexed] + if not_indexed: + lines = [ + "Конфигурация 1С есть в репозитории, но отсутствует в индексе cache/1c_meta/index.sqlite:", + *(f" - {n}" for n in not_indexed), + "", + "Проиндексировано:", + ] + if indexed: + lines.extend(f" - {n}" for n in sorted(indexed)) + else: + lines.append(" (индекс пуст)") + lines.extend( + [ + "", + f"Переиндексация: python tools/index/index_1c.py reindex --full -b {not_indexed[0]}", + ] + ) + raise BaseconfError("\n".join(lines)) + + return resolved + + +def get_baseconfs_for_query( + args: argparse.Namespace, + conn, + project_root: Path, +) -> list[str] | None: + """None = все конфигурации в индексе; иначе список имён.""" + raw = getattr(args, "baseconf", None) + if not raw: + return None + return validate_baseconfs(project_root, raw, conn=conn, require_indexed=True) + + +def format_not_found_object( + name: str, + *, + baseconfs: list[str] | None, + conn, +) -> str: + """Подсказка, если объект не найден.""" + lines = [f"Объект «{name}» не найден."] + if baseconfs: + lines.append(f"Ограничение --baseconf: {', '.join(baseconfs)}") + + # похожие имена + like = f"%{name}%" + params: list[Any] = [like, like, like] + sql = """ + SELECT config, full_name, synonym FROM objects + WHERE name LIKE ? OR full_name LIKE ? OR synonym LIKE ? + """ + if baseconfs: + sql += f" AND config IN ({','.join('?' * len(baseconfs))})" + params.extend(baseconfs) + sql += " ORDER BY config, full_name LIMIT 15" + rows = conn.execute(sql, params).fetchall() + if rows: + lines.append("") + lines.append("Возможно, имелось в виду:") + for r in rows: + syn = f" — {r['synonym']}" if r["synonym"] else "" + lines.append(f" [{r['config']}] {r['full_name']}{syn}") + else: + indexed = [c["name"] for c in dbmod.list_configs(conn)] + lines.append("") + lines.append("В индексе конфигурации: " + (", ".join(indexed) if indexed else "(пусто)")) + lines.append("Проверьте имя (Document.ЗаказКлиента) или выполните: query_1c.py search \"…\"") + return "\n".join(lines) + + +def format_not_found_modules( + query: str, + *, + baseconfs: list[str] | None, + conn, +) -> str: + lines = [f"Модули по запросу «{query}» не найдены."] + if baseconfs: + lines.append(f"Ограничение --baseconf: {', '.join(baseconfs)}") + indexed = [c["name"] for c in dbmod.list_configs(conn)] + lines.append("Проиндексировано: " + ", ".join(indexed) if indexed else "Индекс пуст.") + return "\n".join(lines) + + +def handle_baseconf_error(e: BaseconfError) -> int: + print(str(e), file=sys.stderr) + return e.exit_code + + +def index_db_missing_message(project_root: Path) -> str: + path = dbmod.global_db_path(cache_dir(project_root)) + return ( + f"Индекс не найден: {path}\n" + "Сначала выполните:\n" + " python tools/index/index_1c.py reindex --full -j 12\n" + "или для одной конфигурации:\n" + " python tools/index/index_1c.py reindex --full -b crm3-26" + ) diff --git a/index1c/config.py b/index1c/config.py new file mode 100644 index 0000000..7dd20ce --- /dev/null +++ b/index1c/config.py @@ -0,0 +1,231 @@ +"""Каталоги конфигураций проекта и типы объектов метаданных 1С.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +# Папки выгрузок относительно корня проекта (есть src/). +DEFAULT_CONFIGS: tuple[str, ...] = ( + "crm3-26", + "crm3-26.rhana", + "crm3-dev", + "crm3-dev.rhana", + "crm3-dev.docs", + "ws-rhana", + "bu-corp", + "bu-corp.rhana", + "diadoc.ext.rhana", +) + +# Алиасы имён конфигураций 1С для CLI (--baseconf / -b). +CONFIG_ALIASES: dict[str, str] = { + "crm3_26": "crm3-26", + "crm3_old": "crm3-dev", + "crm3-old": "crm3-dev", + "crm3_dev": "crm3-dev", + "target": "crm3-26", + "source": "crm3-dev", +} + +# Папка EDT → канонический тип (английский идентификатор выгрузки). +OBJECT_TYPE_FOLDERS: dict[str, str] = { + "AccumulationRegisters": "AccumulationRegister", + "BusinessProcesses": "BusinessProcess", + "Catalogs": "Catalog", + "ChartsOfAccounts": "ChartOfAccounts", + "ChartsOfCalculationTypes": "ChartOfCalculationTypes", + "ChartsOfCharacteristicTypes": "ChartOfCharacteristicTypes", + "CommandGroups": "CommandGroup", + "CommonAttributes": "CommonAttribute", + "CommonCommands": "CommonCommand", + "CommonForms": "CommonForm", + "CommonModules": "CommonModule", + "CommonPictures": "CommonPicture", + "CommonTemplates": "CommonTemplate", + "Constants": "Constant", + "DataProcessors": "DataProcessor", + "DefinedTypes": "DefinedType", + "DocumentJournals": "DocumentJournal", + "DocumentNumerators": "DocumentNumerator", + "Documents": "Document", + "Enums": "Enum", + "EventSubscriptions": "EventSubscription", + "ExchangePlans": "ExchangePlan", + "FilterCriteria": "FilterCriterion", + "FunctionalOptions": "FunctionalOption", + "FunctionalOptionsParameters": "FunctionalOptionsParameter", + "HTTPServices": "HTTPService", + "InformationRegisters": "InformationRegister", + "IntegrationServices": "IntegrationService", + "Reports": "Report", + "Roles": "Role", + "ScheduledJobs": "ScheduledJob", + "SessionParameters": "SessionParameter", + "SettingsStorages": "SettingsStorage", + "StyleItems": "StyleItem", + "Subsystems": "Subsystem", + "Tasks": "Task", + "WebServices": "WebService", + "WSReferences": "WSReference", + "XDTOPackages": "XDTOPackage", + "Bots": "Bot", + "ExternalDataSources": "ExternalDataSource", + "Languages": "Language", +} + +# Алиасы типов для --types (русский / короткий / мн.ч.). +TYPE_ALIASES: dict[str, str] = { + "catalog": "Catalog", + "catalogs": "Catalog", + "справочник": "Catalog", + "справочники": "Catalog", + "document": "Document", + "documents": "Document", + "документ": "Document", + "документы": "Document", + "informationregister": "InformationRegister", + "informationregisters": "InformationRegister", + "регистрсведений": "InformationRegister", + "регистрысведений": "InformationRegister", + "рс": "InformationRegister", + "accumulationregister": "AccumulationRegister", + "accumulationregisters": "AccumulationRegister", + "регистрнакопления": "AccumulationRegister", + "регистрынакопления": "AccumulationRegister", + "рн": "AccumulationRegister", + "constant": "Constant", + "constants": "Constant", + "константа": "Constant", + "константы": "Constant", + "enum": "Enum", + "enums": "Enum", + "перечисление": "Enum", + "перечисления": "Enum", + "commonmodule": "CommonModule", + "commonmodules": "CommonModule", + "общиймодуль": "CommonModule", + "общиемодули": "CommonModule", + "dataprocessor": "DataProcessor", + "dataprocessors": "DataProcessor", + "обработка": "DataProcessor", + "обработки": "DataProcessor", + "report": "Report", + "reports": "Report", + "отчет": "Report", + "отчеты": "Report", + "exchangeplan": "ExchangePlan", + "exchangeplans": "ExchangePlan", + "планыобмена": "ExchangePlan", + "definedtype": "DefinedType", + "definedtypes": "DefinedType", + "определяемыетипы": "DefinedType", + "chartofcharacteristictypes": "ChartOfCharacteristicTypes", + "пвх": "ChartOfCharacteristicTypes", + "role": "Role", + "roles": "Role", + "роль": "Role", + "роли": "Role", + "subsystem": "Subsystem", + "subsystems": "Subsystem", + "подсистема": "Subsystem", + "подсистемы": "Subsystem", + "httpservice": "HTTPService", + "httpservices": "HTTPService", + "documentjournal": "DocumentJournal", + "documentjournals": "DocumentJournal", + "журналыдокументов": "DocumentJournal", + "task": "Task", + "tasks": "Task", + "задача": "Task", + "задачи": "Task", + "businessprocess": "BusinessProcess", + "businessprocesses": "BusinessProcess", + "бизнеспроцесс": "BusinessProcess", + "бизнеспроцессы": "BusinessProcess", + "модуль": "CommonModule", + "модули": "CommonModule", + "bsl": "CommonModule", +} + +# Типы с разбором реквизитов / измерений / ссылок. +FIELD_RICH_TYPES: frozenset[str] = frozenset( + { + "Catalog", + "Document", + "InformationRegister", + "AccumulationRegister", + "ChartOfCharacteristicTypes", + "ChartOfAccounts", + "ChartOfCalculationTypes", + "ExchangePlan", + "BusinessProcess", + "Task", + "DocumentJournal", + "Constant", + "DefinedType", + "CommonAttribute", + "Enum", + } +) + +# Типы, которые по умолчанию пропускаем (шум / картинки). +DEFAULT_SKIP_TYPES: frozenset[str] = frozenset( + { + "CommonPicture", + "StyleItem", + "XDTOPackage", + "CommonTemplate", + "Language", + "Bot", + } +) + +FOLDER_BY_TYPE: dict[str, str] = {v: k for k, v in OBJECT_TYPE_FOLDERS.items()} + + +@dataclass(frozen=True) +class ConfigPaths: + name: str + root: Path + src: Path + + +def project_root_from_here() -> Path: + """tools/index/index1c → корень проекта.""" + return Path(__file__).resolve().parents[3] + + +def resolve_config_name(name: str) -> str: + key = name.strip() + return CONFIG_ALIASES.get(key, CONFIG_ALIASES.get(key.lower(), key)) + + +def resolve_object_type(name: str) -> str: + raw = name.strip() + if raw in FOLDER_BY_TYPE: + return raw + if raw in OBJECT_TYPE_FOLDERS: + return OBJECT_TYPE_FOLDERS[raw] + compact = raw.replace(" ", "").replace("_", "").replace("-", "").casefold() + if compact in TYPE_ALIASES: + return TYPE_ALIASES[compact] + for folder, otype in OBJECT_TYPE_FOLDERS.items(): + if folder.casefold() == raw.casefold() or otype.casefold() == raw.casefold(): + return otype + raise ValueError(f"Неизвестный тип объекта метаданных: {name!r}") + + +def discover_configs(project_root: Path, names: list[str] | None = None) -> list[ConfigPaths]: + wanted = [resolve_config_name(n) for n in names] if names else list(DEFAULT_CONFIGS) + found: list[ConfigPaths] = [] + for name in wanted: + root = project_root / name + src = root / "src" + if src.is_dir(): + found.append(ConfigPaths(name=name, root=root, src=src)) + return found + + +def cache_dir(project_root: Path) -> Path: + return project_root / "cache" / "1c_meta" diff --git a/index1c/db.py b/index1c/db.py new file mode 100644 index 0000000..bee92e3 --- /dev/null +++ b/index1c/db.py @@ -0,0 +1,520 @@ +"""Единый SQLite-индекс проекта (все конфигурации 1С).""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +SCHEMA_VERSION = 2 + +DDL = """ +PRAGMA journal_mode=WAL; +PRAGMA synchronous=NORMAL; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS configs ( + name TEXT PRIMARY KEY, + src TEXT NOT NULL, + objects INTEGER NOT NULL DEFAULT 0, + fields INTEGER NOT NULL DEFAULT 0, + refs INTEGER NOT NULL DEFAULT 0, + modules INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, + config TEXT NOT NULL, + rel_path TEXT NOT NULL, + kind TEXT NOT NULL, -- meta_xml | module_bsl + mtime_ns INTEGER NOT NULL, + size INTEGER NOT NULL, + sha1 TEXT, + object_type TEXT, + UNIQUE(config, rel_path) +); + +CREATE TABLE IF NOT EXISTS objects ( + id INTEGER PRIMARY KEY, + config TEXT NOT NULL, + object_type TEXT NOT NULL, + name TEXT NOT NULL, + full_name TEXT NOT NULL, + synonym TEXT, + comment TEXT, + tooltip TEXT, + explanation TEXT, + uuid TEXT, + path TEXT, + file_id INTEGER, + json TEXT NOT NULL, + UNIQUE(config, full_name), + FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS fields ( + id INTEGER PRIMARY KEY, + object_id INTEGER NOT NULL, + config TEXT NOT NULL, + kind TEXT, + name TEXT, + synonym TEXT, + comment TEXT, + types TEXT, + tabular_section TEXT, + refs_json TEXT, + FOREIGN KEY(object_id) REFERENCES objects(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS refs ( + id INTEGER PRIMARY KEY, + config TEXT NOT NULL, + from_full_name TEXT NOT NULL, + from_field TEXT NOT NULL, + to_kind TEXT NOT NULL, + to_name TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS modules ( + id INTEGER PRIMARY KEY, + config TEXT NOT NULL, + rel_path TEXT NOT NULL, + owner TEXT, + module_kind TEXT, + symbols TEXT, + line_count INTEGER, + size INTEGER, + file_id INTEGER, + UNIQUE(config, rel_path), + FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE +); + +CREATE VIRTUAL TABLE IF NOT EXISTS objects_fts USING fts5( + full_name, + synonym, + comment, + fields_text, + object_id UNINDEXED, + config UNINDEXED, + tokenize='unicode61' +); + +CREATE VIRTUAL TABLE IF NOT EXISTS modules_fts USING fts5( + rel_path, + owner, + module_kind, + symbols, + body, + module_id UNINDEXED, + config UNINDEXED, + tokenize='unicode61' +); + +CREATE INDEX IF NOT EXISTS idx_objects_config_type ON objects(config, object_type); +CREATE INDEX IF NOT EXISTS idx_objects_name ON objects(name); +CREATE INDEX IF NOT EXISTS idx_fields_object ON fields(object_id); +CREATE INDEX IF NOT EXISTS idx_refs_to ON refs(to_kind, to_name); +CREATE INDEX IF NOT EXISTS idx_refs_from ON refs(from_full_name); +CREATE INDEX IF NOT EXISTS idx_refs_config ON refs(config); +CREATE INDEX IF NOT EXISTS idx_modules_config ON modules(config); +CREATE INDEX IF NOT EXISTS idx_modules_owner ON modules(owner); +CREATE INDEX IF NOT EXISTS idx_files_config ON files(config); +""" + + +def utcnow() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def global_db_path(cache_root: Path) -> Path: + return cache_root / "index.sqlite" + + +def connect(db_path: Path, *, readonly: bool = False) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + if readonly and db_path.is_file(): + uri = f"file:{db_path.resolve()}?mode=ro" + conn = sqlite3.connect(uri, uri=True) + else: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(conn: sqlite3.Connection) -> None: + conn.executescript(DDL) + row = conn.execute("SELECT value FROM meta WHERE key='schema_version'").fetchone() + if row is None: + conn.execute( + "INSERT INTO meta(key, value) VALUES('schema_version', ?)", + (str(SCHEMA_VERSION),), + ) + conn.execute( + "INSERT OR REPLACE INTO meta(key, value) VALUES('created_at', ?)", + (utcnow(),), + ) + elif int(row["value"]) != SCHEMA_VERSION: + raise RuntimeError( + f"Несовместимая схема индекса (есть {row['value']}, нужна {SCHEMA_VERSION}). " + "Запустите: python tools/index/index_1c.py reindex --full" + ) + conn.commit() + + +def reset_db(db_path: Path) -> sqlite3.Connection: + if db_path.exists(): + db_path.unlink() + for suffix in ("-wal", "-shm"): + p = Path(str(db_path) + suffix) + if p.exists(): + p.unlink() + conn = connect(db_path) + init_db(conn) + return conn + + +def ensure_db(db_path: Path) -> sqlite3.Connection: + conn = connect(db_path) + try: + init_db(conn) + except RuntimeError: + conn.close() + raise + return conn + + +def get_file_index(conn: sqlite3.Connection, config: str | None = None) -> dict[str, dict[str, Any]]: + """rel_path → {mtime_ns, size, id, kind} для инкремента.""" + if config: + rows = conn.execute( + "SELECT id, rel_path, kind, mtime_ns, size FROM files WHERE config=?", + (config,), + ).fetchall() + else: + rows = conn.execute( + "SELECT id, config, rel_path, kind, mtime_ns, size FROM files" + ).fetchall() + out: dict[str, dict[str, Any]] = {} + for r in rows: + key = r["rel_path"] if config else f"{r['config']}:{r['rel_path']}" + out[key] = { + "id": r["id"], + "kind": r["kind"], + "mtime_ns": r["mtime_ns"], + "size": r["size"], + } + return out + + +def delete_file_cascade(conn: sqlite3.Connection, file_id: int) -> None: + """Удалить файл и связанные объекты/модули/FTS.""" + obj = conn.execute( + "SELECT id FROM objects WHERE file_id=?", (file_id,) + ).fetchone() + if obj: + oid = obj["id"] + conn.execute("DELETE FROM objects_fts WHERE object_id=?", (oid,)) + conn.execute("DELETE FROM fields WHERE object_id=?", (oid,)) + row = conn.execute( + "SELECT config, full_name FROM objects WHERE id=?", (oid,) + ).fetchone() + if row: + conn.execute( + "DELETE FROM refs WHERE config=? AND from_full_name=?", + (row["config"], row["full_name"]), + ) + conn.execute("DELETE FROM objects WHERE id=?", (oid,)) + + mod = conn.execute( + "SELECT id FROM modules WHERE file_id=?", (file_id,) + ).fetchone() + if mod: + mid = mod["id"] + conn.execute("DELETE FROM modules_fts WHERE module_id=?", (mid,)) + conn.execute("DELETE FROM modules WHERE id=?", (mid,)) + + conn.execute("DELETE FROM files WHERE id=?", (file_id,)) + + +def delete_config(conn: sqlite3.Connection, config: str) -> None: + oids = [ + r["id"] + for r in conn.execute("SELECT id FROM objects WHERE config=?", (config,)) + ] + for oid in oids: + conn.execute("DELETE FROM objects_fts WHERE object_id=?", (oid,)) + mids = [ + r["id"] + for r in conn.execute("SELECT id FROM modules WHERE config=?", (config,)) + ] + for mid in mids: + conn.execute("DELETE FROM modules_fts WHERE module_id=?", (mid,)) + conn.execute("DELETE FROM fields WHERE config=?", (config,)) + conn.execute("DELETE FROM refs WHERE config=?", (config,)) + conn.execute("DELETE FROM objects WHERE config=?", (config,)) + conn.execute("DELETE FROM modules WHERE config=?", (config,)) + conn.execute("DELETE FROM files WHERE config=?", (config,)) + conn.execute("DELETE FROM configs WHERE name=?", (config,)) + + +def upsert_file( + conn: sqlite3.Connection, + *, + config: str, + rel_path: str, + kind: str, + mtime_ns: int, + size: int, + object_type: str | None = None, + sha1: str | None = None, +) -> int: + conn.execute( + """ + INSERT INTO files(config, rel_path, kind, mtime_ns, size, sha1, object_type) + VALUES(?,?,?,?,?,?,?) + ON CONFLICT(config, rel_path) DO UPDATE SET + kind=excluded.kind, + mtime_ns=excluded.mtime_ns, + size=excluded.size, + sha1=excluded.sha1, + object_type=excluded.object_type + """, + (config, rel_path, kind, mtime_ns, size, sha1, object_type), + ) + row = conn.execute( + "SELECT id FROM files WHERE config=? AND rel_path=?", + (config, rel_path), + ).fetchone() + assert row is not None + return int(row["id"]) + + +def upsert_object( + conn: sqlite3.Connection, + obj: dict[str, Any], + *, + file_id: int, + fields_text: str, +) -> int: + config = obj["config"] + full_name = obj["full_name"] + old = conn.execute( + "SELECT id FROM objects WHERE config=? AND full_name=?", + (config, full_name), + ).fetchone() + if old: + oid = int(old["id"]) + conn.execute("DELETE FROM objects_fts WHERE object_id=?", (oid,)) + conn.execute("DELETE FROM fields WHERE object_id=?", (oid,)) + conn.execute( + "DELETE FROM refs WHERE config=? AND from_full_name=?", + (config, full_name), + ) + conn.execute( + """ + UPDATE objects SET object_type=?, name=?, synonym=?, comment=?, tooltip=?, + explanation=?, uuid=?, path=?, file_id=?, json=? + WHERE id=? + """, + ( + obj["object_type"], + obj["name"], + obj.get("synonym"), + obj.get("comment"), + obj.get("tooltip"), + obj.get("explanation"), + obj.get("uuid"), + obj.get("path"), + file_id, + json.dumps(obj, ensure_ascii=False), + oid, + ), + ) + else: + cur = conn.execute( + """ + INSERT INTO objects(config, object_type, name, full_name, synonym, comment, + tooltip, explanation, uuid, path, file_id, json) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + config, + obj["object_type"], + obj["name"], + full_name, + obj.get("synonym"), + obj.get("comment"), + obj.get("tooltip"), + obj.get("explanation"), + obj.get("uuid"), + obj.get("path"), + file_id, + json.dumps(obj, ensure_ascii=False), + ), + ) + oid = int(cur.lastrowid) + + conn.execute( + """ + INSERT INTO objects_fts(full_name, synonym, comment, fields_text, object_id, config) + VALUES(?,?,?,?,?,?) + """, + ( + full_name, + obj.get("synonym") or "", + obj.get("comment") or "", + fields_text, + oid, + config, + ), + ) + + for f in obj.get("fields") or []: + conn.execute( + """ + INSERT INTO fields(object_id, config, kind, name, synonym, comment, types, + tabular_section, refs_json) + VALUES(?,?,?,?,?,?,?,?,?) + """, + ( + oid, + config, + f.get("kind"), + f.get("name"), + f.get("synonym"), + f.get("comment"), + ", ".join(f.get("types") or []), + f.get("tabular_section") or "", + json.dumps(f.get("refs") or [], ensure_ascii=False), + ), + ) + for r in f.get("refs") or []: + loc = f"{f['tabular_section']}." if f.get("tabular_section") else "" + conn.execute( + """ + INSERT INTO refs(config, from_full_name, from_field, to_kind, to_name) + VALUES(?,?,?,?,?) + """, + ( + config, + full_name, + f"{loc}{f.get('name')}", + r.get("kind"), + r.get("name"), + ), + ) + return oid + + +def upsert_module( + conn: sqlite3.Connection, + *, + config: str, + rel_path: str, + owner: str, + module_kind: str, + symbols: list[str], + body: str, + line_count: int, + size: int, + file_id: int, +) -> int: + sym_text = "\n".join(symbols) + old = conn.execute( + "SELECT id FROM modules WHERE config=? AND rel_path=?", + (config, rel_path), + ).fetchone() + if old: + mid = int(old["id"]) + conn.execute("DELETE FROM modules_fts WHERE module_id=?", (mid,)) + conn.execute( + """ + UPDATE modules SET owner=?, module_kind=?, symbols=?, line_count=?, size=?, file_id=? + WHERE id=? + """, + (owner, module_kind, sym_text, line_count, size, file_id, mid), + ) + else: + cur = conn.execute( + """ + INSERT INTO modules(config, rel_path, owner, module_kind, symbols, line_count, size, file_id) + VALUES(?,?,?,?,?,?,?,?) + """, + (config, rel_path, owner, module_kind, sym_text, line_count, size, file_id), + ) + mid = int(cur.lastrowid) + + conn.execute( + """ + INSERT INTO modules_fts(rel_path, owner, module_kind, symbols, body, module_id, config) + VALUES(?,?,?,?,?,?,?) + """, + (rel_path, owner, module_kind, sym_text, body, mid, config), + ) + return mid + + +def refresh_config_stats(conn: sqlite3.Connection, config: str, src: str) -> dict[str, Any]: + objects = conn.execute( + "SELECT COUNT(*) AS n FROM objects WHERE config=?", (config,) + ).fetchone()["n"] + fields = conn.execute( + "SELECT COUNT(*) AS n FROM fields WHERE config=?", (config,) + ).fetchone()["n"] + refs = conn.execute( + "SELECT COUNT(*) AS n FROM refs WHERE config=?", (config,) + ).fetchone()["n"] + modules = conn.execute( + "SELECT COUNT(*) AS n FROM modules WHERE config=?", (config,) + ).fetchone()["n"] + stats = { + "name": config, + "src": src, + "objects": objects, + "fields": fields, + "refs": refs, + "modules": modules, + "updated_at": utcnow(), + } + conn.execute( + """ + INSERT INTO configs(name, src, objects, fields, refs, modules, updated_at) + VALUES(?,?,?,?,?,?,?) + ON CONFLICT(name) DO UPDATE SET + src=excluded.src, + objects=excluded.objects, + fields=excluded.fields, + refs=excluded.refs, + modules=excluded.modules, + updated_at=excluded.updated_at + """, + ( + stats["name"], + stats["src"], + stats["objects"], + stats["fields"], + stats["refs"], + stats["modules"], + stats["updated_at"], + ), + ) + return stats + + +def list_configs(conn: sqlite3.Connection) -> list[dict[str, Any]]: + return [dict(r) for r in conn.execute("SELECT * FROM configs ORDER BY name")] + + +def set_meta(conn: sqlite3.Connection, key: str, value: str) -> None: + conn.execute( + "INSERT OR REPLACE INTO meta(key, value) VALUES(?, ?)", + (key, value), + ) diff --git a/index1c/parse_meta.py b/index1c/parse_meta.py new file mode 100644 index 0000000..8628f7c --- /dev/null +++ b/index1c/parse_meta.py @@ -0,0 +1,351 @@ +"""Разбор XML метаданных объектов 1С (выгрузка EDT).""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Iterable + +from .config import FIELD_RICH_TYPES, OBJECT_TYPE_FOLDERS + +MD = "{http://v8.1c.ru/8.3/MDClasses}" +V8 = "{http://v8.1c.ru/8.1/data/core}" + +REF_TYPE_RE = re.compile( + r"^cfg:(CatalogRef|DocumentRef|EnumRef|ChartOfCharacteristicTypesRef|" + r"ChartOfAccountsRef|ChartOfCalculationTypesRef|ExchangePlanRef|" + r"BusinessProcessRef|TaskRef|DefinedType)\.(.+)$" +) + +FIELD_TAGS = frozenset( + { + "Attribute", + "Dimension", + "Resource", + "TabularSection", + "Column", + "EnumValue", + "AddressingAttribute", + } +) + + +def local(tag: str) -> str: + return tag.split("}")[-1] if "}" in tag else tag + + +def text_of(el: ET.Element | None) -> str: + if el is None: + return "" + return (el.text or "").strip() + + +def child_text(props: ET.Element, tag: str) -> str: + node = props.find(f"{MD}{tag}") + if node is None: + # без namespace — на всякий случай + for ch in props: + if local(ch.tag) == tag: + return (ch.text or "").strip() + return "" + return (node.text or "").strip() + + +def collect_localized(props: ET.Element, tag: str) -> dict[str, str]: + """Синоним / подсказка: lang → content.""" + node = props.find(f"{MD}{tag}") + if node is None: + return {} + out: dict[str, str] = {} + for item in node.findall(f"{V8}item"): + lang = text_of(item.find(f"{V8}lang")) or "ru" + content = text_of(item.find(f"{V8}content")) + if content: + out[lang] = content + return out + + +def synonym_str(loc: dict[str, str]) -> str: + if not loc: + return "" + if "ru" in loc: + parts = [loc["ru"]] + [f"{k}:{v}" for k, v in loc.items() if k != "ru"] + return " | ".join(parts) + return " | ".join(f"{k}:{v}" for k, v in loc.items()) + + +def collect_types(props: ET.Element) -> list[str]: + typ = props.find(f"{MD}Type") + if typ is None: + return [] + types: list[str] = [] + for t in typ.findall(f".//{V8}Type"): + raw = (t.text or "").strip() + if raw: + types.append(raw) + return types + + +def parse_ref_targets(types: Iterable[str]) -> list[dict[str, str]]: + refs: list[dict[str, str]] = [] + for t in types: + m = REF_TYPE_RE.match(t) + if m: + refs.append({"kind": m.group(1), "name": m.group(2), "raw": t}) + elif t.startswith("cfg:") and "Ref." in t: + # прочие cfg:*Ref.* + body = t[4:] + if "." in body: + kind, name = body.split(".", 1) + refs.append({"kind": kind, "name": name, "raw": t}) + return refs + + +@dataclass +class FieldRec: + kind: str # Attribute | Dimension | Resource | Column | EnumValue | ... + name: str + synonym: str = "" + comment: str = "" + tooltip: str = "" + types: list[str] = field(default_factory=list) + refs: list[dict[str, str]] = field(default_factory=list) + tabular_section: str = "" + + +@dataclass +class ObjectRec: + config: str + object_type: str + name: str + synonym: str = "" + comment: str = "" + tooltip: str = "" + explanation: str = "" + uuid: str = "" + path: str = "" + fields: list[FieldRec] = field(default_factory=list) + owners: list[str] = field(default_factory=list) + register_records: list[str] = field(default_factory=list) + based_on: list[str] = field(default_factory=list) + input_by_string: list[str] = field(default_factory=list) + + @property + def full_name(self) -> str: + return f"{self.object_type}.{self.name}" + + def search_text(self) -> str: + parts = [ + self.object_type, + self.name, + self.synonym, + self.comment, + self.tooltip, + self.explanation, + ] + for f in self.fields: + parts.extend([f.kind, f.name, f.synonym, f.comment, f.tooltip, f.tabular_section]) + parts.extend(f.types) + parts.extend(self.owners) + parts.extend(self.register_records) + parts.extend(self.based_on) + return "\n".join(p for p in parts if p) + + def to_dict(self) -> dict[str, Any]: + d = asdict(self) + d["full_name"] = self.full_name + return d + + +def _parse_field_props(props: ET.Element, kind: str, tabular: str = "") -> FieldRec | None: + name = child_text(props, "Name") + if not name: + return None + types = collect_types(props) + return FieldRec( + kind=kind, + name=name, + synonym=synonym_str(collect_localized(props, "Synonym")), + comment=child_text(props, "Comment"), + tooltip=synonym_str(collect_localized(props, "ToolTip")), + types=types, + refs=parse_ref_targets(types), + tabular_section=tabular, + ) + + +def _walk_child_objects(co: ET.Element | None, object_type: str) -> list[FieldRec]: + if co is None or object_type not in FIELD_RICH_TYPES: + return [] + fields: list[FieldRec] = [] + + for child in co: + tag = local(child.tag) + if tag == "TabularSection": + ts_props = child.find(f"{MD}Properties") + ts_name = child_text(ts_props, "Name") if ts_props is not None else "" + if ts_props is not None and ts_name: + fields.append( + FieldRec( + kind="TabularSection", + name=ts_name, + synonym=synonym_str(collect_localized(ts_props, "Synonym")), + comment=child_text(ts_props, "Comment"), + ) + ) + inner = child.find(f"{MD}ChildObjects") + if inner is not None: + for col in inner: + if local(col.tag) != "Attribute": + continue + cprops = col.find(f"{MD}Properties") + if cprops is None: + continue + rec = _parse_field_props(cprops, "Column", tabular=ts_name) + if rec: + fields.append(rec) + continue + + if tag not in FIELD_TAGS: + continue + props = child.find(f"{MD}Properties") + if props is None: + continue + rec = _parse_field_props(props, tag) + if rec: + fields.append(rec) + + return fields + + +_META_PREFIXES = ( + "Catalog.", + "Document.", + "InformationRegister.", + "AccumulationRegister.", + "Enum.", + "Constant.", + "ChartOfCharacteristicTypes.", + "ExchangePlan.", + "BusinessProcess.", + "Task.", + "DocumentJournal.", +) + + +def _list_from_props(props: ET.Element, tag: str) -> list[str]: + node = props.find(f"{MD}{tag}") + if node is None: + return [] + items: list[str] = [] + for el in node.iter(): + t = (el.text or "").strip() + if not t or "." not in t: + continue + if any(t.startswith(p) for p in _META_PREFIXES): + items.append(t) + seen: set[str] = set() + out: list[str] = [] + for x in items: + if x not in seen: + seen.add(x) + out.append(x) + return out + + +def parse_metadata_xml( + path: Path, + *, + config: str, + object_type: str, +) -> ObjectRec | None: + try: + tree = ET.parse(path) + except ET.ParseError: + return None + root = tree.getroot() + + # Корневой элемент объекта: , , ... + obj_el: ET.Element | None = None + for ch in root: + if local(ch.tag) == object_type: + obj_el = ch + break + if obj_el is None: + for ch in root: + if ch.find(f"{MD}Properties") is not None or ch.find("Properties") is not None: + obj_el = ch + break + if obj_el is None: + return None + + props = obj_el.find(f"{MD}Properties") + if props is None: + props = obj_el.find("Properties") + if props is None: + return None + + name = child_text(props, "Name") + if not name: + return None + + uuid = obj_el.attrib.get("uuid", "") + + fields = _walk_child_objects(obj_el.find(f"{MD}ChildObjects"), object_type) + + # Для констант / определяемых типов тип лежит в Properties + if object_type in {"Constant", "DefinedType", "CommonAttribute", "SessionParameter"} and not fields: + types = collect_types(props) + if types: + fields.append( + FieldRec( + kind="ValueType", + name="Type", + types=types, + refs=parse_ref_targets(types), + ) + ) + + return ObjectRec( + config=config, + object_type=object_type, + name=name, + synonym=synonym_str(collect_localized(props, "Synonym")), + comment=child_text(props, "Comment"), + tooltip=synonym_str(collect_localized(props, "ToolTip")), + explanation=synonym_str(collect_localized(props, "Explanation")), + uuid=uuid, + path=str(path), + fields=fields, + owners=_list_from_props(props, "Owners"), + register_records=_list_from_props(props, "RegisterRecords"), + based_on=_list_from_props(props, "BasedOn"), + input_by_string=_list_from_props(props, "InputByString"), + ) + + +def iter_object_xml_files( + src: Path, + types: set[str] | None = None, + skip_types: set[str] | None = None, +) -> list[tuple[str, Path]]: + """Список (object_type, xml_path) только верхний уровень src//*.xml.""" + skip = skip_types or set() + out: list[tuple[str, Path]] = [] + if not src.is_dir(): + return out + for folder in sorted(src.iterdir()): + if not folder.is_dir(): + continue + otype = OBJECT_TYPE_FOLDERS.get(folder.name) + if otype is None: + continue + if otype in skip: + continue + if types is not None and otype not in types: + continue + for xml in sorted(folder.glob("*.xml")): + out.append((otype, xml)) + return out diff --git a/index1c/parse_modules.py b/index1c/parse_modules.py new file mode 100644 index 0000000..22ea680 --- /dev/null +++ b/index1c/parse_modules.py @@ -0,0 +1,89 @@ +"""Индексация BSL-модулей конфигурации.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +from .config import OBJECT_TYPE_FOLDERS + +# Процедура/Функция Имя(...) +RE_SYMBOL = re.compile( + r"(?im)^\s*(?:&[А-Яа-яA-Za-z_]+)*\s*" + r"(?:Процедура|Функция|Procedure|Function)\s+" + r"([А-Яа-яA-Za-z_][А-Яа-яA-Za-z0-9_]*)" +) + +# Лимит тела для FTS (символы) — полные гигантские модули не кладём целиком. +MAX_BODY_CHARS = 200_000 + + +@dataclass +class ModuleRec: + config: str + rel_path: str + abs_path: str + owner: str + module_kind: str + symbols: list[str] + body: str + line_count: int + size: int + mtime_ns: int + + +def _module_kind(rel: Path) -> str: + name = rel.name + if name == "Module.bsl": + parent = rel.parent.name + if parent == "Form": + return "FormModule" + if parent == "Ext": + # CommonModules/.../Ext/Module.bsl или HTTPServices/.../Ext/Module.bsl + return "Module" + return "Module" + if name.endswith("Module.bsl"): + return name[: -len(".bsl")] # ObjectModule, ManagerModule, ... + return name + + +def _owner_from_rel(rel: Path) -> str: + """Documents/ЗаказКлиента/Ext/ObjectModule.bsl → Document.ЗаказКлиента.""" + parts = rel.parts + if not parts: + return "" + folder = parts[0] + otype = OBJECT_TYPE_FOLDERS.get(folder) + if otype and len(parts) >= 2: + return f"{otype}.{parts[1]}" + return folder + + +def parse_bsl_file(path: Path, *, config: str, src_root: Path) -> ModuleRec | None: + try: + st = path.stat() + text = path.read_text(encoding="utf-8-sig", errors="replace") + except OSError: + return None + rel = path.relative_to(src_root).as_posix() + symbols = sorted(set(RE_SYMBOL.findall(text))) + body = text if len(text) <= MAX_BODY_CHARS else text[:MAX_BODY_CHARS] + return ModuleRec( + config=config, + rel_path=rel, + abs_path=str(path), + owner=_owner_from_rel(Path(rel)), + module_kind=_module_kind(Path(rel)), + symbols=symbols, + body=body, + line_count=text.count("\n") + (1 if text and not text.endswith("\n") else 0), + size=st.st_size, + mtime_ns=getattr(st, "st_mtime_ns", int(st.st_mtime * 1e9)), + ) + + +def iter_bsl_files(src: Path) -> list[Path]: + if not src.is_dir(): + return [] + return sorted(src.rglob("*.bsl")) diff --git a/index1c/search.py b/index1c/search.py new file mode 100644 index 0000000..0cd4a83 --- /dev/null +++ b/index1c/search.py @@ -0,0 +1,287 @@ +"""Поиск по единому index.sqlite проекта.""" + +from __future__ import annotations + +import json +import re +import sqlite3 +from pathlib import Path +from typing import Any + +from . import db as dbmod +from .config import cache_dir + + +def _fts_query(raw: str) -> str: + tokens = re.findall(r"[\wА-Яа-яЁё]+", raw, flags=re.UNICODE) + if not tokens: + return raw + return " ".join(f"{t}*" for t in tokens) + + +def open_index(project_root: Path, *, readonly: bool = True) -> sqlite3.Connection: + path = dbmod.global_db_path(cache_dir(project_root)) + if not path.is_file(): + raise FileNotFoundError( + f"Индекс не найден: {path}. Запустите: python tools/index/index_1c.py reindex --full" + ) + return dbmod.connect(path, readonly=readonly) + + +def search_objects( + conn: sqlite3.Connection, + query: str, + *, + configs: list[str] | None = None, + object_type: str | None = None, + limit: int = 30, +) -> list[dict[str, Any]]: + exact_sql = """ + SELECT config, full_name, object_type, name, synonym, comment, path, '' AS snip, -100.0 AS score + FROM objects + WHERE (name = ? OR full_name = ? OR full_name LIKE ?) + """ + exact_params: list[Any] = [query, query, f"%.{query}"] + if configs: + exact_sql += f" AND config IN ({','.join('?' * len(configs))})" + exact_params.extend(configs) + if object_type: + exact_sql += " AND object_type = ?" + exact_params.append(object_type) + exact_sql += " LIMIT ?" + exact_params.append(limit) + exact = [dict(r) for r in conn.execute(exact_sql, exact_params)] + seen = {(r["config"], r["full_name"]) for r in exact} + + fts = _fts_query(query) + fetch_n = max(limit * 5, 50) + sql = """ + SELECT o.config, o.full_name, o.object_type, o.name, o.synonym, o.comment, o.path, + snippet(objects_fts, 0, '[', ']', '…', 12) AS snip, + bm25(objects_fts, 10.0, 5.0, 2.0, 1.0) AS score + FROM objects_fts + JOIN objects o ON o.id = objects_fts.object_id + WHERE objects_fts MATCH ? + """ + params: list[Any] = [fts] + if configs: + sql += f" AND o.config IN ({','.join('?' * len(configs))})" + params.extend(configs) + if object_type: + sql += " AND o.object_type = ?" + params.append(object_type) + sql += " ORDER BY score LIMIT ?" + params.append(fetch_n) + + try: + rows = [dict(r) for r in conn.execute(sql, params)] + except sqlite3.OperationalError: + like = f"%{query}%" + sql2 = """ + SELECT config, full_name, object_type, name, synonym, comment, path, '' AS snip, 0 AS score + FROM objects + WHERE full_name LIKE ? OR synonym LIKE ? OR comment LIKE ? OR name LIKE ? + """ + params2: list[Any] = [like, like, like, like] + if configs: + sql2 += f" AND config IN ({','.join('?' * len(configs))})" + params2.extend(configs) + if object_type: + sql2 += " AND object_type = ?" + params2.append(object_type) + sql2 += " LIMIT ?" + params2.append(fetch_n) + rows = [dict(r) for r in conn.execute(sql2, params2)] + + merged = list(exact) + for r in rows: + key = (r["config"], r["full_name"]) + if key in seen: + continue + seen.add(key) + merged.append(r) + + q_cf = query.casefold() + for r in merged: + name_cf = (r.get("name") or "").casefold() + full_cf = (r.get("full_name") or "").casefold() + if name_cf == q_cf or full_cf == q_cf or full_cf.endswith("." + q_cf): + r["_boost"] = 0 + elif q_cf in name_cf or q_cf in full_cf: + r["_boost"] = 1 + else: + r["_boost"] = 2 + merged.sort( + key=lambda x: (x.get("_boost", 9), x.get("score") if x.get("score") is not None else 0) + ) + return merged[:limit] + + +def search_modules( + conn: sqlite3.Connection, + query: str, + *, + configs: list[str] | None = None, + limit: int = 30, +) -> list[dict[str, Any]]: + fts = _fts_query(query) + sql = """ + SELECT m.config, m.rel_path, m.owner, m.module_kind, m.symbols, m.line_count, + snippet(modules_fts, 4, '[', ']', '…', 16) AS snip, + bm25(modules_fts, 2.0, 5.0, 2.0, 8.0, 1.0) AS score + FROM modules_fts + JOIN modules m ON m.id = modules_fts.module_id + WHERE modules_fts MATCH ? + """ + params: list[Any] = [fts] + if configs: + sql += f" AND m.config IN ({','.join('?' * len(configs))})" + params.extend(configs) + sql += " ORDER BY score LIMIT ?" + params.append(limit) + try: + return [dict(r) for r in conn.execute(sql, params)] + except sqlite3.OperationalError: + like = f"%{query}%" + sql2 = """ + SELECT config, rel_path, owner, module_kind, symbols, line_count, '' AS snip, 0 AS score + FROM modules + WHERE rel_path LIKE ? OR owner LIKE ? OR symbols LIKE ? + """ + params2: list[Any] = [like, like, like] + if configs: + sql2 += f" AND config IN ({','.join('?' * len(configs))})" + params2.extend(configs) + sql2 += " LIMIT ?" + params2.append(limit) + return [dict(r) for r in conn.execute(sql2, params2)] + + +def find_refs_to( + conn: sqlite3.Connection, + target: str, + *, + configs: list[str] | None = None, + limit: int = 100, +) -> list[dict[str, Any]]: + kind = "" + name = target + if "." in target: + kind, name = target.split(".", 1) + kind_ref = kind + if kind and not kind.endswith("Ref"): + if kind in { + "Catalog", + "Document", + "Enum", + "ExchangePlan", + "BusinessProcess", + "Task", + "ChartOfCharacteristicTypes", + "ChartOfAccounts", + "ChartOfCalculationTypes", + }: + kind_ref = kind + "Ref" + + if kind_ref: + sql = """ + SELECT config, from_full_name, from_field, to_kind, to_name + FROM refs + WHERE to_name = ? AND (to_kind = ? OR to_kind = ?) + """ + params: list[Any] = [name, kind_ref, kind] + else: + sql = """ + SELECT config, from_full_name, from_field, to_kind, to_name + FROM refs WHERE to_name = ? + """ + params = [name] + if configs: + sql += f" AND config IN ({','.join('?' * len(configs))})" + params.extend(configs) + sql += " ORDER BY config, from_full_name LIMIT ?" + params.append(limit) + return [dict(r) for r in conn.execute(sql, params)] + + +def get_object( + conn: sqlite3.Connection, + name: str, + *, + configs: list[str] | None = None, +) -> dict[str, Any] | None: + sql = "SELECT config, json FROM objects WHERE full_name = ? OR name = ?" + params: list[Any] = [name, name] + if configs: + sql += f" AND config IN ({','.join('?' * len(configs))})" + params.extend(configs) + sql += " LIMIT 1" + row = conn.execute(sql, params).fetchone() + if not row: + sql = "SELECT config, json FROM objects WHERE full_name LIKE ?" + params = [f"%.{name}"] + if configs: + sql += f" AND config IN ({','.join('?' * len(configs))})" + params.extend(configs) + sql += " LIMIT 1" + row = conn.execute(sql, params).fetchone() + if not row: + return None + data = json.loads(row["json"]) + data["_config"] = row["config"] + return data + + +def get_module( + conn: sqlite3.Connection, + path_or_owner: str, + *, + configs: list[str] | None = None, +) -> list[dict[str, Any]]: + sql = """ + SELECT config, rel_path, owner, module_kind, symbols, line_count, size + FROM modules + WHERE rel_path = ? OR rel_path LIKE ? OR owner = ? OR owner LIKE ? + """ + like = f"%{path_or_owner}%" + params: list[Any] = [path_or_owner, like, path_or_owner, like] + if configs: + sql += f" AND config IN ({','.join('?' * len(configs))})" + params.extend(configs) + sql += " ORDER BY config, rel_path LIMIT 50" + return [dict(r) for r in conn.execute(sql, params)] + + +def list_indexed_configs(conn_or_cache) -> list[str]: + """Совместимость: Connection или Path cache_root.""" + if isinstance(conn_or_cache, Path): + db_path = dbmod.global_db_path(conn_or_cache) + if not db_path.is_file(): + # legacy: подкаталоги с manifest + return [ + p.name + for p in sorted(conn_or_cache.iterdir()) + if p.is_dir() and (p / "manifest.json").is_file() + ] + conn = dbmod.connect(db_path, readonly=True) + try: + return [c["name"] for c in dbmod.list_configs(conn)] + finally: + conn.close() + return [c["name"] for c in dbmod.list_configs(conn_or_cache)] + + +# --- legacy wrappers (per-config sqlite) --- + +def search_sqlite(db_path: Path, query: str, *, limit: int = 30, object_type: str | None = None): + """Устарело: ищет в per-config sqlite; для нового индекса используйте search_objects.""" + if not db_path.is_file(): + return [] + # если передали index.sqlite — открыть как глобальный + if db_path.name == "index.sqlite": + conn = dbmod.connect(db_path, readonly=True) + try: + return search_objects(conn, query, object_type=object_type, limit=limit) + finally: + conn.close() + return [] diff --git a/index1c/writers.py b/index1c/writers.py new file mode 100644 index 0000000..90d5b25 --- /dev/null +++ b/index1c/writers.py @@ -0,0 +1,334 @@ +"""Запись индексов: JSONL, Markdown, SQLite FTS5.""" + +from __future__ import annotations + +import json +import sqlite3 +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +from .parse_meta import ObjectRec + + +def _utcnow() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def write_jsonl(path: Path, rows: Iterable[dict[str, Any]]) -> int: + path.parent.mkdir(parents=True, exist_ok=True) + n = 0 + with path.open("w", encoding="utf-8") as f: + for row in rows: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + n += 1 + return n + + +def write_objects_markdown(path: Path, objects: list[ObjectRec], *, title: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + by_type: dict[str, list[ObjectRec]] = defaultdict(list) + for obj in objects: + by_type[obj.object_type].append(obj) + + lines: list[str] = [ + f"# {title}", + "", + f"Сгенерировано: {_utcnow()}. Быстрый поиск метаданных без обхода XML.", + "", + ] + for otype in sorted(by_type): + lines.append(f"## {otype} ({len(by_type[otype])})") + lines.append("") + for obj in sorted(by_type[otype], key=lambda o: o.name.casefold()): + syn = f" — {obj.synonym}" if obj.synonym else "" + lines.append(f"### `{obj.full_name}`{syn}") + if obj.comment: + lines.append(f"- Комментарий: {obj.comment}") + if obj.tooltip: + lines.append(f"- Подсказка: {obj.tooltip}") + if obj.owners: + lines.append(f"- Владельцы: {', '.join(obj.owners)}") + if obj.register_records: + lines.append(f"- Движения: {', '.join(obj.register_records)}") + if obj.based_on: + lines.append(f"- Вводится на основании: {', '.join(obj.based_on)}") + attrs = [f for f in obj.fields if f.kind != "TabularSection"] + if attrs: + lines.append("- Поля:") + for f in attrs: + loc = f".{f.tabular_section}" if f.tabular_section else "" + types = ", ".join(f.types) if f.types else "" + syn_f = f" ({f.synonym})" if f.synonym else "" + type_s = f" : `{types}`" if types else "" + refs = "" + if f.refs: + refs = " → " + ", ".join( + f"{r['kind']}.{r['name']}" for r in f.refs + ) + lines.append( + f" - `{f.kind}{loc}.{f.name}`{syn_f}{type_s}{refs}" + ) + lines.append("") + path.write_text("\n".join(lines), encoding="utf-8") + + +def write_refs_markdown(path: Path, objects: list[ObjectRec], *, title: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + inv: dict[str, list[str]] = defaultdict(list) + for obj in objects: + for f in obj.fields: + for r in f.refs: + target = f"{r['kind']}.{r['name']}" + loc = f".{f.tabular_section}" if f.tabular_section else "" + inv[target].append(f"{obj.full_name}{loc}.{f.name}") + + lines = [ + f"# {title}", + "", + f"Сгенерировано: {_utcnow()}.", + "", + ] + for target in sorted(inv, key=lambda s: s.casefold()): + lines.append(f"## `{target}`") + for src in sorted(set(inv[target])): + lines.append(f"- `{src}`") + lines.append("") + path.write_text("\n".join(lines), encoding="utf-8") + + +def write_sqlite_fts(path: Path, objects: list[ObjectRec], config: str) -> None: + if path.exists(): + path.unlink() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + try: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute( + """ + CREATE TABLE objects ( + id INTEGER PRIMARY KEY, + config TEXT NOT NULL, + object_type TEXT NOT NULL, + name TEXT NOT NULL, + full_name TEXT NOT NULL, + synonym TEXT, + comment TEXT, + path TEXT, + json TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE VIRTUAL TABLE objects_fts USING fts5( + full_name, + synonym, + comment, + fields_text, + object_id UNINDEXED, + tokenize='unicode61' + ) + """ + ) + conn.execute( + """ + CREATE TABLE fields ( + object_id INTEGER NOT NULL, + kind TEXT, + name TEXT, + synonym TEXT, + types TEXT, + tabular_section TEXT, + refs_json TEXT, + FOREIGN KEY(object_id) REFERENCES objects(id) + ) + """ + ) + conn.execute( + """ + CREATE TABLE refs ( + from_full_name TEXT NOT NULL, + from_field TEXT NOT NULL, + to_kind TEXT NOT NULL, + to_name TEXT NOT NULL + ) + """ + ) + + for obj in objects: + fields_text = " ".join( + " ".join( + filter( + None, + [ + f.kind, + f.name, + f.synonym, + f.comment, + f.tabular_section, + " ".join(f.types), + ], + ) + ) + for f in obj.fields + ) + payload = json.dumps(obj.to_dict(), ensure_ascii=False) + cur = conn.execute( + """ + INSERT INTO objects + (config, object_type, name, full_name, synonym, comment, path, json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + config, + obj.object_type, + obj.name, + obj.full_name, + obj.synonym, + obj.comment, + obj.path, + payload, + ), + ) + oid = cur.lastrowid + conn.execute( + """ + INSERT INTO objects_fts + (full_name, synonym, comment, fields_text, object_id) + VALUES (?, ?, ?, ?, ?) + """, + (obj.full_name, obj.synonym or "", obj.comment or "", fields_text, oid), + ) + for f in obj.fields: + conn.execute( + """ + INSERT INTO fields + (object_id, kind, name, synonym, types, tabular_section, refs_json) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + oid, + f.kind, + f.name, + f.synonym, + ", ".join(f.types), + f.tabular_section, + json.dumps(f.refs, ensure_ascii=False), + ), + ) + for r in f.refs: + loc = f"{f.tabular_section}." if f.tabular_section else "" + conn.execute( + """ + INSERT INTO refs + (from_full_name, from_field, to_kind, to_name) + VALUES (?, ?, ?, ?) + """, + (obj.full_name, f"{loc}{f.name}", r["kind"], r["name"]), + ) + + conn.execute("CREATE INDEX idx_objects_type ON objects(object_type)") + conn.execute("CREATE INDEX idx_objects_name ON objects(name)") + conn.execute("CREATE INDEX idx_refs_to ON refs(to_kind, to_name)") + conn.execute("CREATE INDEX idx_refs_from ON refs(from_full_name)") + conn.commit() + finally: + conn.close() + + +def write_manifest(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = {**data, "generated_at": _utcnow()} + path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + +def write_type_summaries(out_dir: Path, objects: list[ObjectRec]) -> None: + by_type: dict[str, list[ObjectRec]] = defaultdict(list) + for obj in objects: + by_type[obj.object_type].append(obj) + types_dir = out_dir / "by_type" + types_dir.mkdir(parents=True, exist_ok=True) + for otype, items in by_type.items(): + lines = [f"# {otype}", "", f"Объектов: {len(items)}", ""] + for obj in sorted(items, key=lambda o: o.name.casefold()): + syn = f" — {obj.synonym}" if obj.synonym else "" + n_fields = len([f for f in obj.fields if f.kind != "TabularSection"]) + lines.append(f"- `{obj.name}`{syn} (полей: {n_fields})") + (types_dir / f"{otype}.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_global_index_md(cache_root: Path, summary: dict[str, Any]) -> Path: + """Сводка по единому index.sqlite.""" + cache_root.mkdir(parents=True, exist_ok=True) + configs = summary.get("configs") or [] + lines = [ + "# Индекс метаданных и модулей 1С (проект)", + "", + f"Сгенерировано: {_utcnow()}.", + f"Режим: `{summary.get('mode', '?')}`, БД: `cache/1c_meta/index.sqlite`.", + f"Потоки: {summary.get('workers', '?')}, время: {summary.get('elapsed_sec', '?')} с.", + "", + "Поиск: `python tools/index/query_1c.py search \"…\"`", + "Переиндексация: `python tools/index/index_1c.py reindex` (инкремент) / `--full`.", + "", + "## Конфигурации", + "", + "| Конфигурация | Объектов | Полей | Ссылок | Модулей |", + "|---|---:|---:|---:|---:|", + ] + for c in sorted(configs, key=lambda x: x.get("name") or x.get("config") or ""): + name = c.get("name") or c.get("config") or "?" + lines.append( + "| `{name}` | {objects} | {fields} | {refs} | {modules} |".format( + name=name, + objects=c.get("objects", 0), + fields=c.get("fields", 0), + refs=c.get("refs", 0), + modules=c.get("modules", 0), + ) + ) + lines.extend( + [ + "", + "## Статистика прогона", + "", + f"- meta разобрано: {summary.get('meta_parsed', 0)}, пропущено (без изменений): {summary.get('meta_skipped', 0)}", + f"- modules разобрано: {summary.get('modules_parsed', 0)}, пропущено: {summary.get('modules_skipped', 0)}", + f"- удалено файлов: {summary.get('files_removed', 0)}, ошибок: {summary.get('errors', 0)}", + "", + "## Артефакты", + "", + "- `index.sqlite` — единый кэш проекта (объекты, поля, ссылки, модули, FTS)", + "- `INDEX.md` — эта сводка", + "- `md//` — опциональный Markdown (`reindex --md`)", + "", + ] + ) + path = cache_root / "INDEX.md" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + write_manifest( + cache_root / "manifest.json", + { + "db": "index.sqlite", + "mode": summary.get("mode"), + "configs": configs, + "summary": { + k: summary.get(k) + for k in ( + "meta_parsed", + "meta_skipped", + "modules_parsed", + "modules_skipped", + "files_removed", + "errors", + "elapsed_sec", + "workers", + ) + }, + }, + ) + return path diff --git a/index_1c.py b/index_1c.py new file mode 100755 index 0000000..c077373 --- /dev/null +++ b/index_1c.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Переиндексация метаданных и модулей конфигураций 1С → cache/1c_meta/index.sqlite. + +Параметры: + --config, -c файл настроек утилиты (JSON) + --baseconf, -b какие выгрузки 1С индексировать (crm3-26, target, …) + +Примеры: + + python tools/index/index_1c.py reindex -j 12 + python tools/index/index_1c.py reindex --full -j 12 + python tools/index/index_1c.py reindex --full -b crm3-26 -j 12 + python tools/index/index_1c.py reindex -b target -b ws-rhana -t Documents,Catalogs + python tools/index/index_1c.py status + python tools/index/index_1c.py list + +Поиск: python tools/index/query_1c.py … +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from index1c.build import build_project_index, default_workers # noqa: E402 +from index1c.cli import ( # noqa: E402 + BaseconfError, + add_baseconf_args, + add_tool_args, + handle_baseconf_error, + index_db_missing_message, + resolve_project_root, + validate_baseconfs, +) +from index1c.config import ( # noqa: E402 + CONFIG_ALIASES, + DEFAULT_SKIP_TYPES, + cache_dir, + discover_configs, + resolve_object_type, +) +from index1c import db as dbmod # noqa: E402 + + +def _parse_types(raw: str | None) -> set[str] | None: + if not raw: + return None + types: set[str] = set() + for part in raw.replace(";", ",").split(","): + part = part.strip() + if not part: + continue + types.add(resolve_object_type(part)) + return types or None + + +def _select_configs_for_reindex(args: argparse.Namespace, root: Path) -> list: + """Какие конфигурации 1С индексировать.""" + if args.baseconf: + try: + names = validate_baseconfs(root, args.baseconf, require_indexed=False) + except BaseconfError as e: + print(str(e), file=sys.stderr) + sys.exit(e.exit_code) + return discover_configs(root, names) + + # без -b: все известные с src/ + return discover_configs(root, None) + + +def cmd_reindex(args: argparse.Namespace) -> int: + root = resolve_project_root(args) + configs = _select_configs_for_reindex(args, root) + if not configs: + print( + "Нет конфигураций для индексации (нужна папка /src/).\n" + "Укажите -b crm3-26 или проверьте: python tools/index/index_1c.py list", + file=sys.stderr, + ) + return 1 + + types = _parse_types(args.types) + skip = set(DEFAULT_SKIP_TYPES) + if args.include_skipped: + skip.clear() + if args.skip_types: + for t in args.skip_types.replace(";", ",").split(","): + t = t.strip() + if t: + skip.add(resolve_object_type(t)) + + workers = args.workers if args.workers and args.workers > 0 else default_workers() + # --full без -b → пересоздать всю БД + wipe_db = bool(args.full and not args.baseconf) + print( + f"Режим: {'full-wipe' if wipe_db else ('full' if args.full else 'incremental')}, " + f"workers={workers}, baseconf={[c.name for c in configs]}, modules={not args.no_modules}" + ) + + summary = build_project_index( + configs, + root, + full=args.full or wipe_db, + wipe_db=wipe_db, + types=types, + skip_types=skip, + index_modules=not args.no_modules, + workers=workers, + write_md=args.md, + ) + print( + f"Готово: {summary['db']} ({summary['elapsed_sec']}s) " + f"meta+{summary['meta_parsed']}/skip{summary['meta_skipped']} " + f"mod+{summary['modules_parsed']}/skip{summary['modules_skipped']} " + f"removed={summary['files_removed']} err={summary['errors']}" + ) + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + root = resolve_project_root(args) + db_path = dbmod.global_db_path(cache_dir(root)) + if not db_path.is_file(): + print(index_db_missing_message(root), file=sys.stderr) + return 1 + conn = dbmod.connect(db_path, readonly=True) + try: + print(f"БД: {db_path} ({db_path.stat().st_size // (1024 * 1024)} МБ)") + for row in conn.execute("SELECT key, value FROM meta ORDER BY key"): + print(f" meta.{row['key']} = {row['value']}") + print("Конфигурации 1С в индексе (--baseconf):") + for c in dbmod.list_configs(conn): + print( + f"- {c['name']}: objects={c['objects']} fields={c['fields']} " + f"refs={c['refs']} modules={c['modules']} @ {c['updated_at']}" + ) + finally: + conn.close() + return 0 + + +def cmd_list_configs(args: argparse.Namespace) -> int: + root = resolve_project_root(args) + found = discover_configs(root, None) + print("Выгрузки 1С в репозитории (для --baseconf / -b):") + for c in found: + aliases = [k for k, v in CONFIG_ALIASES.items() if v == c.name] + alias_s = f" алиасы: {', '.join(aliases)}" if aliases else "" + print(f"- {c.name}{alias_s}") + db_path = dbmod.global_db_path(cache_dir(root)) + if db_path.is_file(): + conn = dbmod.connect(db_path, readonly=True) + try: + print("\nПроиндексировано (в index.sqlite):") + for c in dbmod.list_configs(conn): + print(f"- {c['name']}") + finally: + conn.close() + else: + print(f"\nИндекс отсутствует. {index_db_missing_message(root)}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Построение единого SQLite-индекса метаданных и BSL-модулей 1С.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Конфигурация 1С: --baseconf (-b). Файл настроек утилиты: --config (-c).\n" + "Алиасы baseconf: target→crm3-26, source→crm3-dev" + ), + ) + add_tool_args(p) + sub = p.add_subparsers(dest="cmd", required=True) + + r = sub.add_parser("reindex", help="Построить / обновить index.sqlite") + add_baseconf_args(r) + r.add_argument("--types", "-t", help="Только типы: Documents,Catalogs,рс,рн,…") + r.add_argument("--skip-types", help="Дополнительно пропустить типы") + r.add_argument("--include-skipped", action="store_true") + r.add_argument( + "--full", + action="store_true", + help="Полная пересборка (вся БД или только указанные -b)", + ) + r.add_argument("--no-modules", action="store_true", help="Не индексировать .bsl") + r.add_argument( + "--workers", + "-j", + type=int, + default=0, + help=f"Потоков (по умолчанию {default_workers()})", + ) + r.add_argument("--md", action="store_true", help="Markdown в cache/1c_meta/md/") + r.set_defaults(func=cmd_reindex) + + st = sub.add_parser("status", help="Состояние index.sqlite") + st.set_defaults(func=cmd_status) + + lc = sub.add_parser("list", help="Выгрузки в репозитории и в индексе") + lc.set_defaults(func=cmd_list_configs) + + return p + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/query_1c.py b/query_1c.py new file mode 100755 index 0000000..ef11b97 --- /dev/null +++ b/query_1c.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Запросы к единому индексу конфигураций 1С (cache/1c_meta/index.sqlite). + +Параметры: + --config, -c файл настроек утилиты (JSON), не конфигурация 1С + --baseconf, -b выгрузка конфигурации 1С в репозитории (crm3-26, target, …) + +Примеры: + + python tools/index/query_1c.py search \"ЗаказКлиента\" + python tools/index/query_1c.py search \"Партнер\" --type Document -b crm3-26 + python tools/index/query_1c.py show ЗаказКлиента -b target + python tools/index/query_1c.py show Document.ЗаказКлиента -b crm3-26 + python tools/index/query_1c.py modules \"РВС_Transfer\" -b ws-rhana + python tools/index/query_1c.py refs Catalog.Партнеры -b source + python tools/index/query_1c.py module CommonModules/РВС_TransferAPIДокументы +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from index1c.cli import ( # noqa: E402 + BaseconfError, + add_baseconf_args, + add_tool_args, + format_not_found_modules, + format_not_found_object, + get_baseconfs_for_query, + handle_baseconf_error, + index_db_missing_message, + resolve_project_root, +) +from index1c.config import resolve_object_type # noqa: E402 +from index1c.search import ( # noqa: E402 + find_refs_to, + get_module, + get_object, + open_index, + search_modules, + search_objects, +) + + +def _open_or_exit(root: Path): + try: + return open_index(root) + except FileNotFoundError: + print(index_db_missing_message(root), file=sys.stderr) + sys.exit(1) + + +def cmd_search(args: argparse.Namespace) -> int: + root = resolve_project_root(args) + conn = _open_or_exit(root) + try: + try: + baseconfs = get_baseconfs_for_query(args, conn, root) + except BaseconfError as e: + return handle_baseconf_error(e) + otype = resolve_object_type(args.type) if args.type else None + rows = search_objects( + conn, + args.query, + configs=baseconfs, + object_type=otype, + limit=args.limit, + ) + if not rows: + scope = f" (baseconf: {', '.join(baseconfs)})" if baseconfs else "" + print(f"Ничего не найдено{scope}.") + return 2 + for r in rows: + syn = f" — {r['synonym']}" if r.get("synonym") else "" + print(f"- [{r['config']}] `{r['full_name']}`{syn}") + if r.get("snip"): + print(f" {r['snip']}") + return 0 + finally: + conn.close() + + +def cmd_modules(args: argparse.Namespace) -> int: + root = resolve_project_root(args) + conn = _open_or_exit(root) + try: + try: + baseconfs = get_baseconfs_for_query(args, conn, root) + except BaseconfError as e: + return handle_baseconf_error(e) + rows = search_modules(conn, args.query, configs=baseconfs, limit=args.limit) + if not rows: + print(format_not_found_modules(args.query, baseconfs=baseconfs, conn=conn)) + return 2 + for r in rows: + print(f"- [{r['config']}] `{r['rel_path']}` ({r['module_kind']}, owner={r['owner']})") + if r.get("snip"): + print(f" {r['snip']}") + return 0 + finally: + conn.close() + + +def cmd_refs(args: argparse.Namespace) -> int: + root = resolve_project_root(args) + conn = _open_or_exit(root) + try: + try: + baseconfs = get_baseconfs_for_query(args, conn, root) + except BaseconfError as e: + return handle_baseconf_error(e) + rows = find_refs_to(conn, args.target, configs=baseconfs, limit=args.limit) + if not rows: + scope = f" в {', '.join(baseconfs)}" if baseconfs else "" + print(f"Ссылок на «{args.target}» не найдено{scope}.") + return 2 + for r in rows: + print( + f"- [{r['config']}] `{r['from_full_name']}.{r['from_field']}` " + f"→ {r['to_kind']}.{r['to_name']}" + ) + return 0 + finally: + conn.close() + + +def cmd_show(args: argparse.Namespace) -> int: + root = resolve_project_root(args) + conn = _open_or_exit(root) + try: + try: + baseconfs = get_baseconfs_for_query(args, conn, root) + except BaseconfError as e: + return handle_baseconf_error(e) + obj = get_object(conn, args.name, configs=baseconfs) + if not obj: + print(format_not_found_object(args.name, baseconfs=baseconfs, conn=conn), file=sys.stderr) + return 2 + print(json.dumps(obj, ensure_ascii=False, indent=2)) + return 0 + finally: + conn.close() + + +def cmd_module(args: argparse.Namespace) -> int: + root = resolve_project_root(args) + conn = _open_or_exit(root) + try: + try: + baseconfs = get_baseconfs_for_query(args, conn, root) + except BaseconfError as e: + return handle_baseconf_error(e) + rows = get_module(conn, args.path, configs=baseconfs) + if not rows: + print(format_not_found_modules(args.path, baseconfs=baseconfs, conn=conn)) + return 2 + for r in rows: + print(json.dumps(dict(r), ensure_ascii=False, indent=2)) + return 0 + finally: + conn.close() + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Поиск по cache/1c_meta/index.sqlite (метаданные и BSL-модули).", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Конфигурация 1С — параметр --baseconf (-b): папка выгрузки в репозитории.\n" + "Файл настроек утилиты — --config (-c): см. tools/index/config.example.json" + ), + ) + add_tool_args(p) + sub = p.add_subparsers(dest="cmd", required=True) + + s = sub.add_parser("search", help="Полнотекстовый поиск объектов метаданных") + s.add_argument("query", help="Имя, синоним, фрагмент поля") + add_baseconf_args(s) + s.add_argument("--type", help="Тип объекта: Document, Catalog, рс, …") + s.add_argument("--limit", type=int, default=30) + s.set_defaults(func=cmd_search) + + m = sub.add_parser("modules", help="Поиск по BSL: процедуры, тело модуля, путь") + m.add_argument("query") + add_baseconf_args(m) + m.add_argument("--limit", type=int, default=30) + m.set_defaults(func=cmd_modules) + + ref = sub.add_parser("refs", help="Обратный индекс: кто ссылается на объект") + ref.add_argument("target", help="Catalog.Партнеры | CatalogRef.Партнеры | Партнеры") + add_baseconf_args(ref) + ref.add_argument("--limit", type=int, default=100) + ref.set_defaults(func=cmd_refs) + + sh = sub.add_parser("show", help="Карточка объекта (JSON: поля, типы, ссылки)") + sh.add_argument("name", help="ЗаказКлиента | Document.ЗаказКлиента") + add_baseconf_args(sh) + sh.set_defaults(func=cmd_show) + + md = sub.add_parser("module", help="Метаданные модуля по пути или owner") + md.add_argument("path", help="CommonModules/… или CommonModule.Имя") + add_baseconf_args(md) + md.set_defaults(func=cmd_module) + + return p + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main())