/** * Updater Module - GitHub update checking and notification system */ const Updater = { // State _checkInterval: null, _toastElement: null, _modalElement: null, _updateData: null, // Configuration CHECK_INTERVAL_MS: 6 * 60 * 60 * 1000, // 6 hours in milliseconds /** * Initialize the updater module */ init() { // Create toast container if it doesn't exist this._ensureToastContainer(); // Check for updates on page load this.checkForUpdates(); // Set up periodic checks this._checkInterval = setInterval(() => { this.checkForUpdates(); }, this.CHECK_INTERVAL_MS); }, /** * Ensure toast container exists in DOM */ _ensureToastContainer() { if (!document.getElementById('toastContainer')) { const container = document.createElement('div'); container.id = 'toastContainer'; document.body.appendChild(container); } }, /** * Check for updates from the server * @param {boolean} force - Bypass cache and check GitHub directly */ async checkForUpdates(force = false) { try { const url = force ? '/updater/check?force=true' : '/updater/check'; const response = await fetch(url); const data = await response.json(); if (data.success && data.show_notification) { this._updateData = data; this.showUpdateToast(data); } return data; } catch (error) { console.warn('Failed to check for updates:', error); return { success: false, error: error.message }; } }, /** * Get cached update status without triggering a check */ async getStatus() { try { const response = await fetch('/updater/status'); return await response.json(); } catch (error) { console.warn('Failed to get update status:', error); return { success: false, error: error.message }; } }, /** * Show update toast notification * @param {Object} data - Update data from server */ showUpdateToast(data) { // Remove existing toast if present this.hideToast(); const toast = document.createElement('div'); toast.className = 'update-toast'; toast.innerHTML = `
Update Available
Version ${data.latest_version} is ready
`; const container = document.getElementById('toastContainer'); if (container) { container.appendChild(toast); } else { document.body.appendChild(toast); } this._toastElement = toast; // Trigger animation requestAnimationFrame(() => { toast.classList.add('show'); }); }, /** * Hide the update toast */ hideToast() { if (this._toastElement) { this._toastElement.classList.remove('show'); setTimeout(() => { if (this._toastElement && this._toastElement.parentNode) { this._toastElement.parentNode.removeChild(this._toastElement); } this._toastElement = null; }, 300); } }, /** * Dismiss update notification for this version */ async dismissUpdate() { this.hideToast(); if (this._updateData && this._updateData.latest_version) { try { await fetch('/updater/dismiss', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: this._updateData.latest_version }) }); } catch (error) { console.warn('Failed to dismiss update:', error); } } }, /** * Show the full update modal with details */ showUpdateModal() { this.hideToast(); if (!this._updateData) { console.warn('No update data available'); return; } // Remove existing modal if present this.hideModal(); const data = this._updateData; const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.'); const modal = document.createElement('div'); modal.className = 'update-modal-overlay'; modal.onclick = (e) => { if (e.target === modal) this.hideModal(); }; modal.innerHTML = `
Update Available
Current v${data.current_version}
Latest v${data.latest_version}
Release Notes
${releaseNotes}
`; document.body.appendChild(modal); this._modalElement = modal; // Trigger animation requestAnimationFrame(() => { modal.classList.add('show'); }); }, /** * Hide the update modal */ hideModal() { if (this._modalElement) { this._modalElement.classList.remove('show'); setTimeout(() => { if (this._modalElement && this._modalElement.parentNode) { this._modalElement.parentNode.removeChild(this._modalElement); } this._modalElement = null; }, 200); } }, /** * Perform the update */ async performUpdate() { const progressEl = document.getElementById('updateProgress'); const progressText = document.getElementById('updateProgressText'); const resultEl = document.getElementById('updateResult'); const updateBtn = document.getElementById('updateNowBtn'); const warningEl = document.getElementById('updateWarning'); const optionsEl = document.getElementById('updateOptions'); const stashCheckbox = document.getElementById('stashChanges'); // Show progress if (progressEl) progressEl.style.display = 'flex'; if (progressText) progressText.textContent = 'Checking repository status...'; if (updateBtn) updateBtn.disabled = true; if (resultEl) resultEl.style.display = 'none'; try { const stashChanges = stashCheckbox ? stashCheckbox.checked : false; if (progressText) progressText.textContent = 'Fetching and applying updates...'; const response = await fetch('/updater/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stash_changes: stashChanges }) }); const data = await response.json(); if (progressEl) progressEl.style.display = 'none'; if (data.success) { this._showResult(resultEl, true, data); } else { // Handle specific error cases if (data.error === 'local_changes') { if (warningEl) { warningEl.style.display = 'flex'; const warningText = document.getElementById('updateWarningText'); if (warningText) { warningText.textContent = data.message; } } if (optionsEl) optionsEl.style.display = 'block'; if (updateBtn) updateBtn.disabled = false; } else if (data.manual_update) { this._showResult(resultEl, false, data, true); } else { this._showResult(resultEl, false, data); } } } catch (error) { if (progressEl) progressEl.style.display = 'none'; this._showResult(resultEl, false, { error: error.message }); } }, /** * Show update result */ _showResult(resultEl, success, data, isManual = false) { if (!resultEl) return; resultEl.style.display = 'block'; if (success) { if (data.updated) { let message = 'Update successful!
Please restart the application to complete the update.'; if (data.requirements_changed) { message += '

Dependencies changed! Run:
pip install -r requirements.txt'; } resultEl.className = 'update-result update-result-success'; resultEl.innerHTML = `
${message}
`; } else { resultEl.className = 'update-result update-result-info'; resultEl.innerHTML = `
${data.message || 'Already up to date.'}
`; } } else { if (isManual) { resultEl.className = 'update-result update-result-warning'; resultEl.innerHTML = `
Manual update required
${data.message || 'Please download the latest release from GitHub.'}
`; } else { resultEl.className = 'update-result update-result-error'; resultEl.innerHTML = `
Update failed
${data.message || data.error || 'An error occurred during the update.'} ${data.details ? '
' + data.details.substring(0, 200) + '' : ''}
`; } } }, /** * Format release notes (basic markdown to HTML) */ _formatReleaseNotes(notes) { if (!notes) return '

No release notes available.

'; // Escape HTML let html = notes .replace(/&/g, '&') .replace(//g, '>'); // Convert markdown-style formatting html = html // Headers .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') .replace(/^# (.+)$/gm, '

$1

') // Bold .replace(/\*\*(.+?)\*\*/g, '$1') // Italic .replace(/\*(.+?)\*/g, '$1') // Code .replace(/`(.+?)`/g, '$1') // Lists .replace(/^- (.+)$/gm, '
  • $1
  • ') .replace(/^(\d+)\. (.+)$/gm, '
  • $2
  • ') // Paragraphs .replace(/\n\n/g, '

    ') // Line breaks .replace(/\n/g, '
    '); // Wrap list items html = html.replace(/(

  • .*<\/li>)+/g, ''); return '

    ' + html + '

    '; }, /** * Manual trigger for settings panel */ async checkNow() { return await this.checkForUpdates(true); }, /** * Clean up on page unload */ destroy() { if (this._checkInterval) { clearInterval(this._checkInterval); this._checkInterval = null; } this.hideToast(); this.hideModal(); } }; // Initialize on DOM ready document.addEventListener('DOMContentLoaded', () => { Updater.init(); }); // Clean up on page unload window.addEventListener('beforeunload', () => { Updater.destroy(); });