@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,602 @@
1
+ import { MEDIA_TYPES, createClientFromEnv } from '../bw-client.js';
2
+ import { bwActivate } from './activation.js';
3
+ /**
4
+ * Parse <atom:entry> elements from a BW Atom feed (xref / search responses).
5
+ * Each entry contains a <bwModel:object> with objectName, objectType, objectStatus.
6
+ */
7
+ function parseAtomEntries(xml) {
8
+ const entries = [];
9
+ const entryRegex = /<atom:entry>([\s\S]*?)<\/atom:entry>/g;
10
+ let match;
11
+ while ((match = entryRegex.exec(xml)) !== null) {
12
+ const body = match[1];
13
+ const nameMatch = body.match(/objectName="([^"]+)"/);
14
+ const typeMatch = body.match(/objectType="([^"]+)"/);
15
+ const statusMatch = body.match(/objectStatus="([^"]+)"/);
16
+ const titleMatch = body.match(/<atom:title>([^<]+)<\/atom:title>/);
17
+ const hrefMatch = body.match(/href="([^"]+)"/);
18
+ if (nameMatch && typeMatch) {
19
+ entries.push({
20
+ objectName: nameMatch[1],
21
+ objectType: typeMatch[1],
22
+ objectStatus: statusMatch?.[1] ?? 'unknown',
23
+ title: titleMatch?.[1] ?? '',
24
+ href: hrefMatch?.[1] ?? '',
25
+ });
26
+ }
27
+ }
28
+ return entries;
29
+ }
30
+ /**
31
+ * bw_get_dtps — list DTPs that depend on an object (via xref).
32
+ *
33
+ * Uses the cross-reference endpoint to find all DTPA objects that use the given object.
34
+ * Filters xref results to objectType=DTPA only.
35
+ *
36
+ * After activating a Transformation, the activation response lists deactivated DTPs.
37
+ * This tool can be used independently to find dependent DTPs before activation.
38
+ */
39
+ export async function bwGetDtps(client, objectType, objectName) {
40
+ const path = `/sap/bw/modeling/repo/is/xref?objectType=${encodeURIComponent(objectType.toUpperCase())}&objectName=${encodeURIComponent(objectName.toUpperCase())}`;
41
+ const result = await client.get(path, 'application/atom+xml;type=feed');
42
+ const allEntries = parseAtomEntries(result.body);
43
+ const dtps = allEntries.filter((e) => e.objectType === 'DTPA');
44
+ if (dtps.length === 0) {
45
+ return `No dependent DTPs found for ${objectType.toUpperCase()} ${objectName.toUpperCase()}.`;
46
+ }
47
+ const lines = [
48
+ `Found ${dtps.length} DTP(s) dependent on ${objectType.toUpperCase()} ${objectName.toUpperCase()}:`,
49
+ '',
50
+ ...dtps.map((d, i) => `${i + 1}. ${d.objectName} — status: ${d.objectStatus}` +
51
+ (d.title ? ` — "${d.title}"` : '')),
52
+ '',
53
+ 'To activate all inactive DTPs: call bw_activate for each with object_type="dtpa" and lock_handle="".',
54
+ ];
55
+ return lines.join('\n');
56
+ }
57
+ /**
58
+ * bw_get_dtp_details — read a single DTP definition.
59
+ * (Used internally; exposed via bw_get_dtps in index.ts if needed.)
60
+ */
61
+ export async function bwGetDtpDetails(client, dtpName) {
62
+ const path = `/sap/bw/modeling/dtpa/${dtpName.toLowerCase()}/m`;
63
+ const result = await client.get(path, MEDIA_TYPES['dtpa']);
64
+ const status = result.headers['object_status'] ?? result.headers['OBJECT_STATUS'] ?? 'unknown';
65
+ return `DTP: ${dtpName.toUpperCase()}\nStatus: ${status}\n\n${result.body}`;
66
+ }
67
+ function parseDtpXml(xml, status) {
68
+ const attr = (tag, name) => {
69
+ const m = tag.match(new RegExp(`\\b${name}="([^"]*)"`));
70
+ return m ? m[1] : '';
71
+ };
72
+ // Root attributes
73
+ const rootMatch = xml.match(/<dtpa:dataTransferProcess([^>]*)>/);
74
+ const rootAttrs = rootMatch?.[1] ?? '';
75
+ const name = attr(rootAttrs, 'name');
76
+ const description = attr(rootAttrs, 'description');
77
+ // Extraction settings
78
+ const extrMatch = xml.match(/<extractionSettings([^>]*)\/?>/);
79
+ const extractionMode = attr(extrMatch?.[1] ?? '', 'extractionMode');
80
+ const packageSize = attr(extrMatch?.[1] ?? '', 'packageSize');
81
+ // Source / target
82
+ const srcMatch = xml.match(/<source([^>]*)\/?>/);
83
+ const tgtMatch = xml.match(/<target([^>]*)\/?>/);
84
+ const source = {
85
+ type: attr(srcMatch?.[1] ?? '', 'type'),
86
+ name: attr(srcMatch?.[1] ?? '', 'name'),
87
+ description: attr(srcMatch?.[1] ?? '', 'description'),
88
+ };
89
+ const target = {
90
+ type: attr(tgtMatch?.[1] ?? '', 'type'),
91
+ name: attr(tgtMatch?.[1] ?? '', 'name'),
92
+ description: attr(tgtMatch?.[1] ?? '', 'description'),
93
+ };
94
+ // Transformation (overview/object)
95
+ const ovMatch = xml.match(/<overview>[\s\S]*?<object([^>]*)\/?>[\s\S]*?<\/overview>/);
96
+ const transformation = {
97
+ name: attr(ovMatch?.[1] ?? '', 'name'),
98
+ description: attr(ovMatch?.[1] ?? '', 'description'),
99
+ };
100
+ // Filter fields
101
+ const filterFields = [];
102
+ const fieldsRegex = /<fields([^>]*)>([\s\S]*?)<\/fields>/g;
103
+ let fm;
104
+ while ((fm = fieldsRegex.exec(xml)) !== null) {
105
+ const fieldAttrs = fm[1];
106
+ const fieldBody = fm[2];
107
+ const selections = [];
108
+ const selRegex = /<selection\b([^>]*)(?:\/>|>([\s\S]*?)<\/selection>)/g;
109
+ let sm;
110
+ while ((sm = selRegex.exec(fieldBody)) !== null) {
111
+ const selAttrs = sm[1];
112
+ const selBody = sm[2] ?? '';
113
+ const operator = selAttrs.match(/\boperator="([^"]*)"/)?.[1] ?? '';
114
+ const excluding = selAttrs.includes('excluding="true"');
115
+ const low = selBody.match(/<low[^>]*\bvalue="([^"]*)"/)?.[1] ?? '';
116
+ selections.push({ operator, excluding, low });
117
+ }
118
+ const hasRoutine = /<routine[\s>]/.test(fieldBody) && !/<routine\s*\/>/.test(fieldBody);
119
+ const routineCode = [];
120
+ if (hasRoutine) {
121
+ const codeRegex = /<code(?:\s[^>]*)?>([^<]*)<\/code>/g;
122
+ let cm;
123
+ while ((cm = codeRegex.exec(fieldBody)) !== null) {
124
+ routineCode.push(cm[1]);
125
+ }
126
+ }
127
+ filterFields.push({
128
+ name: attr(fieldAttrs, 'name'),
129
+ dtaName: attr(fieldAttrs, 'dtaName'),
130
+ description: attr(fieldAttrs, 'description'),
131
+ selected: fieldAttrs.includes('selected="true"'),
132
+ selections,
133
+ hasRoutine,
134
+ routineCode,
135
+ });
136
+ }
137
+ // Global routine code
138
+ const globalRoutineCode = [];
139
+ const globalRegex = /<globalRoutineCode>([^<]*)<\/globalRoutineCode>/g;
140
+ let gm;
141
+ while ((gm = globalRegex.exec(xml)) !== null) {
142
+ globalRoutineCode.push(gm[1]);
143
+ }
144
+ return {
145
+ name,
146
+ description,
147
+ status,
148
+ source,
149
+ target,
150
+ transformation,
151
+ extractionMode,
152
+ packageSize,
153
+ filterFields,
154
+ globalRoutineCode,
155
+ };
156
+ }
157
+ /**
158
+ * bw_get_dtp — read a DTP definition and return a structured summary + raw XML.
159
+ *
160
+ * Flow:
161
+ * GET /sap/bw/modeling/dtpa/{dtpName}/m?forceCacheUpdate=true
162
+ * Parse key fields and return readable summary.
163
+ */
164
+ export async function bwGetDtp(client, dtpName) {
165
+ const path = `/sap/bw/modeling/dtpa/${dtpName.toLowerCase()}/m?forceCacheUpdate=true`;
166
+ const result = await client.get(path, MEDIA_TYPES['dtpa']);
167
+ const status = result.headers['object_status'] ?? result.headers['OBJECT_STATUS'] ?? 'unknown';
168
+ const info = parseDtpXml(result.body, status);
169
+ const modeLabel = info.extractionMode === 'F' ? 'Full' : info.extractionMode === 'D' ? 'Delta' : info.extractionMode;
170
+ const lines = [
171
+ `DTP: ${info.name}`,
172
+ `Status: ${info.status}`,
173
+ `Description: ${info.description}`,
174
+ '',
175
+ `Source: ${info.source.type} ${info.source.name}` + (info.source.description ? ` — ${info.source.description}` : ''),
176
+ `Target: ${info.target.type} ${info.target.name}` + (info.target.description ? ` — ${info.target.description}` : ''),
177
+ `Transformation: ${info.transformation.name}` + (info.transformation.description ? ` — ${info.transformation.description}` : ''),
178
+ '',
179
+ `── Extraction Settings ──`,
180
+ ` Mode: ${modeLabel} (${info.extractionMode})`,
181
+ ` Package Size: ${info.packageSize}`,
182
+ '',
183
+ `── Filter Fields (${info.filterFields.length}) ──`,
184
+ ];
185
+ if (info.filterFields.length === 0) {
186
+ lines.push(' (no filter fields)');
187
+ }
188
+ else {
189
+ for (const f of info.filterFields) {
190
+ lines.push(` [${f.selected ? 'selected' : 'inactive'}] ${f.name} (${f.dtaName})`);
191
+ if (f.selections.length > 0) {
192
+ for (const s of f.selections) {
193
+ const sign = s.excluding ? '≠' : '=';
194
+ const val = s.low === '' ? "''" : `"${s.low}"`;
195
+ lines.push(` → ${s.operator} ${sign} ${val}`);
196
+ }
197
+ }
198
+ if (f.hasRoutine) {
199
+ lines.push(` → Routine (${f.routineCode.length} lines):`);
200
+ for (const codeLine of f.routineCode) {
201
+ lines.push(` ${codeLine}`);
202
+ }
203
+ }
204
+ if (!f.hasRoutine && f.selections.length === 0) {
205
+ lines.push(' → (no selection / no routine)');
206
+ }
207
+ }
208
+ }
209
+ if (info.globalRoutineCode.length > 0) {
210
+ lines.push('', '── Global Routine Code ──');
211
+ for (const line of info.globalRoutineCode) {
212
+ lines.push(` ${line}`);
213
+ }
214
+ }
215
+ lines.push('', `── Raw XML ──`, result.body);
216
+ return lines.join('\n');
217
+ }
218
+ /**
219
+ * bw_create_dtp — create a new DTP for an existing Transformation, then activate it.
220
+ *
221
+ * Flow:
222
+ * 1. POST generateDtpId → DTP name from Location header
223
+ * 2. Lock with activity_context: CREA (rawPost on lockClient = passed-in client)
224
+ * 3. POST minimal XML with fresh createClientFromEnv() (session isolation)
225
+ * 4. Explicit unlock (rawPost on lockClient)
226
+ * 5a. If filter_field: Lock (new client) → GET (fresh) → PUT (fresh) → bwActivate
227
+ * 5b. If no filter: bwActivate with empty lockHandle
228
+ */
229
+ export async function bwCreateDtp(client, args) {
230
+ const trfnName = args.trfn_name.toUpperCase();
231
+ const srcName = args.source_name.toUpperCase();
232
+ const srcType = args.source_type.toUpperCase();
233
+ const tgtName = args.target_name.toUpperCase();
234
+ const tgtType = args.target_type.toUpperCase();
235
+ const desc = args.description ?? '';
236
+ const pkg = args.package ?? '$TMP';
237
+ // Source element attributes. For ADSO/TRCS sources tlogo and type coincide and the name is
238
+ // passed through. For a DataSource source (source_type "RSDS") tlogo and type differ
239
+ // (tlogo "RSDS", type "DTASRC") and the name is the RSDS compound key.
240
+ let sourceTlogo;
241
+ let sourceTypeAttr;
242
+ let sourceNameAttr;
243
+ if (srcType === 'RSDS') {
244
+ if (!args.source_system) {
245
+ throw new Error('source_type "RSDS" (DataSource source) requires source_system to build the ' +
246
+ 'RSDS compound source name.');
247
+ }
248
+ sourceTlogo = 'RSDS';
249
+ sourceTypeAttr = 'DTASRC';
250
+ // RSDS compound key: DataSource name left-justified in a 30-char field, then the
251
+ // source system name appended with no trailing padding (total length 40).
252
+ sourceNameAttr = srcName.padEnd(30, ' ') + args.source_system.toUpperCase();
253
+ }
254
+ else {
255
+ sourceTlogo = srcType;
256
+ sourceTypeAttr = srcType;
257
+ sourceNameAttr = srcName;
258
+ }
259
+ const language = process.env.BW_LANGUAGE ?? 'DE';
260
+ const masterSystem = new URL(process.env.BW_URL ?? 'http://localhost').hostname.split('.')[0].toUpperCase();
261
+ const responsible = (process.env.BW_USER ?? '').toUpperCase();
262
+ // Step 1: Generate DTP name via POST generateDtpId — DTP name is in Location header
263
+ const csrfToken = await client.getCsrfToken();
264
+ const genResponse = await client.rawPost('/sap/bw/modeling/dtpa/generateDtpId', '', {
265
+ 'Accept': MEDIA_TYPES['dtpa'],
266
+ 'Content-Type': MEDIA_TYPES['dtpa'],
267
+ 'x-csrf-token': csrfToken,
268
+ });
269
+ const location = genResponse.headers['location'] ?? genResponse.headers['Location'] ?? '';
270
+ if (!location) {
271
+ throw new Error(`generateDtpId returned no Location header. Response: ${JSON.stringify(genResponse.headers)}`);
272
+ }
273
+ const dtpName = location.split('/').pop().toUpperCase();
274
+ const dtpLower = dtpName.toLowerCase();
275
+ // Step 2: Lock with CREA
276
+ const csrfToken2 = await client.getCsrfToken();
277
+ const lockResponse = await client.rawPost(`/sap/bw/modeling/dtpa/${dtpLower}?action=lock`, '', {
278
+ 'activity_context': 'CREA',
279
+ 'Accept': MEDIA_TYPES['dtpa'],
280
+ 'x-csrf-token': csrfToken2,
281
+ });
282
+ const lockHandle = lockResponse.body.match(/<LOCK_HANDLE>([^<]+)<\/LOCK_HANDLE>/)?.[1] ?? '';
283
+ if (!lockHandle) {
284
+ throw new Error(`No <LOCK_HANDLE> in CREA lock response:\n${lockResponse.body}`);
285
+ }
286
+ // Step 3: POST minimal XML — fresh session (same isolation as bwCreateTransformation)
287
+ const postBody = `<?xml version="1.0" encoding="UTF-8"?>
288
+ <Dtpa:dataTransferProcess
289
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
290
+ xmlns:Dtpa="http://www.sap.com/bw/modeling/DataTransferProcess.ecore"
291
+ xmlns:adtcore="http://www.sap.com/adt/core"
292
+ description="${desc}"
293
+ name="${dtpName}">
294
+ <generalInformation>
295
+ <tlogoProperties
296
+ adtcore:language="${language}"
297
+ adtcore:name="${dtpName}"
298
+ adtcore:type="DTPA"
299
+ adtcore:masterLanguage="${language}"
300
+ adtcore:masterSystem="${masterSystem}"
301
+ adtcore:responsible="${responsible}"/>
302
+ </generalInformation>
303
+ <overview>
304
+ <object xsi:type="Dtpa:DTPObject" name="${trfnName}" tlogo="TRFN"/>${args.trfn_name_2 ? `\n <object xsi:type="Dtpa:DTPObject" name="${args.trfn_name_2.toUpperCase()}" tlogo="TRFN"/>` : ''}
305
+ </overview>
306
+ <source name="${sourceNameAttr}" tlogo="${sourceTlogo}" type="${sourceTypeAttr}"/>
307
+ <target name="${tgtName}" tlogo="${tgtType}" type="${tgtType}"/>
308
+ </Dtpa:dataTransferProcess>`;
309
+ const createClient = createClientFromEnv();
310
+ const createCsrf = await createClient.getCsrfToken();
311
+ await createClient.rawPost(`/sap/bw/modeling/dtpa/${dtpLower}?lockHandle=${lockHandle}`, postBody, {
312
+ 'Development-Class': pkg,
313
+ 'Content-Type': MEDIA_TYPES['dtpa'],
314
+ 'Accept': MEDIA_TYPES['dtpa'],
315
+ 'x-csrf-token': createCsrf,
316
+ });
317
+ // Step 4: Explicit unlock
318
+ const csrfToken3 = await client.getCsrfToken();
319
+ await client.rawPost(`/sap/bw/modeling/dtpa/${dtpLower}?action=unlock`, '', {
320
+ 'Content-Type': MEDIA_TYPES['dtpa'],
321
+ 'Accept': MEDIA_TYPES['dtpa'],
322
+ 'x-csrf-token': csrfToken3,
323
+ });
324
+ // Step 4b: If description or filter provided, update via Lock → GET → PUT → unlock
325
+ if (desc || (args.filter_field && args.filter_value)) {
326
+ const descLockCsrf = await client.getCsrfToken();
327
+ const descLockResponse = await client.rawPost(`/sap/bw/modeling/dtpa/${dtpLower}?action=lock`, '', {
328
+ 'Accept': MEDIA_TYPES['dtpa'],
329
+ 'x-csrf-token': descLockCsrf,
330
+ });
331
+ const descLockHandle = descLockResponse.body.match(/<LOCK_HANDLE>([^<]+)<\/LOCK_HANDLE>/)?.[1] ?? '';
332
+ if (!descLockHandle) {
333
+ throw new Error(`No <LOCK_HANDLE> in description/filter lock response:\n${descLockResponse.body}`);
334
+ }
335
+ // GET DTP XML (fresh client) — read timestamp
336
+ const descGetClient = createClientFromEnv();
337
+ const descGetResponse = await descGetClient.get(`/sap/bw/modeling/dtpa/${dtpLower}/m`, MEDIA_TYPES['dtpa']);
338
+ const descTimestamp = descGetResponse.headers['timestamp'] ?? '';
339
+ let descXml = descGetResponse.body;
340
+ // Update description attribute if provided
341
+ if (desc) {
342
+ descXml = descXml.replace(/(<dtpa:dataTransferProcess\b[^>]*\bdescription=)"[^"]*"/, `$1"${desc}"`);
343
+ }
344
+ // Inject filter if provided
345
+ if (args.filter_field && args.filter_value) {
346
+ const fieldBlockRegex = new RegExp(`(<fields[^>]*\\bname="${args.filter_field}"[^>]*>[\\s\\S]*?)<routine\\/>`);
347
+ descXml = descXml.replace(fieldBlockRegex, `$1<routine/>\n <selection excluding="false" operator="Equal">\n <low description="${args.filter_value}" value="${args.filter_value}"/>\n </selection>`);
348
+ }
349
+ // PUT with fresh client
350
+ const descPutClient = createClientFromEnv();
351
+ await descPutClient.put('dtpa', dtpName, descLockHandle, descXml, descTimestamp);
352
+ // Unlock
353
+ const descUnlockCsrf = await client.getCsrfToken();
354
+ await client.rawPost(`/sap/bw/modeling/dtpa/${dtpLower}?action=unlock`, '', {
355
+ 'Content-Type': MEDIA_TYPES['dtpa'],
356
+ 'Accept': MEDIA_TYPES['dtpa'],
357
+ 'x-csrf-token': descUnlockCsrf,
358
+ });
359
+ }
360
+ // Step 5: Activate
361
+ await bwActivate(client, 'dtpa', dtpName, '');
362
+ return JSON.stringify({
363
+ success: true,
364
+ dtp_name: dtpName,
365
+ transformation: trfnName,
366
+ source: { type: srcType, name: srcName },
367
+ target: { type: tgtType, name: tgtName },
368
+ package: pkg,
369
+ message: `DTP '${dtpName}' created and activated successfully.`,
370
+ });
371
+ }
372
+ // ── bwRunDtp ──────────────────────────────────────────────────────────────────
373
+ /**
374
+ * bw_run_dtp — start (execute) a DTP run.
375
+ *
376
+ * Single POST to /sap/bw/modeling/dtpa/executerun with a minimal <executeRun> body naming the
377
+ * DTP. The server responds 201 Created with a Location header whose last path segment is the new
378
+ * request id.
379
+ *
380
+ * Runs in a fresh session (createClientFromEnv()) — like the DTP-activation path — because the
381
+ * executerun touches the DTP and its transformation and the shared module-level client can hold a
382
+ * stale session buffer; a fresh session also avoids concurrency collisions across runs.
383
+ *
384
+ * The returned request id is a timestamp-based executerun id. It is returned for information only:
385
+ * it is not verified to match the request TSN used by bw_get_request, so load status is monitored
386
+ * via bw_list_requests (by target InfoProvider) and bw_get_request.
387
+ */
388
+ export async function bwRunDtp(dtpName) {
389
+ const dtpUpper = dtpName.toUpperCase();
390
+ const runClient = createClientFromEnv();
391
+ const csrfToken = await runClient.getCsrfToken();
392
+ const body = `<?xml version="1.0" encoding="UTF-8"?><executeRun dataTransferProcess="${dtpUpper}"></executeRun>`;
393
+ const response = await runClient.rawPost('/sap/bw/modeling/dtpa/executerun', body, {
394
+ 'Accept': MEDIA_TYPES['dtpa'],
395
+ 'Content-Type': MEDIA_TYPES['dtpa'],
396
+ 'x-csrf-token': csrfToken,
397
+ });
398
+ const location = response.headers['location'] ?? response.headers['Location'] ?? '';
399
+ if (!location) {
400
+ throw new Error(`executerun returned no Location header. Response headers: ${JSON.stringify(response.headers)}`);
401
+ }
402
+ const requestId = location.split('/').filter(Boolean).pop() ?? '';
403
+ return JSON.stringify({
404
+ success: true,
405
+ dtp_name: dtpUpper,
406
+ request_id: requestId,
407
+ message: `DTP run started for ${dtpUpper} (request id ${requestId}). ` +
408
+ `Monitor load status via bw_list_requests (by target InfoProvider) and bw_get_request.`,
409
+ });
410
+ }
411
+ /**
412
+ * bw_update_dtp — update a DTP (description).
413
+ *
414
+ * Flow: Lock → GET (fresh) → PUT (fresh) → bwActivate (handles unlock).
415
+ */
416
+ export async function bwUpdateDtp(client, args) {
417
+ const dtpName = args.dtp_name.toUpperCase();
418
+ const dtpLower = args.dtp_name.toLowerCase();
419
+ // Lock (stateful_enqueue — same pattern as bwUpdateInfoObject)
420
+ const lockHandle = await client.lock('dtpa', dtpLower, {}, 'stateful_enqueue');
421
+ // GET current DTP XML (fresh client) — read timestamp
422
+ const getClient = createClientFromEnv();
423
+ const getResponse = await getClient.get(`/sap/bw/modeling/dtpa/${dtpLower}/m`, MEDIA_TYPES['dtpa']);
424
+ const timestamp = getResponse.headers['timestamp'] ?? '';
425
+ // Apply modifications
426
+ let putXml = getResponse.body;
427
+ if (args.description !== undefined) {
428
+ putXml = putXml.replace(/(<dtpa:dataTransferProcess\b[^>]*\bdescription=)"[^"]*"/, `$1"${args.description}"`);
429
+ }
430
+ if (args.filter_field && args.filter_value !== undefined) {
431
+ const excluding = args.filter_excluding ? 'true' : 'false';
432
+ // Preserve empty string (= '' filter) — do not filter(Boolean); deduplicate via Set
433
+ const values = [...new Set(args.filter_value.split(',').map((v) => v.trim()))];
434
+ // Empty string → self-closing <selection> (no <low>); non-empty → <low value="..."/>
435
+ const selectionsXml = values
436
+ .map((v) => v === ''
437
+ ? `<selection excluding="${excluding}" operator="Equal"/>`
438
+ : `<selection excluding="${excluding}" operator="Equal">\n <low description="${v}" value="${v}"/>\n </selection>`)
439
+ .join('\n ') + '\n ';
440
+ // 1. Mark field as selected
441
+ putXml = putXml.replace(new RegExp(`(<fields[^>]*\\bname="${args.filter_field}"(?![^>]*\\bselected="true")[^>]*)(>)`), `$1 selected="true"$2`);
442
+ // 2. Remove any existing <selection> elements
443
+ putXml = putXml.replace(new RegExp(`(<fields[^>]*\\bname="${args.filter_field}"[^>]*>)(<selection[^\\s/>][^>]*>[\\s\\S]*?<\\/selection>|<selection[^>]*\\/?>)\\s*(?=<(?:infoObject|operators))`, 'g'), '$1');
444
+ // 3. Remove <routine/> if already present (to avoid duplicates)
445
+ putXml = putXml.replace(new RegExp(`(<fields[^>]*\\bname="${args.filter_field}"[^>]*>)<routine\\/>`), '$1');
446
+ // 4. Insert <routine/> + selections before <infoObject> (InfoObject fields) or <operators> (plain fields)
447
+ putXml = putXml.replace(new RegExp(`(<fields[^>]*\\bname="${args.filter_field}"[^>]*>)(<(?:infoObject|operators))`), `$1<routine/>\n ${selectionsXml}$2`);
448
+ }
449
+ if (args.filter_clear_fields) {
450
+ const fieldsToClear = args.filter_clear_fields.split(',').map((f) => f.trim()).filter(Boolean);
451
+ for (const fieldName of fieldsToClear) {
452
+ // Remove selected="true"
453
+ putXml = putXml.replace(new RegExp(`(<fields[^>]*\\bname="${fieldName}"[^>]*)\\s+selected="true"`), '$1');
454
+ // Remove all <selection> elements (self-closing and with body)
455
+ putXml = putXml.replace(new RegExp(`(<fields[^>]*\\bname="${fieldName}"[^>]*>)([\\s\\S]*?)(<\\/fields>)`, 'g'), (_match, open, body, close) => {
456
+ const cleaned = body
457
+ .replace(/<selection\b[^>]*\/>/g, '')
458
+ .replace(/<selection\b[^>]*>[\s\S]*?<\/selection>/g, '');
459
+ return open + cleaned + close;
460
+ });
461
+ }
462
+ }
463
+ // Extraction mode: rewrite only extractionMode + deltaSettingStatus on the <extractionSettings>
464
+ // element (Full = F/0, Delta = D/2). allowedExtractionModes, packageSize and parallelExtraction
465
+ // are left unchanged; attributes may appear in any order.
466
+ if (args.extraction_mode !== undefined) {
467
+ const extractionMode = args.extraction_mode === 'full' ? 'F' : 'D';
468
+ const deltaSettingStatus = args.extraction_mode === 'full' ? '0' : '2';
469
+ putXml = putXml.replace(/<extractionSettings\b[^>]*\/>/, (tag) => tag
470
+ .replace(/\bextractionMode="[^"]*"/, `extractionMode="${extractionMode}"`)
471
+ .replace(/\bdeltaSettingStatus="[^"]*"/, `deltaSettingStatus="${deltaSettingStatus}"`));
472
+ }
473
+ // PUT on a fresh stateless client — Eclipse uses a separate stateless session for PUT
474
+ const putClient = createClientFromEnv();
475
+ await putClient.put('dtpa', dtpName, lockHandle, putXml, timestamp, args.transport, args.transport_lock_holder);
476
+ // Activate — handles unlock
477
+ await bwActivate(client, 'dtpa', dtpName, lockHandle, args.transport);
478
+ return JSON.stringify({
479
+ success: true,
480
+ dtp_name: dtpName,
481
+ message: `DTP '${dtpName}' updated and activated successfully.`,
482
+ });
483
+ }
484
+ /**
485
+ * bw_set_dtp_filter_routine — set an ABAP filter routine on a DTP filter field.
486
+ *
487
+ * Flow:
488
+ * 1. Lock (no CREA)
489
+ * 2. POST generateRoutineProgram → ABAP program name from Location header
490
+ * 3. ADT activate the ABAP program (fresh client)
491
+ * 4. GET routineReports → read back routine XML
492
+ * 5. DELETE routineReports (mandatory cleanup)
493
+ * 6. GET DTP XML (fresh client, read timestamp)
494
+ * 7. Convert routineReports XML → DTP PUT format, inject into <fields> block
495
+ * 8. PUT DTP XML (fresh client)
496
+ * 9. bwActivate with lockHandle
497
+ */
498
+ export async function bwSetDtpFilterRoutine(client, args) {
499
+ const dtpUpper = args.dtp_name.toUpperCase();
500
+ const dtpLower = args.dtp_name.toLowerCase();
501
+ const fieldName = args.field_name;
502
+ // Step 1: Lock (no CREA)
503
+ const lockCsrf = await client.getCsrfToken();
504
+ const lockResponse = await client.rawPost(`/sap/bw/modeling/dtpa/${dtpLower}?action=lock`, '', {
505
+ 'Accept': MEDIA_TYPES['dtpa'],
506
+ 'x-csrf-token': lockCsrf,
507
+ });
508
+ const lockHandle = lockResponse.body.match(/<LOCK_HANDLE>([^<]+)<\/LOCK_HANDLE>/)?.[1] ?? '';
509
+ if (!lockHandle) {
510
+ throw new Error(`No <LOCK_HANDLE> in lock response:\n${lockResponse.body}`);
511
+ }
512
+ // Step 2: POST generateRoutineProgram
513
+ const escapeXml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
514
+ const codeLines = args.routine_code.split('\n');
515
+ const codeXml = codeLines.map(l => ` <line>${escapeXml(l)}</line>`).join('\n');
516
+ let globalXml = '';
517
+ if (args.global_code) {
518
+ const globalLines = args.global_code.split('\n');
519
+ globalXml = ` <globalCode>\n${globalLines.map(l => ` <line>${escapeXml(l)}</line>`).join('\n')}\n </globalCode>\n`;
520
+ }
521
+ const routineBody = `<routine>\n${globalXml} <code>\n${codeXml}\n </code>\n</routine>`;
522
+ const genCsrf = await client.getCsrfToken();
523
+ const genResponse = await client.rawPost(`/sap/bw/modeling/dtpa/${dtpUpper}/${fieldName}/generateRoutineProgram`, routineBody, {
524
+ 'Content-Type': 'application/vnd.sap.bw.modeling.dtpa.routine.code-v1_0_0+xml',
525
+ 'Accept': MEDIA_TYPES['dtpa'],
526
+ 'x-csrf-token': genCsrf,
527
+ });
528
+ const genLocation = genResponse.headers['location'] ?? genResponse.headers['Location'] ?? '';
529
+ if (!genLocation) {
530
+ throw new Error(`generateRoutineProgram returned no Location header. Headers: ${JSON.stringify(genResponse.headers)}`);
531
+ }
532
+ const encodedProgram = genLocation.split('/routineReports/').pop() ?? '';
533
+ const programName = decodeURIComponent(encodedProgram);
534
+ // Step 3: ADT activate ABAP program (fresh client for session isolation)
535
+ const urlEncodedProgram = encodeURIComponent(programName).toLowerCase();
536
+ const adtClient = createClientFromEnv();
537
+ const adtCsrf = await adtClient.getCsrfToken();
538
+ const adtBody = `<?xml version="1.0" encoding="UTF-8"?>\n` +
539
+ `<adtcore:objectReferences xmlns:adtcore="http://www.sap.com/adt/core">\n` +
540
+ ` <adtcore:objectReference adtcore:uri="/sap/bc/adt/programs/programs/${urlEncodedProgram}"\n` +
541
+ ` adtcore:name="${programName.toUpperCase()}"/>\n` +
542
+ `</adtcore:objectReferences>`;
543
+ await adtClient.rawPost('/sap/bc/adt/activation?method=activate&preauditRequested=true', adtBody, {
544
+ 'Content-Type': 'application/xml',
545
+ 'Accept': 'application/xml',
546
+ 'x-csrf-token': adtCsrf,
547
+ });
548
+ // Step 4: GET routineReports (read back routine code as XML)
549
+ const routineGetClient = createClientFromEnv();
550
+ const routineGetResponse = await routineGetClient.get(`/sap/bw/modeling/dtpa/${dtpUpper}/${fieldName}/routineReports/${encodedProgram}`, MEDIA_TYPES['dtpa']);
551
+ const routineXml = routineGetResponse.body;
552
+ // Step 5: DELETE routineReports (mandatory cleanup)
553
+ await client.rawDelete(`/sap/bw/modeling/dtpa/${dtpUpper}/${fieldName}/routineReports/${encodedProgram}`, {
554
+ 'Content-Type': MEDIA_TYPES['dtpa'],
555
+ 'Accept': MEDIA_TYPES['dtpa'],
556
+ });
557
+ // Step 6: GET current DTP XML (fresh client, read timestamp)
558
+ const dtpGetClient = createClientFromEnv();
559
+ const dtpGetResponse = await dtpGetClient.get(`/sap/bw/modeling/dtpa/${dtpLower}/m`, MEDIA_TYPES['dtpa']);
560
+ const timestamp = dtpGetResponse.headers['timestamp'] ?? '';
561
+ // Step 7: Convert routineReports XML → DTP PUT format
562
+ // Extract code lines from <code>...</code>
563
+ const codeSection = routineXml.match(/<code>([\s\S]*?)<\/code>/)?.[1] ?? '';
564
+ const extractedCodeLines = [...codeSection.matchAll(/<line>([\s\S]*?)<\/line>/g)].map(m => m[1]);
565
+ // Extract global lines from <globalCode>...</globalCode>
566
+ const globalSection = routineXml.match(/<globalCode>([\s\S]*?)<\/globalCode>/)?.[1] ?? '';
567
+ const extractedGlobalLines = [...globalSection.matchAll(/<line>([\s\S]*?)<\/line>/g)].map(m => m[1]);
568
+ // <line> → <code>, empty lines → <code xsi:nil="true"/>
569
+ const codeElements = extractedCodeLines
570
+ .map(line => (line ? `<code>${line}</code>` : `<code xsi:nil="true"/>`))
571
+ .join('\n ');
572
+ const routineInjection = `<routine>\n ${codeElements}\n </routine>`;
573
+ // <globalCode><line> → <globalRoutineCode>
574
+ const globalElements = extractedGlobalLines
575
+ .map(line => ` <globalRoutineCode>${line}</globalRoutineCode>`)
576
+ .join('\n');
577
+ // Step 8: Inject into DTP XML
578
+ let putXml = dtpGetResponse.body;
579
+ // Fix 3: Add selected="true" to the matching <fields> element if not already present
580
+ putXml = putXml.replace(new RegExp(`(<fields[^>]*\\bname="${fieldName}"(?![^>]*\\bselected="true")[^>]*)(>)`), `$1 selected="true"$2`);
581
+ // Fix 1: Inject <routine> before the first <operators> inside the matching fields block.
582
+ // Remove any existing <routine/> or <routine>...</routine> first, then inject before <operators>.
583
+ putXml = putXml.replace(new RegExp(`(<fields[^>]*\\bname="${fieldName}"[^>]*>[\\s\\S]*?)<routine\\s*\\/>`), '$1');
584
+ putXml = putXml.replace(new RegExp(`(<fields[^>]*\\bname="${fieldName}"[^>]*>[\\s\\S]*?)(<operators)`), `$1${routineInjection}\n $2`);
585
+ // Fix 2: Remove all existing <globalRoutineCode> elements before inserting new ones
586
+ putXml = putXml.replace(/<globalRoutineCode>[^<]*<\/globalRoutineCode>\s*/g, '');
587
+ // Append globalRoutineCode elements before </filter>
588
+ if (globalElements) {
589
+ putXml = putXml.replace('</filter>', `${globalElements}\n </filter>`);
590
+ }
591
+ // PUT with fresh client
592
+ const putClient = createClientFromEnv();
593
+ await putClient.put('dtpa', dtpUpper, lockHandle, putXml, timestamp);
594
+ // Step 9: Activate (activation framework handles unlock for dtpa)
595
+ await bwActivate(client, 'dtpa', dtpUpper, lockHandle);
596
+ return JSON.stringify({
597
+ success: true,
598
+ dtp_name: dtpUpper,
599
+ field_name: fieldName,
600
+ message: `Filter routine for field '${fieldName}' on DTP '${dtpUpper}' set and activated successfully.`,
601
+ });
602
+ }