Детализирована документация. Небольшие изменения имен параметров.

This commit is contained in:
mihailkudravcev
2026-07-16 15:38:16 +03:00
parent 7f9d198f79
commit 24200b401a
22 changed files with 2986 additions and 49 deletions
+136 -49
View File
@@ -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С** в репозитории — папка `<name>/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` | какие `<name>/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/<baseconf>/` для `@` в чате.
### Что попадает в индекс
- **Метаданные:** `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 ```bash
source .venv-nt-041/bin/activate # опционально (используется stdlib) # все конфигурации в индексе
python tools/index/index_1c.py reindex python tools/index/query_1c.py search "ЗаказКлиента"
python tools/index/index_1c.py search "ЗаказКлиента"
python tools/index/index_1c.py refs Catalog.Партнеры --config crm3-26 # только целевая (алиас target)
python tools/index/index_1c.py show Document.ЗаказКлиента --config crm3-26 python tools/index/query_1c.py show ЗаказКлиента -b target
python tools/index/index_1c.py status 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 ```bash
# только целевая конфигурация cp tools/index/config.example.json tools/index/config.local.json
python tools/index/index_1c.py reindex --config crm3-26 # отредактировать project_root
python tools/index/query_1c.py --config tools/index/config.local.json search "Заказ"
# только документы и справочники
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
``` ```
## Куда пишется кэш Приоритет корня: `--root` > `project_root` в JSON > автоопределение от `tools/index/`.
`cache/1c_meta/``.gitignore`): ---
| Путь | Назначение | ## Применение в Cursor
|------|------------|
| `INDEX.md` | сводка по конфигурациям |
| `<config>/objects.jsonl` | объекты (машиночитаемо) |
| `<config>/fields.jsonl` | плоский список полей |
| `<config>/refs.jsonl` | связи по ссылочным типам |
| `<config>/search.sqlite` | FTS5 полнотекстовый поиск |
| `<config>/objects.md` | человекочитаемый перечень |
| `<config>/refs.md` | обратный индекс ссылок |
| `<config>/by_type/*.md` | краткие списки для `@` в Cursor |
| `<config>/manifest.json` | статистика прогона |
Конфигурации по умолчанию: `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/<config>/by_type/Document.md` или CLI `search` / `show` / `refs`.
3. Не Grep'ить сырой XML по всей конфигурации, пока нет основания, что индекса недостаточно. ```
cache/1c_meta/
index.sqlite # единая БД
INDEX.md # сводка последнего reindex
manifest.json
md/<baseconf>/ # опционально (--md)
```
Старые per-config `search.sqlite` в подкаталогах не используются.
+4
View File
@@ -0,0 +1,4 @@
{
"project_root": "/path/to/crm3-26",
"comment": "Файл настроек утилиты tools/index (НЕ конфигурация 1С). Копируйте в config.local.json и укажите --config config.local.json"
}
+3
View File
@@ -0,0 +1,3 @@
"""Индексация метаданных конфигураций 1С (выгрузки EDT/XML) для быстрого поиска."""
__version__ = "0.1.0"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+349
View File
@@ -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)
+242
View File
@@ -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С в репозитории (папка <name>/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С не найдена в репозитории (нет каталога <name>/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"
)
+231
View File
@@ -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"
+520
View File
@@ -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),
)
+351
View File
@@ -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()
# Корневой элемент объекта: <Catalog>, <Document>, ...
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/<Folder>/*.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
+89
View File
@@ -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"))
+287
View File
@@ -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 []
+334
View File
@@ -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/<config>/` — опциональный 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
Executable
+218
View File
@@ -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(
"Нет конфигураций для индексации (нужна папка <name>/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())
Executable
+222
View File
@@ -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())