// Client-side JavaScript for Prezenta Secure class SecureExcelViewer { constructor() { this.config = null; this.backendUrl = ''; // Use same-origin /api through Nginx so deployment works from any host/port this.currentMeetingFilter = null; this.recordingsBackupPollTimer = null; this.appInitialized = false; this.init(); } async init() { this.setupLoginUi(); const authenticated = await this.checkAuthStatus(); if (authenticated) { await this.startApp(); } else { this.showLogin(); } } async startApp() { try { this.showApp(); await this.loadConfig(); if (!this.appInitialized) { this.setupTabs(); this.setupGroupUi(); this.setupRecordingBackupUi(); this.appInitialized = true; } const loadingElement = document.getElementById('loading'); const excelViewsContainer = document.getElementById('excel-views'); const errorElement = document.getElementById('config-error'); if (loadingElement) loadingElement.style.display = 'none'; if (excelViewsContainer) excelViewsContainer.style.display = 'none'; if (errorElement) errorElement.style.display = 'none'; await Promise.all([ this.loadAvailableMeetings(), this.loadGroups(), this.loadGoogleDriveOAuthStatus(), this.loadZoomAccounts(), this.loadRecordingBackupStatus() ]); } catch (error) { console.error('Error initializing Excel viewer:', error); this.showError('Failed to initialize Excel viewer. Please check the console for details.'); } } setupLoginUi() { const form = document.getElementById('login-form'); const logoutButton = document.getElementById('logout-button'); if (form) { form.addEventListener('submit', event => { event.preventDefault(); this.login(); }); } if (logoutButton) { logoutButton.addEventListener('click', () => this.logout()); } } async checkAuthStatus() { try { const response = await fetch(`${this.backendUrl}/api/auth/status`, { credentials: 'same-origin' }); if (!response.ok) return false; const data = await response.json(); const usernameInput = document.getElementById('login-username'); if (usernameInput && data.username && !usernameInput.value) usernameInput.value = data.username; return Boolean(data.authenticated); } catch (error) { console.error('Could not check login status:', error); this.showLoginMessage('Could not check login status. Please try again.', 'error'); return false; } } showLogin(message = '') { const loginScreen = document.getElementById('login-screen'); const appShell = document.getElementById('app-shell'); const logoutButton = document.getElementById('logout-button'); if (loginScreen) loginScreen.style.display = 'flex'; if (appShell) appShell.style.display = 'none'; if (logoutButton) logoutButton.style.display = 'none'; if (message) this.showLoginMessage(message, 'error'); const passwordInput = document.getElementById('login-password'); const usernameInput = document.getElementById('login-username'); if (passwordInput) passwordInput.value = ''; setTimeout(() => (usernameInput && !usernameInput.value ? usernameInput : passwordInput)?.focus(), 0); } showApp() { const loginScreen = document.getElementById('login-screen'); const appShell = document.getElementById('app-shell'); const logoutButton = document.getElementById('logout-button'); if (loginScreen) loginScreen.style.display = 'none'; if (appShell) appShell.style.display = 'block'; if (logoutButton) logoutButton.style.display = 'inline-flex'; this.showLoginMessage('', 'error'); } showLoginMessage(message, type = 'error') { const element = document.getElementById('login-message'); if (!element) return; element.textContent = message; element.className = `status-message ${type}`; element.style.display = message ? 'block' : 'none'; } async login() { const usernameInput = document.getElementById('login-username'); const passwordInput = document.getElementById('login-password'); const submitButton = document.getElementById('login-submit'); const username = usernameInput ? usernameInput.value.trim() : ''; const password = passwordInput ? passwordInput.value : ''; if (submitButton) submitButton.disabled = true; this.showLoginMessage('', 'error'); try { const response = await fetch(`${this.backendUrl}/api/auth/login`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!response.ok) throw new Error(await this.readErrorMessage(response)); await this.startApp(); } catch (error) { this.showLoginMessage(error.message || 'Login failed.', 'error'); } finally { if (submitButton) submitButton.disabled = false; } } async logout() { try { await fetch(`${this.backendUrl}/api/auth/logout`, { method: 'POST', credentials: 'same-origin' }); } catch (error) { console.error('Logout failed:', error); } finally { this.showLogin(); } } async loadConfig() { this.config = { appName: 'Prezenta Secure', appDescription: 'Secure Excel Viewer for Google Cloud Sheets', updateInterval: 300000, theme: 'light' }; } setupTabs() { const tabButtons = document.querySelectorAll('.tab-button'); tabButtons.forEach(button => { button.addEventListener('click', () => { const tabName = button.dataset.tab; tabButtons.forEach(btn => btn.classList.toggle('active', btn === button)); document.querySelectorAll('.tab-panel').forEach(panel => { panel.classList.toggle('active', panel.id === `tab-${tabName}`); }); if (tabName === 'grupe') { this.loadGroups(); } }); }); } setupGroupUi() { const openButton = document.getElementById('open-add-group-dialog'); const closeButton = document.getElementById('close-add-group-dialog'); const cancelButton = document.getElementById('cancel-add-group'); const form = document.getElementById('add-group-form'); const dialog = document.getElementById('add-group-dialog'); const connectDriveButton = document.getElementById('connect-google-drive'); const disconnectDriveButton = document.getElementById('disconnect-google-drive'); if (openButton) { openButton.addEventListener('click', () => this.openAddGroupDialog()); } if (closeButton) { closeButton.addEventListener('click', () => this.closeAddGroupDialog()); } if (cancelButton) { cancelButton.addEventListener('click', () => this.closeAddGroupDialog()); } if (dialog) { dialog.addEventListener('click', event => { if (event.target === dialog) this.closeAddGroupDialog(); }); } if (form) { form.addEventListener('submit', event => { event.preventDefault(); this.submitAddGroup(); }); } if (connectDriveButton) { connectDriveButton.addEventListener('click', () => this.connectGoogleDriveOAuth()); } if (disconnectDriveButton) { disconnectDriveButton.addEventListener('click', () => this.disconnectGoogleDriveOAuth()); } window.addEventListener('storage', event => { if (event.key === 'googleDriveOAuthConnected') this.loadGoogleDriveOAuthStatus(); }); } setupRecordingBackupUi() { const backupButton = document.getElementById('backup-recordings'); const deleteButton = document.getElementById('delete-backed-up-recordings'); if (backupButton) { backupButton.addEventListener('click', () => this.startRecordingsBackup()); } if (deleteButton) { deleteButton.addEventListener('click', () => this.deleteBackedUpRecordings()); } } async loadRecordingBackupStatus() { const usageElement = document.getElementById('recordings-cloud-usage'); if (!usageElement) return; try { usageElement.textContent = 'Cloud usage: loading...'; const response = await fetch(`${this.backendUrl}/api/recordings-backup/status`); if (!response.ok) throw new Error(await this.readErrorMessage(response)); const status = await response.json(); const scanErrors = status.scanErrors || []; const hasZoomUsage = status.zoomCloud && ((status.zoomCloud.recordings || 0) > 0 || (status.zoomCloud.files || 0) > 0 || (status.zoomCloud.bytes || 0) > 0); const zoomSource = status.zoomCloud && status.zoomCloud.source === 'zoom-all-users-recordings' && status.zoomCloud.usersScanned ? `, ${status.zoomCloud.usersScanned} users scanned` : ''; const zoomUsage = hasZoomUsage ? `Zoom account cloud: ${status.zoomCloud.bytesLabel || '0 B'} (${status.zoomCloud.recordings || 0} recordings, ${status.zoomCloud.files || 0} files, last ${status.zoomCloud.monthsBack || 1} month${Number(status.zoomCloud.monthsBack || 1) === 1 ? '' : 's'}${zoomSource})` : scanErrors.length ? `Zoom account cloud unavailable: ${scanErrors[0].error || 'scan failed'}` : 'Zoom account cloud: 0 B (0 recordings)'; const nextcloudUsage = status.nextcloudQuota ? `Nextcloud: ${status.nextcloudQuota.usedLabel} / ${status.nextcloudQuota.quotaLabel}` : status.nextcloudQuotaError ? `Nextcloud quota unavailable: ${status.nextcloudQuotaError}` : 'Nextcloud quota unavailable'; const warningSuffix = scanErrors.length && hasZoomUsage ? ` • Scan warnings: ${scanErrors.length}` : ''; usageElement.textContent = `${zoomUsage} • ${nextcloudUsage}${warningSuffix}`; usageElement.className = scanErrors.length ? 'copy-hint warn-text' : 'copy-hint'; } catch (error) { usageElement.textContent = `Cloud usage unavailable: ${error.message}`; usageElement.className = 'copy-hint error-text'; } } async startRecordingsBackup() { const backupButton = document.getElementById('backup-recordings'); const deleteButton = document.getElementById('delete-backed-up-recordings'); if (backupButton) backupButton.disabled = true; if (deleteButton) deleteButton.disabled = true; this.showRecordingsBackupMessage('Starting recordings backup...', 'info'); this.updateRecordingsProgress({ state: 'queued', totalBytes: 0, bytesProcessed: 0, totalFiles: 0, completedFiles: 0, message: 'Queued' }); try { const response = await fetch(`${this.backendUrl}/api/recordings-backup/start`, { method: 'POST' }); if (!response.ok) throw new Error(await this.readErrorMessage(response)); const result = await response.json(); const jobId = result.job && result.job.id; if (!jobId) throw new Error('Backend did not return a backup job id'); this.pollRecordingsBackupJob(jobId); } catch (error) { console.error('Error starting recordings backup:', error); this.showRecordingsBackupMessage(`Could not start recordings backup: ${error.message}`, 'error'); this.hideRecordingsProgressIfIdle(); if (backupButton) backupButton.disabled = false; if (deleteButton) deleteButton.disabled = false; } } pollRecordingsBackupJob(jobId) { if (this.recordingsBackupPollTimer) clearInterval(this.recordingsBackupPollTimer); const poll = async () => { try { const response = await fetch(`${this.backendUrl}/api/recordings-backup/jobs/${encodeURIComponent(jobId)}`); if (!response.ok) throw new Error(await this.readErrorMessage(response)); const data = await response.json(); const job = data.job || {}; this.updateRecordingsProgress(job); if (job.state === 'completed' || job.state === 'failed') { clearInterval(this.recordingsBackupPollTimer); this.recordingsBackupPollTimer = null; const backupButton = document.getElementById('backup-recordings'); const deleteButton = document.getElementById('delete-backed-up-recordings'); if (backupButton) backupButton.disabled = false; if (deleteButton) deleteButton.disabled = false; this.showRecordingsBackupMessage(job.message || (job.state === 'completed' ? 'Backup complete.' : 'Backup failed.'), job.state === 'completed' ? 'success' : 'error'); await this.loadRecordingBackupStatus(); } } catch (error) { clearInterval(this.recordingsBackupPollTimer); this.recordingsBackupPollTimer = null; const backupButton = document.getElementById('backup-recordings'); const deleteButton = document.getElementById('delete-backed-up-recordings'); if (backupButton) backupButton.disabled = false; if (deleteButton) deleteButton.disabled = false; this.showRecordingsBackupMessage(`Could not read backup progress: ${error.message}`, 'error'); } }; poll(); this.recordingsBackupPollTimer = setInterval(poll, 1500); } updateRecordingsProgress(job) { const wrap = document.getElementById('recordings-progress-wrap'); const progress = document.getElementById('recordings-progress'); const label = document.getElementById('recordings-progress-label'); if (!wrap || !progress || !label) return; wrap.style.display = 'flex'; const totalBytes = Number(job.totalBytes) || 0; const bytesProcessed = Number(job.bytesProcessed) || 0; const totalFiles = Number(job.totalFiles) || 0; const completedFiles = Number(job.completedFiles) || 0; const percent = totalBytes > 0 ? Math.min(100, Math.round((bytesProcessed / totalBytes) * 100)) : totalFiles > 0 ? Math.min(100, Math.round((completedFiles / totalFiles) * 100)) : 0; progress.value = percent; const fileText = totalFiles ? `${completedFiles}/${totalFiles} files` : job.state || 'starting'; const byteText = totalBytes ? ` • ${job.bytesProcessedLabel || ''} / ${job.totalBytesLabel || ''}` : ''; const currentFile = job.currentFile ? ` • ${job.currentFile}` : ''; label.textContent = `${percent}% (${fileText}${byteText})${currentFile}`; } hideRecordingsProgressIfIdle() { const wrap = document.getElementById('recordings-progress-wrap'); if (wrap) wrap.style.display = 'none'; } async deleteBackedUpRecordings() { const confirmed = confirm('Delete Zoom cloud recordings only if every recording file is confirmed on Nextcloud with matching size? This moves those Zoom recordings to trash.'); if (!confirmed) return; const backupButton = document.getElementById('backup-recordings'); const deleteButton = document.getElementById('delete-backed-up-recordings'); if (backupButton) backupButton.disabled = true; if (deleteButton) deleteButton.disabled = true; this.showRecordingsBackupMessage('Verifying Nextcloud copies before deleting Zoom recordings...', 'info'); try { const response = await fetch(`${this.backendUrl}/api/recordings-backup/delete-moved`, { method: 'POST' }); if (!response.ok) throw new Error(await this.readErrorMessage(response)); const result = await response.json(); const summary = result.skippedSummary || {}; const skipReasons = []; if (summary.missingFiles) skipReasons.push(`${summary.missingFiles} files missing on Nextcloud`); if (summary.sizeMismatchFiles) skipReasons.push(`${summary.sizeMismatchFiles} files with size mismatch`); if (summary.missingUuidRecordings) skipReasons.push(`${summary.missingUuidRecordings} recordings missing Zoom UUID`); if (summary.zoomDeleteFailedRecordings) { skipReasons.push(`${summary.zoomDeleteFailedRecordings} Zoom delete failures${summary.firstZoomDeleteError ? `: ${summary.firstZoomDeleteError}` : ''}`); } const skippedText = result.skipped && result.skipped.length ? ` Skipped ${result.skipped.length} recordings${skipReasons.length ? ` (${skipReasons.join(', ')})` : ' that were not fully confirmed'}.` : ''; const warningText = result.warnings && result.warnings.length ? ` Scan warnings: ${result.warnings.length}.` : ''; this.showRecordingsBackupMessage(`Deleted ${result.deletedRecordings || 0} Zoom recordings (${result.deletedFiles || 0} files, ${result.deletedBytesLabel || '0 B'}).${skippedText}${warningText}`, result.skipped && result.skipped.length ? 'warning' : 'success'); await this.loadRecordingBackupStatus(); } catch (error) { console.error('Error deleting backed up recordings:', error); this.showRecordingsBackupMessage(`Could not delete backed up recordings: ${error.message}`, 'error'); } finally { if (backupButton) backupButton.disabled = false; if (deleteButton) deleteButton.disabled = false; } } showRecordingsBackupMessage(message, type) { const element = document.getElementById('recordings-backup-message'); if (!element) return; element.textContent = message; element.className = `status-message ${type || 'info'}`; element.style.display = 'block'; } openAddGroupDialog() { const dialog = document.getElementById('add-group-dialog'); const message = document.getElementById('add-group-message'); if (message) message.style.display = 'none'; this.loadZoomAccounts(); if (!dialog) return; if (typeof dialog.showModal === 'function') { dialog.showModal(); } else { dialog.setAttribute('open', 'open'); } } closeAddGroupDialog() { const dialog = document.getElementById('add-group-dialog'); if (!dialog) return; if (typeof dialog.close === 'function') { dialog.close(); } else { dialog.removeAttribute('open'); } } async loadGoogleDriveOAuthStatus() { const statusElement = document.getElementById('google-drive-oauth-status'); const redirectElement = document.getElementById('google-drive-oauth-redirect'); const connectButton = document.getElementById('connect-google-drive'); const disconnectButton = document.getElementById('disconnect-google-drive'); if (!statusElement) return; try { const response = await fetch(`${this.backendUrl}/api/google-drive-oauth/status`); if (!response.ok) throw new Error(await this.readErrorMessage(response)); const status = await response.json(); if (!status.configured) { statusElement.textContent = 'Google OAuth is not configured. Add googleOAuth.clientId and googleOAuth.clientSecret to config.json.'; statusElement.className = 'copy-hint oauth-status error-text'; if (connectButton) connectButton.disabled = true; if (disconnectButton) disconnectButton.disabled = true; } else if (status.connected) { statusElement.textContent = `Connected${status.userEmail ? ` as ${status.userEmail}` : ''}. New group spreadsheets will be copied using this Google account.`; statusElement.className = 'copy-hint oauth-status success-text'; if (connectButton) connectButton.disabled = false; if (disconnectButton) disconnectButton.disabled = false; } else { statusElement.textContent = 'Not connected. Connect Google Drive before adding groups.'; statusElement.className = 'copy-hint oauth-status warn-text'; if (connectButton) connectButton.disabled = false; if (disconnectButton) disconnectButton.disabled = true; } if (redirectElement) { redirectElement.textContent = status.redirectUri ? `OAuth redirect URI: ${status.redirectUri}` : ''; } } catch (error) { statusElement.textContent = `Could not read Google Drive OAuth status: ${error.message}`; statusElement.className = 'copy-hint oauth-status error-text'; } } async connectGoogleDriveOAuth() { try { const response = await fetch(`${this.backendUrl}/api/google-drive-oauth/url`); if (!response.ok) throw new Error(await this.readErrorMessage(response)); const data = await response.json(); if (!data.url) throw new Error('Backend did not return an OAuth URL'); window.open(data.url, '_blank', 'noopener,noreferrer'); this.showGroupMessage('Google OAuth opened in a new tab. After approving access, return here and refresh the status if needed.', 'info'); setTimeout(() => this.loadGoogleDriveOAuthStatus(), 3000); } catch (error) { this.showGroupMessage(`Could not start Google OAuth: ${error.message}`, 'error'); } } async disconnectGoogleDriveOAuth() { if (!confirm('Disconnect the stored Google Drive OAuth token?')) return; try { const response = await fetch(`${this.backendUrl}/api/google-drive-oauth/disconnect`, { method: 'POST' }); if (!response.ok) throw new Error(await this.readErrorMessage(response)); this.showGroupMessage('Google Drive disconnected.', 'success'); await this.loadGoogleDriveOAuthStatus(); } catch (error) { this.showGroupMessage(`Could not disconnect Google Drive: ${error.message}`, 'error'); } } async loadZoomAccounts() { const select = document.getElementById('group-zoom-account'); if (!select) return; try { const previousValue = select.value; const response = await fetch(`${this.backendUrl}/api/zoom-accounts`); if (!response.ok) throw new Error(await this.readErrorMessage(response)); const data = await response.json(); const accounts = data.accounts || []; select.innerHTML = ''; if (!accounts.length) { select.innerHTML = ''; return; } accounts.forEach(account => { const option = document.createElement('option'); option.value = account.email; option.textContent = account.email; select.appendChild(option); }); if (previousValue && accounts.some(account => account.email === previousValue)) { select.value = previousValue; } } catch (error) { console.error('Error loading Zoom accounts:', error); select.innerHTML = ``; } } async loadGroups() { const container = document.getElementById('groups-list'); if (!container) return; container.innerHTML = '
Loading groups...
'; try { const response = await fetch(`${this.backendUrl}/api/groups`); if (!response.ok) throw new Error(await this.readErrorMessage(response)); const data = await response.json(); this.renderGroups(data.groups || []); } catch (error) { console.error('Error loading groups:', error); container.innerHTML = `Error loading groups: ${this.escapeHtml(error.message)}
`; } } renderGroups(groups) { const container = document.getElementById('groups-list'); if (!container) return; if (!groups.length) { container.innerHTML = 'No active groups in config.
'; return; } let html = '| Title | Spreadsheet | Zoom ID | Zoom account | Ranges | Action | '; html += '
|---|---|---|---|---|---|
| ${this.escapeHtml(group.title || '')} | `; html += ''; if (group.spreadsheetUrl) { html += `Open sheet`; } else { html += '—'; } html += ' | '; html += `${this.escapeHtml(group.zoomMeetingId || '')} | `; html += `${this.escapeHtml(group.zoomAccountEmail || '')} | `; html += `${this.escapeHtml(group.usersRange || '')} ${this.escapeHtml(group.attendanceRange || '')} | `;
html += ``; html += ' |
Loading meeting data...
'; const response = await fetch(`${this.backendUrl}/api/zoom-meetings?meetingId=${encodeURIComponent(meetingId)}`); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const data = await response.json(); this.renderMeetingData(data.meetings, meetingTitle); } catch (error) { console.error('Error loading meeting data:', error); const meetingDataContainer = document.getElementById('meeting-data'); if (meetingDataContainer) meetingDataContainer.innerHTML = `Error loading meeting data: ${this.escapeHtml(error.message)}
`; } } renderMeetingData(meetings, meetingTitle) { const meetingDataContainer = document.getElementById('meeting-data'); if (!meetingDataContainer) return; if (!meetings || meetings.length === 0) { meetingDataContainer.innerHTML = 'No meetings found for this filter.
'; return; } let html = `| Topic | Start Time | Duration | Participants | Action |
|---|---|---|---|---|
| ${this.escapeHtml(meetingTopic)} | `; html += `${this.escapeHtml(meetingStartTime || 'N/A')} | `; html += `${this.escapeHtml(meeting.duration || 'N/A')} min | `; html += `${this.escapeHtml(participants)} ${participants === 0 ? '(No participant data)' : ''} | `; html += ``; html += ' |
${this.escapeHtml(message)}
`; } selectMeeting(meetingUuid, meetingTopic, zoomMeetingId, meetingStartTime) { console.log(`Selected meeting occurrence: ${meetingUuid} - ${meetingTopic}`); this.createReport(meetingUuid, meetingTopic, zoomMeetingId, meetingStartTime); } escapeHtml(value) { return String(value === null || value === undefined ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .split('"').join('"') .split("'").join('''); } certaintyClass(certainty) { if (certainty >= 85) return 'certainty-high'; if (certainty >= 65) return 'certainty-medium'; return 'certainty-low'; } extractCurlHeader(curlText, headerName) { const escapedName = headerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`-H\\s+["']${escapedName}\\s*:\\s*([^"']+)["']`, 'i'); const match = curlText.match(regex); return match ? match[1].trim() : ''; } parseZoomWebCurl(curlText) { const cleaned = String(curlText || '') .replace(/\^\r?\n/g, ' ') .replace(/\^/g, '') .replace(/\\"/g, '"') .trim(); const urlMatch = cleaned.match(/https:\/\/[^\s"']+\/(?:rest\/)?account\/report\/historymeetings\/participants\/list/i); const cookieMatch = cleaned.match(/(?:-b|--cookie)\s+["']([^"']+)["']/i); const cookieHeader = this.extractCurlHeader(cleaned, 'cookie'); const accountIdMatch = cleaned.match(/"accountId"\s*:\s*"([^"]+)"/i); const meetingIdMatch = cleaned.match(/"meetingId"\s*:\s*"([^"]+)"/i); const csrfToken = this.extractCurlHeader(cleaned, 'zoom-csrftoken'); const userAgent = this.extractCurlHeader(cleaned, 'user-agent'); const authorization = this.extractCurlHeader(cleaned, 'authorization'); const zoomJwt = this.extractCurlHeader(cleaned, 'x-zm-jwt') || this.extractCurlHeader(cleaned, 'x-zm-token') || this.extractCurlHeader(cleaned, 'jwt'); if (!urlMatch || (!cookieMatch && !cookieHeader && !authorization && !zoomJwt)) { throw new Error('Could not parse Zoom cURL. Make sure you pasted “Copy as cURL” from the participants/list request.'); } const parsedUrl = new URL(urlMatch[0]); return { enabled: true, baseUrl: parsedUrl.origin, accountId: accountIdMatch ? accountIdMatch[1] : '', meetingId: meetingIdMatch ? meetingIdMatch[1] : '', cookie: cookieMatch ? cookieMatch[1] : cookieHeader, csrfToken, userAgent, authorization, zoomJwt }; } getZoomWebAuthForRequest() { const useZoomWebAuth = document.getElementById('use-zoom-web-auth'); if (!useZoomWebAuth || !useZoomWebAuth.checked) return null; const curlInput = document.getElementById('zoom-web-curl'); const curlText = curlInput ? curlInput.value.trim() : ''; if (curlText) return this.parseZoomWebCurl(curlText); return { enabled: true, source: 'stored-auth' }; } async createReport(meetingUuid, meetingTopic, zoomMeetingId, meetingStartTime) { const meetingDataContainer = document.getElementById('meeting-data'); try { console.log(`Writing attendance for meeting occurrence: ${meetingUuid}`); const zoomWebAuth = this.getZoomWebAuthForRequest(); const sourceLabel = zoomWebAuth && zoomWebAuth.enabled ? 'Zoom browser unique export' : 'Public Zoom API matcher'; console.log(`Attendance source requested: ${sourceLabel}`); if (meetingDataContainer) { meetingDataContainer.insertAdjacentHTML('beforeend', `Calculating attendance with ${this.escapeHtml(sourceLabel)} and writing to Google Sheet...
`); } const url = `${this.backendUrl}/api/write-attendance?meetingUuid=${encodeURIComponent(meetingUuid)}&zoomMeetingId=${encodeURIComponent(zoomMeetingId)}&meetingTopic=${encodeURIComponent(meetingTopic)}&meetingStartTime=${encodeURIComponent(meetingStartTime || '')}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ meetingUuid, zoomWebAuth }) }); if (!response.ok) throw new Error(await this.readErrorMessage(response)); const writeResult = await response.json(); console.log('Attendance write result received:', writeResult); this.renderAttendanceWriteResult(writeResult); } catch (error) { console.error('Error writing attendance:', error); const loading = document.getElementById('match-loading'); if (loading) loading.remove(); alert(`Error writing attendance: ${error.message}`); } } renderAttendanceWriteResult(writeResult) { const existingLoading = document.getElementById('match-loading'); if (existingLoading) existingLoading.remove(); const existingReport = document.getElementById('attendance-match-report'); if (existingReport) existingReport.remove(); const meetingDataContainer = document.getElementById('meeting-data'); if (!meetingDataContainer) return; const rows = writeResult.writtenRows || []; const sheet = writeResult.sheet || {}; const unmatchedAdded = writeResult.unmatchedAdded || []; const unmatchedNotAdded = writeResult.unmatchedNotAdded || []; let html = 'Date column: ${this.escapeHtml(sheet.date || '')} in column ${this.escapeHtml(sheet.attendanceColumn || '')}. Max attendance cap: ${this.escapeHtml(sheet.maxAttendance || 'none')}.
`; html += `Attendance source: ${this.escapeHtml(source)}.
`; html += `Rows written: ${this.escapeHtml(rows.length)}. New unmatched Zoom users added: ${this.escapeHtml(unmatchedAdded.length)}. Unmatched not added: ${this.escapeHtml(unmatchedNotAdded.length)}.
`; if (unmatchedNotAdded.length) { html += `Could not append unmatched Zoom users because the configured users range is full: ${this.escapeHtml(unmatchedNotAdded.map(item => item.name).join(', '))}
`; } html += '| Sheet row | Name | Minutes written | Calculated before cap | Type | Zoom name(s) |
|---|---|---|---|---|---|
| ${this.escapeHtml(row.row)} | `; html += `${this.escapeHtml(row.name)} | `; html += `${this.escapeHtml(row.minutes)}${row.capped ? ' (capped)' : ''} | `; html += `${this.escapeHtml(row.originalCalculatedMinutes)} | `; html += `${row.matched ? 'Matched user' : 'Added unmatched Zoom user'} | `; html += `${this.escapeHtml((row.zoomNames || []).join(', ') || '—')} | `; html += '