sfcc-metadata-cli 0.0.1 → 1.0.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.
- package/.github/workflows/check.yml +80 -0
- package/AGENTS.md +82 -0
- package/LICENSE +661 -0
- package/README.md +249 -0
- package/biome.json +32 -0
- package/commands/create-migration.js +157 -0
- package/commands/custom-object.js +426 -0
- package/commands/site-preference.js +503 -0
- package/commands/system-object.js +572 -0
- package/index.js +34 -0
- package/lib/merge.js +271 -0
- package/lib/templates.js +315 -0
- package/lib/utils.js +188 -0
- package/package.json +24 -15
- package/test/merge.test.js +84 -0
- package/test/templates.test.js +133 -0
- package/test/utils.test.js +79 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Preference Command
|
|
3
|
+
* Creates site preference attribute definitions in migrations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const inquirer = require('inquirer');
|
|
9
|
+
const chalk = require('chalk');
|
|
10
|
+
const {
|
|
11
|
+
listMigrations,
|
|
12
|
+
getLatestMigration,
|
|
13
|
+
ensureDir,
|
|
14
|
+
writeFile,
|
|
15
|
+
} = require('../lib/utils');
|
|
16
|
+
const { generateSitePreference } = require('../lib/templates');
|
|
17
|
+
const { mergeSystemObjectExtension } = require('../lib/merge');
|
|
18
|
+
|
|
19
|
+
const ATTRIBUTE_TYPES = [
|
|
20
|
+
{ name: 'String', value: 'string' },
|
|
21
|
+
{ name: 'Text (multi-line)', value: 'text' },
|
|
22
|
+
{ name: 'HTML', value: 'html' },
|
|
23
|
+
{ name: 'Integer', value: 'int' },
|
|
24
|
+
{ name: 'Double/Decimal', value: 'double' },
|
|
25
|
+
{ name: 'Boolean', value: 'boolean' },
|
|
26
|
+
{ name: 'Date', value: 'date' },
|
|
27
|
+
{ name: 'Date & Time', value: 'datetime' },
|
|
28
|
+
{ name: 'Enum (String)', value: 'enum-of-string' },
|
|
29
|
+
{ name: 'Enum (Integer)', value: 'enum-of-int' },
|
|
30
|
+
{ name: 'Set of Strings', value: 'set-of-string' },
|
|
31
|
+
{ name: 'Image', value: 'image' },
|
|
32
|
+
{ name: 'Password', value: 'password' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Base group choices (project groups will be detected from existing migrations)
|
|
36
|
+
const BASE_GROUP_CHOICES = [{ name: '+ Create new group', value: '__new__' }];
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
command: 'site-preference',
|
|
40
|
+
aliases: ['sp', 'sitepref', 'preference'],
|
|
41
|
+
desc: 'Create a site preference attribute',
|
|
42
|
+
builder: (yargs) => {
|
|
43
|
+
return yargs
|
|
44
|
+
.option('migration', {
|
|
45
|
+
alias: 'm',
|
|
46
|
+
type: 'string',
|
|
47
|
+
description:
|
|
48
|
+
'Target migration folder (uses latest if not specified)',
|
|
49
|
+
})
|
|
50
|
+
.option('attribute-id', {
|
|
51
|
+
alias: 'a',
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: 'Attribute ID (e.g., myPreference)',
|
|
54
|
+
})
|
|
55
|
+
.option('display-name', {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description: 'Display name for the attribute',
|
|
58
|
+
})
|
|
59
|
+
.option('description', {
|
|
60
|
+
alias: 'd',
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Description for the attribute',
|
|
63
|
+
})
|
|
64
|
+
.option('type', {
|
|
65
|
+
alias: 't',
|
|
66
|
+
type: 'string',
|
|
67
|
+
choices: [
|
|
68
|
+
'string',
|
|
69
|
+
'text',
|
|
70
|
+
'html',
|
|
71
|
+
'int',
|
|
72
|
+
'double',
|
|
73
|
+
'boolean',
|
|
74
|
+
'date',
|
|
75
|
+
'datetime',
|
|
76
|
+
'enum-of-string',
|
|
77
|
+
'set-of-string',
|
|
78
|
+
],
|
|
79
|
+
default: 'string',
|
|
80
|
+
description: 'Attribute type',
|
|
81
|
+
})
|
|
82
|
+
.option('group', {
|
|
83
|
+
alias: 'g',
|
|
84
|
+
type: 'string',
|
|
85
|
+
description: 'Attribute group ID',
|
|
86
|
+
})
|
|
87
|
+
.option('default-value', {
|
|
88
|
+
type: 'string',
|
|
89
|
+
description: 'Default value',
|
|
90
|
+
})
|
|
91
|
+
.option('mandatory', {
|
|
92
|
+
type: 'boolean',
|
|
93
|
+
default: false,
|
|
94
|
+
description: 'Is mandatory',
|
|
95
|
+
})
|
|
96
|
+
.option('interactive', {
|
|
97
|
+
alias: 'i',
|
|
98
|
+
type: 'boolean',
|
|
99
|
+
default: true,
|
|
100
|
+
description: 'Interactive mode with prompts',
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
handler: async (argv) => {
|
|
104
|
+
const migrationsDir = path.resolve(argv.migrationsDir);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const existingMigrations = listMigrations(migrationsDir);
|
|
108
|
+
|
|
109
|
+
if (existingMigrations.length === 0) {
|
|
110
|
+
console.log(
|
|
111
|
+
chalk.yellow(
|
|
112
|
+
'\nNo migrations found. Creating a new migration first...',
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
const createCmd = require('./create-migration');
|
|
116
|
+
await createCmd.handler({ ...argv, interactive: true });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let config = {
|
|
121
|
+
attributeId: argv.attributeId,
|
|
122
|
+
displayName: argv.displayName,
|
|
123
|
+
description: argv.description,
|
|
124
|
+
type: argv.type,
|
|
125
|
+
groupId: argv.group,
|
|
126
|
+
defaultValue: argv.defaultValue,
|
|
127
|
+
mandatory: argv.mandatory,
|
|
128
|
+
existingGroupAttributes: [],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
let targetMigration =
|
|
132
|
+
argv.migration || getLatestMigration(migrationsDir);
|
|
133
|
+
|
|
134
|
+
if (argv.interactive) {
|
|
135
|
+
console.log(
|
|
136
|
+
chalk.cyan('\n=== Site Preference Definition ===\n'),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Select target migration
|
|
140
|
+
const migrationAnswer = await inquirer.prompt([
|
|
141
|
+
{
|
|
142
|
+
type: 'list',
|
|
143
|
+
name: 'migration',
|
|
144
|
+
message: 'Select target migration:',
|
|
145
|
+
choices: [
|
|
146
|
+
...existingMigrations.slice(-10).reverse(),
|
|
147
|
+
new inquirer.Separator(),
|
|
148
|
+
{
|
|
149
|
+
name: '+ Create new migration',
|
|
150
|
+
value: '__new__',
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
default: targetMigration,
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
if (migrationAnswer.migration === '__new__') {
|
|
158
|
+
const createCmd = require('./create-migration');
|
|
159
|
+
await createCmd.handler({ ...argv, interactive: true });
|
|
160
|
+
targetMigration = getLatestMigration(migrationsDir);
|
|
161
|
+
} else {
|
|
162
|
+
targetMigration = migrationAnswer.migration;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Parse existing group attributes from migrations
|
|
166
|
+
const existingGroups = await parseExistingGroups(migrationsDir);
|
|
167
|
+
|
|
168
|
+
// Basic info
|
|
169
|
+
const basicInfo = await inquirer.prompt([
|
|
170
|
+
{
|
|
171
|
+
type: 'input',
|
|
172
|
+
name: 'attributeId',
|
|
173
|
+
message: 'Attribute ID (e.g., myPreference):',
|
|
174
|
+
validate: (input) => {
|
|
175
|
+
if (!input) return 'Attribute ID is required';
|
|
176
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(input)) {
|
|
177
|
+
return 'Invalid attribute ID format';
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
},
|
|
181
|
+
when: !config.attributeId,
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
type: 'input',
|
|
185
|
+
name: 'displayName',
|
|
186
|
+
message: 'Display name:',
|
|
187
|
+
default: (answers) => {
|
|
188
|
+
const id =
|
|
189
|
+
answers.attributeId || config.attributeId;
|
|
190
|
+
// Convert myPreference to "My Preference"
|
|
191
|
+
return id
|
|
192
|
+
.replace(/_/g, ' ')
|
|
193
|
+
.replace(/([A-Z])/g, ' $1')
|
|
194
|
+
.trim()
|
|
195
|
+
.split(' ')
|
|
196
|
+
.map(
|
|
197
|
+
(w) =>
|
|
198
|
+
w.charAt(0).toUpperCase() + w.slice(1),
|
|
199
|
+
)
|
|
200
|
+
.join(' ');
|
|
201
|
+
},
|
|
202
|
+
when: !config.displayName,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
type: 'input',
|
|
206
|
+
name: 'description',
|
|
207
|
+
message: 'Description (optional):',
|
|
208
|
+
when: !config.description,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
type: 'list',
|
|
212
|
+
name: 'type',
|
|
213
|
+
message: 'Attribute type:',
|
|
214
|
+
choices: ATTRIBUTE_TYPES,
|
|
215
|
+
default: config.type || 'string',
|
|
216
|
+
},
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
config = { ...config, ...basicInfo };
|
|
220
|
+
|
|
221
|
+
// Type-specific prompts
|
|
222
|
+
if (
|
|
223
|
+
config.type === 'enum-of-string' ||
|
|
224
|
+
config.type === 'enum-of-int'
|
|
225
|
+
) {
|
|
226
|
+
const enumConfig = await inquirer.prompt([
|
|
227
|
+
{
|
|
228
|
+
type: 'confirm',
|
|
229
|
+
name: 'multiSelect',
|
|
230
|
+
message: 'Allow multiple selections?',
|
|
231
|
+
default: false,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
type: 'input',
|
|
235
|
+
name: 'enumValues',
|
|
236
|
+
message:
|
|
237
|
+
'Enum values (comma-separated, format: value:display or just value):',
|
|
238
|
+
filter: (input) => {
|
|
239
|
+
return input
|
|
240
|
+
.split(',')
|
|
241
|
+
.map((v) => {
|
|
242
|
+
const parts = v.trim().split(':');
|
|
243
|
+
return {
|
|
244
|
+
value: parts[0].trim(),
|
|
245
|
+
display: parts[1]
|
|
246
|
+
? parts[1].trim()
|
|
247
|
+
: parts[0].trim(),
|
|
248
|
+
};
|
|
249
|
+
})
|
|
250
|
+
.filter((v) => v.value);
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
]);
|
|
254
|
+
config.multiSelect = enumConfig.multiSelect;
|
|
255
|
+
config.enumValues = enumConfig.enumValues;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Default value
|
|
259
|
+
const defaultPrompt = await inquirer.prompt([
|
|
260
|
+
{
|
|
261
|
+
type: config.type === 'boolean' ? 'list' : 'input',
|
|
262
|
+
name: 'defaultValue',
|
|
263
|
+
message: 'Default value:',
|
|
264
|
+
choices:
|
|
265
|
+
config.type === 'boolean'
|
|
266
|
+
? [
|
|
267
|
+
{ name: 'None', value: null },
|
|
268
|
+
{ name: 'True', value: 'true' },
|
|
269
|
+
{ name: 'False', value: 'false' },
|
|
270
|
+
]
|
|
271
|
+
: undefined,
|
|
272
|
+
when: config.defaultValue === undefined,
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
type: 'confirm',
|
|
276
|
+
name: 'mandatory',
|
|
277
|
+
message: 'Is mandatory?',
|
|
278
|
+
default: false,
|
|
279
|
+
when: config.mandatory === undefined,
|
|
280
|
+
},
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
config.defaultValue =
|
|
284
|
+
defaultPrompt.defaultValue || config.defaultValue || null;
|
|
285
|
+
config.mandatory =
|
|
286
|
+
defaultPrompt.mandatory !== undefined
|
|
287
|
+
? defaultPrompt.mandatory
|
|
288
|
+
: config.mandatory;
|
|
289
|
+
|
|
290
|
+
// Group selection
|
|
291
|
+
const existingGroupIds = Object.keys(existingGroups);
|
|
292
|
+
const groupChoices = [
|
|
293
|
+
...existingGroupIds.map((g) => ({ name: g, value: g })),
|
|
294
|
+
...BASE_GROUP_CHOICES,
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
const groupAnswer = await inquirer.prompt([
|
|
298
|
+
{
|
|
299
|
+
type: 'list',
|
|
300
|
+
name: 'groupId',
|
|
301
|
+
message: 'Select attribute group:',
|
|
302
|
+
choices: groupChoices,
|
|
303
|
+
default: existingGroupIds[0] || '__new__',
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
type: 'input',
|
|
307
|
+
name: 'newGroupId',
|
|
308
|
+
message: 'New group ID:',
|
|
309
|
+
when: (answers) => answers.groupId === '__new__',
|
|
310
|
+
validate: (input) =>
|
|
311
|
+
input ? true : 'Group ID is required',
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
type: 'input',
|
|
315
|
+
name: 'newGroupDisplayName',
|
|
316
|
+
message: 'New group display name:',
|
|
317
|
+
when: (answers) => answers.groupId === '__new__',
|
|
318
|
+
default: (answers) => answers.newGroupId,
|
|
319
|
+
},
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
if (groupAnswer.groupId === '__new__') {
|
|
323
|
+
config.groupId = groupAnswer.newGroupId;
|
|
324
|
+
config.groupDisplayName = groupAnswer.newGroupDisplayName;
|
|
325
|
+
config.existingGroupAttributes = [];
|
|
326
|
+
} else {
|
|
327
|
+
config.groupId = groupAnswer.groupId;
|
|
328
|
+
config.groupDisplayName = groupAnswer.groupId;
|
|
329
|
+
config.existingGroupAttributes =
|
|
330
|
+
existingGroups[groupAnswer.groupId] || [];
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Generate XML
|
|
335
|
+
const xml = generateSitePreference(config);
|
|
336
|
+
|
|
337
|
+
// Write to file
|
|
338
|
+
const metaPath = path.join(migrationsDir, targetMigration, 'meta');
|
|
339
|
+
const filePath = path.join(
|
|
340
|
+
metaPath,
|
|
341
|
+
'system-objecttype-extensions.xml',
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
ensureDir(metaPath);
|
|
345
|
+
|
|
346
|
+
// Check if file exists
|
|
347
|
+
if (fs.existsSync(filePath)) {
|
|
348
|
+
console.log(
|
|
349
|
+
chalk.yellow(
|
|
350
|
+
`\nNote: ${chalk.bold('system-objecttype-extensions.xml')} already exists.`,
|
|
351
|
+
),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const { action } = await inquirer.prompt([
|
|
355
|
+
{
|
|
356
|
+
type: 'list',
|
|
357
|
+
name: 'action',
|
|
358
|
+
message: 'What would you like to do?',
|
|
359
|
+
choices: [
|
|
360
|
+
{
|
|
361
|
+
name: 'Merge into existing file',
|
|
362
|
+
value: 'merge',
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: 'Show generated XML (copy manually)',
|
|
366
|
+
value: 'show',
|
|
367
|
+
},
|
|
368
|
+
{ name: 'Overwrite file', value: 'overwrite' },
|
|
369
|
+
{ name: 'Cancel', value: 'cancel' },
|
|
370
|
+
],
|
|
371
|
+
default: 'merge',
|
|
372
|
+
},
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
if (action === 'cancel') {
|
|
376
|
+
console.log(chalk.yellow('\nOperation cancelled.'));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (action === 'show') {
|
|
381
|
+
console.log(chalk.cyan('\n--- Generated XML ---\n'));
|
|
382
|
+
console.log(xml);
|
|
383
|
+
console.log(chalk.cyan('\n--- End XML ---\n'));
|
|
384
|
+
console.log(chalk.gray(`Manually add to: ${filePath}`));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (action === 'merge') {
|
|
389
|
+
const existingXml = fs.readFileSync(filePath, 'utf8');
|
|
390
|
+
const mergedXml = mergeSystemObjectExtension(existingXml, {
|
|
391
|
+
objectTypeId: 'SitePreferences',
|
|
392
|
+
attributeId: config.attributeId,
|
|
393
|
+
displayName: config.displayName,
|
|
394
|
+
description: config.description,
|
|
395
|
+
type: config.type,
|
|
396
|
+
mandatory: config.mandatory,
|
|
397
|
+
externallyManaged: false,
|
|
398
|
+
defaultValue: config.defaultValue,
|
|
399
|
+
groupId: config.groupId,
|
|
400
|
+
groupDisplayName: config.groupDisplayName,
|
|
401
|
+
enumValues: config.enumValues,
|
|
402
|
+
multiSelect: config.multiSelect,
|
|
403
|
+
minLength: config.minLength || 0,
|
|
404
|
+
});
|
|
405
|
+
writeFile(filePath, result.xml);
|
|
406
|
+
console.log(
|
|
407
|
+
chalk.green(
|
|
408
|
+
`\n✓ Merged site preference: ${config.attributeId}`,
|
|
409
|
+
),
|
|
410
|
+
);
|
|
411
|
+
console.log(chalk.cyan(` File: ${filePath}`));
|
|
412
|
+
console.log(chalk.gray(` Group: ${config.groupId}`));
|
|
413
|
+
|
|
414
|
+
if (result.groupExisted) {
|
|
415
|
+
console.log(
|
|
416
|
+
chalk.yellow(
|
|
417
|
+
`\n⚠ Warning: Group "${config.groupId}" already existed in this file.`,
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
console.log(
|
|
421
|
+
chalk.yellow(
|
|
422
|
+
' Review the group-definitions to ensure all attributes are listed.',
|
|
423
|
+
),
|
|
424
|
+
);
|
|
425
|
+
console.log(
|
|
426
|
+
chalk.gray(
|
|
427
|
+
' The merge only adds the new attribute reference, existing ones are preserved.',
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
writeFile(filePath, xml);
|
|
436
|
+
|
|
437
|
+
console.log(
|
|
438
|
+
chalk.green(
|
|
439
|
+
`\n✓ Created site preference: ${config.attributeId}`,
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
console.log(chalk.cyan(` File: ${filePath}`));
|
|
443
|
+
console.log(chalk.gray(` Group: ${config.groupId}`));
|
|
444
|
+
} catch (error) {
|
|
445
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Parses existing group attributes from migration files
|
|
453
|
+
* @param {string} migrationsDir - Migrations directory
|
|
454
|
+
* @returns {Promise<Object>} Map of group IDs to attribute arrays
|
|
455
|
+
*/
|
|
456
|
+
async function parseExistingGroups(migrationsDir) {
|
|
457
|
+
const groups = {};
|
|
458
|
+
const migrations = fs.readdirSync(migrationsDir);
|
|
459
|
+
|
|
460
|
+
for (const migration of migrations) {
|
|
461
|
+
const filePath = path.join(
|
|
462
|
+
migrationsDir,
|
|
463
|
+
migration,
|
|
464
|
+
'meta',
|
|
465
|
+
'system-objecttype-extensions.xml',
|
|
466
|
+
);
|
|
467
|
+
if (fs.existsSync(filePath)) {
|
|
468
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
469
|
+
|
|
470
|
+
// Extract SitePreferences groups
|
|
471
|
+
const sitePrefsMatch = content.match(
|
|
472
|
+
/<type-extension type-id="SitePreferences">([\s\S]*?)<\/type-extension>/g,
|
|
473
|
+
);
|
|
474
|
+
if (sitePrefsMatch) {
|
|
475
|
+
for (const match of sitePrefsMatch) {
|
|
476
|
+
// Extract group definitions
|
|
477
|
+
const groupMatches = match.matchAll(
|
|
478
|
+
/<attribute-group group-id="([^"]+)">([\s\S]*?)<\/attribute-group>/g,
|
|
479
|
+
);
|
|
480
|
+
for (const groupMatch of groupMatches) {
|
|
481
|
+
const groupId = groupMatch[1];
|
|
482
|
+
const groupContent = groupMatch[2];
|
|
483
|
+
|
|
484
|
+
// Extract attribute IDs
|
|
485
|
+
const attrMatches = groupContent.matchAll(
|
|
486
|
+
/<attribute attribute-id="([^"]+)"\/>/g,
|
|
487
|
+
);
|
|
488
|
+
if (!groups[groupId]) {
|
|
489
|
+
groups[groupId] = [];
|
|
490
|
+
}
|
|
491
|
+
for (const attrMatch of attrMatches) {
|
|
492
|
+
if (!groups[groupId].includes(attrMatch[1])) {
|
|
493
|
+
groups[groupId].push(attrMatch[1]);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return groups;
|
|
503
|
+
}
|