pdf-accessibility/js/upload.js
michael 4080638856 Migrate PDF processing from Redis worker to Google Cloud Run
Replace the Redis queue + Python worker daemon with a synchronous HTTP
call to a Cloud Run service, eliminating Redis and simplifying the
infrastructure from 4 containers (web, worker, redis, postgres) to just
web + postgres (with Cloud Run handling processing).

- Add cloudrun_service.py: Flask app wrapping EnterprisePDFChecker with
  POST /check and GET /health endpoints, GCS image upload
- Add Dockerfile.cloudrun + requirements-cloudrun.txt for Cloud Run image
- Add cloudbuild.yaml for Cloud Build with custom Dockerfile
- Rewrite api.php: remove all Redis code, add Cloud Run OIDC auth
  (getCloudRunToken), synchronous processing in handleCheck(), file-based
  rate limiting, GCS redirect in handleImage(), DB helper updateJobInDatabase()
- Update js/upload.js: handle synchronous completed response from Cloud Run,
  increase poll timeout to 15 minutes
- Update js/page-viewer.js: use GCS URLs directly for page images
- Simplify docker-compose.yml and docker-compose.prod.yml: remove worker
  and redis services
- Remove PHP Redis extension from Dockerfile.web
- Set 900s timeouts across nginx, PHP-FPM, gunicorn, curl, and Cloud Run
- Update cleanup.py: remove result_images pattern (now on GCS), add
  rate_limits cleanup
- Update .env.example: replace Redis vars with Cloud Run/GCS config

Cloud Run service deployed to:
  https://pdf-checker-bcb6ipdqka-uc.a.run.app
GCS bucket: gs://optical-pdf-images (7-day lifecycle, public read)
GCP project: optical-414516

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 14:50:38 -06:00

201 lines
7.8 KiB
JavaScript

/* Upload handling — drag-drop, file validation, check flow */
let currentJobId = null;
let pollInterval = null;
let pollCount = 0;
function initUpload() {
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) handleFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) handleFile(e.target.files[0]);
});
}
async function handleFile(file) {
if (!file.name.toLowerCase().endsWith('.pdf')) {
alert('Please select a PDF file');
return;
}
if (file.size > 50 * 1024 * 1024) {
alert('File too large. Maximum size is 50MB.');
return;
}
clearLog();
document.getElementById('progressContainer').style.display = 'block';
updateProgress(0, 'Preparing upload...');
addLog('File selected: ' + file.name + ' (' + (file.size / 1024 / 1024).toFixed(2) + ' MB)', 'info');
try {
updateProgress(10, 'Uploading file...');
addLog('Uploading to server...', 'info');
const result = await uploadFile(file);
if (result.success) {
currentJobId = result.data.job_id;
updateProgress(20, 'Upload complete');
addLog('Upload successful — Job ID: ' + currentJobId, 'success');
await new Promise(r => setTimeout(r, 500));
beginCheck();
} else {
addLog('Upload failed: ' + result.error, 'error');
alert('Upload failed: ' + result.error);
document.getElementById('progressContainer').style.display = 'none';
}
} catch (error) {
addLog('Upload error: ' + error.message, 'error');
alert('Upload failed: ' + error.message);
document.getElementById('progressContainer').style.display = 'none';
}
}
async function beginCheck() {
updateProgress(25, 'Initializing accessibility check...');
addLog('Preparing accessibility analysis...', 'info');
const quickMode = document.getElementById('quickMode').checked;
if (quickMode) addLog('Quick mode enabled — skipping expensive checks', 'info');
try {
updateProgress(30, 'Analyzing PDF (this may take a few minutes)...');
const result = await startCheck(currentJobId, quickMode);
if (result.success) {
if (result.data && result.data.status === 'completed') {
// Synchronous Cloud Run response — results are ready
updateProgress(98, 'Loading results...');
addLog('Analysis complete!', 'success');
loadResults();
} else {
// Async/local mode fallback — poll for status
updateProgress(35, 'Analysis started');
addLog('Job processing...', 'success');
pollJobStatus();
}
} else {
addLog('Check failed: ' + result.error, 'error');
alert('Check failed: ' + result.error);
document.getElementById('progressContainer').style.display = 'none';
}
} catch (error) {
addLog('Check error: ' + error.message, 'error');
alert('Check failed: ' + error.message);
document.getElementById('progressContainer').style.display = 'none';
}
}
async function pollJobStatus() {
pollCount = 0;
const simStages = [
{ percent: 40, message: 'Loading PDF...', log: 'Reading PDF structure and metadata' },
{ percent: 50, message: 'Analyzing document structure...', log: 'Checking PDF tagging and structure' },
{ percent: 60, message: 'Analyzing images...', log: 'Processing images with AI' },
{ percent: 70, message: 'Checking color contrast...', log: 'Calculating WCAG contrast ratios' },
{ percent: 80, message: 'Analyzing readability...', log: 'Computing readability scores' },
{ percent: 90, message: 'Running final checks...', log: 'Font embedding, bookmarks, headings, tab order' },
{ percent: 95, message: 'Compiling results...', log: 'Generating accessibility report' }
];
let stageIdx = 0;
const tick = async () => {
pollCount++;
try {
const result = await checkStatus(currentJobId);
if (result.success) {
const data = result.data;
// Use real progress from Redis if available
if (data.progress && data.progress > 0) {
updateProgress(data.progress, data.status_message || data.status);
} else if (stageIdx < simStages.length && pollCount % 3 === 0) {
const s = simStages[stageIdx];
updateProgress(s.percent, s.message);
addLog(s.log, 'info');
stageIdx++;
}
if (data.status === 'completed') {
clearInterval(pollInterval);
updateProgress(98, 'Loading results...');
addLog('Analysis complete! Loading results...', 'success');
loadResults();
} else if (data.status === 'failed' || data.status === 'error') {
clearInterval(pollInterval);
addLog('Analysis failed', 'error');
if (data.error_log) addLog('Error: ' + data.error_log.substring(0, 500), 'error');
document.getElementById('progressContainer').style.display = 'none';
alert('Analysis failed. Check the error log for details.');
} else if (pollCount > 450) {
clearInterval(pollInterval);
addLog('Analysis timed out after 15 minutes', 'error');
addLog('Try using Quick Mode for faster results', 'info');
document.getElementById('progressContainer').style.display = 'none';
}
}
} catch (error) {
console.error('Status check failed:', error);
addLog('Status check error (retrying...): ' + error.message, 'warning');
}
};
tick();
pollInterval = setInterval(tick, 2000);
}
async function loadResults() {
updateProgress(100, 'Complete!');
addLog('Fetching results from server...', 'info');
try {
const result = await getResult(currentJobId);
if (result.success) {
addLog('Results loaded — Score: ' + result.data.accessibility_score + '/100', 'success');
await new Promise(r => setTimeout(r, 800));
displayResults(result.data);
} else {
addLog('Failed to load results: ' + result.error, 'error');
}
} catch (error) {
addLog('Error loading results: ' + error.message, 'error');
}
}
function resetCheck() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
if (batchPollInterval) { clearInterval(batchPollInterval); batchPollInterval = null; }
pollCount = 0;
document.getElementById('uploadSection').style.display = 'block';
document.getElementById('resultsSection').style.display = 'none';
document.getElementById('progressContainer').style.display = 'none';
document.getElementById('pageViewerCard').style.display = 'none';
document.getElementById('fileInput').value = '';
var remCard = document.getElementById('remediationCard');
if (remCard) remCard.style.display = 'none';
currentJobId = null;
clearLog();
}