@gefyra/diffyr6-cli 1.0.0 → 1.0.1
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/LICENSE +21 -21
- package/README.md +447 -447
- package/config/README.md +27 -27
- package/config/default-rules.json +135 -135
- package/config/resources-r4-not-in-r6.json +42 -42
- package/package.json +54 -54
- package/src/cli.js +92 -92
- package/src/compare-profiles.js +386 -386
- package/src/config.js +147 -147
- package/src/generate-fsh.js +457 -457
- package/src/index.js +394 -394
- package/src/rules-engine.js +642 -642
- package/src/upgrade-sushi.js +553 -553
- package/src/utils/fs.js +38 -38
- package/src/utils/html.js +28 -28
- package/src/utils/process.js +101 -101
- package/src/utils/removed-resources.js +135 -135
- package/src/utils/sushi-log.js +46 -46
- package/src/utils/validator.js +103 -103
package/src/rules-engine.js
CHANGED
|
@@ -1,642 +1,642 @@
|
|
|
1
|
-
import { stripHtml, normalizeWhitespace, decodeEntities } from './utils/html.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Evaluates rules against HTML comparison files and returns matches
|
|
5
|
-
*/
|
|
6
|
-
export function evaluateRulesForHtmlFiles(htmlFiles, rulesConfig) {
|
|
7
|
-
if (!rulesConfig || !Array.isArray(rulesConfig.tables)) {
|
|
8
|
-
return [];
|
|
9
|
-
}
|
|
10
|
-
const results = [];
|
|
11
|
-
|
|
12
|
-
// First, process error files (xx-*.html) for comparison failures
|
|
13
|
-
for (const htmlFile of htmlFiles) {
|
|
14
|
-
if (!htmlFile || !htmlFile.content) {
|
|
15
|
-
continue;
|
|
16
|
-
}
|
|
17
|
-
const filename = htmlFile.filename || '';
|
|
18
|
-
if (filename.startsWith('xx-')) {
|
|
19
|
-
const errorResult = processComparisonError(htmlFile, rulesConfig);
|
|
20
|
-
if (errorResult) {
|
|
21
|
-
results.push(errorResult);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
for (const tableConfig of rulesConfig.tables) {
|
|
27
|
-
if (!Array.isArray(tableConfig.rules) || tableConfig.rules.length === 0) {
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
const sectionHeading = tableConfig.sectionHeading || 'Structure';
|
|
31
|
-
const groupOrderMap = computeGroupOrder(tableConfig.rules);
|
|
32
|
-
for (const htmlFile of htmlFiles) {
|
|
33
|
-
if (!htmlFile || !htmlFile.content) {
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
// Skip error files, already processed
|
|
37
|
-
const filename = htmlFile.filename || '';
|
|
38
|
-
if (filename.startsWith('xx-')) {
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
const tables = findSectionTables(htmlFile.content, sectionHeading);
|
|
42
|
-
if (tables.length === 0) {
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
const targetTables =
|
|
46
|
-
typeof tableConfig.tableIndex === 'number'
|
|
47
|
-
? tables[tableConfig.tableIndex]
|
|
48
|
-
? [tables[tableConfig.tableIndex]]
|
|
49
|
-
: []
|
|
50
|
-
: tables;
|
|
51
|
-
if (targetTables.length === 0) {
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
for (const tableHtml of targetTables) {
|
|
55
|
-
const parsed = parseTable(tableHtml);
|
|
56
|
-
if (!parsed || parsed.headers.length === 0) {
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
for (const row of parsed.rows) {
|
|
60
|
-
const matches = applyRules(
|
|
61
|
-
tableConfig.rules,
|
|
62
|
-
row,
|
|
63
|
-
parsed.headers,
|
|
64
|
-
htmlFile.filename || '',
|
|
65
|
-
sectionHeading,
|
|
66
|
-
groupOrderMap
|
|
67
|
-
);
|
|
68
|
-
for (const match of matches) {
|
|
69
|
-
results.push({
|
|
70
|
-
text: match.text,
|
|
71
|
-
group: match.group,
|
|
72
|
-
description: match.description,
|
|
73
|
-
rank: match.rank,
|
|
74
|
-
value: match.value,
|
|
75
|
-
elementPath: match.elementPath,
|
|
76
|
-
file: match.file,
|
|
77
|
-
groupOrder: match.groupOrder,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return results;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function processComparisonError(htmlFile, rulesConfig) {
|
|
88
|
-
const filename = htmlFile.filename || '';
|
|
89
|
-
const content = htmlFile.content || '';
|
|
90
|
-
|
|
91
|
-
// Extract error message from content (between <pre> tags)
|
|
92
|
-
const preMatch = content.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i);
|
|
93
|
-
let errorMessage = preMatch ? stripHtml(preMatch[1]).trim() : 'Unknown comparison error';
|
|
94
|
-
|
|
95
|
-
// Keep only the first line of the error message
|
|
96
|
-
const firstLine = errorMessage.split('\n')[0];
|
|
97
|
-
errorMessage = firstLine || 'Unknown comparison error';
|
|
98
|
-
|
|
99
|
-
// Extract profile name from filename (xx-ProfileName-ProfileNameR6.html)
|
|
100
|
-
const nameMatch = filename.match(/^xx-(.+?)(?:-[A-Za-z0-9]+)?\.html$/);
|
|
101
|
-
const profileName = nameMatch ? nameMatch[1] : filename.replace(/\.html$/, '');
|
|
102
|
-
|
|
103
|
-
// Find the error rule in rulesConfig
|
|
104
|
-
if (!rulesConfig.tables || !Array.isArray(rulesConfig.tables)) {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
let errorRule = null;
|
|
109
|
-
for (const tableConfig of rulesConfig.tables) {
|
|
110
|
-
if (Array.isArray(tableConfig.rules)) {
|
|
111
|
-
errorRule = tableConfig.rules.find(rule =>
|
|
112
|
-
rule.name && rule.name.includes('Comparison Error')
|
|
113
|
-
);
|
|
114
|
-
if (errorRule) break;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (!errorRule) {
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Build the template variables - use lowercase keys for template matching
|
|
123
|
-
const variables = {
|
|
124
|
-
name: profileName,
|
|
125
|
-
message: errorMessage,
|
|
126
|
-
file: filename,
|
|
127
|
-
section: 'Error'
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const renderedText = renderTemplate(errorRule.template || '', variables);
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
text: renderedText,
|
|
134
|
-
group: errorRule.name || 'Comparison Errors',
|
|
135
|
-
description: errorRule.description || '',
|
|
136
|
-
rank: errorRule.rank || 999,
|
|
137
|
-
value: errorRule.value || 15,
|
|
138
|
-
elementPath: profileName,
|
|
139
|
-
file: filename,
|
|
140
|
-
groupOrder: 0
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function computeGroupOrder(rules = []) {
|
|
145
|
-
const order = new Map();
|
|
146
|
-
rules.forEach((rule, index) => {
|
|
147
|
-
const key = resolveRuleGroup(rule);
|
|
148
|
-
if (!order.has(key)) {
|
|
149
|
-
order.set(key, index);
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
return order;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function resolveRuleGroup(rule) {
|
|
156
|
-
return rule.name || rule.description || '';
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function findSectionTables(html, heading) {
|
|
160
|
-
const tables = [];
|
|
161
|
-
const headingRegex = /<h3\b[^>]*>([\s\S]*?)<\/h3>/gi;
|
|
162
|
-
let match;
|
|
163
|
-
const desiredHeading = heading.toLowerCase();
|
|
164
|
-
while ((match = headingRegex.exec(html))) {
|
|
165
|
-
const text = normalizeWhitespace(stripHtml(match[1])).toLowerCase();
|
|
166
|
-
if (!text.includes(desiredHeading)) {
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
const sectionStart = headingRegex.lastIndex;
|
|
170
|
-
const nextHeadingRegex = /<h3\b[^>]*>([\s\S]*?)<\/h3>/gi;
|
|
171
|
-
nextHeadingRegex.lastIndex = sectionStart;
|
|
172
|
-
const nextMatch = nextHeadingRegex.exec(html);
|
|
173
|
-
const sectionEnd = nextMatch ? nextMatch.index : html.length;
|
|
174
|
-
let cursor = sectionStart;
|
|
175
|
-
while (cursor < sectionEnd) {
|
|
176
|
-
const { block, endIndex } = captureNextTable(
|
|
177
|
-
html,
|
|
178
|
-
cursor,
|
|
179
|
-
sectionEnd
|
|
180
|
-
);
|
|
181
|
-
if (!block) {
|
|
182
|
-
break;
|
|
183
|
-
}
|
|
184
|
-
tables.push(block);
|
|
185
|
-
cursor = endIndex;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return tables;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function captureNextTable(html, startIndex, limitIndex) {
|
|
192
|
-
const openRegex = /<table\b[^>]*>/gi;
|
|
193
|
-
openRegex.lastIndex = startIndex;
|
|
194
|
-
const openMatch = openRegex.exec(html);
|
|
195
|
-
if (!openMatch || (limitIndex != null && openMatch.index >= limitIndex)) {
|
|
196
|
-
return { block: null, endIndex: startIndex };
|
|
197
|
-
}
|
|
198
|
-
const start = openMatch.index;
|
|
199
|
-
const tagRegex = /<\/?table\b[^>]*>/gi;
|
|
200
|
-
tagRegex.lastIndex = start;
|
|
201
|
-
let depth = 0;
|
|
202
|
-
let end = null;
|
|
203
|
-
let tagMatch;
|
|
204
|
-
while ((tagMatch = tagRegex.exec(html))) {
|
|
205
|
-
if (limitIndex != null && tagMatch.index >= limitIndex) {
|
|
206
|
-
break;
|
|
207
|
-
}
|
|
208
|
-
if (tagMatch[0][1] === '/') {
|
|
209
|
-
depth -= 1;
|
|
210
|
-
if (depth === 0) {
|
|
211
|
-
end = tagRegex.lastIndex;
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
} else {
|
|
215
|
-
depth += 1;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
if (end === null) {
|
|
219
|
-
return { block: null, endIndex: startIndex };
|
|
220
|
-
}
|
|
221
|
-
return { block: html.slice(start, end), endIndex: end };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function parseTable(tableHtml) {
|
|
225
|
-
const header = parseHeaderRow(tableHtml);
|
|
226
|
-
if (!header) {
|
|
227
|
-
return null;
|
|
228
|
-
}
|
|
229
|
-
const rows = parseDataRows(tableHtml, header);
|
|
230
|
-
return { headers: header, rows };
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function parseHeaderRow(tableHtml) {
|
|
234
|
-
const rowRegex = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
235
|
-
let match;
|
|
236
|
-
while ((match = rowRegex.exec(tableHtml))) {
|
|
237
|
-
if (/<th\b/i.test(match[1])) {
|
|
238
|
-
const cells = [...match[1].matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)];
|
|
239
|
-
const names = cells.map((cell) =>
|
|
240
|
-
normalizeWhitespace(stripHtml(cell[1]))
|
|
241
|
-
);
|
|
242
|
-
return buildHeaderMeta(names);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function buildHeaderMeta(names) {
|
|
249
|
-
const used = new Set();
|
|
250
|
-
return names.map((label, index) => {
|
|
251
|
-
let alias = label
|
|
252
|
-
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
253
|
-
.replace(/^_+|_+$/g, '');
|
|
254
|
-
if (!alias) {
|
|
255
|
-
alias = `Column${index + 1}`;
|
|
256
|
-
}
|
|
257
|
-
let finalAlias = alias;
|
|
258
|
-
let counter = 2;
|
|
259
|
-
while (used.has(finalAlias.toLowerCase())) {
|
|
260
|
-
finalAlias = `${alias}_${counter}`;
|
|
261
|
-
counter += 1;
|
|
262
|
-
}
|
|
263
|
-
used.add(finalAlias.toLowerCase());
|
|
264
|
-
return {
|
|
265
|
-
label,
|
|
266
|
-
alias: finalAlias,
|
|
267
|
-
index,
|
|
268
|
-
lookupKey: finalAlias.toLowerCase(),
|
|
269
|
-
};
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function parseDataRows(tableHtml, headers) {
|
|
274
|
-
const rows = [];
|
|
275
|
-
const rowRegex = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
276
|
-
let match;
|
|
277
|
-
let headerConsumed = false;
|
|
278
|
-
while ((match = rowRegex.exec(tableHtml))) {
|
|
279
|
-
if (!headerConsumed) {
|
|
280
|
-
if (/<th\b/i.test(match[1])) {
|
|
281
|
-
headerConsumed = true;
|
|
282
|
-
}
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
if (!/<td\b/i.test(match[1])) {
|
|
286
|
-
continue;
|
|
287
|
-
}
|
|
288
|
-
rows.push(parseRow(match[1], headers));
|
|
289
|
-
}
|
|
290
|
-
return rows;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function parseRow(rowHtml, headers) {
|
|
294
|
-
const cells = splitTopLevelCells(rowHtml).map((cell) => {
|
|
295
|
-
const cellInfo = extractCellText(cell.html || '');
|
|
296
|
-
return {
|
|
297
|
-
raw: cell.html || '',
|
|
298
|
-
attrs: cell.attrs || '',
|
|
299
|
-
text: cellInfo.text,
|
|
300
|
-
titles: cellInfo.titles,
|
|
301
|
-
span: parseColspan(cell.attrs),
|
|
302
|
-
};
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
const values = {};
|
|
306
|
-
const indexVars = {};
|
|
307
|
-
const meta = {};
|
|
308
|
-
let columnIndex = 0;
|
|
309
|
-
|
|
310
|
-
for (const cell of cells) {
|
|
311
|
-
const span = cell.span > 0 ? cell.span : 1;
|
|
312
|
-
for (let i = 0; i < span && columnIndex < headers.length; i += 1) {
|
|
313
|
-
const header = headers[columnIndex];
|
|
314
|
-
if (!Object.prototype.hasOwnProperty.call(values, header.alias)) {
|
|
315
|
-
values[header.alias] = cell.text;
|
|
316
|
-
}
|
|
317
|
-
if (!meta[header.alias]) {
|
|
318
|
-
meta[header.alias] = {
|
|
319
|
-
titles: cell.titles || [],
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
indexVars[`col${columnIndex + 1}`] = values[header.alias];
|
|
323
|
-
columnIndex += 1;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
while (columnIndex < headers.length) {
|
|
328
|
-
const header = headers[columnIndex];
|
|
329
|
-
values[header.alias] = values[header.alias] || '';
|
|
330
|
-
indexVars[`col${columnIndex + 1}`] = values[header.alias];
|
|
331
|
-
columnIndex += 1;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
propagateFlagNotes(values, meta, headers);
|
|
335
|
-
|
|
336
|
-
return {
|
|
337
|
-
values,
|
|
338
|
-
indexVars,
|
|
339
|
-
rawRow: rowHtml,
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function extractCellText(innerHtml) {
|
|
344
|
-
const titles = extractAttributeTexts(innerHtml);
|
|
345
|
-
const anchors = extractAnchorTexts(innerHtml);
|
|
346
|
-
const baseText = stripHtml(innerHtml);
|
|
347
|
-
const parts = [];
|
|
348
|
-
if (anchors.length > 0) {
|
|
349
|
-
parts.push(anchors.join(' '));
|
|
350
|
-
} else if (baseText) {
|
|
351
|
-
parts.push(baseText);
|
|
352
|
-
}
|
|
353
|
-
if (titles.length > 0) {
|
|
354
|
-
parts.push(titles.join(' '));
|
|
355
|
-
}
|
|
356
|
-
return {
|
|
357
|
-
text: normalizeWhitespace(parts.join(' ')),
|
|
358
|
-
titles,
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function extractAttributeTexts(html = '') {
|
|
363
|
-
const titles = [];
|
|
364
|
-
const attrRegex =
|
|
365
|
-
/<([a-z0-9]+)\b[^>]*\btitle\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>/gi;
|
|
366
|
-
let match;
|
|
367
|
-
while ((match = attrRegex.exec(html))) {
|
|
368
|
-
const tagName = (match[1] || '').toLowerCase();
|
|
369
|
-
if (tagName === 'img') {
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
const value = match[2] || match[3] || '';
|
|
373
|
-
if (value) {
|
|
374
|
-
titles.push(decodeEntities(value));
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
return titles;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function extractAnchorTexts(html = '') {
|
|
381
|
-
const anchors = [];
|
|
382
|
-
const anchorRegex =
|
|
383
|
-
/<a\b[^>]*\bname\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>/gi;
|
|
384
|
-
let match;
|
|
385
|
-
while ((match = anchorRegex.exec(html))) {
|
|
386
|
-
const value = match[1] || match[2] || '';
|
|
387
|
-
const formatted = formatAnchorName(value);
|
|
388
|
-
if (formatted) {
|
|
389
|
-
anchors.push(formatted);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
return anchors;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function formatAnchorName(value = '') {
|
|
396
|
-
if (!value) {
|
|
397
|
-
return '';
|
|
398
|
-
}
|
|
399
|
-
let text = value.trim();
|
|
400
|
-
if (!text) {
|
|
401
|
-
return '';
|
|
402
|
-
}
|
|
403
|
-
if (text.startsWith('cmp-')) {
|
|
404
|
-
text = text.slice(4);
|
|
405
|
-
}
|
|
406
|
-
text = text.replace(/_x_/gi, '[x]');
|
|
407
|
-
text = text.replace(/_$/g, '');
|
|
408
|
-
return text;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function propagateFlagNotes(values, meta, headers) {
|
|
412
|
-
appendFlagNotes(values, meta, headers, 'L Flags', 'L Description & Constraints');
|
|
413
|
-
appendFlagNotes(values, meta, headers, 'R Flags', 'R Description & Constraints');
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function appendFlagNotes(values, meta, headers, flagLabel, descLabel) {
|
|
417
|
-
const flagAlias = findAliasByLabel(headers, flagLabel);
|
|
418
|
-
const descAlias = findAliasByLabel(headers, descLabel);
|
|
419
|
-
if (!flagAlias || !descAlias) {
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
const titles = meta[flagAlias] ? meta[flagAlias].titles : null;
|
|
423
|
-
if (!titles || titles.length === 0) {
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
const extra = normalizeWhitespace(titles.join(' '));
|
|
427
|
-
if (!extra) {
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
const current = values[descAlias] || '';
|
|
431
|
-
values[descAlias] = normalizeWhitespace(`${current} ${extra}`.trim());
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function findAliasByLabel(headers, label) {
|
|
435
|
-
const lower = label.toLowerCase();
|
|
436
|
-
const header = headers.find((item) => item.label.toLowerCase() === lower);
|
|
437
|
-
return header ? header.alias : null;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function parseColspan(attrText = '') {
|
|
441
|
-
const match = attrText.match(/colspan\s*=\s*"?(\d+)"?/i);
|
|
442
|
-
if (!match) {
|
|
443
|
-
return 1;
|
|
444
|
-
}
|
|
445
|
-
const span = parseInt(match[1], 10);
|
|
446
|
-
return Number.isNaN(span) ? 1 : span;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function splitTopLevelCells(rowHtml) {
|
|
450
|
-
const cells = [];
|
|
451
|
-
const tokenRegex =
|
|
452
|
-
/<td\b([^>]*)\/>|<td\b([^>]*)>|<\/td\s*>|<table\b[^>]*>|<\/table\s*>/gi;
|
|
453
|
-
let nestedTableDepth = 0;
|
|
454
|
-
let currentCell = null;
|
|
455
|
-
let match;
|
|
456
|
-
while ((match = tokenRegex.exec(rowHtml))) {
|
|
457
|
-
const token = match[0];
|
|
458
|
-
if (/^<table/i.test(token)) {
|
|
459
|
-
if (currentCell) {
|
|
460
|
-
nestedTableDepth += 1;
|
|
461
|
-
}
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
if (/^<\/table/i.test(token)) {
|
|
465
|
-
if (currentCell && nestedTableDepth > 0) {
|
|
466
|
-
nestedTableDepth -= 1;
|
|
467
|
-
}
|
|
468
|
-
continue;
|
|
469
|
-
}
|
|
470
|
-
if (nestedTableDepth > 0) {
|
|
471
|
-
continue;
|
|
472
|
-
}
|
|
473
|
-
if (match[1] !== undefined) {
|
|
474
|
-
cells.push({
|
|
475
|
-
attrs: match[1] || '',
|
|
476
|
-
html: '',
|
|
477
|
-
});
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
if (match[2] !== undefined) {
|
|
481
|
-
currentCell = {
|
|
482
|
-
attrs: match[2] || '',
|
|
483
|
-
startIndex: tokenRegex.lastIndex,
|
|
484
|
-
};
|
|
485
|
-
continue;
|
|
486
|
-
}
|
|
487
|
-
if (/^<\/td/i.test(token) && currentCell) {
|
|
488
|
-
const cellHtml = rowHtml.slice(currentCell.startIndex, match.index);
|
|
489
|
-
cells.push({
|
|
490
|
-
attrs: currentCell.attrs,
|
|
491
|
-
html: cellHtml,
|
|
492
|
-
});
|
|
493
|
-
currentCell = null;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
return cells;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function applyRules(rules, row, headers, fileName, sectionHeading, groupOrderMap) {
|
|
500
|
-
const outputs = [];
|
|
501
|
-
const headerLookup = buildHeaderLookup(headers);
|
|
502
|
-
const variables = {
|
|
503
|
-
...row.values,
|
|
504
|
-
...row.indexVars,
|
|
505
|
-
file: fileName,
|
|
506
|
-
section: sectionHeading,
|
|
507
|
-
profile: fileName,
|
|
508
|
-
};
|
|
509
|
-
|
|
510
|
-
for (const rule of rules) {
|
|
511
|
-
if (!Array.isArray(rule.conditions) || rule.conditions.length === 0) {
|
|
512
|
-
continue;
|
|
513
|
-
}
|
|
514
|
-
if (!rule.template) {
|
|
515
|
-
continue;
|
|
516
|
-
}
|
|
517
|
-
let matches = true;
|
|
518
|
-
for (const condition of rule.conditions) {
|
|
519
|
-
if (!evaluateCondition(condition, row, headerLookup)) {
|
|
520
|
-
matches = false;
|
|
521
|
-
break;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
if (matches) {
|
|
525
|
-
const rank = Number.isFinite(Number(rule.rank)) ? Number(rule.rank) : null;
|
|
526
|
-
const value = Number.isFinite(Number(rule.value)) ? Number(rule.value) : null;
|
|
527
|
-
const elementPath = (row.values['Name'] || '').trim().split(/\s+/)[0] || '';
|
|
528
|
-
outputs.push({
|
|
529
|
-
text: renderTemplate(rule.template, variables),
|
|
530
|
-
group: resolveRuleGroup(rule),
|
|
531
|
-
description: rule.description || '',
|
|
532
|
-
groupOrder: resolveGroupOrder(rule, groupOrderMap),
|
|
533
|
-
rank,
|
|
534
|
-
value,
|
|
535
|
-
elementPath,
|
|
536
|
-
file: fileName,
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
return outputs;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function resolveGroupOrder(rule, groupOrderMap) {
|
|
544
|
-
const key = resolveRuleGroup(rule);
|
|
545
|
-
if (!groupOrderMap) {
|
|
546
|
-
return Number.MAX_SAFE_INTEGER;
|
|
547
|
-
}
|
|
548
|
-
return groupOrderMap.get(key) ?? Number.MAX_SAFE_INTEGER;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
function buildHeaderLookup(headers) {
|
|
552
|
-
const lookup = {};
|
|
553
|
-
headers.forEach((header, index) => {
|
|
554
|
-
lookup[header.alias.toLowerCase()] = header.alias;
|
|
555
|
-
lookup[header.label.toLowerCase()] = header.alias;
|
|
556
|
-
lookup[`col${index + 1}`.toLowerCase()] = header.alias;
|
|
557
|
-
});
|
|
558
|
-
return lookup;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function evaluateCondition(condition, row, lookup) {
|
|
562
|
-
const columnAlias = resolveColumnAlias(condition.column || '', lookup);
|
|
563
|
-
if (!columnAlias) {
|
|
564
|
-
return false;
|
|
565
|
-
}
|
|
566
|
-
const cellValue = row.values[columnAlias] || '';
|
|
567
|
-
const expected = resolveExpectedValue(condition, row, lookup);
|
|
568
|
-
const operator = (condition.operator || '').toLowerCase();
|
|
569
|
-
const caseSensitive = Boolean(condition.caseSensitive);
|
|
570
|
-
if (operator === 'equals') {
|
|
571
|
-
return compareEquals(cellValue, expected, caseSensitive);
|
|
572
|
-
}
|
|
573
|
-
if (operator === '!equals' || operator === 'notequals' || operator === 'not-equals') {
|
|
574
|
-
return !compareEquals(cellValue, expected, caseSensitive);
|
|
575
|
-
}
|
|
576
|
-
if (operator === 'contains') {
|
|
577
|
-
return compareContains(cellValue, expected, caseSensitive);
|
|
578
|
-
}
|
|
579
|
-
return false;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
function resolveExpectedValue(condition, row, lookup) {
|
|
583
|
-
if (condition.valueColumn) {
|
|
584
|
-
const alias = resolveColumnAlias(condition.valueColumn, lookup);
|
|
585
|
-
if (alias) {
|
|
586
|
-
return row.values[alias] || '';
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
return condition.value || '';
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
function resolveColumnAlias(column, lookup) {
|
|
593
|
-
if (!column) {
|
|
594
|
-
return null;
|
|
595
|
-
}
|
|
596
|
-
const key = column.toLowerCase();
|
|
597
|
-
return lookup[key] || null;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function compareEquals(left, right, caseSensitive) {
|
|
601
|
-
if (caseSensitive) {
|
|
602
|
-
return String(left) === String(right);
|
|
603
|
-
}
|
|
604
|
-
return String(left).toLowerCase() === String(right).toLowerCase();
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
function compareContains(left, right, caseSensitive) {
|
|
608
|
-
if (caseSensitive) {
|
|
609
|
-
return String(left).includes(String(right));
|
|
610
|
-
}
|
|
611
|
-
return String(left).toLowerCase().includes(String(right).toLowerCase());
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function renderTemplate(template, variables) {
|
|
615
|
-
const rendered = template.replace(/{{\s*([^}]+)\s*}}/g, (_, key) => {
|
|
616
|
-
const resolved = resolveVariableValue(key.trim(), variables);
|
|
617
|
-
return resolved != null ? resolved : '';
|
|
618
|
-
});
|
|
619
|
-
return rendered.replace(/2147483647/g, '*');
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
function resolveVariableValue(key, variables) {
|
|
623
|
-
if (key in variables) {
|
|
624
|
-
return variables[key];
|
|
625
|
-
}
|
|
626
|
-
const normalizedKey = normalizePlaceholderKey(key);
|
|
627
|
-
for (const candidate of Object.keys(variables)) {
|
|
628
|
-
if (normalizePlaceholderKey(candidate) === normalizedKey) {
|
|
629
|
-
return variables[candidate];
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
return null;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
function normalizePlaceholderKey(value) {
|
|
636
|
-
return String(value)
|
|
637
|
-
.trim()
|
|
638
|
-
.toLowerCase()
|
|
639
|
-
.replace(/[^a-z0-9]+/g, '_')
|
|
640
|
-
.replace(/_+/g, '_')
|
|
641
|
-
.replace(/^_|_$/g, '');
|
|
642
|
-
}
|
|
1
|
+
import { stripHtml, normalizeWhitespace, decodeEntities } from './utils/html.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Evaluates rules against HTML comparison files and returns matches
|
|
5
|
+
*/
|
|
6
|
+
export function evaluateRulesForHtmlFiles(htmlFiles, rulesConfig) {
|
|
7
|
+
if (!rulesConfig || !Array.isArray(rulesConfig.tables)) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
const results = [];
|
|
11
|
+
|
|
12
|
+
// First, process error files (xx-*.html) for comparison failures
|
|
13
|
+
for (const htmlFile of htmlFiles) {
|
|
14
|
+
if (!htmlFile || !htmlFile.content) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const filename = htmlFile.filename || '';
|
|
18
|
+
if (filename.startsWith('xx-')) {
|
|
19
|
+
const errorResult = processComparisonError(htmlFile, rulesConfig);
|
|
20
|
+
if (errorResult) {
|
|
21
|
+
results.push(errorResult);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const tableConfig of rulesConfig.tables) {
|
|
27
|
+
if (!Array.isArray(tableConfig.rules) || tableConfig.rules.length === 0) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const sectionHeading = tableConfig.sectionHeading || 'Structure';
|
|
31
|
+
const groupOrderMap = computeGroupOrder(tableConfig.rules);
|
|
32
|
+
for (const htmlFile of htmlFiles) {
|
|
33
|
+
if (!htmlFile || !htmlFile.content) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// Skip error files, already processed
|
|
37
|
+
const filename = htmlFile.filename || '';
|
|
38
|
+
if (filename.startsWith('xx-')) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const tables = findSectionTables(htmlFile.content, sectionHeading);
|
|
42
|
+
if (tables.length === 0) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const targetTables =
|
|
46
|
+
typeof tableConfig.tableIndex === 'number'
|
|
47
|
+
? tables[tableConfig.tableIndex]
|
|
48
|
+
? [tables[tableConfig.tableIndex]]
|
|
49
|
+
: []
|
|
50
|
+
: tables;
|
|
51
|
+
if (targetTables.length === 0) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
for (const tableHtml of targetTables) {
|
|
55
|
+
const parsed = parseTable(tableHtml);
|
|
56
|
+
if (!parsed || parsed.headers.length === 0) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const row of parsed.rows) {
|
|
60
|
+
const matches = applyRules(
|
|
61
|
+
tableConfig.rules,
|
|
62
|
+
row,
|
|
63
|
+
parsed.headers,
|
|
64
|
+
htmlFile.filename || '',
|
|
65
|
+
sectionHeading,
|
|
66
|
+
groupOrderMap
|
|
67
|
+
);
|
|
68
|
+
for (const match of matches) {
|
|
69
|
+
results.push({
|
|
70
|
+
text: match.text,
|
|
71
|
+
group: match.group,
|
|
72
|
+
description: match.description,
|
|
73
|
+
rank: match.rank,
|
|
74
|
+
value: match.value,
|
|
75
|
+
elementPath: match.elementPath,
|
|
76
|
+
file: match.file,
|
|
77
|
+
groupOrder: match.groupOrder,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function processComparisonError(htmlFile, rulesConfig) {
|
|
88
|
+
const filename = htmlFile.filename || '';
|
|
89
|
+
const content = htmlFile.content || '';
|
|
90
|
+
|
|
91
|
+
// Extract error message from content (between <pre> tags)
|
|
92
|
+
const preMatch = content.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i);
|
|
93
|
+
let errorMessage = preMatch ? stripHtml(preMatch[1]).trim() : 'Unknown comparison error';
|
|
94
|
+
|
|
95
|
+
// Keep only the first line of the error message
|
|
96
|
+
const firstLine = errorMessage.split('\n')[0];
|
|
97
|
+
errorMessage = firstLine || 'Unknown comparison error';
|
|
98
|
+
|
|
99
|
+
// Extract profile name from filename (xx-ProfileName-ProfileNameR6.html)
|
|
100
|
+
const nameMatch = filename.match(/^xx-(.+?)(?:-[A-Za-z0-9]+)?\.html$/);
|
|
101
|
+
const profileName = nameMatch ? nameMatch[1] : filename.replace(/\.html$/, '');
|
|
102
|
+
|
|
103
|
+
// Find the error rule in rulesConfig
|
|
104
|
+
if (!rulesConfig.tables || !Array.isArray(rulesConfig.tables)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let errorRule = null;
|
|
109
|
+
for (const tableConfig of rulesConfig.tables) {
|
|
110
|
+
if (Array.isArray(tableConfig.rules)) {
|
|
111
|
+
errorRule = tableConfig.rules.find(rule =>
|
|
112
|
+
rule.name && rule.name.includes('Comparison Error')
|
|
113
|
+
);
|
|
114
|
+
if (errorRule) break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!errorRule) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Build the template variables - use lowercase keys for template matching
|
|
123
|
+
const variables = {
|
|
124
|
+
name: profileName,
|
|
125
|
+
message: errorMessage,
|
|
126
|
+
file: filename,
|
|
127
|
+
section: 'Error'
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const renderedText = renderTemplate(errorRule.template || '', variables);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
text: renderedText,
|
|
134
|
+
group: errorRule.name || 'Comparison Errors',
|
|
135
|
+
description: errorRule.description || '',
|
|
136
|
+
rank: errorRule.rank || 999,
|
|
137
|
+
value: errorRule.value || 15,
|
|
138
|
+
elementPath: profileName,
|
|
139
|
+
file: filename,
|
|
140
|
+
groupOrder: 0
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function computeGroupOrder(rules = []) {
|
|
145
|
+
const order = new Map();
|
|
146
|
+
rules.forEach((rule, index) => {
|
|
147
|
+
const key = resolveRuleGroup(rule);
|
|
148
|
+
if (!order.has(key)) {
|
|
149
|
+
order.set(key, index);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return order;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function resolveRuleGroup(rule) {
|
|
156
|
+
return rule.name || rule.description || '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function findSectionTables(html, heading) {
|
|
160
|
+
const tables = [];
|
|
161
|
+
const headingRegex = /<h3\b[^>]*>([\s\S]*?)<\/h3>/gi;
|
|
162
|
+
let match;
|
|
163
|
+
const desiredHeading = heading.toLowerCase();
|
|
164
|
+
while ((match = headingRegex.exec(html))) {
|
|
165
|
+
const text = normalizeWhitespace(stripHtml(match[1])).toLowerCase();
|
|
166
|
+
if (!text.includes(desiredHeading)) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const sectionStart = headingRegex.lastIndex;
|
|
170
|
+
const nextHeadingRegex = /<h3\b[^>]*>([\s\S]*?)<\/h3>/gi;
|
|
171
|
+
nextHeadingRegex.lastIndex = sectionStart;
|
|
172
|
+
const nextMatch = nextHeadingRegex.exec(html);
|
|
173
|
+
const sectionEnd = nextMatch ? nextMatch.index : html.length;
|
|
174
|
+
let cursor = sectionStart;
|
|
175
|
+
while (cursor < sectionEnd) {
|
|
176
|
+
const { block, endIndex } = captureNextTable(
|
|
177
|
+
html,
|
|
178
|
+
cursor,
|
|
179
|
+
sectionEnd
|
|
180
|
+
);
|
|
181
|
+
if (!block) {
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
tables.push(block);
|
|
185
|
+
cursor = endIndex;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return tables;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function captureNextTable(html, startIndex, limitIndex) {
|
|
192
|
+
const openRegex = /<table\b[^>]*>/gi;
|
|
193
|
+
openRegex.lastIndex = startIndex;
|
|
194
|
+
const openMatch = openRegex.exec(html);
|
|
195
|
+
if (!openMatch || (limitIndex != null && openMatch.index >= limitIndex)) {
|
|
196
|
+
return { block: null, endIndex: startIndex };
|
|
197
|
+
}
|
|
198
|
+
const start = openMatch.index;
|
|
199
|
+
const tagRegex = /<\/?table\b[^>]*>/gi;
|
|
200
|
+
tagRegex.lastIndex = start;
|
|
201
|
+
let depth = 0;
|
|
202
|
+
let end = null;
|
|
203
|
+
let tagMatch;
|
|
204
|
+
while ((tagMatch = tagRegex.exec(html))) {
|
|
205
|
+
if (limitIndex != null && tagMatch.index >= limitIndex) {
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
if (tagMatch[0][1] === '/') {
|
|
209
|
+
depth -= 1;
|
|
210
|
+
if (depth === 0) {
|
|
211
|
+
end = tagRegex.lastIndex;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
depth += 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (end === null) {
|
|
219
|
+
return { block: null, endIndex: startIndex };
|
|
220
|
+
}
|
|
221
|
+
return { block: html.slice(start, end), endIndex: end };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function parseTable(tableHtml) {
|
|
225
|
+
const header = parseHeaderRow(tableHtml);
|
|
226
|
+
if (!header) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const rows = parseDataRows(tableHtml, header);
|
|
230
|
+
return { headers: header, rows };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseHeaderRow(tableHtml) {
|
|
234
|
+
const rowRegex = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
235
|
+
let match;
|
|
236
|
+
while ((match = rowRegex.exec(tableHtml))) {
|
|
237
|
+
if (/<th\b/i.test(match[1])) {
|
|
238
|
+
const cells = [...match[1].matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)];
|
|
239
|
+
const names = cells.map((cell) =>
|
|
240
|
+
normalizeWhitespace(stripHtml(cell[1]))
|
|
241
|
+
);
|
|
242
|
+
return buildHeaderMeta(names);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildHeaderMeta(names) {
|
|
249
|
+
const used = new Set();
|
|
250
|
+
return names.map((label, index) => {
|
|
251
|
+
let alias = label
|
|
252
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
253
|
+
.replace(/^_+|_+$/g, '');
|
|
254
|
+
if (!alias) {
|
|
255
|
+
alias = `Column${index + 1}`;
|
|
256
|
+
}
|
|
257
|
+
let finalAlias = alias;
|
|
258
|
+
let counter = 2;
|
|
259
|
+
while (used.has(finalAlias.toLowerCase())) {
|
|
260
|
+
finalAlias = `${alias}_${counter}`;
|
|
261
|
+
counter += 1;
|
|
262
|
+
}
|
|
263
|
+
used.add(finalAlias.toLowerCase());
|
|
264
|
+
return {
|
|
265
|
+
label,
|
|
266
|
+
alias: finalAlias,
|
|
267
|
+
index,
|
|
268
|
+
lookupKey: finalAlias.toLowerCase(),
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function parseDataRows(tableHtml, headers) {
|
|
274
|
+
const rows = [];
|
|
275
|
+
const rowRegex = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
276
|
+
let match;
|
|
277
|
+
let headerConsumed = false;
|
|
278
|
+
while ((match = rowRegex.exec(tableHtml))) {
|
|
279
|
+
if (!headerConsumed) {
|
|
280
|
+
if (/<th\b/i.test(match[1])) {
|
|
281
|
+
headerConsumed = true;
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (!/<td\b/i.test(match[1])) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
rows.push(parseRow(match[1], headers));
|
|
289
|
+
}
|
|
290
|
+
return rows;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function parseRow(rowHtml, headers) {
|
|
294
|
+
const cells = splitTopLevelCells(rowHtml).map((cell) => {
|
|
295
|
+
const cellInfo = extractCellText(cell.html || '');
|
|
296
|
+
return {
|
|
297
|
+
raw: cell.html || '',
|
|
298
|
+
attrs: cell.attrs || '',
|
|
299
|
+
text: cellInfo.text,
|
|
300
|
+
titles: cellInfo.titles,
|
|
301
|
+
span: parseColspan(cell.attrs),
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const values = {};
|
|
306
|
+
const indexVars = {};
|
|
307
|
+
const meta = {};
|
|
308
|
+
let columnIndex = 0;
|
|
309
|
+
|
|
310
|
+
for (const cell of cells) {
|
|
311
|
+
const span = cell.span > 0 ? cell.span : 1;
|
|
312
|
+
for (let i = 0; i < span && columnIndex < headers.length; i += 1) {
|
|
313
|
+
const header = headers[columnIndex];
|
|
314
|
+
if (!Object.prototype.hasOwnProperty.call(values, header.alias)) {
|
|
315
|
+
values[header.alias] = cell.text;
|
|
316
|
+
}
|
|
317
|
+
if (!meta[header.alias]) {
|
|
318
|
+
meta[header.alias] = {
|
|
319
|
+
titles: cell.titles || [],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
indexVars[`col${columnIndex + 1}`] = values[header.alias];
|
|
323
|
+
columnIndex += 1;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
while (columnIndex < headers.length) {
|
|
328
|
+
const header = headers[columnIndex];
|
|
329
|
+
values[header.alias] = values[header.alias] || '';
|
|
330
|
+
indexVars[`col${columnIndex + 1}`] = values[header.alias];
|
|
331
|
+
columnIndex += 1;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
propagateFlagNotes(values, meta, headers);
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
values,
|
|
338
|
+
indexVars,
|
|
339
|
+
rawRow: rowHtml,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function extractCellText(innerHtml) {
|
|
344
|
+
const titles = extractAttributeTexts(innerHtml);
|
|
345
|
+
const anchors = extractAnchorTexts(innerHtml);
|
|
346
|
+
const baseText = stripHtml(innerHtml);
|
|
347
|
+
const parts = [];
|
|
348
|
+
if (anchors.length > 0) {
|
|
349
|
+
parts.push(anchors.join(' '));
|
|
350
|
+
} else if (baseText) {
|
|
351
|
+
parts.push(baseText);
|
|
352
|
+
}
|
|
353
|
+
if (titles.length > 0) {
|
|
354
|
+
parts.push(titles.join(' '));
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
text: normalizeWhitespace(parts.join(' ')),
|
|
358
|
+
titles,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function extractAttributeTexts(html = '') {
|
|
363
|
+
const titles = [];
|
|
364
|
+
const attrRegex =
|
|
365
|
+
/<([a-z0-9]+)\b[^>]*\btitle\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>/gi;
|
|
366
|
+
let match;
|
|
367
|
+
while ((match = attrRegex.exec(html))) {
|
|
368
|
+
const tagName = (match[1] || '').toLowerCase();
|
|
369
|
+
if (tagName === 'img') {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const value = match[2] || match[3] || '';
|
|
373
|
+
if (value) {
|
|
374
|
+
titles.push(decodeEntities(value));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return titles;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function extractAnchorTexts(html = '') {
|
|
381
|
+
const anchors = [];
|
|
382
|
+
const anchorRegex =
|
|
383
|
+
/<a\b[^>]*\bname\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>/gi;
|
|
384
|
+
let match;
|
|
385
|
+
while ((match = anchorRegex.exec(html))) {
|
|
386
|
+
const value = match[1] || match[2] || '';
|
|
387
|
+
const formatted = formatAnchorName(value);
|
|
388
|
+
if (formatted) {
|
|
389
|
+
anchors.push(formatted);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return anchors;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function formatAnchorName(value = '') {
|
|
396
|
+
if (!value) {
|
|
397
|
+
return '';
|
|
398
|
+
}
|
|
399
|
+
let text = value.trim();
|
|
400
|
+
if (!text) {
|
|
401
|
+
return '';
|
|
402
|
+
}
|
|
403
|
+
if (text.startsWith('cmp-')) {
|
|
404
|
+
text = text.slice(4);
|
|
405
|
+
}
|
|
406
|
+
text = text.replace(/_x_/gi, '[x]');
|
|
407
|
+
text = text.replace(/_$/g, '');
|
|
408
|
+
return text;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function propagateFlagNotes(values, meta, headers) {
|
|
412
|
+
appendFlagNotes(values, meta, headers, 'L Flags', 'L Description & Constraints');
|
|
413
|
+
appendFlagNotes(values, meta, headers, 'R Flags', 'R Description & Constraints');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function appendFlagNotes(values, meta, headers, flagLabel, descLabel) {
|
|
417
|
+
const flagAlias = findAliasByLabel(headers, flagLabel);
|
|
418
|
+
const descAlias = findAliasByLabel(headers, descLabel);
|
|
419
|
+
if (!flagAlias || !descAlias) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const titles = meta[flagAlias] ? meta[flagAlias].titles : null;
|
|
423
|
+
if (!titles || titles.length === 0) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const extra = normalizeWhitespace(titles.join(' '));
|
|
427
|
+
if (!extra) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const current = values[descAlias] || '';
|
|
431
|
+
values[descAlias] = normalizeWhitespace(`${current} ${extra}`.trim());
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function findAliasByLabel(headers, label) {
|
|
435
|
+
const lower = label.toLowerCase();
|
|
436
|
+
const header = headers.find((item) => item.label.toLowerCase() === lower);
|
|
437
|
+
return header ? header.alias : null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function parseColspan(attrText = '') {
|
|
441
|
+
const match = attrText.match(/colspan\s*=\s*"?(\d+)"?/i);
|
|
442
|
+
if (!match) {
|
|
443
|
+
return 1;
|
|
444
|
+
}
|
|
445
|
+
const span = parseInt(match[1], 10);
|
|
446
|
+
return Number.isNaN(span) ? 1 : span;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function splitTopLevelCells(rowHtml) {
|
|
450
|
+
const cells = [];
|
|
451
|
+
const tokenRegex =
|
|
452
|
+
/<td\b([^>]*)\/>|<td\b([^>]*)>|<\/td\s*>|<table\b[^>]*>|<\/table\s*>/gi;
|
|
453
|
+
let nestedTableDepth = 0;
|
|
454
|
+
let currentCell = null;
|
|
455
|
+
let match;
|
|
456
|
+
while ((match = tokenRegex.exec(rowHtml))) {
|
|
457
|
+
const token = match[0];
|
|
458
|
+
if (/^<table/i.test(token)) {
|
|
459
|
+
if (currentCell) {
|
|
460
|
+
nestedTableDepth += 1;
|
|
461
|
+
}
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (/^<\/table/i.test(token)) {
|
|
465
|
+
if (currentCell && nestedTableDepth > 0) {
|
|
466
|
+
nestedTableDepth -= 1;
|
|
467
|
+
}
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (nestedTableDepth > 0) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (match[1] !== undefined) {
|
|
474
|
+
cells.push({
|
|
475
|
+
attrs: match[1] || '',
|
|
476
|
+
html: '',
|
|
477
|
+
});
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (match[2] !== undefined) {
|
|
481
|
+
currentCell = {
|
|
482
|
+
attrs: match[2] || '',
|
|
483
|
+
startIndex: tokenRegex.lastIndex,
|
|
484
|
+
};
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (/^<\/td/i.test(token) && currentCell) {
|
|
488
|
+
const cellHtml = rowHtml.slice(currentCell.startIndex, match.index);
|
|
489
|
+
cells.push({
|
|
490
|
+
attrs: currentCell.attrs,
|
|
491
|
+
html: cellHtml,
|
|
492
|
+
});
|
|
493
|
+
currentCell = null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return cells;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function applyRules(rules, row, headers, fileName, sectionHeading, groupOrderMap) {
|
|
500
|
+
const outputs = [];
|
|
501
|
+
const headerLookup = buildHeaderLookup(headers);
|
|
502
|
+
const variables = {
|
|
503
|
+
...row.values,
|
|
504
|
+
...row.indexVars,
|
|
505
|
+
file: fileName,
|
|
506
|
+
section: sectionHeading,
|
|
507
|
+
profile: fileName,
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
for (const rule of rules) {
|
|
511
|
+
if (!Array.isArray(rule.conditions) || rule.conditions.length === 0) {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (!rule.template) {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
let matches = true;
|
|
518
|
+
for (const condition of rule.conditions) {
|
|
519
|
+
if (!evaluateCondition(condition, row, headerLookup)) {
|
|
520
|
+
matches = false;
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (matches) {
|
|
525
|
+
const rank = Number.isFinite(Number(rule.rank)) ? Number(rule.rank) : null;
|
|
526
|
+
const value = Number.isFinite(Number(rule.value)) ? Number(rule.value) : null;
|
|
527
|
+
const elementPath = (row.values['Name'] || '').trim().split(/\s+/)[0] || '';
|
|
528
|
+
outputs.push({
|
|
529
|
+
text: renderTemplate(rule.template, variables),
|
|
530
|
+
group: resolveRuleGroup(rule),
|
|
531
|
+
description: rule.description || '',
|
|
532
|
+
groupOrder: resolveGroupOrder(rule, groupOrderMap),
|
|
533
|
+
rank,
|
|
534
|
+
value,
|
|
535
|
+
elementPath,
|
|
536
|
+
file: fileName,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return outputs;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function resolveGroupOrder(rule, groupOrderMap) {
|
|
544
|
+
const key = resolveRuleGroup(rule);
|
|
545
|
+
if (!groupOrderMap) {
|
|
546
|
+
return Number.MAX_SAFE_INTEGER;
|
|
547
|
+
}
|
|
548
|
+
return groupOrderMap.get(key) ?? Number.MAX_SAFE_INTEGER;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function buildHeaderLookup(headers) {
|
|
552
|
+
const lookup = {};
|
|
553
|
+
headers.forEach((header, index) => {
|
|
554
|
+
lookup[header.alias.toLowerCase()] = header.alias;
|
|
555
|
+
lookup[header.label.toLowerCase()] = header.alias;
|
|
556
|
+
lookup[`col${index + 1}`.toLowerCase()] = header.alias;
|
|
557
|
+
});
|
|
558
|
+
return lookup;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function evaluateCondition(condition, row, lookup) {
|
|
562
|
+
const columnAlias = resolveColumnAlias(condition.column || '', lookup);
|
|
563
|
+
if (!columnAlias) {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
const cellValue = row.values[columnAlias] || '';
|
|
567
|
+
const expected = resolveExpectedValue(condition, row, lookup);
|
|
568
|
+
const operator = (condition.operator || '').toLowerCase();
|
|
569
|
+
const caseSensitive = Boolean(condition.caseSensitive);
|
|
570
|
+
if (operator === 'equals') {
|
|
571
|
+
return compareEquals(cellValue, expected, caseSensitive);
|
|
572
|
+
}
|
|
573
|
+
if (operator === '!equals' || operator === 'notequals' || operator === 'not-equals') {
|
|
574
|
+
return !compareEquals(cellValue, expected, caseSensitive);
|
|
575
|
+
}
|
|
576
|
+
if (operator === 'contains') {
|
|
577
|
+
return compareContains(cellValue, expected, caseSensitive);
|
|
578
|
+
}
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function resolveExpectedValue(condition, row, lookup) {
|
|
583
|
+
if (condition.valueColumn) {
|
|
584
|
+
const alias = resolveColumnAlias(condition.valueColumn, lookup);
|
|
585
|
+
if (alias) {
|
|
586
|
+
return row.values[alias] || '';
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return condition.value || '';
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function resolveColumnAlias(column, lookup) {
|
|
593
|
+
if (!column) {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
const key = column.toLowerCase();
|
|
597
|
+
return lookup[key] || null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function compareEquals(left, right, caseSensitive) {
|
|
601
|
+
if (caseSensitive) {
|
|
602
|
+
return String(left) === String(right);
|
|
603
|
+
}
|
|
604
|
+
return String(left).toLowerCase() === String(right).toLowerCase();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function compareContains(left, right, caseSensitive) {
|
|
608
|
+
if (caseSensitive) {
|
|
609
|
+
return String(left).includes(String(right));
|
|
610
|
+
}
|
|
611
|
+
return String(left).toLowerCase().includes(String(right).toLowerCase());
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function renderTemplate(template, variables) {
|
|
615
|
+
const rendered = template.replace(/{{\s*([^}]+)\s*}}/g, (_, key) => {
|
|
616
|
+
const resolved = resolveVariableValue(key.trim(), variables);
|
|
617
|
+
return resolved != null ? resolved : '';
|
|
618
|
+
});
|
|
619
|
+
return rendered.replace(/2147483647/g, '*');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function resolveVariableValue(key, variables) {
|
|
623
|
+
if (key in variables) {
|
|
624
|
+
return variables[key];
|
|
625
|
+
}
|
|
626
|
+
const normalizedKey = normalizePlaceholderKey(key);
|
|
627
|
+
for (const candidate of Object.keys(variables)) {
|
|
628
|
+
if (normalizePlaceholderKey(candidate) === normalizedKey) {
|
|
629
|
+
return variables[candidate];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function normalizePlaceholderKey(value) {
|
|
636
|
+
return String(value)
|
|
637
|
+
.trim()
|
|
638
|
+
.toLowerCase()
|
|
639
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
640
|
+
.replace(/_+/g, '_')
|
|
641
|
+
.replace(/^_|_$/g, '');
|
|
642
|
+
}
|