/** * Autosave service for automatically saving projects at regular intervals */ import SettingsStore from '../addons/settings-store-singleton.js'; class AutosaveService { constructor () { this.vm = null; this.store = null; this.intervalId = null; this.enabled = false; this.interval = 5; // minutes this.showNotifications = true; this.onlyWhenChanged = true; this.lastSaveTime = 0; this.initialized = false; this.addonSettingsListener = null; } /** * Check if the service is initialized * @returns {boolean} True if initialized */ isInitialized () { return this.initialized; } /** * Initialize the autosave service * @param {VM} vm - The Scratch virtual machine instance * @param {Store} store - The Redux store */ initialize (vm, store) { this.vm = vm; this.store = store; this.initialized = true; // Load settings from localStorage this.loadSettings(); // Listen for Redux settings changes store.subscribe(() => { const state = store.getState(); const autosaveState = state.scratchGui.autosave; // Only apply Redux settings if autosave addon is not enabled if (!SettingsStore.getAddonEnabled('autosave')) { if (autosaveState.enabled !== this.enabled) { this.setEnabled(autosaveState.enabled); } if (autosaveState.interval !== this.interval) { this.setInterval(autosaveState.interval); } if (autosaveState.showNotifications !== this.showNotifications) { this.showNotifications = autosaveState.showNotifications; } } }); // Listen for addon settings changes this.addonSettingsListener = (e) => { if (e.detail.addonId === 'autosave') { this.updateFromAddonSettings(); } }; SettingsStore.addEventListener('setting-changed', this.addonSettingsListener); // Initial load from addon settings if enabled this.updateFromAddonSettings(); } /** * Load settings from localStorage */ loadSettings () { try { const saved = localStorage.getItem('scratch-autosave-settings'); if (saved) { const settings = JSON.parse(saved); this.enabled = settings.enabled || false; this.interval = settings.interval || 5; this.showNotifications = settings.showNotifications !== false; this.onlyWhenChanged = settings.onlyWhenChanged !== false; } } catch (e) { console.warn('Failed to load autosave settings:', e); } } /** * Save settings to localStorage */ saveSettings () { try { const settings = { enabled: this.enabled, interval: this.interval, showNotifications: this.showNotifications, onlyWhenChanged: this.onlyWhenChanged }; localStorage.setItem('scratch-autosave-settings', JSON.stringify(settings)); } catch (e) { console.warn('Failed to save autosave settings:', e); } } /** * Enable or disable autosave * @param {boolean} enabled - Whether autosave should be enabled */ setEnabled (enabled) { this.enabled = enabled; this.saveSettings(); if (enabled) { this.start(); } else { this.stop(); } } /** * Set the autosave interval * @param {number} interval - Interval in minutes */ setInterval (interval) { this.interval = Math.max(1, Math.min(60, interval)); // Clamp between 1-60 minutes this.saveSettings(); if (this.enabled) { this.restart(); } } /** * Update settings from addon settings */ updateFromAddonSettings () { if (SettingsStore.getAddonEnabled('autosave')) { const wasEnabled = this.enabled; this.enabled = SettingsStore.getAddonSetting('autosave', 'enabled'); this.interval = SettingsStore.getAddonSetting('autosave', 'interval'); this.showNotifications = SettingsStore.getAddonSetting('autosave', 'showNotifications'); this.onlyWhenChanged = SettingsStore.getAddonSetting('autosave', 'saveOnlyWhenChanged'); // Start/stop autosave based on enabled state if (this.enabled && !wasEnabled) { this.start(); } else if (!this.enabled && wasEnabled) { this.stop(); } else if (this.enabled) { // Restart with new interval if needed this.start(); } } } /** * Clean up the service and remove event listeners */ destroy () { this.stop(); if (this.addonSettingsListener) { SettingsStore.removeEventListener('setting-changed', this.addonSettingsListener); this.addonSettingsListener = null; } this.initialized = false; } /** * Start the autosave timer */ start () { if (this.intervalId) { clearInterval(this.intervalId); } if (!this.enabled || !this.vm) return; const intervalMs = this.interval * 60 * 1000; this.intervalId = setInterval(() => { this.performAutosave(); }, intervalMs); console.log(`Autosave started with ${this.interval} minute interval`); if (this.showNotifications) { this.showNotification(`Autosave enabled - saving every ${this.interval} minutes`); } } /** * Stop the autosave timer */ stop () { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } console.log('Autosave stopped'); } /** * Restart the autosave timer (stop and start) */ restart () { this.stop(); this.start(); } /** * Check if the project has changes that need saving * @returns {boolean} True if project needs saving */ hasProjectChanged () { if (!this.onlyWhenChanged) return true; try { const state = this.store.getState(); return state.scratchGui.projectChanged; } catch (e) { console.warn('Failed to check project changed state:', e); return true; // Default to saving if we can't check } } /** * Generate a filename for the autosaved project * @returns {string} Generated filename */ generateAutosaveFilename () { const now = new Date(); const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); try { const state = this.store.getState(); const projectTitle = state.scratchGui.projectTitle || 'Untitled'; return `${projectTitle}_autosave_${timestamp}.sb3`; } catch (e) { return `Scratch_Project_autosave_${timestamp}.sb3`; } } /** * Perform an autosave * @returns {Promise} True if save was successful */ async performAutosave () { if (!this.enabled || !this.vm) { return false; } try { // Check if we have a project loaded if (!this.vm.runtime || !this.vm.runtime.targets || this.vm.runtime.targets.length === 0) { console.log('Autosave: No project loaded, skipping save'); return false; } // Check if project has changed if (!this.hasProjectChanged()) { console.log('Autosave: Project unchanged, skipping save'); return false; } console.log('Autosave: Starting automatic save...'); if (this.showNotifications) { this.showNotification('Saving project...', 'saving'); } // Get project data as blob const projectBlob = await this.vm.saveProjectSb3(); const filename = this.generateAutosaveFilename(); // Create download link and trigger download const url = URL.createObjectURL(projectBlob); const downloadLink = document.createElement('a'); downloadLink.href = url; downloadLink.download = filename; downloadLink.style.display = 'none'; document.body.appendChild(downloadLink); downloadLink.click(); document.body.removeChild(downloadLink); // Clean up the URL object setTimeout(() => URL.revokeObjectURL(url), 1000); this.lastSaveTime = Date.now(); if (this.showNotifications) { this.showNotification(`Project saved as ${filename}`, 'success'); } console.log(`Autosave: Successfully saved project as ${filename}`); return true; } catch (error) { console.error('Autosave: Failed to save project:', error); if (this.showNotifications) { this.showNotification('Failed to save project automatically', 'error'); } return false; } } /** * Manually trigger an autosave * @returns {Promise} True if save was successful */ async saveNow () { if (!this.enabled) { if (this.showNotifications) { this.showNotification('Autosave is disabled. Enable it in File menu first.', 'error'); } return false; } return await this.performAutosave(); } /** * Show a notification to the user * @param {string} message - The message to show * @param {string} type - The type of notification (info, success, error, saving) */ showNotification (message, type = 'info') { if (!this.showNotifications) return; // Create notification element const notification = document.createElement('div'); notification.className = 'autosave-notification'; notification.textContent = message; // Add type-specific styling if (type === 'success') { notification.classList.add('autosave-success'); } else if (type === 'error') { notification.classList.add('autosave-error'); } else if (type === 'saving') { notification.classList.add('autosave-saving'); } // Position and style the notification Object.assign(notification.style, { position: 'fixed', top: '80px', right: '20px', backgroundColor: type === 'error' ? '#f44336' : type === 'success' ? '#4caf50' : '#333', color: 'white', padding: '12px 20px', borderRadius: '8px', boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)', zIndex: '10000', fontSize: '14px', fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif', maxWidth: '320px', wordWrap: 'break-word', opacity: '0', transform: 'translateX(100%)', transition: 'all 0.3s ease' }); document.body.appendChild(notification); // Animate in requestAnimationFrame(() => { notification.style.opacity = '1'; notification.style.transform = 'translateX(0)'; }); // Auto-remove after duration const duration = type === 'saving' ? 2000 : 4000; setTimeout(() => { if (notification.parentNode) { notification.style.opacity = '0'; notification.style.transform = 'translateX(100%)'; setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 300); } }, duration); } /** * Get the current autosave status * @returns {object} Status information */ getStatus () { return { enabled: this.enabled, interval: this.interval, lastSaveTime: this.lastSaveTime, showNotifications: this.showNotifications, onlyWhenChanged: this.onlyWhenChanged }; } } // Create a singleton instance const autosaveService = new AutosaveService(); export default autosaveService;