@mseep/bw-modeling-mcp 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ import { createClientFromEnv } from '../bw-client.js';
2
+ // Every GET in the runtime manage family returns HTTP 415 unless it carries
3
+ // Content-Type: application/json, even though GET has no body.
4
+ const GET_HEADERS = { 'Content-Type': 'application/json', Accept: '*/*' };
5
+ // Module-scope caches for the system domain text maps. The status tables are
6
+ // stable for the process lifetime, so they are fetched once and reused.
7
+ let requestStatusMap = null;
8
+ let processStatusMap = null;
9
+ async function fetchDomainMap(client, domain) {
10
+ const url = `/sap/bc/http/sap/bw4/v1/system/domains/${domain}/texts`;
11
+ const result = await client.rawGet(url, GET_HEADERS);
12
+ const parsed = JSON.parse(result.body);
13
+ const map = new Map();
14
+ for (const entry of parsed) {
15
+ if (entry.key !== undefined) {
16
+ map.set(entry.key, entry.text ?? entry.key);
17
+ }
18
+ }
19
+ return map;
20
+ }
21
+ async function getRequestStatusMap(client) {
22
+ if (!requestStatusMap) {
23
+ requestStatusMap = await fetchDomainMap(client, 'rspm_request_status');
24
+ }
25
+ return requestStatusMap;
26
+ }
27
+ async function getProcessStatusMap(client) {
28
+ if (!processStatusMap) {
29
+ processStatusMap = await fetchDomainMap(client, 'rspm_process_status');
30
+ }
31
+ return processStatusMap;
32
+ }
33
+ function decodeStatus(map, code) {
34
+ const raw = code ?? '';
35
+ const text = map.get(raw);
36
+ return text ? `${text} (${raw})` : raw;
37
+ }
38
+ export async function bwListRequests(client, target, targetType = 'ADSO', storage = 'AQ,AX,AT', status = 'N,GG,GR,YG,RR,YR,RG,U,Y,X', top = 3, createdFrom) {
39
+ // top bounds the result set; each returned row triggers an expensive per-row
40
+ // backend enrichment, so createdfrom only helps by shrinking the result set.
41
+ let url = `/sap/bc/http/sap/bw4/v1/manage/requests` +
42
+ `?tlogo=${encodeURIComponent(targetType.toLowerCase())}` +
43
+ `&datatarget=${encodeURIComponent(target.toLowerCase())}` +
44
+ `&storage=${encodeURIComponent(storage)}`;
45
+ if (createdFrom) {
46
+ // Server-side lower time bound; the Cockpit drops latestrequests in this case.
47
+ url += `&createdfrom=${encodeURIComponent(createdFrom)}`;
48
+ }
49
+ else {
50
+ url += `&latestrequests=${top}`;
51
+ }
52
+ url += `&top=${top}` + `&status=${encodeURIComponent(status)}`;
53
+ const result = await client.rawGet(url, GET_HEADERS);
54
+ const requests = JSON.parse(result.body);
55
+ const statusMap = await getRequestStatusMap(client);
56
+ const processStatusMapList = await getProcessStatusMap(client);
57
+ const lines = [];
58
+ lines.push(`Requests of ${target.toUpperCase()} (${targetType.toUpperCase()}) — ${requests.length} shown`);
59
+ lines.push('');
60
+ if (requests.length === 0) {
61
+ lines.push('(no requests found)');
62
+ return lines.join('\n');
63
+ }
64
+ for (const req of requests) {
65
+ lines.push(`Request: ${req.requestTsnExternal ?? ''}`);
66
+ lines.push(` Status: ${decodeStatus(statusMap, req.requestStatus)}`);
67
+ lines.push(` Last Process: ${decodeStatus(processStatusMapList, req.lastProcessStatus)}`);
68
+ if (req.lastAction)
69
+ lines.push(` Last Action: ${req.lastAction}`);
70
+ lines.push(` Records: ${req.records ?? ''}`);
71
+ lines.push(` Timestamp: ${req.lastTimeStamp ?? ''}`);
72
+ lines.push(` User: ${req.user?.fullName ?? ''}`);
73
+ lines.push(` TSN: ${req.requestTsn ?? ''}`);
74
+ lines.push('');
75
+ }
76
+ return lines.join('\n').trimEnd();
77
+ }
78
+ export async function bwGetRequest(client, requestTsn, storage = 'AQ', format = 'text') {
79
+ const s = storage.toLowerCase();
80
+ const headerUrl = `/sap/bc/http/sap/bw4/v1/manage/requests/${encodeURIComponent(requestTsn)}/${s}`;
81
+ const dtpInfoUrl = `/sap/bc/http/sap/bw4/v1/manage/requests/${encodeURIComponent(requestTsn)}/${s}/datatransferprocessinformation`;
82
+ const processesUrl = `/sap/bc/http/sap/bw4/v1/manage/processes?request=${encodeURIComponent(requestTsn)}&storage=${s}`;
83
+ const logsUrl = `/sap/bc/http/sap/bw4/v1/manage/processes/${encodeURIComponent(requestTsn)}/logs?top=100&readMaximumNumberOfResults=true`;
84
+ const [headerRes, dtpInfoRes, processesRes, logsRes] = await Promise.all([
85
+ client.rawGet(headerUrl, GET_HEADERS),
86
+ client.rawGet(dtpInfoUrl, GET_HEADERS),
87
+ client.rawGet(processesUrl, GET_HEADERS),
88
+ client.rawGet(logsUrl, GET_HEADERS),
89
+ ]);
90
+ const header = JSON.parse(headerRes.body);
91
+ const dtpInfo = JSON.parse(dtpInfoRes.body);
92
+ const processes = JSON.parse(processesRes.body);
93
+ const logs = JSON.parse(logsRes.body);
94
+ if (format === 'raw') {
95
+ return JSON.stringify({ header, dtpInformation: dtpInfo, processes, logs }, null, 2);
96
+ }
97
+ const requestStatus = await getRequestStatusMap(client);
98
+ const processStatus = await getProcessStatusMap(client);
99
+ const lines = [];
100
+ // Section 1 — header
101
+ // Both the header request status and the last process status are shown: for some green
102
+ // inbound loads requestStatus lags at "Y" (in process) while lastProcessStatus already
103
+ // reads "G" (green). Surfacing both keeps a genuinely running request distinguishable
104
+ // from a finished one without remapping requestStatus.
105
+ lines.push(`Request: ${header.requestTsnExternal ?? requestTsn}`);
106
+ lines.push(` Status: ${decodeStatus(requestStatus, header.requestStatus)}`);
107
+ lines.push(` Last Process: ${decodeStatus(processStatus, header.lastProcessStatus)}`);
108
+ lines.push(` Last Action: ${header.lastAction ?? ''}`);
109
+ lines.push(` Tooltip: ${header.statusTooltip ?? ''}`);
110
+ lines.push(` Records: ${header.records ?? ''}`);
111
+ lines.push(` User: ${header.user?.fullName ?? ''}`);
112
+ lines.push(` Timestamp: ${header.lastTimeStamp ?? ''}`);
113
+ // Section 2 — DTP information
114
+ lines.push('');
115
+ lines.push('── DTP Information ──');
116
+ lines.push(` DTP: ${dtpInfo.dataTransferProcess ?? ''}`);
117
+ lines.push(` Description: ${dtpInfo.dtpDescription ?? ''}`);
118
+ lines.push(` Process Mode: ${dtpInfo.processModeDescription ?? ''}`);
119
+ lines.push(` Start: ${dtpInfo.requestStart ?? ''}`);
120
+ lines.push(` Finish: ${dtpInfo.requestFinish ?? ''}`);
121
+ lines.push(` Duration: ${dtpInfo.requestDuration ?? ''}`);
122
+ lines.push(` Package Size: ${dtpInfo.packageSize ?? ''}`);
123
+ // Section 3 — process steps
124
+ lines.push('');
125
+ lines.push(`── Process Steps (${processes.length}) ──`);
126
+ for (const step of processes) {
127
+ lines.push(` ${step.processTsnExternal ?? ''} — ${step.processTypeDescription ?? ''}`);
128
+ lines.push(` Status: ${decodeStatus(processStatus, step.processStatus)}`);
129
+ lines.push(` Timestamp: ${step.timestamp ?? ''}`);
130
+ }
131
+ // Section 4 — message log
132
+ lines.push('');
133
+ lines.push(`── Message Log (${logs.length}) ──`);
134
+ for (const log of logs) {
135
+ lines.push(` [${log.severity ?? ''}] ${log.message ?? ''}`);
136
+ if (log.longText && log.longText.length > 0) {
137
+ lines.push(` ${log.longText}`);
138
+ }
139
+ }
140
+ return lines.join('\n');
141
+ }
142
+ /**
143
+ * bw_activate_request — activate loaded data (DSO request activation).
144
+ *
145
+ * Moves a finished load from the Inbound Table into the active data table + change log. This is the
146
+ * runtime RSPM request activation under the BW4 manage API ("Aktivieren" in the load-request
147
+ * details) — NOT the modeling-object activation that bw_activate performs (different endpoint).
148
+ *
149
+ * Single POST to .../manage/requests/{tsn}/{storage}/activate with an empty body; the URL is
150
+ * self-contained and the default activates all previous loads up to this request.
151
+ *
152
+ * Runs in a fresh session (createClientFromEnv()) — like the DTP-activation and DTP-run tools —
153
+ * to avoid a stale shared-session buffer and cross-call session/CSRF collisions.
154
+ *
155
+ * Activation is asynchronous: a 200 means it was kicked off, not that it finished. Completion is
156
+ * monitored via bw_list_requests / bw_get_request.
157
+ */
158
+ export async function bwActivateRequest(requestTsn, storage = 'AQ') {
159
+ const s = storage.toLowerCase();
160
+ const url = `/sap/bc/http/sap/bw4/v1/manage/requests/${encodeURIComponent(requestTsn)}/${s}/activate`;
161
+ const runClient = createClientFromEnv();
162
+ const csrfToken = await runClient.getCsrfToken();
163
+ await runClient.rawPost(url, '', {
164
+ 'Content-Type': 'application/json',
165
+ 'Accept': '*/*',
166
+ 'x-csrf-token': csrfToken,
167
+ });
168
+ return JSON.stringify({
169
+ success: true,
170
+ request_tsn: requestTsn,
171
+ message: `Data activation started for request ${requestTsn} (storage ${storage.toUpperCase()}). ` +
172
+ `Activation runs asynchronously; monitor completion via bw_list_requests / bw_get_request.`,
173
+ });
174
+ }
@@ -0,0 +1,503 @@
1
+ // ── XML helpers ───────────────────────────────────────────────────────────────
2
+ function xmlDecode(s) {
3
+ return s
4
+ .replace(/&/g, '&')
5
+ .replace(/&lt;/g, '<')
6
+ .replace(/&gt;/g, '>')
7
+ .replace(/&quot;/g, '"')
8
+ .replace(/&apos;/g, "'");
9
+ }
10
+ function xmlEscape(s) {
11
+ return s
12
+ .replace(/&/g, '&amp;')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;');
16
+ }
17
+ function parseAttrs(attrStr) {
18
+ const attrs = {};
19
+ const re = /(\w+)="([^"]*)"/g;
20
+ let m;
21
+ while ((m = re.exec(attrStr)) !== null) {
22
+ attrs[m[1]] = xmlDecode(m[2]);
23
+ }
24
+ return attrs;
25
+ }
26
+ // ── Recursive XML node parser ─────────────────────────────────────────────────
27
+ function parseNodes(xml) {
28
+ const nodes = [];
29
+ let pos = 0;
30
+ while (pos < xml.length) {
31
+ const nodeStart = xml.indexOf('<node', pos);
32
+ if (nodeStart === -1)
33
+ break;
34
+ const tagEnd = xml.indexOf('>', nodeStart);
35
+ if (tagEnd === -1)
36
+ break;
37
+ const tagInner = xml.substring(nodeStart + 5, tagEnd);
38
+ const isSelfClosing = tagInner.trimEnd().endsWith('/');
39
+ const attrStr = isSelfClosing ? tagInner.trimEnd().slice(0, -1) : tagInner;
40
+ const attrs = parseAttrs(attrStr);
41
+ const node = {
42
+ nodeid: attrs['nodeid'] ?? '',
43
+ role: attrs['role'] ?? '',
44
+ name: attrs['name'] ?? '',
45
+ type: attrs['type'] ?? 'FOLDER',
46
+ txt: attrs['txt'] ?? '',
47
+ children: [],
48
+ };
49
+ if (attrs['attribKey'] !== undefined)
50
+ node.attribKey = attrs['attribKey'];
51
+ if (attrs['attribTxt'] !== undefined)
52
+ node.attribTxt = attrs['attribTxt'];
53
+ if (isSelfClosing) {
54
+ pos = tagEnd + 1;
55
+ }
56
+ else {
57
+ // Find the matching </node> tracking nesting depth
58
+ let depth = 1;
59
+ let searchPos = tagEnd + 1;
60
+ let closingPos = -1;
61
+ while (depth > 0 && searchPos < xml.length) {
62
+ const nextOpen = xml.indexOf('<node', searchPos);
63
+ const nextClose = xml.indexOf('</node>', searchPos);
64
+ if (nextClose === -1)
65
+ break;
66
+ if (nextOpen !== -1 && nextOpen < nextClose) {
67
+ const innerTagEnd = xml.indexOf('>', nextOpen);
68
+ const innerTagInner = xml.substring(nextOpen + 5, innerTagEnd);
69
+ if (!innerTagInner.trimEnd().endsWith('/')) {
70
+ depth++;
71
+ }
72
+ searchPos = innerTagEnd + 1;
73
+ }
74
+ else {
75
+ depth--;
76
+ if (depth === 0) {
77
+ closingPos = nextClose;
78
+ }
79
+ searchPos = nextClose + 7; // '</node>'.length === 7
80
+ }
81
+ }
82
+ if (closingPos !== -1) {
83
+ const childXml = xml.substring(tagEnd + 1, closingPos);
84
+ node.children = parseNodes(childXml);
85
+ pos = closingPos + 7;
86
+ }
87
+ else {
88
+ pos = tagEnd + 1;
89
+ }
90
+ }
91
+ nodes.push(node);
92
+ }
93
+ return nodes;
94
+ }
95
+ // ── Rendering ─────────────────────────────────────────────────────────────────
96
+ function renderFolderChild(node, indent, lines) {
97
+ lines.push(`${indent}[FOLDER] ${node.txt} (nodeid: ${node.nodeid})`);
98
+ for (const child of node.children) {
99
+ renderFolderChild(child, indent + ' ', lines);
100
+ }
101
+ }
102
+ function renderTreeNode(node, indent, lines) {
103
+ if (node.type === 'ROLE') {
104
+ lines.push(`${indent}[ROLE] ${node.name} — ${node.txt}`);
105
+ lines.push(`${indent} nodeid: ${node.nodeid}`);
106
+ for (const child of node.children) {
107
+ renderFolderChild(child, indent + ' ', lines);
108
+ }
109
+ }
110
+ else {
111
+ // Top-level FOLDER wrapper — show name (txt is empty at this level)
112
+ lines.push(`${indent}[FOLDER] ${node.name || node.txt}`);
113
+ for (const child of node.children) {
114
+ renderTreeNode(child, indent + ' ', lines);
115
+ }
116
+ }
117
+ }
118
+ // ── PUT body builders ─────────────────────────────────────────────────────────
119
+ function buildFolderChildXml(node, indent) {
120
+ const lines = [];
121
+ if (node.children.length === 0) {
122
+ lines.push(`${indent}<node nodeid="${node.nodeid}" role="${xmlEscape(node.role)}" ` +
123
+ `state="unchanged" type="${node.type}" txt="${xmlEscape(node.txt)}"/>`);
124
+ }
125
+ else {
126
+ lines.push(`${indent}<node nodeid="${node.nodeid}" role="${xmlEscape(node.role)}" ` +
127
+ `state="unchanged" type="${node.type}" txt="${xmlEscape(node.txt)}">`);
128
+ for (const child of node.children) {
129
+ lines.push(...buildFolderChildXml(child, indent + ' '));
130
+ }
131
+ lines.push(`${indent}</node>`);
132
+ }
133
+ return lines;
134
+ }
135
+ function buildAddFolderBody(queryName, node) {
136
+ return [
137
+ `<?xml version="1.0" encoding="utf-8"?>`,
138
+ `<tree type="SAP_BW_QUERY" viewType="ancestors" refName="${xmlEscape(queryName)}">`,
139
+ ` <node nodeid="${node.nodeid}" role="${xmlEscape(node.role)}" state="added" type="FOLDER" txt="${xmlEscape(node.txt)}">`,
140
+ ` </node>`,
141
+ `</tree>`,
142
+ ].join('\n');
143
+ }
144
+ function buildAddRoleBody(queryName, node) {
145
+ const lines = [
146
+ `<?xml version="1.0" encoding="utf-8"?>`,
147
+ `<tree type="SAP_BW_QUERY" viewType="ancestors" refName="${xmlEscape(queryName)}">`,
148
+ ` <node nodeid="${node.nodeid}" role="${xmlEscape(node.role)}" state="added" type="ROLE" ` +
149
+ `txt="${xmlEscape(node.txt)}" name="${xmlEscape(node.name)}">`,
150
+ ];
151
+ for (const child of node.children) {
152
+ lines.push(...buildFolderChildXml(child, ' '));
153
+ }
154
+ lines.push(` </node>`);
155
+ lines.push(`</tree>`);
156
+ return lines.join('\n');
157
+ }
158
+ function buildDeleteBody(queryName, node) {
159
+ const attribKey = xmlEscape(node.attribKey ?? '');
160
+ const attribTxt = xmlEscape(node.attribTxt ?? '');
161
+ const nodeAttrs = node.type === 'ROLE'
162
+ ? `nodeid="${node.nodeid}" role="${xmlEscape(node.role)}" state="deleted" type="ROLE" ` +
163
+ `txt="${xmlEscape(node.txt)}" attribKey="${attribKey}" attribTxt="${attribTxt}" name="${xmlEscape(node.name)}"`
164
+ : `nodeid="${node.nodeid}" role="${xmlEscape(node.role)}" state="deleted" type="FOLDER" ` +
165
+ `txt="${xmlEscape(node.txt)}" attribKey="${attribKey}" attribTxt="${attribTxt}"`;
166
+ return [
167
+ `<?xml version="1.0" encoding="utf-8"?>`,
168
+ `<tree type="SAP_BW_QUERY" viewType="ancestors" refName="${xmlEscape(queryName)}">`,
169
+ ` <node ${nodeAttrs}>`,
170
+ ` </node>`,
171
+ `</tree>`,
172
+ ].join('\n');
173
+ }
174
+ // ── PUT response parser ───────────────────────────────────────────────────────
175
+ function parsePutResponse(xml) {
176
+ // The response is an atom:feed; the entry-level title/summary carry the actual message.
177
+ const entryMatch = xml.match(/<atom:entry\b[^>]*>([\s\S]*?)<\/atom:entry>/);
178
+ const entryXml = entryMatch?.[1] ?? xml;
179
+ const summary = entryXml.match(/<atom:summary[^>]*>([^<]*)<\/atom:summary>/)?.[1] ?? '';
180
+ const title = entryXml.match(/<atom:title[^>]*>([^<]*)<\/atom:title>/)?.[1] ?? xml;
181
+ if (summary.includes('Fehler')) {
182
+ throw new Error(title);
183
+ }
184
+ return title;
185
+ }
186
+ // ── Internal fetch helpers ────────────────────────────────────────────────────
187
+ async function fetchRolesTree(client) {
188
+ const { body } = await client.rawGet('/sap/bw/modeling/comp/roles?level=10&requestchk=true&readleaves=false', { Accept: 'application/xml', 'X-sap-adt-sessiontype': 'stateless' });
189
+ const treeMatch = body.match(/<tree\b[^>]*>([\s\S]*)<\/tree>/);
190
+ if (!treeMatch)
191
+ return [];
192
+ return parseNodes(treeMatch[1]);
193
+ }
194
+ async function fetchQueryAssignments(client, queryName) {
195
+ const { body } = await client.rawGet(`/sap/bw/modeling/comp/roles?type=SAP_BW_QUERY&ancof=${encodeURIComponent(queryName)}`, { Accept: 'application/xml', 'X-sap-adt-sessiontype': 'stateless' });
196
+ const treeAttrStr = body.match(/<tree\b([^>]*?)(?:\/>|>)/)?.[1] ?? '';
197
+ const treeAttrs = parseAttrs(treeAttrStr);
198
+ const refName = treeAttrs['refName'] ?? queryName;
199
+ const queryTxt = treeAttrs['txt'] ?? '';
200
+ // Self-closing or empty tree means not published
201
+ if (/<tree\b[^>]*\/>/.test(body)) {
202
+ return { refName, queryTxt, assignments: [] };
203
+ }
204
+ const treeMatch = body.match(/<tree\b[^>]*>([\s\S]*)<\/tree>/);
205
+ if (!treeMatch)
206
+ return { refName, queryTxt, assignments: [] };
207
+ const roleNodes = parseNodes(treeMatch[1]);
208
+ const assignments = roleNodes.map(rn => {
209
+ // The ancestor view returns a top-level FOLDER node when the query is assigned inside a
210
+ // subfolder. The role name is embedded in the `role` attribute ("ROLENAME ORIGINID").
211
+ // When assigned at role level, a ROLE node is returned with `name` set directly.
212
+ if (rn.type === 'FOLDER') {
213
+ return {
214
+ roleName: rn.role.split(/\s+/)[0],
215
+ roleNodeid: '',
216
+ roleRole: '',
217
+ roleTxt: '',
218
+ attribKey: rn.attribKey ?? '',
219
+ attribTxt: rn.attribTxt ?? '',
220
+ folder: { nodeid: rn.nodeid, role: rn.role, txt: rn.txt },
221
+ };
222
+ }
223
+ const a = {
224
+ roleName: rn.name || rn.role.split(/\s+/)[0],
225
+ roleNodeid: rn.nodeid,
226
+ roleRole: rn.role,
227
+ roleTxt: rn.txt,
228
+ attribKey: rn.attribKey ?? '',
229
+ attribTxt: rn.attribTxt ?? '',
230
+ };
231
+ if (rn.children.length > 0) {
232
+ const f = rn.children[0];
233
+ a.folder = { nodeid: f.nodeid, role: f.role, txt: f.txt };
234
+ }
235
+ return a;
236
+ });
237
+ return { refName, queryTxt, assignments };
238
+ }
239
+ function parseLeaves(xml) {
240
+ const leaves = [];
241
+ const re = /<leaf\s([^>]*?)\/?>/g;
242
+ let m;
243
+ while ((m = re.exec(xml)) !== null) {
244
+ const attrs = parseAttrs(m[1]);
245
+ leaves.push({
246
+ nodeid: attrs['nodeid'] ?? '',
247
+ name: attrs['name'] ?? '',
248
+ txt: attrs['txt'] ?? '',
249
+ objectType: attrs['objectType'] ?? '',
250
+ objectSubType: attrs['objectSubType'] ?? '',
251
+ infoprov: attrs['infoprov'] ?? '',
252
+ guid: attrs['guid'] ?? '',
253
+ responsible: attrs['responsible'] ?? '',
254
+ timestamp: attrs['timestamp'] ?? '',
255
+ });
256
+ }
257
+ return leaves;
258
+ }
259
+ function parseRolesWithLeaves(xml) {
260
+ const result = [];
261
+ function walk(content) {
262
+ let pos = 0;
263
+ while (pos < content.length) {
264
+ const nodeStart = content.indexOf('<node', pos);
265
+ if (nodeStart === -1)
266
+ break;
267
+ const tagEnd = content.indexOf('>', nodeStart);
268
+ if (tagEnd === -1)
269
+ break;
270
+ const tagInner = content.substring(nodeStart + 5, tagEnd);
271
+ const isSelfClosing = tagInner.trimEnd().endsWith('/');
272
+ const attrStr = isSelfClosing ? tagInner.trimEnd().slice(0, -1) : tagInner;
273
+ const attrs = parseAttrs(attrStr);
274
+ if (isSelfClosing) {
275
+ pos = tagEnd + 1;
276
+ continue;
277
+ }
278
+ let depth = 1;
279
+ let searchPos = tagEnd + 1;
280
+ let closingPos = -1;
281
+ while (depth > 0 && searchPos < content.length) {
282
+ const nextOpen = content.indexOf('<node', searchPos);
283
+ const nextClose = content.indexOf('</node>', searchPos);
284
+ if (nextClose === -1)
285
+ break;
286
+ if (nextOpen !== -1 && nextOpen < nextClose) {
287
+ const innerTagEnd = content.indexOf('>', nextOpen);
288
+ const innerTagInner = content.substring(nextOpen + 5, innerTagEnd);
289
+ if (!innerTagInner.trimEnd().endsWith('/'))
290
+ depth++;
291
+ searchPos = innerTagEnd + 1;
292
+ }
293
+ else {
294
+ depth--;
295
+ if (depth === 0)
296
+ closingPos = nextClose;
297
+ searchPos = nextClose + 7;
298
+ }
299
+ }
300
+ const childContent = closingPos !== -1 ? content.substring(tagEnd + 1, closingPos) : '';
301
+ if (attrs['type'] === 'ROLE') {
302
+ const leaves = parseLeaves(childContent);
303
+ if (leaves.length > 0) {
304
+ result.push({ roleName: attrs['name'] ?? '', roleTxt: attrs['txt'] ?? '', leaves });
305
+ }
306
+ }
307
+ else {
308
+ walk(childContent);
309
+ }
310
+ pos = closingPos !== -1 ? closingPos + 7 : tagEnd + 1;
311
+ }
312
+ }
313
+ walk(xml);
314
+ return result;
315
+ }
316
+ // ── Exported functions ────────────────────────────────────────────────────────
317
+ export async function bwGetRoles(client, roleFilter) {
318
+ const { body } = await client.rawGet('/sap/bw/modeling/comp/roles?level=10&requestchk=true&readleaves=false', { Accept: 'application/xml', 'X-sap-adt-sessiontype': 'stateless' });
319
+ const treeMatch = body.match(/<tree\b[^>]*>([\s\S]*)<\/tree>/);
320
+ if (!treeMatch)
321
+ return 'No role tree found.';
322
+ const topNodes = parseNodes(treeMatch[1]);
323
+ function countRoles(nodes) {
324
+ let count = 0;
325
+ for (const n of nodes) {
326
+ if (n.type === 'ROLE')
327
+ count++;
328
+ count += countRoles(n.children);
329
+ }
330
+ return count;
331
+ }
332
+ function filterNodes(nodes) {
333
+ if (!roleFilter)
334
+ return nodes;
335
+ return nodes.flatMap(n => {
336
+ if (n.type === 'ROLE') {
337
+ return n.name.startsWith(roleFilter) ? [n] : [];
338
+ }
339
+ // FOLDER: keep as structural container if it has matching descendants
340
+ const filteredChildren = filterNodes(n.children);
341
+ return filteredChildren.length > 0 ? [{ ...n, children: filteredChildren }] : [];
342
+ });
343
+ }
344
+ const totalRoles = countRoles(topNodes);
345
+ const filteredNodes = filterNodes(topNodes);
346
+ const lines = [
347
+ 'BW Query Role Tree',
348
+ '==================',
349
+ `Total roles: ${totalRoles}`,
350
+ '',
351
+ ];
352
+ for (const node of filteredNodes) {
353
+ renderTreeNode(node, '', lines);
354
+ }
355
+ return lines.join('\n');
356
+ }
357
+ export async function bwGetQueryRoles(client, queryName) {
358
+ const { refName, queryTxt, assignments } = await fetchQueryAssignments(client, queryName.toUpperCase());
359
+ if (assignments.length === 0) {
360
+ return `Query ${refName} is not published in any role.`;
361
+ }
362
+ const lines = [
363
+ `Query: ${refName}${queryTxt ? ` — ${queryTxt}` : ''}`,
364
+ '',
365
+ `Published in ${assignments.length} role(s):`,
366
+ ];
367
+ for (const a of assignments) {
368
+ const roleDesc = a.roleTxt ? ` — ${a.roleTxt}` : '';
369
+ lines.push(` [ROLE] ${a.roleName}${roleDesc}`);
370
+ if (a.folder) {
371
+ lines.push(` Folder: ${a.folder.txt}`);
372
+ }
373
+ }
374
+ return lines.join('\n');
375
+ }
376
+ export async function bwSetQueryRoles(client, queryName, action, targetName, targetType, parentRoleName) {
377
+ const qName = queryName.toUpperCase();
378
+ const putUrl = `/sap/bw/modeling/comp/roles?type=SAP_BW_QUERY&ancof=${encodeURIComponent(qName)}`;
379
+ let putBody;
380
+ if (action === 'add') {
381
+ const tree = await fetchRolesTree(client);
382
+ if (targetType === 'role') {
383
+ function findRole(nodes) {
384
+ for (const n of nodes) {
385
+ if (n.type === 'ROLE' && n.name === targetName)
386
+ return n;
387
+ const found = findRole(n.children);
388
+ if (found)
389
+ return found;
390
+ }
391
+ return undefined;
392
+ }
393
+ const roleNode = findRole(tree);
394
+ if (!roleNode)
395
+ throw new Error(`Role "${targetName}" not found in role tree.`);
396
+ putBody = buildAddRoleBody(qName, roleNode);
397
+ }
398
+ else {
399
+ if (!parentRoleName)
400
+ throw new Error('parent_role_name is required when target_type is "folder".');
401
+ function findParentRole(nodes) {
402
+ for (const n of nodes) {
403
+ if (n.type === 'ROLE' && n.name === parentRoleName)
404
+ return n;
405
+ const found = findParentRole(n.children);
406
+ if (found)
407
+ return found;
408
+ }
409
+ return undefined;
410
+ }
411
+ const parentRole = findParentRole(tree);
412
+ if (!parentRole)
413
+ throw new Error(`Role "${parentRoleName}" not found in role tree.`);
414
+ function findFolder(nodes) {
415
+ for (const n of nodes) {
416
+ if (n.type === 'FOLDER' && n.txt === targetName)
417
+ return n;
418
+ const found = findFolder(n.children);
419
+ if (found)
420
+ return found;
421
+ }
422
+ return undefined;
423
+ }
424
+ const folderNode = findFolder(parentRole.children);
425
+ if (!folderNode)
426
+ throw new Error(`Folder "${targetName}" not found in role "${parentRoleName}".`);
427
+ putBody = buildAddFolderBody(qName, folderNode);
428
+ }
429
+ }
430
+ else {
431
+ // action === 'remove'
432
+ const { assignments } = await fetchQueryAssignments(client, qName);
433
+ if (assignments.length === 0)
434
+ throw new Error(`Query "${qName}" is not published in any role.`);
435
+ if (targetType === 'role') {
436
+ const a = assignments.find(x => x.roleName === targetName);
437
+ if (!a)
438
+ throw new Error(`Query "${qName}" is not published in role "${targetName}".`);
439
+ putBody = buildDeleteBody(qName, {
440
+ nodeid: a.roleNodeid,
441
+ role: a.roleRole,
442
+ name: a.roleName,
443
+ type: 'ROLE',
444
+ txt: a.roleTxt,
445
+ attribKey: a.attribKey,
446
+ attribTxt: a.attribTxt,
447
+ children: [],
448
+ });
449
+ }
450
+ else {
451
+ if (!parentRoleName)
452
+ throw new Error('parent_role_name is required when target_type is "folder".');
453
+ const a = assignments.find(x => x.roleName === parentRoleName && x.folder?.txt === targetName);
454
+ if (!a || !a.folder)
455
+ throw new Error(`Query "${qName}" is not published in folder "${targetName}" of role "${parentRoleName}".`);
456
+ putBody = buildDeleteBody(qName, {
457
+ nodeid: a.folder.nodeid,
458
+ role: a.folder.role,
459
+ name: '',
460
+ type: 'FOLDER',
461
+ txt: a.folder.txt,
462
+ attribKey: a.attribKey,
463
+ attribTxt: a.attribTxt,
464
+ children: [],
465
+ });
466
+ }
467
+ }
468
+ const csrfToken = await client.getCsrfToken();
469
+ const { body: putResponse } = await client.rawPut(putUrl, putBody, {
470
+ 'Content-Type': 'application/xml',
471
+ 'X-sap-adt-sessiontype': 'stateless',
472
+ 'X-CSRF-Token': csrfToken,
473
+ });
474
+ return parsePutResponse(putResponse);
475
+ }
476
+ export async function bwGetRoleQueries(client, roleName) {
477
+ const { body } = await client.rawGet('/sap/bw/modeling/comp/roles?level=10&requestchk=true&readleaves=true', { Accept: 'application/xml', 'X-sap-adt-sessiontype': 'stateless' });
478
+ const treeMatch = body.match(/<tree\b[^>]*>([\s\S]*)<\/tree>/);
479
+ if (!treeMatch)
480
+ return 'No role tree found.';
481
+ const rolesWithLeaves = parseRolesWithLeaves(treeMatch[1]);
482
+ const filtered = roleName
483
+ ? rolesWithLeaves.filter(r => r.roleName === roleName)
484
+ : rolesWithLeaves;
485
+ if (filtered.length === 0) {
486
+ return roleName
487
+ ? `No published objects found in role "${roleName}".`
488
+ : 'No published objects found in any role.';
489
+ }
490
+ const lines = [];
491
+ let total = 0;
492
+ for (const r of filtered) {
493
+ lines.push(`[ROLE] ${r.roleName} — ${r.roleTxt}`);
494
+ for (const leaf of r.leaves) {
495
+ const label = leaf.txt && leaf.txt !== leaf.name ? `${leaf.name} — ${leaf.txt}` : leaf.name;
496
+ lines.push(` ${label} (${leaf.objectType}/${leaf.objectSubType}, InfoProv: ${leaf.infoprov})`);
497
+ total++;
498
+ }
499
+ lines.push('');
500
+ }
501
+ lines.unshift(`Total published objects: ${total}`, '');
502
+ return lines.join('\n');
503
+ }