@gefyra/diffyr6-cli 1.0.3 → 1.1.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.
@@ -44,6 +44,20 @@
44
44
  ],
45
45
  "template": "For element {{Name}}, the cardinality changed in R6 and it had an MS in R4: {{Comments}}"
46
46
  },
47
+ {
48
+ "name": "Element type changed in R6",
49
+ "description": "The data type of an element has changed between R4 and R6. This may require data transformation during migration.",
50
+ "rank": 15,
51
+ "value": 10,
52
+ "conditions": [
53
+ {
54
+ "column": "L Type",
55
+ "operator": "!equals",
56
+ "valueColumn": "R Type"
57
+ }
58
+ ],
59
+ "template": "The element type switched in R6 at element {{Name}}"
60
+ },
47
61
  {
48
62
  "name": "Element removed in R6",
49
63
  "description": "An element from R4 no longer exists in R6. Data in this element may need to be migrated to another element or discarded.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gefyra/diffyr6-cli",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "FHIR R4 to R6 migration pipeline runner with automated profile comparison and rule-based analysis",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -8,11 +8,12 @@ import { directoryExists, fileExists } from './utils/fs.js';
8
8
  * Compares terminology bindings between R4 and R6 profiles
9
9
  *
10
10
  * Steps:
11
- * 1. Run sushi -s in Resources and ResourcesR6 directories to generate snapshots
12
- * 2. Find all profile pairs from the comparison run
13
- * 3. Compare element[].binding.strength and valueSet between R4 and R6
14
- * 4. If valueSet has a version (pipe notation), compare the actual ValueSet content from local package cache
15
- * 5. Generate a markdown report with all findings
11
+ * 1. Find all profile pairs from the comparison run
12
+ * 2. Compare element[].binding.strength and valueSet between R4 and R6
13
+ * 3. If valueSet has a version (pipe notation), compare the actual ValueSet content from local package cache
14
+ * 4. Generate a markdown report with all findings
15
+ *
16
+ * Note: Snapshots must already exist (built by runSnapshotBuild in index.js before calling this function)
16
17
  *
17
18
  * @param {string} resourcesDir - R4 resources directory
18
19
  * @param {string} resourcesR6Dir - R6 resources directory
@@ -23,38 +24,9 @@ import { directoryExists, fileExists } from './utils/fs.js';
23
24
  export async function compareTerminology(resourcesDir, resourcesR6Dir, outputDir, options = {}) {
24
25
  const { debug = false } = options;
25
26
 
26
- console.log(' Checking for existing snapshots...');
27
-
28
- // Step 1: Check if snapshots already exist, if not run sushi -s
29
- const resourcesDirHasSnapshots = await hasSnapshots(resourcesDir);
30
- const resourcesR6DirHasSnapshots = await hasSnapshots(resourcesR6Dir);
31
-
32
- if (resourcesDirHasSnapshots && resourcesR6DirHasSnapshots) {
33
- console.log(' Snapshots already exist in both directories, skipping SUSHI build');
34
- } else {
35
- console.log(' Building snapshots with SUSHI...');
36
-
37
- try {
38
- if (!resourcesDirHasSnapshots) {
39
- await runSushiWithSnapshots(resourcesDir, debug);
40
- } else {
41
- console.log(` Skipping ${path.basename(resourcesDir)} (snapshots already exist)`);
42
- }
43
-
44
- if (!resourcesR6DirHasSnapshots) {
45
- await runSushiWithSnapshots(resourcesR6Dir, debug);
46
- } else {
47
- console.log(` Skipping ${path.basename(resourcesR6Dir)} (snapshots already exist)`);
48
- }
49
- } catch (error) {
50
- throw new Error(`Failed to build snapshots: ${error.message}`);
51
- }
52
-
53
- console.log(' Snapshots built successfully');
54
- }
55
27
  console.log(' Analyzing binding differences...');
56
28
 
57
- // Step 2-3: Collect profile pairs and compare bindings
29
+ // Collect profile pairs and compare bindings
58
30
  const r4Profiles = await collectStructureDefinitions(resourcesDir);
59
31
  const r6Profiles = await collectStructureDefinitions(resourcesR6Dir);
60
32
 
@@ -81,13 +53,13 @@ export async function compareTerminology(resourcesDir, resourcesR6Dir, outputDir
81
53
 
82
54
  console.log(` Found ${findings.length} profile(s) with binding differences`);
83
55
 
84
- // Step 4: Identify common bindings across all profiles
56
+ // Identify common bindings across all profiles
85
57
  const commonBindings = identifyCommonBindings(findings);
86
58
 
87
59
  // Remove common bindings from individual profiles
88
60
  const filteredFindings = removeCommonBindingsFromProfiles(findings, commonBindings);
89
61
 
90
- // Step 5: Generate reports
62
+ // Generate reports
91
63
  const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
92
64
  const reportFilename = `terminology-report-${timestamp}.md`;
93
65
  const reportPath = path.join(outputDir, reportFilename);
@@ -123,7 +95,7 @@ export async function compareTerminology(resourcesDir, resourcesR6Dir, outputDir
123
95
  /**
124
96
  * Check if snapshots already exist in the StructureDefinition files
125
97
  */
126
- async function hasSnapshots(dir) {
98
+ export async function hasSnapshots(dir) {
127
99
  const resourcesPath = path.join(dir, 'fsh-generated', 'resources');
128
100
  const exists = await directoryExists(resourcesPath);
129
101
 
@@ -160,7 +132,7 @@ async function hasSnapshots(dir) {
160
132
  /**
161
133
  * Run sushi with snapshots flag in a directory
162
134
  */
163
- async function runSushiWithSnapshots(dir, debug = false) {
135
+ export async function runSushiWithSnapshots(dir, debug = false) {
164
136
  const sushiConfigPath = path.join(dir, 'sushi-config.yaml');
165
137
  const exists = await fileExists(sushiConfigPath);
166
138
 
package/src/config.js CHANGED
@@ -6,7 +6,7 @@ import { pathExists } from './utils/fs.js';
6
6
  const __filename = fileURLToPath(import.meta.url);
7
7
  const __dirname = path.dirname(__filename);
8
8
 
9
- export const CONFIG_VERSION = '1.0.1';
9
+ export const CONFIG_VERSION = '1.0.2';
10
10
 
11
11
  /**
12
12
  * Default configuration values
@@ -25,6 +25,7 @@ export const DEFAULT_CONFIG = {
25
25
  workdir: null,
26
26
  compareMode: 'incremental',
27
27
  exportZip: true,
28
+ skipTerminologyReport: false,
28
29
  };
29
30
 
30
31
  /**
@@ -32,7 +33,17 @@ export const DEFAULT_CONFIG = {
32
33
  */
33
34
  export async function loadConfig(configPath) {
34
35
  const raw = await fsp.readFile(configPath, 'utf8');
35
- const config = JSON.parse(raw);
36
+ let config = JSON.parse(raw);
37
+ const originalVersion = config.configVersion;
38
+
39
+ // Migrate config if needed
40
+ config = migrateConfig(config);
41
+
42
+ // Write back to file if migration occurred
43
+ if (config.configVersion !== originalVersion) {
44
+ await fsp.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
45
+ console.log(` Config file updated to version ${config.configVersion}`);
46
+ }
36
47
 
37
48
  // Validate config version
38
49
  validateConfigVersion(config);
@@ -46,6 +57,34 @@ export async function loadConfig(configPath) {
46
57
  return merged;
47
58
  }
48
59
 
60
+ /**
61
+ * Migrates configuration from older versions to the current version
62
+ */
63
+ function migrateConfig(config) {
64
+ if (!config.configVersion) {
65
+ // Very old config without version - add all new fields
66
+ console.log(' Migrating config from pre-1.0.0 to current version...');
67
+ config.configVersion = CONFIG_VERSION;
68
+ if (config.skipTerminologyReport === undefined) {
69
+ config.skipTerminologyReport = false;
70
+ }
71
+ return config;
72
+ }
73
+
74
+ const [major, minor, patch] = config.configVersion.split('.').map(Number);
75
+
76
+ // Migrate from 1.0.0 or 1.0.1 to 1.0.2
77
+ if (major === 1 && minor === 0 && (patch === 0 || patch === 1)) {
78
+ console.log(` Migrating config from ${config.configVersion} to ${CONFIG_VERSION}...`);
79
+ if (config.skipTerminologyReport === undefined) {
80
+ config.skipTerminologyReport = false;
81
+ }
82
+ config.configVersion = CONFIG_VERSION;
83
+ }
84
+
85
+ return config;
86
+ }
87
+
49
88
  /**
50
89
  * Validates the configuration version
51
90
  */
@@ -99,6 +138,10 @@ function validateConfig(config) {
99
138
  if (typeof config.exportZip !== 'boolean') {
100
139
  errors.push('exportZip must be a boolean');
101
140
  }
141
+
142
+ if (typeof config.skipTerminologyReport !== 'boolean') {
143
+ errors.push('skipTerminologyReport must be a boolean');
144
+ }
102
145
 
103
146
  if (errors.length > 0) {
104
147
  throw new Error(`Invalid configuration:\n${errors.map(e => ` - ${e}`).join('\n')}`);
@@ -122,7 +165,8 @@ export async function createExampleConfig(outputPath) {
122
165
  validatorJarPath: null,
123
166
  workdir: null,
124
167
  compareMode: 'incremental',
125
- exportZip: true
168
+ exportZip: true,
169
+ skipTerminologyReport: false
126
170
  };
127
171
 
128
172
  await fsp.writeFile(
package/src/index.js CHANGED
@@ -7,14 +7,18 @@ import { spawnProcess } from './utils/process.js';
7
7
  import { generateFshFromPackage } from './generate-fsh.js';
8
8
  import { upgradeSushiToR6 } from './upgrade-sushi.js';
9
9
  import { compareProfiles } from './compare-profiles.js';
10
- import { compareTerminology } from './compare-terminology.js';
10
+ import { compareTerminology, hasSnapshots, runSushiWithSnapshots } from './compare-terminology.js';
11
11
  import { findRemovedResources } from './utils/removed-resources.js';
12
12
  import { createZip } from './utils/zip.js';
13
+ import { checkForUpdates } from './utils/update-check.js';
13
14
 
14
15
  /**
15
16
  * Main entry point - runs the FHIR R4 to R6 migration pipeline
16
17
  */
17
18
  export async function runMigration(config) {
19
+ // Check for updates
20
+ await checkForUpdates();
21
+
18
22
  // Resolve paths
19
23
  const workdir = config.workdir ? path.resolve(config.workdir) : process.cwd();
20
24
  const resourcesDir = path.resolve(workdir, config.resourcesDir);
@@ -39,47 +43,56 @@ export async function runMigration(config) {
39
43
  if (config.enableGoFSH) {
40
44
  const shouldRunGoFSH = await checkShouldRunGoFSH(resourcesDir);
41
45
  if (shouldRunGoFSH) {
42
- console.log('\n[1/5] Downloading package and generating FSH...');
46
+ console.log('\n[1/6] Downloading package and generating FSH...');
43
47
  await runGoFSH(context);
44
48
  context.steps.push('gofsh');
45
49
  } else {
46
- console.log('\n[1/5] GoFSH - SKIPPED (Resources directory with sushi-config.yaml already exists)');
50
+ console.log('\n[1/6] GoFSH - SKIPPED (Resources directory with sushi-config.yaml already exists)');
47
51
  }
48
52
  } else {
49
- console.log('\n[1/5] GoFSH - DISABLED in config');
53
+ console.log('\n[1/6] GoFSH - DISABLED in config');
50
54
  }
51
55
 
52
56
  // Step 2: Upgrade to R6
53
57
  const shouldRunUpgrade = await checkShouldRunUpgrade(resourcesR6Dir);
54
58
  if (shouldRunUpgrade) {
55
- console.log('\n[2/5] Upgrading to R6...');
59
+ console.log('\n[2/6] Upgrading to R6...');
56
60
  await runUpgradeToR6(context);
57
61
  context.steps.push('upgrade');
58
62
  } else {
59
- console.log('\n[2/5] Upgrade - SKIPPED (ResourcesR6 directory with sushi-config.yaml already exists)');
63
+ console.log('\n[2/6] Upgrade - SKIPPED (ResourcesR6 directory with sushi-config.yaml already exists)');
60
64
  }
61
65
 
62
- // Step 3: Compare profiles
63
- console.log('\n[3/5] Comparing R4 vs R6 profiles...');
66
+ // Step 3: Build snapshots for both R4 and R6
67
+ console.log('\n[3/6] Building snapshots with SUSHI...');
68
+ await runSnapshotBuild(context);
69
+ context.steps.push('snapshots');
70
+
71
+ // Step 4: Compare profiles
72
+ console.log('\n[4/6] Comparing R4 vs R6 profiles...');
64
73
  const compareResults = await runProfileComparison(context);
65
74
  context.steps.push('compare');
66
75
 
67
- // Step 4: Generat5] Generating migration report...');
76
+ // Step 5: Generat6] Generating migration report...');
68
77
  const removedResources = await findRemovedResources(resourcesDir);
69
78
  const report = await generateReport(context, compareResults, removedResources);
70
79
  context.steps.push('report');
71
80
 
72
- // Step 5: Compare terminology bindings
73
- console.log('\n[5/5] Comparing terminology bindings...');
81
+ // Step 6: Compare terminology bindings
74
82
  let terminologyReport = null;
75
- try {
76
- terminologyReport = await runTerminologyComparison(context);
77
- if (terminologyReport) {
78
- context.steps.push('terminology');
83
+ if (config.skipTerminologyReport) {
84
+ console.log('\n[6/6] Terminology comparison - SKIPPED (skipTerminologyReport is enabled)');
85
+ } else {
86
+ console.log('\n[6/6] Comparing terminology bindings...');
87
+ try {
88
+ terminologyReport = await runTerminologyComparison(context);
89
+ if (terminologyReport) {
90
+ context.steps.push('terminology');
91
+ }
92
+ } catch (error) {
93
+ console.warn(` Terminology comparison failed: ${error.message}`);
94
+ console.warn(' Continuing without terminology report...');
79
95
  }
80
- } catch (error) {
81
- console.warn(` Terminology comparison failed: ${error.message}`);
82
- console.warn(' Continuing without terminology report...');
83
96
  }
84
97
 
85
98
  let exportZipPath = null;
@@ -144,6 +157,44 @@ async function runUpgradeToR6(context) {
144
157
  await upgradeSushiToR6(resourcesDir, sushiExecutable);
145
158
  }
146
159
 
160
+ /**
161
+ * Build snapshots for both R4 and R6 projects with sushi -s
162
+ */
163
+ async function runSnapshotBuild(context) {
164
+ const { resourcesDir, resourcesR6Dir, config } = context;
165
+ const debug = config.debug || false;
166
+
167
+ console.log(' Checking for existing snapshots...');
168
+
169
+ const resourcesDirHasSnapshots = await hasSnapshots(resourcesDir);
170
+ const resourcesR6DirHasSnapshots = await hasSnapshots(resourcesR6Dir);
171
+
172
+ if (resourcesDirHasSnapshots && resourcesR6DirHasSnapshots) {
173
+ console.log(' Snapshots already exist in both directories, skipping SUSHI build');
174
+ return;
175
+ }
176
+
177
+ console.log(' Building snapshots with SUSHI...');
178
+
179
+ try {
180
+ if (!resourcesDirHasSnapshots) {
181
+ await runSushiWithSnapshots(resourcesDir, debug);
182
+ } else {
183
+ console.log(` Skipping ${path.basename(resourcesDir)} (snapshots already exist)`);
184
+ }
185
+
186
+ if (!resourcesR6DirHasSnapshots) {
187
+ await runSushiWithSnapshots(resourcesR6Dir, debug);
188
+ } else {
189
+ console.log(` Skipping ${path.basename(resourcesR6Dir)} (snapshots already exist)`);
190
+ }
191
+
192
+ console.log(' Snapshots built successfully');
193
+ } catch (error) {
194
+ throw new Error(`Failed to build snapshots: ${error.message}`);
195
+ }
196
+ }
197
+
147
198
  /**
148
199
  * Run profile comparison
149
200
  */
@@ -7,7 +7,7 @@ import { parseSushiLog } from './utils/sushi-log.js';
7
7
 
8
8
  const SOURCE_VERSION = '4.0.1';
9
9
  const TARGET_VERSION = '6.0.0-ballot3';
10
- const MAX_ITERATIONS = 25;
10
+ const MAX_ITERATIONS = 10;
11
11
  const SNOMED_CT_ERROR_TEXT = 'Resolved value "SNOMED_CT" is not a valid URI';
12
12
 
13
13
  /**
@@ -248,7 +248,11 @@ async function runSushiUntilSuccess(targetDir, sushiExecutable) {
248
248
  }
249
249
  console.log(` Commented out lines in ${modifications} file(s)`);
250
250
  }
251
- throw new Error(`SUSHI failed after ${MAX_ITERATIONS} attempts`);
251
+ throw new Error(
252
+ `SUSHI failed after ${MAX_ITERATIONS} iterations. ` +
253
+ `Please fix the remaining SUSHI errors manually in the ResourcesR6 directory ` +
254
+ `and then run 'sushi -s' to build the snapshots.`
255
+ );
252
256
  }
253
257
 
254
258
  async function runSushi(executable, targetDir) {
@@ -371,6 +375,69 @@ function expandContainsBlockLines(lines, lineNumbers) {
371
375
 
372
376
  const currentLine = lines[idx].trim();
373
377
 
378
+ // Handle multi-line string literals (e.g., ^comment, ^description, etc.)
379
+ // Check if line contains = followed by a quote that's not closed
380
+ const hasStringAssignment = currentLine.match(/=\s*(""")?(")?/);
381
+ if (hasStringAssignment) {
382
+ const tripleQuote = hasStringAssignment[1]; // """
383
+ const singleQuote = hasStringAssignment[2]; // "
384
+
385
+ if (tripleQuote) {
386
+ // Triple-quoted string - find closing """
387
+ const firstTriplePos = currentLine.indexOf('"""');
388
+ const secondTriplePos = currentLine.indexOf('"""', firstTriplePos + 3);
389
+
390
+ if (secondTriplePos === -1) {
391
+ // No closing """ on this line - include following lines
392
+ let nextIdx = idx + 1;
393
+ while (nextIdx < lines.length) {
394
+ expanded.add(nextIdx + 1);
395
+ if (lines[nextIdx].includes('"""')) {
396
+ break;
397
+ }
398
+ nextIdx++;
399
+ // Safety limit to prevent infinite loops
400
+ if (nextIdx - idx > 100) break;
401
+ }
402
+ }
403
+ } else if (singleQuote) {
404
+ // Single-quoted string - check if it's closed on the same line
405
+ const firstQuotePos = currentLine.indexOf('"');
406
+ let secondQuotePos = -1;
407
+
408
+ // Find closing quote that's not escaped
409
+ for (let i = firstQuotePos + 1; i < currentLine.length; i++) {
410
+ if (currentLine[i] === '"' && currentLine[i - 1] !== '\\') {
411
+ secondQuotePos = i;
412
+ break;
413
+ }
414
+ }
415
+
416
+ if (secondQuotePos === -1) {
417
+ // No closing " on this line - include following lines
418
+ let nextIdx = idx + 1;
419
+ while (nextIdx < lines.length) {
420
+ expanded.add(nextIdx + 1);
421
+ // Check for unescaped closing quote
422
+ const nextLine = lines[nextIdx];
423
+ let foundClosing = false;
424
+ for (let i = 0; i < nextLine.length; i++) {
425
+ if (nextLine[i] === '"' && (i === 0 || nextLine[i - 1] !== '\\')) {
426
+ foundClosing = true;
427
+ break;
428
+ }
429
+ }
430
+ if (foundClosing) {
431
+ break;
432
+ }
433
+ nextIdx++;
434
+ // Safety limit to prevent infinite loops
435
+ if (nextIdx - idx > 100) break;
436
+ }
437
+ }
438
+ }
439
+ }
440
+
374
441
  if (currentLine.includes(' contains') || currentLine.endsWith(' and')) {
375
442
  let nextIdx = idx + 1;
376
443
  while (nextIdx < lines.length) {
@@ -0,0 +1,128 @@
1
+ import https from 'https';
2
+ import { fileURLToPath } from 'url';
3
+ import { readFileSync } from 'fs';
4
+ import path from 'path';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ /**
10
+ * Gets the current package version from package.json
11
+ */
12
+ function getCurrentVersion() {
13
+ const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
14
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
15
+ return packageJson.version;
16
+ }
17
+
18
+ /**
19
+ * Fetches the latest version from NPM registry
20
+ */
21
+ function getLatestVersion(packageName) {
22
+ return new Promise((resolve, reject) => {
23
+ const options = {
24
+ hostname: 'registry.npmjs.org',
25
+ port: 443,
26
+ path: `/${packageName}`,
27
+ method: 'GET',
28
+ headers: {
29
+ 'Accept': 'application/json',
30
+ },
31
+ timeout: 3000,
32
+ };
33
+
34
+ const req = https.request(options, (res) => {
35
+ let data = '';
36
+
37
+ res.on('data', (chunk) => {
38
+ data += chunk;
39
+ });
40
+
41
+ res.on('end', () => {
42
+ try {
43
+ if (res.statusCode === 200) {
44
+ const json = JSON.parse(data);
45
+ resolve(json['dist-tags']?.latest || null);
46
+ } else {
47
+ resolve(null);
48
+ }
49
+ } catch (error) {
50
+ resolve(null);
51
+ }
52
+ });
53
+ });
54
+
55
+ req.on('error', () => {
56
+ resolve(null);
57
+ });
58
+
59
+ req.on('timeout', () => {
60
+ req.destroy();
61
+ resolve(null);
62
+ });
63
+
64
+ req.end();
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Compares two semantic versions
70
+ * Returns true if newVersion is greater than currentVersion
71
+ */
72
+ function isNewerVersion(currentVersion, newVersion) {
73
+ if (!currentVersion || !newVersion) return false;
74
+
75
+ const current = currentVersion.split('.').map(Number);
76
+ const latest = newVersion.split('.').map(Number);
77
+
78
+ for (let i = 0; i < 3; i++) {
79
+ if (latest[i] > current[i]) return true;
80
+ if (latest[i] < current[i]) return false;
81
+ }
82
+
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * Creates a boxed update notification message
88
+ */
89
+ function createUpdateNotification(currentVersion, latestVersion, packageName) {
90
+ const lines = [
91
+ '',
92
+ '╔════════════════════════════════════════════════════════════════╗',
93
+ '║ ║',
94
+ '║ UPDATE AVAILABLE ║',
95
+ '║ ║',
96
+ `║ Current version: ${currentVersion.padEnd(43)} ║`,
97
+ `║ Latest version: ${latestVersion.padEnd(43)} ║`,
98
+ '║ ║',
99
+ '║ Run one of the following commands to update: ║',
100
+ '║ ║',
101
+ `║ npm update -g ${packageName.padEnd(42)} ║`,
102
+ `║ npm install -g ${packageName}@latest`.padEnd(65) + '║',
103
+ '║ ║',
104
+ '╚════════════════════════════════════════════════════════════════╝',
105
+ '',
106
+ ];
107
+
108
+ return lines.join('\n');
109
+ }
110
+
111
+ /**
112
+ * Checks for updates and displays notification if available
113
+ * This function is non-blocking and will not throw errors
114
+ */
115
+ export async function checkForUpdates() {
116
+ try {
117
+ const packageName = '@gefyra/diffyr6-cli';
118
+ const currentVersion = getCurrentVersion();
119
+ const latestVersion = await getLatestVersion(packageName);
120
+
121
+ if (latestVersion && isNewerVersion(currentVersion, latestVersion)) {
122
+ const notification = createUpdateNotification(currentVersion, latestVersion, packageName);
123
+ console.log(notification);
124
+ }
125
+ } catch (error) {
126
+ // Silently fail - update check should never block the main functionality
127
+ }
128
+ }