MCP: инструменты справочников (data tables) — этап 1
- 15 инструментов: list/get_directory, list_directory_rows (flat/tree), get_directory_row, get_directory_column_values (read); create/bulk_create_directory_row (write); create/update/delete_directory, update/delete/move_directory_row, link/unlink_directories (full) - Объектные проверки isTableAllowed по scopes.tableIds - Логика дерева строк вынесена в server/utils/data-table-tree.ts (переиспользуется REST-эндпоинтом и MCP) - bulkCreateDataTableRows: поддержка parentId и position
This commit is contained in:
181
server/utils/data-table-tree.ts
Normal file
181
server/utils/data-table-tree.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { DataTable, DataTableRow } from "@shared/schema";
|
||||
|
||||
// Узел дерева строк справочника:
|
||||
// group — виртуальная группа (по значению колонки или строка с детьми),
|
||||
// row — конкретная строка справочника.
|
||||
export type DataTableTreeNode =
|
||||
| { type: 'group'; id: string; label: string; level: number; children: DataTableTreeNode[] }
|
||||
| { type: 'row'; id: number; label: string; level: number; row: DataTableRow };
|
||||
|
||||
export interface DataTableTreeResult {
|
||||
columnIndex: number | null;
|
||||
treeColumnIndex: number | null;
|
||||
treeColumns: number[];
|
||||
treeDisplayColumns: number[];
|
||||
displayColumnIndex: number;
|
||||
nodes: DataTableTreeNode[];
|
||||
}
|
||||
|
||||
export interface BuildDataTableTreeOptions {
|
||||
// Фильтры: индекс колонки (строкой) → значение; сравнение точное, без учёта регистра.
|
||||
filters?: Record<string, string>;
|
||||
// Подстрока для фильтрации узлов дерева по label (без учёта регистра).
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// Строит дерево строк справочника.
|
||||
// Два режима (как в GET /api/tables/:tableId/rows/tree):
|
||||
// - treeColumns заданы → группировка по значениям этих колонок (мультиуровневая);
|
||||
// - иначе → иерархия по parentId.
|
||||
// Логика вынесена из server/routes/data-tables.routes.ts — поведение не менять.
|
||||
export function buildDataTableTree(
|
||||
table: DataTable,
|
||||
rows: DataTableRow[],
|
||||
options?: BuildDataTableTreeOptions
|
||||
): DataTableTreeResult {
|
||||
let filteredRows = rows;
|
||||
|
||||
const filters = options?.filters;
|
||||
if (filters) {
|
||||
Object.entries(filters).forEach(([col, val]) => {
|
||||
const ci = parseInt(col);
|
||||
if (!isNaN(ci) && val) {
|
||||
filteredRows = filteredRows.filter((r) => {
|
||||
const v = Array.isArray(r.values) ? r.values : [];
|
||||
return String(v[ci] || '').toLowerCase() === String(val).toLowerCase();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const search = (options?.search ?? '').toLowerCase();
|
||||
|
||||
const treeColumnIndex = table.treeColumnIndex;
|
||||
const treeDisplayColumnIndex = table.treeDisplayColumnIndex;
|
||||
const treeColumns = table.treeColumns ?? (treeColumnIndex != null ? [treeColumnIndex] : []);
|
||||
const treeDisplayColumns = table.treeDisplayColumns ?? (treeDisplayColumnIndex != null ? [treeDisplayColumnIndex] : [0]);
|
||||
const displayColumnIndex = treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0;
|
||||
|
||||
const getRowLabel = (row: DataTableRow): string => {
|
||||
const values = Array.isArray(row.values) ? row.values : [];
|
||||
// Последнее заполненное значение из treeDisplayColumns
|
||||
for (let i = treeDisplayColumns.length - 1; i >= 0; i--) {
|
||||
const val = String(values[treeDisplayColumns[i]] ?? '').trim();
|
||||
if (val) return val;
|
||||
}
|
||||
// Fallback: первая заполненная колонка
|
||||
for (const val of values) {
|
||||
const str = String(val ?? '').trim();
|
||||
if (str) return str;
|
||||
}
|
||||
return '(без названия)';
|
||||
};
|
||||
|
||||
const buildParentTree = (parentRows: DataTableRow[], parentId: number | null, level = 0): DataTableTreeNode[] => {
|
||||
const items = parentRows
|
||||
.filter((r) => (r.parentId ?? null) === parentId)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
return items.map((row) => {
|
||||
const children = buildParentTree(parentRows, row.id, level + 1);
|
||||
const label = getRowLabel(row);
|
||||
if (children.length > 0) {
|
||||
return { type: 'group' as const, id: `row-group:${row.id}`, label, level, children: [...children, { type: 'row' as const, id: row.id, label, level: level + 1, row }] };
|
||||
}
|
||||
return { type: 'row' as const, id: row.id, label, level, row };
|
||||
});
|
||||
};
|
||||
|
||||
const buildMultiColumnTree = (allRows: DataTableRow[], columns: number[], level = 0): DataTableTreeNode[] => {
|
||||
if (level >= columns.length) {
|
||||
return allRows
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((row) => ({ type: 'row' as const, id: row.id, label: getRowLabel(row), level, row }));
|
||||
}
|
||||
|
||||
const columnIndex = columns[level];
|
||||
const groups = new Map<string, DataTableRow[]>();
|
||||
const rootRows: DataTableRow[] = [];
|
||||
|
||||
for (const row of allRows) {
|
||||
const vals = Array.isArray(row.values) ? row.values : [];
|
||||
const key = String(vals[columnIndex] ?? '').trim();
|
||||
if (!key) {
|
||||
rootRows.push(row);
|
||||
continue;
|
||||
}
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(row);
|
||||
}
|
||||
|
||||
const nodes: DataTableTreeNode[] = [];
|
||||
for (const [label, groupRows] of groups) {
|
||||
// Если у группы все строки имеют пустое значение в treeDisplayColumns,
|
||||
// группа становится конечной строкой (можно выбрать в задачах)
|
||||
const allEmpty = groupRows.every((row) => {
|
||||
const vals = Array.isArray(row.values) ? row.values : [];
|
||||
return treeDisplayColumns.every((ci) => !String(vals[ci] ?? '').trim());
|
||||
});
|
||||
|
||||
if (allEmpty && groupRows.length === 1) {
|
||||
nodes.push({
|
||||
type: 'row',
|
||||
id: groupRows[0].id,
|
||||
label,
|
||||
level,
|
||||
row: groupRows[0],
|
||||
});
|
||||
} else {
|
||||
nodes.push({
|
||||
type: 'group',
|
||||
id: `group:${level}:${label}`,
|
||||
label,
|
||||
level,
|
||||
children: buildMultiColumnTree(groupRows, columns, level + 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rootRows.length > 0) {
|
||||
nodes.push(...buildMultiColumnTree(rootRows, columns, level + 1));
|
||||
}
|
||||
|
||||
return nodes.sort((a, b) => {
|
||||
const al = a.type === 'group' ? a.label : getRowLabel(a.row);
|
||||
const bl = b.type === 'group' ? b.label : getRowLabel(b.row);
|
||||
return al.localeCompare(bl, 'ru');
|
||||
});
|
||||
};
|
||||
|
||||
const filterNodes = (nodes: DataTableTreeNode[], query: string): DataTableTreeNode[] => {
|
||||
if (!query) return nodes;
|
||||
const result: DataTableTreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'row') {
|
||||
const label = node.label.toLowerCase();
|
||||
if (label.includes(query)) result.push(node);
|
||||
} else {
|
||||
const children = filterNodes(node.children, query);
|
||||
if (children.length > 0) {
|
||||
result.push({ ...node, children });
|
||||
} else if (node.label.toLowerCase().includes(query)) {
|
||||
result.push({ ...node, children: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const rawNodes = treeColumns.length > 0
|
||||
? buildMultiColumnTree(filteredRows, treeColumns)
|
||||
: buildParentTree(filteredRows, null);
|
||||
const nodes = search ? filterNodes(rawNodes, search) : rawNodes;
|
||||
|
||||
return {
|
||||
columnIndex: treeColumnIndex,
|
||||
treeColumnIndex,
|
||||
treeColumns,
|
||||
treeDisplayColumns,
|
||||
displayColumnIndex,
|
||||
nodes,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user