diff --git a/api.php b/api.php index 2ab0d27..7b6964c 100644 --- a/api.php +++ b/api.php @@ -354,6 +354,9 @@ switch ($action) { case 'export': handleExport(); break; + case 'save_adjusted_result': + handleSaveAdjustedResult(); + break; case 'dismiss': handleDismiss(); break; @@ -1167,7 +1170,10 @@ function handleExport() { } $job_id = sanitizeJobId($job_id); - $result_file = RESULTS_DIR . '/' . $job_id . '.result.json'; + // Prefer adjusted result if available (created by save_adjusted_result) + $adj_file = RESULTS_DIR . '/' . $job_id . '.adjusted.json'; + $result_file = file_exists($adj_file) ? $adj_file : RESULTS_DIR . '/' . $job_id . '.result.json'; + if (!file_exists($result_file)) { error('Results not found'); } @@ -1231,6 +1237,119 @@ function handleExport() { exit; } +/** + * Save an adjusted result merging dismissed issues and check overrides into a new JSON file. + * The export endpoint will prefer this file over the original result. + */ +function handleSaveAdjustedResult() { + $data = json_decode(file_get_contents('php://input'), true) ?: []; + $job_id = $data['job_id'] ?? ''; + + if (empty($job_id)) { + error('job_id required'); + } + $job_id = sanitizeJobId($job_id); + + $result_file = RESULTS_DIR . '/' . $job_id . '.result.json'; + if (!file_exists($result_file)) { + error('Results not found'); + } + + $result = json_decode(file_get_contents($result_file), true); + + // Load dismissed and overrides + $dismiss_file = RESULTS_DIR . '/' . $job_id . '.dismissed.json'; + $override_file = RESULTS_DIR . '/' . $job_id . '.overrides.json'; + $dismissed = file_exists($dismiss_file) ? json_decode(file_get_contents($dismiss_file), true) : []; + $overrides = file_exists($override_file) ? json_decode(file_get_contents($override_file), true) : []; + + // 1. Mark dismissed issues in the issues array + if (!empty($dismissed) && isset($result['issues'])) { + foreach ($result['issues'] as $idx => &$issue) { + if (isset($dismissed[$idx])) { + $issue['dismissed'] = true; + } + } + unset($issue); + } + + // 2. Recalculate score (mirrors JS recalculateScore()) + $bd = $result['score_breakdown'] ?? []; + $origSC = $result['severity_counts'] ?? []; + + $adj_crit = (int)($origSC['critical'] ?? 0); + $adj_err = (int)($origSC['error'] ?? 0); + + // Subtract dismissed CRITICAL / ERROR issues + foreach ($dismissed as $idx => $info) { + $sev = strtoupper($result['issues'][$idx]['severity'] ?? ''); + if ($sev === 'CRITICAL') $adj_crit = max(0, $adj_crit - 1); + if ($sev === 'ERROR') $adj_err = max(0, $adj_err - 1); + } + + $new_penalty = min(20, $adj_crit * 5 + $adj_err * 2); + $checks_total = (int)($bd['checks_total'] ?? 0); + $checks_passed = (int)($bd['checks_passed'] ?? 0); + $new_passed = min($checks_total, $checks_passed + count($overrides)); + $new_base = $checks_total > 0 ? (int)round(100 * $new_passed / $checks_total) : 0; + $new_score = max(0, $new_base - $new_penalty); + + $result['accessibility_score'] = $new_score; + $result['severity_counts']['critical'] = $adj_crit; + $result['severity_counts']['error'] = $adj_err; + $result['score_breakdown']['final_score'] = $new_score; + $result['score_breakdown']['checks_passed'] = $new_passed; + $result['score_breakdown']['base_score'] = $new_base; + $result['score_breakdown']['penalty'] = $new_penalty; + $result['score_breakdown']['adjusted'] = true; + + // 3. Mark overridden checks in checks_performed + if (!empty($overrides) && isset($result['checks_performed'])) { + foreach ($result['checks_performed'] as &$check) { + if (isset($overrides[$check['name']])) { + $check['passed'] = true; + $check['manual'] = true; + } + } + unset($check); + } + + // 4. Update Matterhorn checkpoints for H-type CPs linked to overridden checks + $check_to_cp = [ + 'Color Contrast' => ['04'], + 'Image Accessibility' => ['13'], + ]; + $cp_to_check = []; + foreach ($check_to_cp as $checkName => $cpIds) { + foreach ($cpIds as $cpId) { + $cp_to_check[$cpId] = $checkName; + } + } + + if (!empty($overrides) && isset($result['matterhorn_summary']['checkpoints'])) { + foreach ($result['matterhorn_summary']['checkpoints'] as &$cp) { + $cpId = $cp['id']; + if (isset($cp_to_check[$cpId]) && isset($overrides[$cp_to_check[$cpId]])) { + $cp['status'] = 'PASS'; + $cp['manual'] = true; + } + } + unset($cp); + + // Recompute overall_passed + $all_pass = true; + foreach ($result['matterhorn_summary']['checkpoints'] as $cp) { + if ($cp['status'] === 'FAIL') { $all_pass = false; break; } + } + $result['matterhorn_summary']['overall_passed'] = $all_pass; + } + + $adj_file = RESULTS_DIR . '/' . $job_id . '.adjusted.json'; + file_put_contents($adj_file, json_encode($result)); + + success(['saved' => true, 'score' => $new_score]); +} + /** * Dismiss an issue (mark as false positive) */ diff --git a/css/styles.css b/css/styles.css index 3218958..eefe490 100644 --- a/css/styles.css +++ b/css/styles.css @@ -508,7 +508,7 @@ h1::before { } :root[data-theme="dark"] .log-header { - background: #252840; + background: #242424; color: var(--text); } diff --git a/js/batch.js b/js/batch.js index 57f7b1e..b39c588 100644 --- a/js/batch.js +++ b/js/batch.js @@ -273,8 +273,32 @@ async function viewBatchResult(jobId) { } } -function exportReport(format) { +async function exportReport(format) { if (!currentJobId) return; + + const hasAdjustments = + (typeof overriddenChecks !== 'undefined' && overriddenChecks.size > 0) || + (typeof dismissedIndices !== 'undefined' && dismissedIndices.size > 0); + + // Open the window synchronously first to avoid popup-blocker blocking an async call + const win = window.open('about:blank', '_blank'); + + if (hasAdjustments) { + try { + await fetch('api.php?action=save_adjusted_result', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ job_id: currentJobId }) + }); + } catch (e) { + console.warn('Could not save adjusted result before export:', e); + } + } + const url = getExportUrl(currentJobId, format); - window.open(url, '_blank'); + if (win) { + win.location.href = url; + } else { + window.open(url, '_blank'); + } } diff --git a/js/results.js b/js/results.js index 86da7c0..5fceeae 100644 --- a/js/results.js +++ b/js/results.js @@ -6,6 +6,7 @@ let dismissedIndices = new Set(); let overriddenChecks = new Set(); let scoreBreakdownData = null; let originalSeverityCounts = null; +let lastMatterhornData = null; function displayResults(data) { document.getElementById('uploadSection').style.display = 'none'; @@ -38,7 +39,8 @@ function displayResults(data) { displayIssues(allIssues); initializePageViewer(data); displayRemediationOptions(data); - displayMatterhorn(data.matterhorn_summary); + lastMatterhornData = data.matterhorn_summary || null; + displayMatterhorn(lastMatterhornData); // Refresh history so the new result appears in the table if (typeof loadHistory === 'function') loadHistory(); @@ -351,6 +353,9 @@ function displayScoreBreakdown(breakdown) { `; } +// Maps H-type Matterhorn checkpoint IDs to the Score Breakdown check names that drive them +const CP_TO_CHECK = { '04': 'Color Contrast', '13': 'Image Accessibility' }; + function displayMatterhorn(summary) { const card = document.getElementById('matterhornCard'); const banner = document.getElementById('matterhornBanner'); @@ -359,7 +364,25 @@ function displayMatterhorn(summary) { card.style.display = 'block'; - banner.innerHTML = summary.overall_passed + const cpMap = {}; + summary.checkpoints.forEach(cp => { cpMap[cp.id] = cp; }); + + // Compute effective status: H-type FAIL → MANUAL_PASS if linked check is overridden + function effectiveStatus(cp) { + if (cp.how === 'H' && cp.status === 'FAIL') { + const linked = CP_TO_CHECK[cp.id]; + if (linked && overriddenChecks.has(linked)) return 'MANUAL_PASS'; + } + return cp.status; + } + + // Recompute overall_passed based on effective statuses + const effectivelyAllPassed = summary.checkpoints.every(cp => { + const s = effectiveStatus(cp); + return s === 'PASS' || s === 'MANUAL_PASS' || s === 'NOT_TESTED'; + }); + + banner.innerHTML = effectivelyAllPassed ? `
✅ PDF/UA-1 requirements fulfilled
` : `
❌ PDF/UA-1 requirements NOT fulfilled
`; @@ -369,23 +392,35 @@ function displayMatterhorn(summary) { { label: 'Document Elements', ids: ['21','22','23','24','25','26','27','28','29','30','31'] }, ]; - const cpMap = {}; - summary.checkpoints.forEach(cp => { cpMap[cp.id] = cp; }); - let html = ''; sections.forEach(section => { html += `${section.label}`; section.ids.forEach(id => { const cp = cpMap[id]; if (!cp) return; - const statusHtml = cp.status === 'PASS' - ? `✓ PASS` - : cp.status === 'FAIL' - ? `✗ FAIL` - : `— Not tested`; + + const effStatus = effectiveStatus(cp); const howBadge = cp.how === 'M' ? `M` : `H`; + + let statusHtml; + if (effStatus === 'MANUAL_PASS') { + const linked = CP_TO_CHECK[cp.id]; + statusHtml = `✓ Manual Pass + `; + } else if (effStatus === 'PASS') { + statusHtml = `✓ PASS`; + } else if (effStatus === 'FAIL' && cp.how === 'H' && CP_TO_CHECK[cp.id]) { + const linked = CP_TO_CHECK[cp.id]; + statusHtml = `✗ FAIL + `; + } else if (effStatus === 'FAIL') { + statusHtml = `✗ FAIL`; + } else { + statusHtml = `— Not tested`; + } + html += ` CP${cp.id} ${cp.name} ${howBadge} @@ -464,6 +499,8 @@ async function overrideCheck(checkName) { `; } renderRecalcButton(); + // Refresh Matterhorn table so CP status reflects the override + if (lastMatterhornData) displayMatterhorn(lastMatterhornData); } } catch(e) { console.error('Override failed:', e); } } @@ -487,6 +524,8 @@ async function unoverrideCheck(checkName) { `; } renderRecalcButton(); + // Refresh Matterhorn table so CP status reflects the removal + if (lastMatterhornData) displayMatterhorn(lastMatterhornData); } } catch(e) { console.error('Unoverride failed:', e); } } diff --git a/report_generator.py b/report_generator.py index a9ab97f..e596e75 100644 --- a/report_generator.py +++ b/report_generator.py @@ -52,12 +52,13 @@ def generate_html(data: dict) -> str: score = data.get("accessibility_score", 0) grade = grade_from_score(score) sc = data.get("severity_counts", {}) - issues = data.get("issues", []) + issues = [i for i in data.get("issues", []) if not i.get("dismissed")] checks = data.get("checks_performed", []) filename = data.get("filename", "Unknown") total_pages = data.get("total_pages", 0) stats = data.get("stats", {}) now = datetime.now().strftime("%Y-%m-%d %H:%M") + is_adjusted = data.get("score_breakdown", {}).get("adjusted", False) # Score ring color if score >= 80: @@ -93,8 +94,15 @@ def generate_html(data: dict) -> str: # Build checks table check_rows = [] for ch in checks: - status = "PASS" if ch.get("passed") else "FAIL" - status_color = "#10b981" if ch.get("passed") else "#ef4444" + if ch.get("manual"): + status = "Manual Pass" + status_color = "#d97706" + elif ch.get("passed"): + status = "PASS" + status_color = "#10b981" + else: + status = "FAIL" + status_color = "#ef4444" dur = f"{ch.get('duration', 0):.2f}s" check_rows.append(f""" @@ -182,13 +190,15 @@ def generate_html(data: dict) -> str: Accessibility Report — {filename} + + @@ -247,9 +257,10 @@ def generate_html(data: dict) -> str:

Accessibility Score

-