-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscript.js
More file actions
530 lines (444 loc) · 21.4 KB
/
script.js
File metadata and controls
530 lines (444 loc) · 21.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
/**
* -------------------------------------------------------------------
* SAR Data Schema Validator - Core JavaScript Logic (Unified File)
*
* Contains:
* 1. Configuration constants (Schemas, URLs).
* 2. Main interface initialization and setup.
* 3. Validation execution logic (with API retries).
* 4. Results processing and report generation.
* 5. View management (Validator <-> Schema Viewer).
* 6. Schema Viewer logic (fetching and displaying JSON).
* -------------------------------------------------------------------
*/
// --- Configuration Constants ---
/**
* @typedef {Object} SchemaConfig
* @property {string} key - Unique key for internal reference (e.g., "deployment_projects").
* @property {string} name - Display name for the UI (e.g., "1. Deployment Projects").
* @property {string} filename - The schema JSON filename (e.g., "sar_deployment_projects_schema.json").
*/
/** @type {SchemaConfig[]} */
const SCHEMA_CONFIG = [
{ key: "deployment_projects", name: "1. Deployment Projects", filename: "sar_deployment_projects_schema.json" },
{ key: "ee_staffing", name: "2. EE Office Staffing", filename: "sar_ee_broadband_office_staffing_schema.json" },
{ key: "ee_contracts", name: "3. EE Contracts", filename: "sar_ee_contracts_schema.json" },
{ key: "non_deployment_projects", name: "4. Non-Deployment Projects", filename: "sar_non_deployment_projects_schema.json" },
{ key: "no_bead_locations", name: "5. No BEAD Locations", filename: "sar_no_bead_locations_schema.json" },
{ key: "served_bsls", name: "6. Served BSLs", filename: "sar_served_bsls_schema.json" },
{ key: "served_caiss", name: "7. Served CAISs", filename: "sar_served_caiss_schema.json" },
{ key: "subgrantees", name: "8. Subgrantees", filename: "sar_subgrantees_schema.json" },
];
// Base URL for schema files (Absolute path for GitHub raw content)
const BASE_SCHEMA_URL = 'https://raw.githubusercontent.com/GeoCodable/BEAD-SAR-CSVLint/main/sar_schemas/';
const CSVLINT_API_URL = 'https://csvlint.io/validate';
// Constants for API Retry Logic
const MAX_RETRIES = 3;
const INITIAL_DELAY_MS = 1000; // 1 second
// --- Global State ---
const schemaCache = {};
let activeSchemaElement = null;
// --- Interface Initialization and View Management ---
/**
* Initializes the dynamic UI elements based on the SCHEMA_CONFIG array.
*/
function initializeInterface() {
const container = document.getElementById('validation-interface');
// 1. Generate the file upload/validation sections
SCHEMA_CONFIG.forEach(schema => {
const group = document.createElement('div');
group.className = 'schema-group';
group.innerHTML = `
<label>
<span>${schema.name}</span>
<span id="status-container-${schema.key}">
<span class="status-badge status-pending" id="status-${schema.key}">Ready</span>
<a id="download-${schema.key}" class="download-link" style="display:none;">Download Report</a>
<a id="downloadcsv-${schema.key}" class="download-link" style="display:none;">Download CSV</a>
</span>
</label>
<div class="file-input-container">
<input type="file" id="file-${schema.key}" accept=".csv" onchange="resetStatus('${schema.key}')">
<button class="validate-single-btn" onclick="runSingleValidation('${schema.key}', '${schema.filename}')"
id="single-btn-${schema.key}" disabled>Validate</button>
</div>
`;
container.appendChild(group);
// Add event listener to control the state of the individual validate button
document.getElementById(`file-${schema.key}`).addEventListener('change', (event) => {
const singleBtn = document.getElementById(`single-btn-${schema.key}`);
singleBtn.disabled = event.target.files.length === 0;
});
});
// 2. Attach view switching listeners
document.getElementById('nav-validator').addEventListener('click', (e) => switchView(e, 'validator-view'));
document.getElementById('nav-schema-viewer').addEventListener('click', (e) => switchView(e, 'schema-viewer-view'));
// The "About / Documentation" link is now a simple external <a> tag, no JS needed.
}
/**
* Toggles the main application view between the validator and the schema viewer.
* @param {Event} event - The click event.
* @param {string} viewId - The ID of the view container to show.
*/
function switchView(event, viewId) {
event.preventDefault();
// Hide all internal views
document.getElementById('validator-view').style.display = 'none';
document.getElementById('schema-viewer-view').style.display = 'none';
// Show the requested view
document.getElementById(viewId).style.display = 'block';
// Update navigation active status
document.querySelectorAll('.main-nav a').forEach(link => link.classList.remove('active'));
// Only target internal links for active status
if (event.target.id.startsWith('nav-')) {
event.target.classList.add('active');
}
// If switching to schema viewer, ensure the list is populated
if (viewId === 'schema-viewer-view') {
loadSchemaViewer();
}
}
/**
* Resets the status badge and hides download links for a specific schema key.
* @param {string} key - The unique schema key.
*/
function resetStatus(key) {
document.getElementById(`status-${key}`).className = 'status-badge status-pending';
document.getElementById(`status-${key}`).textContent = 'Ready';
document.getElementById(`download-${key}`).style.display = 'none';
document.getElementById(`downloadcsv-${key}`).style.display = 'none';
const fileInput = document.getElementById(`file-${key}`);
const singleBtn = document.getElementById(`single-btn-${key}`);
if (fileInput && singleBtn) {
singleBtn.disabled = (fileInput.files.length === 0);
singleBtn.textContent = 'Validate';
}
}
// --- Utility Functions ---
/**
* Reads CSV headers from the uploaded file using PapaParse for robust parsing.
* @param {File} file - The uploaded CSV file object.
* @returns {Promise<string[]>} A promise that resolves with an array of header names.
*/
async function getCsvHeaders(file) {
return new Promise((resolve, reject) => {
// PapaParse configuration for reading headers only
Papa.parse(file, {
header: true,
skipEmptyLines: true,
preview: 1, // Only read the first row for headers
complete: function(results) {
// PapaParse stores field names in results.meta.fields
resolve(results.meta.fields || []);
},
error: function(err) {
console.error("PapaParse error:", err);
reject(new Error("Failed to parse CSV headers. Check file encoding/format."));
}
});
});
}
/**
* Escapes content strings for safe insertion into a generated CSV report.
* @param {string|number|null|undefined} content - The content to escape.
* @returns {string} The escaped and quoted string.
*/
function escapeCsvContent(content) {
if (content === null || content === undefined) return '""';
const strContent = String(content);
// Replace all double quotes with two double quotes, then wrap the whole string in quotes
return '"' + strContent.replace(/"/g, '""') + '"';
}
/**
* Handles API and network errors by updating the status and providing a downloadable error report.
* @param {string} key - The unique schema key.
* @param {string} errorContent - Detailed error message content.
*/
function handleDownloadableError(key, errorContent) {
const statusBadge = document.getElementById(`status-${key}`);
const downloadLink = document.getElementById(`download-${key}`);
const downloadCsvLink = document.getElementById(`downloadcsv-${key}`);
// Set status
statusBadge.className = 'status-badge status-error';
statusBadge.textContent = 'ERROR 🛑 (Complete)';
// Create downloadable error file
const blob = new Blob([errorContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
downloadLink.href = url;
downloadLink.download = `API_ERROR_${key.toUpperCase()}.txt`;
downloadLink.textContent = 'Download API Error (.txt)';
downloadLink.style.display = 'inline';
downloadCsvLink.style.display = 'none';
console.error(`Validation failed for ${key}:`, errorContent);
}
// --- Validation Execution Functions ---
/**
* Initiates validation for all files with active uploads.
*/
async function runAllValidations() {
const validateAllBtn = document.getElementById('validateAllBtn');
const loadingDiv = document.getElementById('loading');
// UI state management: Disable all controls
validateAllBtn.disabled = true;
validateAllBtn.textContent = 'Processing...';
loadingDiv.style.display = 'block';
SCHEMA_CONFIG.forEach(schema => {
const btn = document.getElementById(`single-btn-${schema.key}`);
if (btn) btn.disabled = true;
});
// Create an array of validation promises for files that are present
const validationPromises = SCHEMA_CONFIG.map(schema => {
const fileInput = document.getElementById(`file-${schema.key}`);
if (fileInput && fileInput.files.length > 0) {
return validateCsv(schema.key, schema.filename);
} else {
return Promise.resolve(); // Resolve immediately if no file is uploaded
}
});
await Promise.all(validationPromises);
// UI state management: Restore controls
loadingDiv.style.display = 'none';
validateAllBtn.textContent = 'Validate All Uploaded Files';
validateAllBtn.disabled = false;
SCHEMA_CONFIG.forEach(schema => {
const btn = document.getElementById(`single-btn-${schema.key}`);
const fileInput = document.getElementById(`file-${schema.key}`);
if (btn && fileInput && fileInput.files.length > 0) {
// Re-enable individual button only if a file is still selected
btn.disabled = false;
}
});
}
/**
* Initiates validation for a single, specific file.
*/
async function runSingleValidation(key, schemaFileName) {
const validateAllBtn = document.getElementById('validateAllBtn');
const singleBtn = document.getElementById(`single-btn-${key}`);
// Disable global button and the current single button
validateAllBtn.disabled = true;
singleBtn.disabled = true;
singleBtn.textContent = 'Processing...';
await validateCsv(key, schemaFileName);
// Re-enable buttons based on state
validateAllBtn.disabled = false;
singleBtn.textContent = 'Validate';
const fileInput = document.getElementById(`file-${key}`);
if (fileInput && fileInput.files.length > 0) {
singleBtn.disabled = false;
}
}
/**
* Core function to send the CSV file to CSVLint.io for validation, using retries.
*/
async function validateCsv(key, schemaFileName) {
const fileInput = document.getElementById(`file-${key}`);
const statusBadge = document.getElementById(`status-${key}`);
statusBadge.className = 'status-badge status-pending';
statusBadge.textContent = 'Validating...';
const file = fileInput.files[0];
// Step 1: Get CSV Headers robustly
let headers;
try {
headers = await getCsvHeaders(file);
} catch (error) {
handleDownloadableError(key, error.message);
return;
}
// Step 2: Construct API Request
const schemaUrl = `${BASE_SCHEMA_URL}${schemaFileName}`;
const formData = new FormData();
formData.append('file', file);
formData.append('schemaUrl', schemaUrl);
// Step 3: Implement Retry Logic
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const response = await fetch(CSVLINT_API_URL, {
method: 'POST',
body: formData,
headers: { 'Accept': 'application/json' }
});
if (response.ok) {
// Success path: API call succeeded and returned a 2xx status
const jsonResponse = await response.json();
handleResults(key, jsonResponse, file.name, headers);
return;
}
// Non-OK status (e.g., 400, 500)
const errorText = await response.text();
// If this is the last attempt, report the final failure
if (attempt === MAX_RETRIES - 1) {
handleDownloadableError(key, `API failed after ${MAX_RETRIES} attempts.\nHTTP Error ${response.status} from CSVLint API.\nSchema URL: ${schemaUrl}\n\nServer Response Text:\n${errorText}`);
return;
}
// Calculate exponential backoff delay (1s, 2s, 4s, ...)
const delay = INITIAL_DELAY_MS * Math.pow(2, attempt);
console.log(`Retrying validation for ${key} in ${delay / 1000}s (Attempt ${attempt + 1}).`);
await new Promise(resolve => setTimeout(resolve, delay));
} catch (error) {
// Network failure (e.g., connection reset, DNS failure)
// If this is the last attempt, report the final network error
if (attempt === MAX_RETRIES - 1) {
handleDownloadableError(key, `Network or Fetch Error after ${MAX_RETRIES} attempts:\n${error.message}`);
return;
}
// Calculate exponential backoff delay
const delay = INITIAL_DELAY_MS * Math.pow(2, attempt);
console.log(`Network error, retrying validation for ${key} in ${delay / 1000}s (Attempt ${attempt + 1}).`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// --- Results Processing and Reporting ---
/**
* Processes the JSON response from CSVLint.io, updates the status, and generates downloadable reports.
* @param {string} key - The unique schema key.
* @param {Object} data - The full JSON response from the CSVLint API.
* @param {string} originalFilename - The name of the uploaded file.
* @param {string[]} headers - Array of CSV column header names.
*/
function handleResults(key, data, originalFilename, headers) {
const statusBadge = document.getElementById(`status-${key}`);
const downloadLink = document.getElementById(`download-${key}`);
const downloadCsvLink = document.getElementById(`downloadcsv-${key}`);
const validation = data.validation;
if (!validation) {
handleDownloadableError(key, 'Bad API Response: Validation property missing or malformed.\n' + JSON.stringify(data, null, 2));
return;
}
// --- Log the state for debugging ---
console.log(`Validation state for ${key}:`, validation.state);
// --- Report Header ---
let fileContent =
`Validation Report for: ${originalFilename}\n` +
`Schema: ${validation.schema}\n` +
`Status: ${validation.state ? validation.state.toUpperCase() : 'UNKNOWN'}\n\n`;
// --- CSV Report Setup ---
let csvContent = "Type,Category,Row,Column,ColumnName,Content\n";
let hasErrorsOrWarnings = false;
// Helper to process errors/warnings
const processIssue = (issue) => {
// CSVLint columns are 1-based. headers array is 0-based.
let colNum = issue.column || 'N/A';
let colIndex = issue.column ? issue.column - 1 : -1;
let colName = (colIndex >= 0 && headers && headers[colIndex]) ? headers[colIndex] : 'N/A';
let content = issue.content || '';
fileContent +=
`Type: ${issue.type}\n` +
`Category: ${issue.category}\n` +
`Row: ${issue.row || 'N/A'}\n` +
`Column: ${colNum} (${colName})\n` +
`Content: ${content || 'N/A'}\n---\n`;
// Use escapeCsvContent for all fields to ensure CSV integrity
csvContent +=
`${escapeCsvContent(issue.type)},${escapeCsvContent(issue.category)},${issue.row || ''},${colNum},${escapeCsvContent(colName)},${escapeCsvContent(content)}\n`;
hasErrorsOrWarnings = true;
};
// Process Errors
if (validation.errors && validation.errors.length > 0) {
fileContent += `--- ERRORS (${validation.errors.length}) ---\n\n`;
validation.errors.forEach(err => processIssue(err));
}
// Process Warnings
if (validation.warnings && validation.warnings.length > 0) {
fileContent += `\n--- WARNINGS (${validation.warnings.length}) ---\n\n`;
validation.warnings.forEach(warn => processIssue(warn));
}
if (!hasErrorsOrWarnings) {
fileContent += "No errors or warnings found.\n";
csvContent += "No errors or warnings found.\n";
}
// --- Download Link Creation ---
// TXT Report
const txtBlob = new Blob([fileContent], { type: 'text/plain' });
const txtUrl = URL.createObjectURL(txtBlob);
downloadLink.href = txtUrl;
downloadLink.download = `${originalFilename.replace('.csv', '')}_REPORT.txt`;
downloadLink.textContent = 'Download Report (.txt)';
downloadLink.style.display = 'inline';
// CSV Report
const csvBlob = new Blob([csvContent], { type: 'text/csv' });
const csvUrl = URL.createObjectURL(csvBlob);
downloadCsvLink.href = csvUrl;
downloadCsvLink.download = `${originalFilename.replace('.csv', '')}_REPORT.csv`;
downloadCsvLink.textContent = 'Download CSV';
downloadCsvLink.style.display = 'inline';
// --- Final Status Badge Update (Uses CSS classes from styles.css) ---
if (validation.state === 'valid') {
statusBadge.className = 'status-badge status-valid';
statusBadge.textContent = 'VALID ✅ (Complete)';
} else if (validation.state === 'invalid') {
statusBadge.className = 'status-badge status-invalid';
statusBadge.textContent = 'INVALID ❌ (Complete)';
} else if (validation.state === 'warnings') {
statusBadge.className = 'status-badge status-warnings';
statusBadge.textContent = 'VALID w/ WARNINGS ⚠️ (Complete)';
} else if (validation.state) {
statusBadge.className = 'status-badge status-valid';
statusBadge.textContent = `COMPLETE (${validation.state})`;
} else {
statusBadge.className = 'status-badge status-error';
statusBadge.textContent = 'Unknown Status (Complete)';
console.warn(`Unexpected validation.state: "${validation.state}" in API response`, validation);
}
}
// --- Schema Viewer Logic ---
/**
* Loads the schema viewer content: populates the list of schemas.
*/
function loadSchemaViewer() {
const listContainer = document.getElementById('schema-list-container');
// Check if the list has already been populated
if (listContainer.children.length > 0) return;
// Clear the list to prevent duplicates on view switch (shouldn't be needed with the check above, but good defense)
listContainer.innerHTML = '';
document.getElementById('schema-content').textContent = 'Select a schema to view its content.';
// Populate the list from the global config
SCHEMA_CONFIG.forEach(schema => {
const button = document.createElement('button');
button.className = 'schema-link-btn';
button.textContent = schema.name;
button.onclick = () => displaySchema(schema.filename, button);
listContainer.appendChild(button);
});
}
/**
* Fetches and displays the JSON content for a selected schema.
* @param {string} filename - The name of the schema file.
* @param {HTMLElement} clickedElement - The button element that was clicked.
*/
async function displaySchema(filename, clickedElement) {
const schemaContentPre = document.getElementById('schema-content');
const schemaUrl = `${BASE_SCHEMA_URL}${filename}`;
// 1. Update active button status
if (activeSchemaElement) {
activeSchemaElement.classList.remove('active');
}
clickedElement.classList.add('active');
activeSchemaElement = clickedElement;
// 2. Check cache
if (schemaCache[filename]) {
schemaContentPre.textContent = schemaCache[filename];
return;
}
// 3. Fetch schema content
schemaContentPre.textContent = `Loading ${filename}...`;
try {
const response = await fetch(schemaUrl);
if (!response.ok) {
throw new Error(`HTTP Error ${response.status} when fetching schema from ${schemaUrl}`);
}
const rawJson = await response.json();
// Format the JSON with indentation for readability
const formattedJson = JSON.stringify(rawJson, null, 2);
// Cache and display
schemaCache[filename] = formattedJson;
schemaContentPre.textContent = formattedJson;
} catch (error) {
// This error will likely occur if the local 'sar_schemas' folder is missing or files aren't found.
schemaContentPre.textContent = `Error loading schema:\n${error.message}\n\nPlease ensure the JSON file exists at the absolute path: ${schemaUrl}`;
console.error("Schema fetch error:", error);
}
}
// Ensure the interface is initialized when the DOM is fully loaded
window.onload = initializeInterface;