UNPKG

@iebh/vuex-tera-json

Version:

A Vuex plugin for syncing state with Tera (using JSON files)

849 lines (754 loc) 26.1 kB
import {nanoid} from 'nanoid'; import pRetry from 'p-retry'; /** * @constant {boolean} * @description Debug mode flag for logging */ const DEBUG = true /** * @typedef {Object} TeraPluginConfig * @property {string} keyPrefix - Prefix for storage keys and filenames * @property {boolean} isSeparateStateForEachUser - Whether to maintain separate state for each user * @property {number} autoSaveIntervalMinutes - Auto-save interval in minutes (0 to disable) * @property {boolean} showInitialAlert - Whether to show initial alert about manual saving * @property {boolean} enableSaveHotkey - Whether to enable Ctrl+S hotkey for saving */ /** * @constant {TeraPluginConfig} * @description Default configuration for the TERA sync plugin */ const DEFAULT_CONFIG = { keyPrefix: '', isSeparateStateForEachUser: false, autoSaveIntervalMinutes: 15, showInitialAlert: false, enableSaveHotkey: true } /** * @enum {string} * @description Save status states */ const SAVE_STATUS = { SAVED: 'Saved', UNSAVED: 'Unsaved changes', SAVING: 'Saving...' } /** * Debug logging utility function * @param {...*} args - Arguments to log */ const debugLog = (...args) => { if (DEBUG) console.log('[TERA File Sync]:', ...args) } /** * Error logging utility function * @param {Error} error - The error object * @param {string} context - Context description for the error */ const logError = (error, context) => { console.error(`[TERA File Sync] ${context}:`, error) } /** * Validates the plugin configuration * @param {TeraPluginConfig} config - The configuration to validate * @throws {Error} If configuration is invalid */ const validateConfig = (config) => { if (typeof config.keyPrefix !== 'string') { throw new Error('keyPrefix must be a string') } if (typeof config.isSeparateStateForEachUser !== 'boolean') { throw new Error('isSeparateStateForEachUser must be a boolean') } if (typeof config.autoSaveIntervalMinutes !== 'number' || config.autoSaveIntervalMinutes < 0) { throw new Error('autoSaveIntervalMinutes must be a non-negative number') } if (typeof config.showInitialAlert !== 'boolean') { throw new Error('showInitialAlert must be a boolean') } if (typeof config.enableSaveHotkey !== 'boolean') { throw new Error('enableSaveHotkey must be a boolean') } } /** * Validates the Vue instance has required TERA properties * @param {Object} instance - The Vue instance to validate * @throws {Error} If Vue instance is invalid */ const validateVueInstance = (instance) => { if (!instance) { throw new Error('Vue instance is required') } if (!instance.$tera) { throw new Error('Vue instance must have $tera property') } if (typeof instance.$tera.getUser !== 'function') { throw new Error('$tera.getUser must be a function') } if (typeof instance.$tera.getProjectFileContents !== 'function') { throw new Error('$tera.getProjectFileContents must be a function') } if (typeof instance.$tera.setProjectFileContents !== 'function') { throw new Error('$tera.setProjectFileContents must be a function') } if (typeof instance.$tera.uiProgress !== 'function') { throw new Error('$tera.uiProgress must be a function') } } /** * Converts Maps and Sets to plain objects and arrays for serialization * @param {*} item - The item to convert * @returns {*} The converted item */ const mapSetToObject = (item) => { try { if (item instanceof Map) { debugLog('Converting Map to object') const obj = { __isMap: true } item.forEach((value, key) => { obj[key] = mapSetToObject(value) }) return obj } if (item instanceof Set) { debugLog('Converting Set to array') return { __isSet: true, values: Array.from(item).map(mapSetToObject) } } if (Array.isArray(item)) { return item.map(mapSetToObject) } if (item && typeof item === 'object' && !(item instanceof Date)) { const obj = {} Object.entries(item).forEach(([key, value]) => { obj[key] = mapSetToObject(value) }) return obj } return item } catch (error) { logError(error, 'mapSetToObject conversion failed') throw error; } } /** * Converts serialized objects back to Maps and Sets * @param {*} obj - The object to convert * @returns {*} The converted object with Maps and Sets restored */ const objectToMapSet = (obj) => { try { if (!obj || typeof obj !== 'object' || obj instanceof Date) { return obj } if ('__isMap' in obj) { debugLog('Converting object back to Map') const map = new Map() Object.entries(obj).forEach(([key, value]) => { if (key !== '__isMap') { map.set(key, objectToMapSet(value)) } }) return map } if ('__isSet' in obj) { debugLog('Converting array back to Set') return new Set(obj.values.map(objectToMapSet)) } if (Array.isArray(obj)) { return obj.map(objectToMapSet) } const newObj = {} Object.entries(obj).forEach(([key, value]) => { newObj[key] = objectToMapSet(value) }) return newObj } catch (error) { logError(error, 'objectToMapSet conversion failed') throw error; } } /** * Shows an alert notification to the user * @param {string} message - The message to display */ const showNotification = (message) => { if (typeof window !== 'undefined' && window.alert) { window.alert(message); } else if (alert) { alert(message); } else { debugLog('Alert would be shown:', message); } }; /** * @class TeraFileSyncPlugin * @description Plugin class for syncing Vuex store state with TERA JSON files */ class TeraFileSyncPlugin { /** * @constructor * @param {TeraPluginConfig} [config=DEFAULT_CONFIG] - Plugin configuration * @throws {Error} If configuration is invalid */ constructor(config = DEFAULT_CONFIG) { const mergedConfig = { ...DEFAULT_CONFIG, ...config } validateConfig(mergedConfig) this.config = mergedConfig this.initialized = false this.teraReady = false this.vueInstance = null this.userId = null this.saveInProgress = false this.autoSaveInterval = null this.store = null this.keydownHandler = this.handleKeyDown.bind(this) this.hasShownInitialAlert = false this.saveStatus = SAVE_STATUS.SAVED this.beforeUnloadHandler = this.handleBeforeUnload.bind(this); // Bind the handler } /** * Handle keyboard events for the Ctrl+S hotkey * @param {KeyboardEvent} event - The keyboard event */ handleKeyDown(event) { // Check for Ctrl+S (Windows/Linux) or Command+S (Mac) if ((event.ctrlKey || event.metaKey) && event.key === 's') { event.preventDefault(); // Prevent the browser's save dialog debugLog('Ctrl+S hotkey detected, saving state'); this.saveStateToFile(this.store.state).then(success => { if (success) { debugLog('Save completed via hotkey'); } }) } } /** * Handles the beforeunload event to warn users about unsaved changes. * @param {BeforeUnloadEvent} event - The beforeunload event. */ handleBeforeUnload(event) { if (this.saveStatus === SAVE_STATUS.UNSAVED) { const message = 'You have unsaved changes. Are you sure you want to leave?'; event.returnValue = message; // Standard for most browsers return message; // For some older browsers } } /** * Register the keyboard event listener for hotkeys */ registerHotkeys() { if (!this.config.enableSaveHotkey) { debugLog('Save hotkey disabled in configuration'); return; } debugLog('Registering Ctrl+S hotkey'); if (typeof window !== 'undefined') { // Remove any existing handler to prevent duplicates window.removeEventListener('keydown', this.keydownHandler); // Add the event listener window.addEventListener('keydown', this.keydownHandler); } } /** * Remove the keyboard event listener */ unregisterHotkeys() { if (typeof window !== 'undefined') { window.removeEventListener('keydown', this.keydownHandler); debugLog('Unregistered hotkeys'); } } /** * Registers the beforeunload event listener. */ registerBeforeUnload() { if (typeof window !== 'undefined') { window.addEventListener('beforeunload', this.beforeUnloadHandler); debugLog('Registered beforeunload listener'); } } /** * Unregisters the beforeunload event listener. */ unregisterBeforeUnload() { if (typeof window !== 'undefined') { window.removeEventListener('beforeunload', this.beforeUnloadHandler); debugLog('Unregistered beforeunload listener'); } } /** * Show initial alert about manual saving */ showInitialAlert() { if (this.config.showInitialAlert && !this.hasShownInitialAlert) { this.hasShownInitialAlert = true; const message = "This TERA tool no longer automatically saves progress, use Ctrl+S, or click save in the top right corner, to save progress. (This is a short-term temporary pop-up, it will be removed at the start of April)"; // Use Vue notification system if available if (this.vueInstance && this.vueInstance.$notify) { this.vueInstance.$notify({ title: 'Important', message, type: 'warning', duration: 10000, showClose: true }); } else { // Fallback to regular alert with a short delay to ensure it shows after UI loads setTimeout(() => { showNotification(message); }, 1000); } debugLog('Showed initial manual save alert'); } } /** * Updates the save status in the store * @param {string} status - The new save status */ updateSaveStatus(status) { if (!this.store) return; debugLog(`Updating save status: ${status}`); this.saveStatus = status; // Commit the status to the store this.store.commit('__tera_file_sync/updateSaveStatus', status); } /** * Gets the storage file name for the current user * @async * @returns {Promise<string>} The storage file name * @throws {Error} If unable to get user ID when separate state is enabled */ async getStorageKey() { if (this.config.isSeparateStateForEachUser) { if (!this.userId) { try { const user = await this.vueInstance.$tera.getUser() this.userId = user.id debugLog('User ID initialized:', this.userId) } catch (error) { logError(error, 'Failed to get user ID') throw error } } return `${this.config.keyPrefix}-${this.userId}` } return `${this.config.keyPrefix}` } /** * Gets the storage file name for the current user * @async * @returns {Promise<string>} The storage file name * @throws {Error} If unable to get user ID when separate state is enabled */ async getStorageFileName() { if (!this.vueInstance) { throw new Error("Error getting fileStorageName: vueInstance missing"); } if (!this.vueInstance.$tera ) { throw new Error("Error getting fileStorageName: $tera missing"); } if (!this.vueInstance.$tera.project) { throw new Error("Error getting fileStorageName: $tera.project missing"); } if (!this.vueInstance.$tera.project.id) { throw new Error("Error getting fileStorageName: $tera.project.id missing"); } if (!this.vueInstance.$tera.project.temp) { console.warn("Error getting fileStorageName: $tera.project.temp missing"); console.warn("Creating $tera.project.temp..."); // TODO: Work out if setProjectState is better this.vueInstance.$tera.project.temp = {} } const key = await this.getStorageKey(); let fileStorageName = this.vueInstance.$tera.project.temp[key]; if (!fileStorageName) { debugLog("No existing file for project/tool, creating one"); fileStorageName = `data-${this.config.keyPrefix}-${nanoid()}.json` // Create file let newFile; await pRetry(async () => { newFile = await this.vueInstance.$tera.createProjectFile(fileStorageName); }, { retries: 2, minTimeout: 200, onFailedAttempt: error => { debugLog(`[Create file attempt ${error.attemptNumber}] Failed for ${fileStorageName}. Error: ${error.message}. Retries left: ${error.retriesLeft}.`); } }); // Save new file with default state await pRetry(async () => { debugLog("Saving default state to newly created file...") await newFile.setContents(this.store.state); }, { retries: 2, minTimeout: 200, onFailedAttempt: error => { debugLog(`[Set default contents attempt ${error.attemptNumber}] Failed for ${fileStorageName}. Error: ${error.message}. Retries left: ${error.retriesLeft}.`); } }); // Set file name in project data await pRetry(async () => { debugLog("Saving file name to project data...") await this.vueInstance.$tera.setProjectState(`temp.${await this.getStorageKey()}`, fileStorageName); }, { retries: 2, minTimeout: 200, onFailedAttempt: error => { debugLog(`[Set storage key attempt ${error.attemptNumber}] Failed for ${fileStorageName}. Error: ${error.message}. Retries left: ${error.retriesLeft}.`); } }); } if (typeof fileStorageName !== 'string') { throw new Error(`fileStorageName is not a string: ${fileStorageName}`); } const fullFileStoragePath = `${this.vueInstance.$tera.project.id}/${fileStorageName}` return fullFileStoragePath; } /** * Loads state from JSON file * @async * @returns {Promise<Object|null>} The loaded state or null if file not found */ async loadStateFromFile() { try { let fileName; let fileContent; // Load file with retries await pRetry(async () => { fileName = await this.getStorageFileName() debugLog(`Loading state from file: ${fileName}`) if (!fileName) { throw new Error('No file name returned when expected!'); } const encodedFileName = btoa(fileName); fileContent = await this.vueInstance.$tera.getProjectFileContents(encodedFileName, { format: 'json' }) }, { retries: 3, minTimeout: 1000, // 1 second initial delay factor: 2, // Exponential backoff onFailedAttempt: error => { debugLog(`[Load Attempt ${error.attemptNumber}] Failed for ${fileName}. Error: ${error.message}. Retries left: ${error.retriesLeft}.`); } }) if (!fileContent) { debugLog('File not found or empty') return null } // Update last saved state for change tracking this.updateSaveStatus(SAVE_STATUS.SAVED); debugLog('State loaded from file successfully:', fileContent) return fileContent } catch (error) { if (error.message && error.message.includes('not found')) { debugLog('State file not found, will be created on first save') return null } logError(error, 'Failed to load state from file') // TODO: This can eventually be removed if (error.message && error.message.includes("Unexpected end of JSON input")) { return null // don't show notification } showNotification('Failed to load state from file, using default state') return null } } /** * Saves state to JSON file * @async * @param {Object} state - The state to save * @returns {Promise<boolean>} Whether the save was successful */ async saveStateToFile(state) { if (this.saveInProgress) { debugLog('Save already in progress, skipping') return false } try { let fileName; this.saveInProgress = true this.updateSaveStatus(SAVE_STATUS.SAVING); // Show loading progress await this.vueInstance.$tera.uiProgress({ title: 'Saving tool data', backdrop: 'static' }); // Save file with retries await pRetry(async () => { fileName = await this.getStorageFileName() if (!fileName) { throw new Error('No fileName returned') } const encodedFileName = btoa(fileName); const stateToSave = mapSetToObject(state) await this.vueInstance.$tera.setProjectFileContents(encodedFileName, stateToSave, {format: 'json'}) // Update last saved state reference after successful save this.updateSaveStatus(SAVE_STATUS.SAVED); }, { retries: 3, minTimeout: 1000, // 1 second initial delay factor: 2, // Exponential backoff onFailedAttempt: error => { debugLog(`[Save Attempt ${error.attemptNumber}] Failed for ${fileName}. Error: ${error.message}. Retries left: ${error.retriesLeft}.`); } }) debugLog(`State saved to file: ${fileName}`) return true } catch (error) { logError(error, 'Failed to save state to file') showNotification('Failed to save state to file, hit F12 for debug information or manually save via File -> Save progress') this.updateSaveStatus(SAVE_STATUS.UNSAVED); return false } finally { this.saveInProgress = false // Hide loading progress await this.vueInstance.$tera.uiProgress(false); } } /** * Initializes the store state from file or legacy data * @async * @param {Object} store - Vuex store instance */ async initializeState(store) { if (!this.teraReady || !this.vueInstance || !this.vueInstance.$tera) { debugLog('TERA not ready, skipping initialization') return } // Show loading if (typeof this.vueInstance.$tera.uiProgress !== 'function') { console.warn('Not showing loading because uiProgress is not a function') } else { await this.vueInstance.$tera.uiProgress({ title: 'Loading tool data', backdrop: 'static' }) } try { // Try to load from file const fileData = await this.loadStateFromFile() if (fileData) { const parsedState = objectToMapSet(fileData) store.replaceState({ ...store.state, ...parsedState }) debugLog('Store initialized from file data') this.updateSaveStatus(SAVE_STATUS.SAVED); } else { debugLog('No existing data found, using default store state') this.updateSaveStatus(SAVE_STATUS.UNSAVED); } // Show initial alert about manual saving this.showInitialAlert(); // Register hotkeys this.registerHotkeys(); // Register the beforeunload listener this.registerBeforeUnload(); } catch (error) { logError(error, 'State initialization failed') showNotification('Error initializing state from file') } finally { this.initialized = true // Hide loading if (typeof this.vueInstance.$tera.uiProgress === 'function') { await this.vueInstance.$tera.uiProgress(false) } } } /** * Sets up automatic saving on a timer */ setupAutoSave() { if (this.config.autoSaveIntervalMinutes <= 0) { debugLog('Auto-save disabled') return } // Clear any existing interval if (this.autoSaveInterval) { clearInterval(this.autoSaveInterval) } const intervalMs = this.config.autoSaveIntervalMinutes * 60 * 1000 debugLog(`Setting up auto-save every ${this.config.autoSaveIntervalMinutes} minutes`) this.autoSaveInterval = setInterval(() => { if (this.saveStatus !== SAVE_STATUS.SAVED) { debugLog('Auto-save triggered') this.saveStateToFile(this.store.state) } else { debugLog('Auto-save skipped - no changes detected') } }, intervalMs) } /** * Sets up state change tracking * @param {Object} store - Vuex store instance */ setupStateChangeTracking(store) { // Subscribe to store mutations to track changes store.subscribe((mutation) => { // Ignore our own save status mutations if (mutation.type === '__tera_file_sync/updateSaveStatus') return; if (this.saveStatus !== SAVE_STATUS.SAVING ) { this.updateSaveStatus(SAVE_STATUS.UNSAVED); } }); } /** * Set the state by prompting the user for a JSON file using TERA * This json file will then also be set to be the pointed to file * in `temp` */ async setStateFromPromptedJsonFile(store) { try { // Prompt user for file const projectFile = await this.vueInstance.$tera.selectProjectFile({ title: 'Load JSON file', showHiddenFiles: true, }); // Check if a file was actually selected if (!projectFile) { debugLog('User cancelled file selection.'); return; // Exit gracefully if no file selected } // Get data from file with retries const fileData = await pRetry(async () => { return await projectFile.getContents({ format: 'json' }); }, { retries: 3, minTimeout: 1000, onFailedAttempt: error => { debugLog(`[Load Prompted File Attempt ${error.attemptNumber}] Failed. Error: ${error.message}. Retries left: ${error.retriesLeft}.`); } }); // Check for null or undefined fileData and handle appropriately if (!fileData) { debugLog('Selected file is empty.'); showNotification('The selected file is empty.'); // Notify user return; // Prevent further processing } // Update state const parsedState = objectToMapSet(fileData); store.replaceState({ ...store.state, ...parsedState }); // Replace file path with new file path const filePath = projectFile.path; this.vueInstance.$tera.setProjectState(`temp.${await this.getStorageKey()}`, filePath); debugLog('Store initialized from file data'); this.updateSaveStatus(SAVE_STATUS.SAVED); } catch (error) { logError(error, 'Failed to set state from prompted JSON file'); showNotification('Failed to load data from the selected file.'); // User-friendly error // Consider resetting the save status or other recovery actions here this.updateSaveStatus(SAVE_STATUS.UNSAVED); } } /** * Creates the Vuex plugin * @returns {Function} Plugin installation function */ createPlugin() { return (store) => { this.store = store // Register the module for save status store.registerModule('__tera_file_sync', { namespaced: true, state: { saveStatus: SAVE_STATUS.SAVED }, mutations: { updateSaveStatus(state, status) { state.saveStatus = status; } }, getters: { getSaveStatus: state => state.saveStatus } }); // Set up change tracking this.setupStateChangeTracking(store); return { /** * Sets the TERA ready state and triggers initial load * @async */ setTeraReady: async () => { validateVueInstance(this.vueInstance) this.teraReady = true await this.initializeState(store) // Enable autosave this.setupAutoSave() }, /** * Sets the Vue instance * @param {Object} instance - Vue instance * @throws {Error} If Vue instance is invalid */ setVueInstance: (instance) => { this.vueInstance = instance }, /** * Manually saves the current state to file * @async * @returns {Promise<boolean>} Whether the save was successful */ saveState: async () => { return await this.saveStateToFile(store.state) }, /** * Gets the current save status * @returns {string} The current save status */ getSaveStatus: () => { return this.saveStatus; }, /** * Prompt the user for a new data json file * @returns {Promise<void>} */ promptForNewJsonFile: async () => { return await this.setStateFromPromptedJsonFile(this.store) }, /** * Cleans up the plugin */ destroy: () => { if (this.autoSaveInterval) { clearInterval(this.autoSaveInterval) } // Unregister hotkey listener this.unregisterHotkeys(); // Unregister the beforeunload listener this.unregisterBeforeUnload(); // Unregister store module if possible if (store.hasModule('__tera_file_sync')) { store.unregisterModule('__tera_file_sync'); } this.initialized = false this.teraReady = false } } } } } /** * Creates a new TERA file sync plugin instance * @param {string} keyPrefix - Prefix for storage keys and filenames * @param {boolean} [isSeparateStateForEachUser=false] - Whether to maintain separate state for each user * @param {Object} [options={}] - Additional plugin options * @param {number} [options.autoSaveIntervalMinutes=10] - Auto-save interval in minutes (0 to disable) * @param {boolean} [options.showInitialAlert=false] - Whether to show initial alert about manual saving * @param {boolean} [options.enableSaveHotkey=true] - Whether to enable Ctrl+S hotkey for saving * @returns {Function} Plugin installation function * @throws {Error} If parameters are invalid */ const createSyncPlugin = (keyPrefix, isSeparateStateForEachUser = false, options = {}) => { if (typeof keyPrefix !== 'string') { throw new Error('keyPrefix must be a string') } const config = { keyPrefix, isSeparateStateForEachUser, ...options } const plugin = new TeraFileSyncPlugin(config) return plugin.createPlugin() }; export { createSyncPlugin };