@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,895 @@
1
+ import { MEDIA_TYPES } from '../bw-client.js';
2
+ import { parseInfoObjectProps } from './infoobject.js';
3
+ // ── aDSO type presets ────────────────────────────────────────────────────────
4
+ const typePresets = {
5
+ standard: {
6
+ activateData: true,
7
+ cubeDeltaOnly: false,
8
+ directUpdate: false,
9
+ isReportingObject: true,
10
+ noAqDeletion: false,
11
+ writeChangelog: true,
12
+ },
13
+ staging_inbound_only: {
14
+ activateData: false,
15
+ cubeDeltaOnly: false,
16
+ directUpdate: false,
17
+ isReportingObject: false,
18
+ noAqDeletion: false,
19
+ writeChangelog: false,
20
+ },
21
+ staging_compress: {
22
+ activateData: true,
23
+ cubeDeltaOnly: false,
24
+ directUpdate: false,
25
+ isReportingObject: false,
26
+ noAqDeletion: false,
27
+ },
28
+ staging_reporting: {
29
+ activateData: true,
30
+ cubeDeltaOnly: false,
31
+ directUpdate: false,
32
+ isReportingObject: true,
33
+ noAqDeletion: true,
34
+ },
35
+ datamart: {
36
+ activateData: true,
37
+ cubeDeltaOnly: true,
38
+ directUpdate: false,
39
+ isReportingObject: true,
40
+ noAqDeletion: false,
41
+ },
42
+ direct_update: {
43
+ activateData: false,
44
+ cubeDeltaOnly: false,
45
+ directUpdate: true,
46
+ isReportingObject: false,
47
+ noAqDeletion: false,
48
+ },
49
+ };
50
+ /**
51
+ * Set or replace a boolean attribute on the root <adso:dataStore> element.
52
+ * If the attribute already exists, its value is replaced in-place.
53
+ * If not, it is injected just before the first closing > of the root tag.
54
+ */
55
+ function setRootAttr(xml, attr, value) {
56
+ const existing = new RegExp(`\\b${attr}="[^"]*"`, 'g');
57
+ if (existing.test(xml)) {
58
+ return xml.replace(new RegExp(`\\b${attr}="[^"]*"`, 'g'), `${attr}="${value}"`);
59
+ }
60
+ // Attribute absent — inject into the root element opening tag before its first >
61
+ return xml.replace(/(<adso:dataStore\b(?:[^>]|\n)*?)(\s*>)/, `$1 ${attr}="${value}"$2`);
62
+ }
63
+ const ADSO_ACCEPT = MEDIA_TYPES['adso'];
64
+ /**
65
+ * bw_update_adso action "update_settings" — change aDSO type and/or boolean flags.
66
+ *
67
+ * Workflow: GET full XML → lock → apply changes → PUT → return result.
68
+ * Lock handle is returned so the caller can invoke bw_activate next.
69
+ * Never modifies <tables>, <hashElements>, <pushURI>, or <tlogoProperties>.
70
+ */
71
+ export async function bwUpdateAdsoSettings(client, adsoName, settings) {
72
+ const adsoUpper = adsoName.toUpperCase();
73
+ const adsoPath = `/sap/bw/modeling/adso/${adsoName.toLowerCase()}/m`;
74
+ // 1. Read current XML
75
+ const adsoResult = await client.get(adsoPath, ADSO_ACCEPT);
76
+ const timestamp = adsoResult.headers['timestamp'] ?? adsoResult.headers['TIMESTAMP'];
77
+ let xml = adsoResult.body;
78
+ // 2. Apply type preset wholesale (overwrites the 5 core attributes)
79
+ if (settings.adsoType !== undefined) {
80
+ const preset = typePresets[settings.adsoType];
81
+ for (const [attr, val] of Object.entries(preset)) {
82
+ xml = setRootAttr(xml, attr, String(val));
83
+ }
84
+ }
85
+ // 3. Apply individual boolean flags on top
86
+ const boolFlags = [
87
+ 'writeChangelog', 'snapShotScenario', 'uniqueDataRecords', 'planningMode',
88
+ ];
89
+ for (const flag of boolFlags) {
90
+ if (settings[flag] !== undefined) {
91
+ xml = setRootAttr(xml, flag, String(settings[flag]));
92
+ }
93
+ }
94
+ // writeInterface maps to the XML attribute "pushMode"
95
+ if (settings.writeInterface !== undefined) {
96
+ xml = setRootAttr(xml, 'pushMode', String(settings.writeInterface));
97
+ }
98
+ // 4. Update label in <endUserTexts label="..."/>
99
+ if (settings.label !== undefined) {
100
+ const endUserTextsTag = `<endUserTexts label="${settings.label}"/>`;
101
+ if (/<endUserTexts[^>]*\/>/.test(xml)) {
102
+ // Replace existing <endUserTexts .../> (with or without label attribute)
103
+ xml = xml.replace(/<endUserTexts[^>]*\/>/, endUserTextsTag);
104
+ }
105
+ else {
106
+ // No <endUserTexts> present yet — insert before <tlogoProperties>
107
+ xml = xml.replace(/(<tlogoProperties)/, `${endUserTextsTag}\n $1`);
108
+ }
109
+ }
110
+ // 5. Lock → PUT
111
+ const lockHandle = await client.lock('adso', adsoName);
112
+ try {
113
+ await client.put('adso', adsoName, lockHandle, xml, timestamp, settings.transport);
114
+ }
115
+ catch (err) {
116
+ await client.unlock('adso', adsoName).catch(() => { });
117
+ throw err;
118
+ }
119
+ return JSON.stringify({
120
+ success: true,
121
+ message: `aDSO ${adsoUpper} settings updated. Call bw_activate to activate.`,
122
+ lock_handle: lockHandle,
123
+ adso_name: adsoUpper,
124
+ object_type: 'adso',
125
+ applied: settings,
126
+ });
127
+ }
128
+ /**
129
+ * bw_get_adso — read aDSO structure (inactive version).
130
+ * format="raw": raw XML + header. format="text": structured plain-text summary.
131
+ */
132
+ export async function bwGetAdso(client, adsoName, format = 'text') {
133
+ const path = `/sap/bw/modeling/adso/${adsoName.toLowerCase()}/m`;
134
+ const result = await client.get(path, ADSO_ACCEPT);
135
+ const status = result.headers['object_status'] ?? result.headers['OBJECT_STATUS'] ?? 'unknown';
136
+ const ts = result.headers['timestamp'] ?? '';
137
+ const rawOutput = `aDSO: ${adsoName.toUpperCase()}\nStatus: ${status}\nTimestamp: ${ts}\n\n${result.body}`;
138
+ if (format === 'raw')
139
+ return rawOutput;
140
+ return summarizeAdso(adsoName.toUpperCase(), status, result.body);
141
+ }
142
+ function summarizeAdso(adsoName, status, xml) {
143
+ const lines = [];
144
+ // ── Attribute helpers ─────────────────────────────────────────────────────
145
+ const rootTagStr = xml.match(/<adso:dataStore\b[^>]*/)?.[0] ?? '';
146
+ const strAttr = (name, src = rootTagStr) => src.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1] ?? '';
147
+ const boolAttr = (name, def = false) => {
148
+ const v = strAttr(name);
149
+ return v === 'true' ? true : v === 'false' ? false : def;
150
+ };
151
+ const flag = (name) => strAttr(name) || 'false';
152
+ // ── Section 1: General ────────────────────────────────────────────────────
153
+ const name = strAttr('name') || adsoName;
154
+ const rawDesc = xml.match(/<endUserTexts label="([^"]*)"/)?.[1] ?? '';
155
+ const desc = rawDesc
156
+ .replace(/&quot;/g, '"').replace(/&amp;/g, '&')
157
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&apos;/g, "'");
158
+ const infoArea = xml.match(/<infoArea>([^<]*)<\/infoArea>/)?.[1] ?? '';
159
+ const pkg = xml.match(/<adtcore:packageRef\b[^>]*\badtcore:name="([^"]*)"/)?.[1] ?? '';
160
+ const objectVersion = xml.match(/<objectVersion>([^<]*)<\/objectVersion>/)?.[1] ?? '';
161
+ const versionMap = { M: 'Inactive', A: 'Active' };
162
+ const versionLabel = objectVersion
163
+ ? `${objectVersion} (${versionMap[objectVersion] ?? objectVersion})`
164
+ : '';
165
+ const tlogoStr = xml.match(/<tlogoProperties\b[^>]*/)?.[0] ?? '';
166
+ const createdAt = strAttr('adtcore:createdAt', tlogoStr);
167
+ const createdBy = strAttr('adtcore:createdBy', tlogoStr);
168
+ const changedAt = strAttr('adtcore:changedAt', tlogoStr);
169
+ const changedBy = strAttr('adtcore:changedBy', tlogoStr);
170
+ lines.push('── General ──');
171
+ lines.push(`aDSO: ${name}`);
172
+ lines.push(`Description: ${desc}`);
173
+ lines.push(`InfoArea: ${infoArea}`);
174
+ lines.push(`Package: ${pkg}`);
175
+ lines.push(`Status: ${status}`);
176
+ lines.push(`Version: ${versionLabel}`);
177
+ lines.push(`Created: ${createdAt} (${createdBy})`);
178
+ lines.push(`Changed: ${changedAt} (${changedBy})`);
179
+ // ── Section 2: Flags ──────────────────────────────────────────────────────
180
+ lines.push('');
181
+ lines.push('── Flags ──');
182
+ lines.push(`Externe SAP HANA-View: ${flag('withHanaModel')}`);
183
+ lines.push(`Lesezugriffsausgabe protokollieren: ${flag('logRalOutput')}`);
184
+ lines.push(`Schreib-Interface aktiviert: ${flag('pushMode')}`);
185
+ lines.push(`Planung aktiviert: ${flag('planningMode')}`);
186
+ lines.push(`Bestand aktiviert: ${flag('isNcum')}`);
187
+ // ── Section 3: Modelling Type ─────────────────────────────────────────────
188
+ const directUpdate = boolAttr('directUpdate');
189
+ const cubeDeltaOnly = boolAttr('cubeDeltaOnly');
190
+ const noAqDeletion = boolAttr('noAqDeletion');
191
+ const isReportingObject = boolAttr('isReportingObject', true);
192
+ const activateData = boolAttr('activateData', true);
193
+ const writeChangelog = boolAttr('writeChangelog');
194
+ const snapShotScenario = boolAttr('snapShotScenario');
195
+ const uniqueDataRecords = boolAttr('uniqueDataRecords');
196
+ let modellingType;
197
+ if (directUpdate) {
198
+ modellingType = 'DataStore-Objekt mit direkter Fortschreibung';
199
+ }
200
+ else if (cubeDeltaOnly && !noAqDeletion) {
201
+ modellingType = 'Staging — Nur Eingangs-Queue';
202
+ }
203
+ else if (!cubeDeltaOnly && noAqDeletion) {
204
+ modellingType = 'Staging — Daten komprimieren';
205
+ }
206
+ else if (cubeDeltaOnly && noAqDeletion) {
207
+ modellingType = 'Staging — Reporting aktiviert';
208
+ }
209
+ else if (!isReportingObject && activateData && !cubeDeltaOnly) {
210
+ modellingType = 'Data-Mart-DataStore-Objekt';
211
+ }
212
+ else {
213
+ modellingType = 'Standard-DataStore-Objekt';
214
+ }
215
+ lines.push('');
216
+ lines.push('── Modelling Type ──');
217
+ lines.push(modellingType);
218
+ if (writeChangelog)
219
+ lines.push(' Change Log schreiben: yes');
220
+ if (snapShotScenario)
221
+ lines.push(' Snapshot-Unterstützung: yes');
222
+ if (uniqueDataRecords)
223
+ lines.push(' Eindeutige Datensätze: yes');
224
+ // ── Section 4: Data Tiering ───────────────────────────────────────────────
225
+ const tempMap = {
226
+ HO: 'Hot',
227
+ HWO: 'Hot, Warm',
228
+ HWCP: 'Hot, Warm, Cold',
229
+ WO: 'Warm only',
230
+ WCP: 'Warm, Cold',
231
+ CO: 'Cold only',
232
+ HCO: 'Hot, Cold',
233
+ };
234
+ const tempRaw = strAttr('temperatureSchema');
235
+ const exceptionalUpdate = flag('exceptionalUpdate');
236
+ const dapOrigin = strAttr('dapOrigin');
237
+ lines.push('');
238
+ lines.push('── Data Tiering ──');
239
+ lines.push(`Data Tiering: ${tempMap[tempRaw] ?? tempRaw}`);
240
+ lines.push(`Außergewöhnliche Fortschreibungen: ${exceptionalUpdate}`);
241
+ if (dapOrigin)
242
+ lines.push(`Verbindung (DAP): ${dapOrigin}`);
243
+ // ── Section 5: Key Fields ─────────────────────────────────────────────────
244
+ const keyElements = [];
245
+ const keySet = new Set();
246
+ const keyRe = /<keyElement>([^<]*)<\/keyElement>/g;
247
+ let km;
248
+ while ((km = keyRe.exec(xml)) !== null) {
249
+ const v = km[1].trim().replace(/^#\/\/\//, '');
250
+ keyElements.push(v);
251
+ keySet.add(v);
252
+ }
253
+ lines.push('');
254
+ lines.push('── Key Fields ──');
255
+ lines.push(keyElements.length > 0 ? keyElements.join(', ') : '(none)');
256
+ const fields = [];
257
+ const elemRe = /<element\b([^>]*)>([\s\S]*?)<\/element>/g;
258
+ let em;
259
+ const getAttr = (s, key) => s.match(new RegExp(`\\b${key}="([^"]*)"`))?.[1] ?? '';
260
+ while ((em = elemRe.exec(xml)) !== null) {
261
+ const openAttrs = em[1];
262
+ const body = em[2];
263
+ const fieldName = getAttr(openAttrs, 'name');
264
+ if (!fieldName)
265
+ continue;
266
+ const itStr = body.match(/<inlineType\b[^>]*/)?.[0] ?? '';
267
+ const fieldType = getAttr(itStr, 'name');
268
+ const lengthRaw = getAttr(itStr, 'length');
269
+ const precRaw = getAttr(itStr, 'precision');
270
+ const scaleRaw = getAttr(itStr, 'scale');
271
+ const agg = getAttr(openAttrs, 'aggregationBehavior');
272
+ const dimRaw = getAttr(openAttrs, 'dimension');
273
+ const dim = dimRaw.replace(/^#\/\/\//, '').replace(/§$/, '');
274
+ // QUAN/CURR: XML has precision=total_digits, scale=decimal_places (no length attr)
275
+ // DEC: XML has length=total_digits, precision=decimal_places
276
+ // Others: length only
277
+ let lengthDisp;
278
+ if (precRaw && scaleRaw) {
279
+ lengthDisp = `${precRaw},${scaleRaw}`;
280
+ }
281
+ else if (lengthRaw && precRaw) {
282
+ lengthDisp = `${lengthRaw},${precRaw}`;
283
+ }
284
+ else {
285
+ lengthDisp = lengthRaw;
286
+ }
287
+ const isKey = keySet.has(fieldName);
288
+ const keyOrder = isKey ? keyElements.indexOf(fieldName) : -1;
289
+ const rawLabel = body.match(/<endUserTexts label="([^"]*)"/)?.[1]
290
+ ?? body.match(/<descriptions label="([^"]*)"/)?.[1]
291
+ ?? '';
292
+ const label = rawLabel
293
+ .replace(/&quot;/g, '"').replace(/&amp;/g, '&')
294
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&apos;/g, "'");
295
+ fields.push({ name: fieldName, type: fieldType, length: lengthDisp, agg, dim, dimRaw, isKey, keyOrder, label });
296
+ }
297
+ fields.sort((a, b) => {
298
+ if (a.isKey && b.isKey)
299
+ return a.keyOrder - b.keyOrder;
300
+ if (a.isKey)
301
+ return -1;
302
+ if (b.isKey)
303
+ return 1;
304
+ const aKyf = a.dimRaw.includes('KEYFIGURES');
305
+ const bKyf = b.dimRaw.includes('KEYFIGURES');
306
+ if (!aKyf && bKyf)
307
+ return -1;
308
+ if (aKyf && !bKyf)
309
+ return 1;
310
+ return 0;
311
+ });
312
+ lines.push('');
313
+ lines.push(`── Fields (${fields.length}) ──`);
314
+ const headers = ['NAME', 'TYPE', 'LENGTH', 'AGG', 'DIM', 'KEY', 'LABEL'];
315
+ const cols = fields.map(f => [f.name, f.type, f.length, f.agg, f.dim, f.isKey ? 'yes' : 'no', f.label]);
316
+ const widths = headers.map((h, i) => Math.max(h.length, ...cols.map(r => r[i].length)));
317
+ const row = (vals) => vals.map((v, i) => v.padEnd(widths[i])).join(' ');
318
+ lines.push(row(headers));
319
+ lines.push(widths.map(w => '-'.repeat(w)).join(' '));
320
+ for (const r of cols)
321
+ lines.push(row(r));
322
+ return lines.join('\n');
323
+ }
324
+ /**
325
+ * Build the <element> XML snippet to inject into an aDSO.
326
+ * Based on the recorded PUT payload pattern from adso_workflow.md Block 3b.
327
+ */
328
+ function buildAdsoElement(iObjName, props) {
329
+ const { conversionRoutine, label, dataType, length } = props;
330
+ const name = iObjName.toUpperCase();
331
+ const convAttr = conversionRoutine ? ` conversionRoutine="${conversionRoutine}"` : '';
332
+ return ` <element xsi:type="adso:AdsoElement" name="${name}" keep="false"
333
+ aggregationBehavior="NONE" infoObjectName="${name}"${convAttr}
334
+ dimension="#///ALL§" sidDeterminationMode="S">
335
+ <endUserTexts label="${label}"/>
336
+ <inlineType name="${dataType}" length="${length}" globalElementName="${name}"/>
337
+ <associationType>1</associationType>
338
+ </element>`;
339
+ }
340
+ /**
341
+ * Remove an <element> block (and any matching <keyElement>) from aDSO XML.
342
+ * The server removes the field when the PUT body no longer contains it.
343
+ */
344
+ function removeElement(adsoXml, iObjName) {
345
+ const name = iObjName.toUpperCase();
346
+ // Remove the full <element ... name="NAME" ...>...</element> block
347
+ const elementRegex = new RegExp(`[ \\t]*<element\\b[^>]*\\bname="${name}"[^>]*>[\\s\\S]*?<\\/element>\\n?`, 'g');
348
+ let result = adsoXml.replace(elementRegex, '');
349
+ // Remove <keyElement>#///NAME</keyElement> if the field was a key
350
+ const keyElementRegex = new RegExp(`[ \\t]*<keyElement>[^<]*\\/${name}<\\/keyElement>\\n?`, 'g');
351
+ result = result.replace(keyElementRegex, '');
352
+ return result;
353
+ }
354
+ /**
355
+ * Insert the new element directly after the last existing <element>.
356
+ *
357
+ * Reporting aDSOs anchor this with <keyElement>; staging / inbound aDSOs have no
358
+ * <keyElement> tags and instead start their <dimension> blocks right after the elements.
359
+ * Anchor on whichever of <keyElement or <dimension appears first so the new <element>
360
+ * always lands before the dimensions (and before the keyElements when present).
361
+ * Falls back to insertion before </adso:dataStore> only when neither anchor exists.
362
+ */
363
+ function injectElement(adsoXml, elementXml) {
364
+ const keyIdx = adsoXml.indexOf('<keyElement');
365
+ const dimIdx = adsoXml.indexOf('<dimension');
366
+ const candidates = [keyIdx, dimIdx].filter((i) => i !== -1);
367
+ if (candidates.length > 0) {
368
+ const idx = Math.min(...candidates);
369
+ return adsoXml.substring(0, idx) + elementXml + '\n ' + adsoXml.substring(idx);
370
+ }
371
+ // Fallback: before closing root tag
372
+ return adsoXml.replace('</adso:dataStore>', elementXml + '\n</adso:dataStore>');
373
+ }
374
+ /**
375
+ * bw_update_adso action "manage_keys" — replace the complete <keyElement> list.
376
+ *
377
+ * Removes all existing <keyElement> entries and inserts one per entry in keyFields,
378
+ * positioned after the last <element> and before <tlogoProperties>.
379
+ * All other XML (elements, tables, hashElements, pushURI, tlogoProperties) is unchanged.
380
+ * Returns the lockHandle so the caller can invoke bw_activate next.
381
+ */
382
+ export async function bwUpdateAdsoManageKeys(client, adsoName, keyFields, transport) {
383
+ const adsoUpper = adsoName.toUpperCase();
384
+ const adsoPath = `/sap/bw/modeling/adso/${adsoName.toLowerCase()}/m`;
385
+ // 1. Read current XML
386
+ const adsoResult = await client.get(adsoPath, ADSO_ACCEPT);
387
+ const timestamp = adsoResult.headers['timestamp'] ?? adsoResult.headers['TIMESTAMP'];
388
+ let xml = adsoResult.body;
389
+ // 2. Strip all existing <keyElement> entries (with any leading whitespace + trailing newline)
390
+ xml = xml.replace(/[ \t]*<keyElement>[^<]*<\/keyElement>\n?/g, '');
391
+ // 3. Build new <keyElement> block
392
+ const normalized = keyFields.map((f) => f.trim().toUpperCase()).filter(Boolean);
393
+ if (normalized.length > 0) {
394
+ const keyBlock = normalized.map((f) => ` <keyElement>#///${f}</keyElement>`).join('\n') + '\n';
395
+ // Insert before <tlogoProperties>; fall back to before </adso:dataStore>
396
+ if (xml.includes('<tlogoProperties')) {
397
+ xml = xml.replace('<tlogoProperties', keyBlock + ' <tlogoProperties');
398
+ }
399
+ else {
400
+ xml = xml.replace('</adso:dataStore>', keyBlock + '</adso:dataStore>');
401
+ }
402
+ }
403
+ // 4. Lock → PUT
404
+ const lockHandle = await client.lock('adso', adsoName);
405
+ try {
406
+ await client.put('adso', adsoName, lockHandle, xml, timestamp, transport);
407
+ }
408
+ catch (err) {
409
+ await client.unlock('adso', adsoName).catch(() => { });
410
+ throw err;
411
+ }
412
+ return JSON.stringify({
413
+ success: true,
414
+ message: `aDSO ${adsoUpper} key fields updated. Call bw_activate to activate.`,
415
+ lock_handle: lockHandle,
416
+ adso_name: adsoUpper,
417
+ object_type: 'adso',
418
+ key_fields: normalized,
419
+ });
420
+ }
421
+ /**
422
+ * bw_update_adso action "update_field_properties" — modify properties of a single field.
423
+ *
424
+ * Finds the <element name="FIELDNAME"> block, applies only the specified properties,
425
+ * and PUTs the full XML back. Never touches inlineType, conversionRoutine, outputLength,
426
+ * associationType, associationValid, or atom:link.
427
+ */
428
+ export async function bwUpdateAdsoFieldProperties(client, adsoName, fieldName, properties) {
429
+ const adsoUpper = adsoName.toUpperCase();
430
+ const nameUpper = fieldName.trim().toUpperCase();
431
+ // 1. GET full XML
432
+ const adsoPath = `/sap/bw/modeling/adso/${adsoName.toLowerCase()}/m`;
433
+ const adsoResult = await client.get(adsoPath, ADSO_ACCEPT);
434
+ const timestamp = adsoResult.headers['timestamp'] ?? adsoResult.headers['TIMESTAMP'];
435
+ const fullXml = adsoResult.body;
436
+ // 2. Find the element block — opening tag may span multiple lines, hence [^>]* matches \n
437
+ const elementRegex = new RegExp(`[ \\t]*<element\\b[^>]*\\bname="${nameUpper}"[^>]*>[\\s\\S]*?<\\/element>\\n?`);
438
+ const match = elementRegex.exec(fullXml);
439
+ if (!match) {
440
+ return JSON.stringify({
441
+ success: false,
442
+ message: `Field ${nameUpper} not found in aDSO ${adsoUpper}.`,
443
+ });
444
+ }
445
+ // 3. Detect field type
446
+ const isInfoObject = /\binfoObjectName="/.test(match[0]);
447
+ let elem = match[0];
448
+ // ── Attribute helpers ────────────────────────────────────────────────────
449
+ function replaceAttr(attr, value) {
450
+ if (new RegExp(`\\b${attr}="[^"]*"`).test(elem)) {
451
+ elem = elem.replace(new RegExp(`\\b${attr}="[^"]*"`), `${attr}="${value}"`);
452
+ }
453
+ else {
454
+ // Inject before the first > of the opening tag (may span lines)
455
+ elem = elem.replace(/(<element\b(?:[^>]|\n)*?)(\s*>)/, `$1 ${attr}="${value}"$2`);
456
+ }
457
+ }
458
+ function replaceDescriptions(desc) {
459
+ if (/<descriptions[^>]*\/>/.test(elem)) {
460
+ elem = elem.replace(/<descriptions[^>]*\/>/, desc);
461
+ }
462
+ else if (/<descriptions>/.test(elem)) {
463
+ elem = elem.replace(/<descriptions>[\s\S]*?<\/descriptions>/, desc);
464
+ }
465
+ else {
466
+ // <localProperties> exists but has no <descriptions> yet — inject inside
467
+ elem = elem.replace(/(<localProperties[^>]*>)/, `$1\n ${desc}`);
468
+ }
469
+ }
470
+ // ── Apply properties ──────────────────────────────────────────────────────
471
+ if (properties.sidDeterminationMode !== undefined) {
472
+ replaceAttr('sidDeterminationMode', properties.sidDeterminationMode);
473
+ }
474
+ if (properties.aggregationBehavior !== undefined) {
475
+ replaceAttr('aggregationBehavior', properties.aggregationBehavior);
476
+ }
477
+ if (properties.localDescription !== undefined) {
478
+ const desc = properties.localDescription === null
479
+ ? '<descriptions/>'
480
+ : `<descriptions label="${properties.localDescription}"/>`;
481
+ replaceDescriptions(desc);
482
+ }
483
+ if (properties.description !== undefined) {
484
+ replaceDescriptions(`<descriptions label="${properties.description}"/>`);
485
+ }
486
+ if (properties.fixedCurrency !== undefined) {
487
+ if (properties.fixedCurrency === null) {
488
+ elem = elem.replace(/[ \t]*<fixedCurrency>[^<]*<\/fixedCurrency>\n?/, '');
489
+ }
490
+ else if (/<fixedCurrency>/.test(elem)) {
491
+ elem = elem.replace(/<fixedCurrency>[^<]*<\/fixedCurrency>/, `<fixedCurrency>${properties.fixedCurrency}</fixedCurrency>`);
492
+ }
493
+ else {
494
+ elem = elem.replace(/(<inlineType[^>]*\/>)/, `$1\n <fixedCurrency>${properties.fixedCurrency}</fixedCurrency>`);
495
+ }
496
+ }
497
+ if (properties.fixedUnit !== undefined) {
498
+ if (properties.fixedUnit === null) {
499
+ elem = elem.replace(/[ \t]*<fixedUnit>[^<]*<\/fixedUnit>\n?/, '');
500
+ }
501
+ else if (/<fixedUnit>/.test(elem)) {
502
+ elem = elem.replace(/<fixedUnit>[^<]*<\/fixedUnit>/, `<fixedUnit>${properties.fixedUnit}</fixedUnit>`);
503
+ }
504
+ else {
505
+ elem = elem.replace(/(<inlineType[^>]*\/>)/, `$1\n <fixedUnit>${properties.fixedUnit}</fixedUnit>`);
506
+ }
507
+ }
508
+ // 4. Splice modified element back into full XML
509
+ const updatedXml = fullXml.substring(0, match.index) +
510
+ elem +
511
+ fullXml.substring(match.index + match[0].length);
512
+ // 5. Lock → PUT
513
+ const lockHandle = await client.lock('adso', adsoName);
514
+ try {
515
+ await client.put('adso', adsoName, lockHandle, updatedXml, timestamp, properties.transport);
516
+ }
517
+ catch (err) {
518
+ await client.unlock('adso', adsoName).catch(() => { });
519
+ throw err;
520
+ }
521
+ return JSON.stringify({
522
+ success: true,
523
+ message: `Field ${nameUpper} in aDSO ${adsoUpper} updated. Call bw_activate to activate.`,
524
+ lock_handle: lockHandle,
525
+ adso_name: adsoUpper,
526
+ object_type: 'adso',
527
+ field_name: nameUpper,
528
+ field_type: isInfoObject ? 'infoobject' : 'pure',
529
+ applied: properties,
530
+ });
531
+ }
532
+ // Keyfigure types: LocalKeyfigureProperties, aggregationBehavior, <semantics> tag
533
+ const KEYFIGURE_TYPES = new Set([
534
+ 'CURR', 'QUAN', 'DEC', 'D16D', 'D34D', 'FLTP',
535
+ 'INT1', 'INT2', 'INT4', 'INT8',
536
+ ]);
537
+ // Fixed-length types: [length, precision, scale] — user input ignored
538
+ // precision=0 is omitted from XML; scale=0 is always omitted
539
+ const FIXED_LENGTH_TYPES = {
540
+ 'INT1': [3, 0, 0],
541
+ 'INT2': [5, 0, 0],
542
+ 'INT4': [10, 0, 0],
543
+ 'INT8': [19, 0, 0],
544
+ 'FLTP': [16, 16, 0],
545
+ 'DATS': [8, 0, 0],
546
+ 'TIMS': [6, 0, 0],
547
+ 'LANG': [1, 0, 0],
548
+ 'CUKY': [5, 0, 0],
549
+ 'UNIT': [3, 0, 0],
550
+ 'D16R': [16, 0, 0],
551
+ 'D16N': [16, 0, 0],
552
+ 'D34N': [34, 0, 0],
553
+ };
554
+ // User-facing type names mapped to internal API names
555
+ const TYPE_NAME_MAP = {
556
+ 'STRING': 'STRG',
557
+ 'RAWSTRING': 'RSTR',
558
+ 'SSTRING': 'SSTR',
559
+ 'DF16_RAW': 'D16R',
560
+ 'DF34_RAW': 'D34R',
561
+ 'DF16_DEC': 'D16D',
562
+ 'DF34_DEC': 'D34D',
563
+ };
564
+ // <semantics> tag value per type (omitted if not in this map)
565
+ const SEMANTICS_TAG = {
566
+ 'INT1': 'INT', 'INT2': 'INT', 'INT4': 'INT', 'INT8': 'INT',
567
+ 'FLTP': 'NUM', 'DEC': 'NUM',
568
+ 'CURR': 'AMO', 'QUAN': 'QUA',
569
+ };
570
+ // semanticType attribute for <inlineType>
571
+ const SEMANTIC_TYPE = {
572
+ 'DATS': 'date',
573
+ 'CUKY': 'currencyCode',
574
+ 'CURR': 'amount',
575
+ 'QUAN': 'quantity',
576
+ };
577
+ function buildPureFieldElement(field) {
578
+ const name = field.name.trim().toUpperCase();
579
+ const apiType = TYPE_NAME_MAP[field.dataType] ?? field.dataType;
580
+ const isKeyfigure = KEYFIGURE_TYPES.has(apiType);
581
+ const fixedDims = FIXED_LENGTH_TYPES[apiType];
582
+ // Build <inlineType> attributes
583
+ const inlineAttrs = [`name="${apiType}"`];
584
+ if (fixedDims) {
585
+ // Fixed-length types: always use lookup values, ignore user input
586
+ inlineAttrs.push(`length="${fixedDims[0]}"`);
587
+ if (fixedDims[1] !== 0)
588
+ inlineAttrs.push(`precision="${fixedDims[1]}"`);
589
+ // scale omitted (always 0)
590
+ }
591
+ else if (apiType === 'CURR' || apiType === 'QUAN') {
592
+ // CURR/QUAN: length always 0; precision in XML = decimal places (scale preferred, fallback to precision)
593
+ inlineAttrs.push('length="0"');
594
+ const decimalPlaces = field.scale ?? field.precision;
595
+ if (decimalPlaces !== undefined)
596
+ inlineAttrs.push(`precision="${decimalPlaces}"`);
597
+ }
598
+ else if (apiType === 'DEC') {
599
+ // DEC: XML length = total digits (precision param), XML precision = decimal places (scale param)
600
+ const totalDigits = field.precision ?? field.length;
601
+ if (totalDigits !== undefined)
602
+ inlineAttrs.push(`length="${totalDigits}"`);
603
+ if (field.scale !== undefined)
604
+ inlineAttrs.push(`precision="${field.scale}"`);
605
+ }
606
+ else if (apiType === 'D16D' || apiType === 'D34D') {
607
+ // Decimal float: length=0, user-defined precision
608
+ inlineAttrs.push('length="0"');
609
+ if (field.precision !== undefined)
610
+ inlineAttrs.push(`precision="${field.precision}"`);
611
+ }
612
+ else if (apiType === 'RSTR' || apiType === 'STRG') {
613
+ // No length attribute for RSTR/STRG
614
+ }
615
+ else {
616
+ // User-defined: CHAR, NUMC, SSTR, RAW etc.
617
+ if (field.length !== undefined)
618
+ inlineAttrs.push(`length="${field.length}"`);
619
+ if (field.precision !== undefined)
620
+ inlineAttrs.push(`precision="${field.precision}"`);
621
+ }
622
+ const semanticType = SEMANTIC_TYPE[apiType] ?? 'empty';
623
+ inlineAttrs.push(`semanticType="${semanticType}"`);
624
+ // aggregationBehavior — LOB types (STRG, RSTR) never get this attribute
625
+ const isLob = apiType === 'STRG' || apiType === 'RSTR';
626
+ const aggr = isLob ? undefined : (field.aggregationBehavior ?? (isKeyfigure ? 'SUM' : undefined));
627
+ const aggrAttr = aggr !== undefined ? ` aggregationBehavior="${aggr}"` : '';
628
+ // conversionRoutine (LANG only)
629
+ const convAttr = apiType === 'LANG' ? ' conversionRoutine="ISOLA"' : '';
630
+ const localPropsType = isKeyfigure
631
+ ? 'BwCore:LocalKeyfigureProperties'
632
+ : 'BwCore:LocalCharacteristicProperties';
633
+ const semanticsTag = SEMANTICS_TAG[apiType];
634
+ const semanticsLine = semanticsTag ? `\n <semantics>${semanticsTag}</semantics>` : '';
635
+ return (` <element xsi:type="adso:AdsoElement" name="${name}"${aggrAttr}${convAttr}\n` +
636
+ ` dimension="#///GROUP1§" sidDeterminationMode="N">\n` +
637
+ ` <inlineType ${inlineAttrs.join(' ')}/>\n` +
638
+ ` <localProperties xsi:type="${localPropsType}">\n` +
639
+ ` <descriptions label="${field.label}"/>\n` +
640
+ ` </localProperties>${semanticsLine}\n` +
641
+ ` </element>`);
642
+ }
643
+ /**
644
+ * bw_create_adso — create a new aDSO shell.
645
+ *
646
+ * action "from_template": proposes fields/keys/settings from a template object (pass templateName).
647
+ * templateType "ADSO" (default): copies from an existing aDSO.
648
+ * templateType "RSDS": proposes fields from a DataSource — sourceSystem is then required.
649
+ * This is a pure template mechanism over the same create endpoint; the only difference is the
650
+ * <template> element, which carries tlogo="RSDS" and the RSDS compound objectName.
651
+ * action "empty": creates a minimal empty aDSO with the given adsoType preset.
652
+ *
653
+ * Workflow: Lock (CREA) → POST minimal XML → Unlock
654
+ * After creation the aDSO is inactive — call bw_activate to activate it.
655
+ */
656
+ export async function bwCreateAdso(client, adsoName, label, infoArea, action = 'from_template', templateName, templateType = 'ADSO', sourceSystem, adsoType = 'standard', pkg = '$TMP', writeInterface = false) {
657
+ const nameUpper = adsoName.toUpperCase();
658
+ const infoAreaUpper = infoArea.toUpperCase();
659
+ const language = process.env.BW_LANGUAGE ?? 'DE';
660
+ // RSDS template requires the source system to build the compound objectName.
661
+ // Validate before locking so we fail fast without leaving a dangling lock.
662
+ if (action === 'from_template' && templateType === 'RSDS' && templateName && !sourceSystem) {
663
+ throw new Error('template_type "RSDS" requires source_system (the DataSource source system name) ' +
664
+ 'to build the RSDS compound objectName.');
665
+ }
666
+ const lockHandle = await client.lock('adso', adsoName, {
667
+ 'activity_context': 'CREA',
668
+ 'parent_name': infoAreaUpper,
669
+ 'parent_type': 'AREA',
670
+ });
671
+ let body;
672
+ const pushModeAttr = writeInterface ? ' pushMode="true"' : '';
673
+ if (action === 'empty') {
674
+ const preset = typePresets[adsoType] ?? typePresets['standard'];
675
+ const typeAttrStr = Object.entries(preset).map(([k, v]) => `${k}="${v}"`).join(' ');
676
+ body =
677
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
678
+ `<adso:dataStore xmlns:adso="http://www.sap.com/bw/modeling/adso.ecore"` +
679
+ ` xmlns:adtcore="http://www.sap.com/adt/core"` +
680
+ ` schemaVersion="1.0" name="${nameUpper}" readOnly="false" ${typeAttrStr}${pushModeAttr}>\n` +
681
+ ` <endUserTexts label="${label}"/>\n` +
682
+ ` <tlogoProperties adtcore:language="${language}" adtcore:name="${nameUpper}"` +
683
+ ` adtcore:type="ADSO" adtcore:masterLanguage="${language}">\n` +
684
+ ` <infoArea>${infoAreaUpper}</infoArea>\n` +
685
+ ` </tlogoProperties>\n` +
686
+ ` <dimension name="GROUP1">\n` +
687
+ ` <descriptions/>\n` +
688
+ ` </dimension>\n` +
689
+ `</adso:dataStore>`;
690
+ }
691
+ else {
692
+ let templateElement = '';
693
+ if (templateName) {
694
+ if (templateType === 'RSDS') {
695
+ // RSDS compound key: DataSource name + Source System name, each left-justified
696
+ // in a fixed 30-char field (total length 60).
697
+ const compoundKey = templateName.toUpperCase().padEnd(30, ' ') +
698
+ (sourceSystem ?? '').toUpperCase().padEnd(30, ' ');
699
+ templateElement = `\n <template objectName="${compoundKey}" tlogo="RSDS"/>`;
700
+ }
701
+ else {
702
+ templateElement = `\n <template objectName="${templateName.toUpperCase()}" tlogo="ADSO"/>`;
703
+ }
704
+ }
705
+ body =
706
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
707
+ `<adso:dataStore xmlns:adso="http://www.sap.com/bw/modeling/adso.ecore"` +
708
+ ` xmlns:adtcore="http://www.sap.com/adt/core"` +
709
+ ` schemaVersion="1.0" name="${nameUpper}" readOnly="false"` +
710
+ ` activateData="true" writeChangelog="true"${pushModeAttr}>\n` +
711
+ ` <endUserTexts label="${label}"/>\n` +
712
+ ` <tlogoProperties adtcore:language="${language}" adtcore:name="${nameUpper}"` +
713
+ ` adtcore:type="ADSO" adtcore:masterLanguage="${language}">\n` +
714
+ ` <infoArea>${infoAreaUpper}</infoArea>\n` +
715
+ ` </tlogoProperties>\n` +
716
+ ` <dimension name="GROUP1">\n` +
717
+ ` <descriptions/>\n` +
718
+ ` </dimension>${templateElement}\n` +
719
+ `</adso:dataStore>`;
720
+ }
721
+ try {
722
+ await client.create('adso', adsoName, lockHandle, body, {
723
+ 'Development-Class': pkg,
724
+ });
725
+ }
726
+ catch (err) {
727
+ await client.unlock('adso', adsoName).catch(() => { });
728
+ throw err;
729
+ }
730
+ await client.unlock('adso', adsoName);
731
+ const fromTemplate = templateName ? ` from template ${templateName.toUpperCase()}` : '';
732
+ return JSON.stringify({
733
+ success: true,
734
+ message: `aDSO ${nameUpper} created${fromTemplate} in package ${pkg}. Call bw_activate to activate.`,
735
+ adso_name: nameUpper,
736
+ object_type: 'adso',
737
+ });
738
+ }
739
+ /**
740
+ * bw_update_adso action "add_pure_field" — add one or more pure (non-InfoObject) fields.
741
+ *
742
+ * Reuses buildPureFieldElement(). Supports isKey to also inject <keyElement> entries.
743
+ * Workflow: GET full XML → inject elements + keyElements → Lock → PUT
744
+ * Returns lockHandle so the caller can invoke bw_activate next.
745
+ */
746
+ export async function bwUpdateAdsoAddPureField(client, adsoName, fields, transport) {
747
+ const adsoUpper = adsoName.toUpperCase();
748
+ const adsoPath = `/sap/bw/modeling/adso/${adsoName.toLowerCase()}/m`;
749
+ const adsoResult = await client.get(adsoPath, ADSO_ACCEPT);
750
+ const timestamp = adsoResult.headers['timestamp'] ?? adsoResult.headers['TIMESTAMP'];
751
+ let xml = adsoResult.body;
752
+ const elementBlocks = [];
753
+ const keyElements = [];
754
+ const processed = [];
755
+ const skipped = [];
756
+ for (const field of fields) {
757
+ const name = field.name.trim().toUpperCase();
758
+ if (xml.includes(`name="${name}"`)) {
759
+ skipped.push(name);
760
+ continue;
761
+ }
762
+ const apiType = TYPE_NAME_MAP[field.dataType] ?? field.dataType;
763
+ if (apiType === 'CURR' || apiType === 'QUAN') {
764
+ const decimalPlaces = field.scale ?? field.precision;
765
+ if (decimalPlaces === undefined || decimalPlaces <= 0) {
766
+ throw new Error(`Field ${name}: data type ${field.dataType} requires scale > 0 (decimal places). ` +
767
+ `Pass e.g. scale: 2 for currency or scale: 3 for quantity.`);
768
+ }
769
+ }
770
+ elementBlocks.push(buildPureFieldElement(field));
771
+ if (field.isKey) {
772
+ keyElements.push(` <keyElement>#///${name}</keyElement>`);
773
+ }
774
+ processed.push(name);
775
+ }
776
+ if (processed.length === 0) {
777
+ return JSON.stringify({
778
+ success: false,
779
+ message: `All fields already present in aDSO ${adsoUpper}. No changes made.`,
780
+ skipped,
781
+ });
782
+ }
783
+ const insertBlock = [...elementBlocks, ...keyElements].join('\n') + '\n';
784
+ if (xml.includes('</tlogoProperties>')) {
785
+ xml = xml.replace('</tlogoProperties>', '</tlogoProperties>\n' + insertBlock);
786
+ }
787
+ else {
788
+ xml = xml.replace('</adso:dataStore>', insertBlock + '</adso:dataStore>');
789
+ }
790
+ const lockHandle = await client.lock('adso', adsoName);
791
+ try {
792
+ await client.put('adso', adsoName, lockHandle, xml, timestamp, transport);
793
+ }
794
+ catch (err) {
795
+ await client.unlock('adso', adsoName).catch(() => { });
796
+ throw err;
797
+ }
798
+ const result = {
799
+ success: true,
800
+ message: `${processed.length} pure field(s) added to aDSO ${adsoUpper}. Call bw_activate to activate.`,
801
+ lock_handle: lockHandle,
802
+ adso_name: adsoUpper,
803
+ object_type: 'adso',
804
+ processed,
805
+ };
806
+ if (skipped.length > 0)
807
+ result['skipped'] = skipped;
808
+ return JSON.stringify(result);
809
+ }
810
+ /**
811
+ * bw_update_adso — add or remove one or more InfoObject fields in an aDSO.
812
+ *
813
+ * infoObjectName may be a single name or a comma-separated list (e.g. "IOBJ_A,IOBJ_B").
814
+ * All fields are applied in one GET → mutate → PUT cycle; Lock/Unlock happen once.
815
+ *
816
+ * action "add_field" (default): reads each InfoObject, injects all elements, then PUT.
817
+ * action "remove_field": removes all matching elements (+ keyElements), then PUT.
818
+ *
819
+ * Returns the lockHandle so the caller can invoke bw_activate next.
820
+ * NOTE: activation (and unlock) is done separately via bw_activate.
821
+ */
822
+ export async function bwUpdateAdso(client, adsoName, infoObjectName, action = 'add_field', transport) {
823
+ const names = infoObjectName
824
+ .split(',')
825
+ .map((n) => n.trim().toUpperCase())
826
+ .filter(Boolean);
827
+ const adsoUpper = adsoName.toUpperCase();
828
+ // Read current aDSO once (full XML + timestamp)
829
+ const adsoPath = `/sap/bw/modeling/adso/${adsoName.toLowerCase()}/m`;
830
+ const adsoResult = await client.get(adsoPath, ADSO_ACCEPT);
831
+ const timestamp = adsoResult.headers['timestamp'] ?? adsoResult.headers['TIMESTAMP'];
832
+ let updatedXml = adsoResult.body;
833
+ const processed = [];
834
+ const skipped = [];
835
+ if (action === 'remove_field') {
836
+ for (const name of names) {
837
+ if (!updatedXml.includes(`name="${name}"`)) {
838
+ skipped.push(name);
839
+ continue;
840
+ }
841
+ updatedXml = removeElement(updatedXml, name);
842
+ processed.push(name);
843
+ }
844
+ if (processed.length === 0) {
845
+ return JSON.stringify({
846
+ success: false,
847
+ message: `None of the fields (${names.join(', ')}) found in aDSO ${adsoUpper}. No changes made.`,
848
+ });
849
+ }
850
+ }
851
+ else {
852
+ // add_field — read each InfoObject and inject
853
+ for (const name of names) {
854
+ if (updatedXml.includes(`infoObjectName="${name}"`)) {
855
+ skipped.push(name);
856
+ continue;
857
+ }
858
+ const iObjResult = await client.get(`/sap/bw/modeling/iobj/${name.toLowerCase()}/m`, MEDIA_TYPES['iobj']);
859
+ const iObjProps = parseInfoObjectProps(iObjResult.body);
860
+ updatedXml = injectElement(updatedXml, buildAdsoElement(name, iObjProps));
861
+ processed.push(name);
862
+ }
863
+ if (processed.length === 0) {
864
+ return JSON.stringify({
865
+ success: false,
866
+ message: `All fields (${names.join(', ')}) are already present in aDSO ${adsoUpper}. No changes made.`,
867
+ });
868
+ }
869
+ }
870
+ // Add snapShotScenario attribute if missing (BWMT includes it in PUT requests)
871
+ if (!updatedXml.includes('snapShotScenario')) {
872
+ updatedXml = updatedXml.replace('nextRemodelingVersion="1"', 'nextRemodelingVersion="1" snapShotScenario="false"');
873
+ }
874
+ // Lock once → PUT → unlock on failure
875
+ const lockHandle = await client.lock('adso', adsoName);
876
+ try {
877
+ await client.put('adso', adsoName, lockHandle, updatedXml, timestamp, transport);
878
+ }
879
+ catch (err) {
880
+ await client.unlock('adso', adsoName).catch(() => { });
881
+ throw err;
882
+ }
883
+ const verb = action === 'remove_field' ? 'removed from' : 'added to';
884
+ const result = {
885
+ success: true,
886
+ message: `${processed.join(', ')} ${verb} aDSO ${adsoUpper}. Call bw_activate to activate.`,
887
+ lock_handle: lockHandle,
888
+ adso_name: adsoUpper,
889
+ object_type: 'adso',
890
+ processed,
891
+ };
892
+ if (skipped.length > 0)
893
+ result['skipped'] = skipped;
894
+ return JSON.stringify(result);
895
+ }