diff --git a/modules/core/DatabaseSystem.js b/modules/core/DatabaseSystem.js new file mode 100644 index 000000000..9e35736d2 --- /dev/null +++ b/modules/core/DatabaseSystem.js @@ -0,0 +1,210 @@ +import { AbstractSystem } from './AbstractSystem.js'; + + +/** + * `DatabaseSystem` is a wrapper around `IndexedDB` + * It is used to store larger amounts of data asynchronously, + * such as the user's edit history. + */ +export class DatabaseSystem extends AbstractSystem { + /** + * @constructor + * @param context Global shared application context + * @param dbName Name of the database + * @param version Version of the database + */ + constructor(context, dbName = 'myDatabase', version = 1) { + super(context); + this.id = 'database'; + this.dependencies = new Set(); + this.dbName = dbName; + this.version = version; + this.db = null; + } + /** + * initAsync + * Called after all core objects have been constructed. + * @return {Promise} Promise resolved when this component has completed initialization + */ + initAsync() { + for (const id of this.dependencies) { + if (!this.context.systems[id]) { + return Promise.reject(`Cannot init: ${this.id} requires ${id}`); + } + } + + return this.open() + .then(() => { + // Initialization successful + }) + .catch((error) => { + console.error('Failed to initialize:', error); + }); + } + + + /** + * open + * Opens a connection to the IndexedDB database + */ + open() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, 2); + request.onupgradeneeded = (event) => { + this.db = event.target.result; + try { + if (!this.db.objectStoreNames.contains('editHistory')) { + this.db.createObjectStore('editHistory', { keyPath: 'id', autoIncrement: true }); + } + if (!this.db.objectStoreNames.contains('backups')) { + this.db.createObjectStore('backups', { keyPath: 'id' }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error during onupgradeneeded:', error); + } + }; + request.onsuccess = (event) => { + this.db = event.target.result; + resolve(this.db); + }; + request.onerror = (event) => { + // eslint-disable-next-line no-console + console.error('Database error:', event.target.error); + reject(`Database error: ${event.target.errorCode}`); + }; + }); + } + + + /** + * add + * Adds data to the specified object store + * @param storeName Name of the object store + * @param data Data to add + */ + add(storeName, data) { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.add(data); + + request.onsuccess = () => resolve(); + request.onerror = (event) => { + // eslint-disable-next-line no-console + console.error('Add error:', event.target.error); + reject(`Add error: ${event.target.errorCode}`); + }; + }); + } + + + /** + * get + * Retrieves data by key from the specified object store + * @param storeName Name of the object store + * @param key Key of the data to retrieve + */ + get(storeName, key) { + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.get(key); + + request.onsuccess = (event) => resolve(event.target.result); + request.onerror = (event) => reject(`Get error: ${event.target.errorCode}`); + }); + } + + + /** + * update + * Updates existing data in the specified object store + * @param storeName Name of the object store + * @param data Data to update + */ + update(storeName, data) { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.put(data); + + request.onsuccess = () => resolve(); + request.onerror = (event) => reject(`Update error: ${event.target.errorCode}`); + }); + } + + + /** + * put + * Inserts or updates data in the specified object store + * @param storeName Name of the object store + * @param data Data to insert or update + */ + put(storeName, data) { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.put(data); + + request.onsuccess = () => resolve(); + request.onerror = (event) => { + // eslint-disable-next-line no-console + console.error('Put error:', event.target.error); + reject(`Put error: ${event.target.errorCode}`); + }; + }); + } + + + /** + * delete + * Deletes data by key from the specified object store + * @param storeName Name of the object store + * @param key Key of the data to delete + */ + delete(storeName, key) { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.delete(key); + + request.onsuccess = () => resolve(); + request.onerror = (event) => reject(`Delete error: ${event.target.errorCode}`); + }); + } + + + /** + * clear + * Clears all data from the specified object store + * @param storeName Name of the object store + */ + clear(storeName) { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.clear(); + + request.onsuccess = () => resolve(); + request.onerror = (event) => reject(`Clear error: ${event.target.errorCode}`); + }); + } + + + /** + * close + * Closes the database connection + */ + close() { + if (this.db) { + this.db.close(); + this.db = null; + } + } +} \ No newline at end of file diff --git a/modules/core/EditSystem.js b/modules/core/EditSystem.js index 6a5d32e5e..2ff26189c 100644 --- a/modules/core/EditSystem.js +++ b/modules/core/EditSystem.js @@ -82,7 +82,7 @@ export class EditSystem extends AbstractSystem { constructor(context) { super(context); this.id = 'editor'; // was: 'history' - this.dependencies = new Set(['gfx', 'imagery', 'map', 'photos', 'storage']); + this.dependencies = new Set(['gfx', 'imagery', 'map', 'photos', 'database']); this._mutex = utilSessionMutex('lock'); this._canRestoreBackup = false; @@ -118,6 +118,10 @@ export class EditSystem extends AbstractSystem { initAsync() { if (this._initPromise) return this._initPromise; + if (!this._mutex.locked()) { + this._mutex.lock(); + } + for (const id of this.dependencies) { if (!this.context.systems[id]) { return Promise.reject(`Cannot init: ${this.id} requires ${id}`); @@ -127,12 +131,28 @@ export class EditSystem extends AbstractSystem { this._reset(); const storage = this.context.systems.storage; - const prerequisites = storage.initAsync(); + const database = this.context.systems.database; + + // Ensure both storage and database are initialized + const prerequisites = Promise.all([ + storage.initAsync(), + database.initAsync() + ]); return this._initPromise = prerequisites - .then(() => { + .then(async () => { if (window.mocha) return; + // Check if a backup exists in IndexedDB + try { + const backup = await database.get('backups', this._backupKey()); + this._canRestoreBackup = !!backup; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to check for backup:', error); + this._canRestoreBackup = false; + } + // Setup event handlers.. window.addEventListener('beforeunload', e => { if (this._history.length > 1) { // user did something @@ -143,9 +163,6 @@ export class EditSystem extends AbstractSystem { }); window.addEventListener('unload', () => this._mutex.unlock()); - - // changes are restorable if Rapid is not open in another window/tab and a backup exists in localStorage - this._canRestoreBackup = this._mutex.lock() && storage.hasItem(this._backupKey()); }); } @@ -1178,15 +1195,16 @@ export class EditSystem extends AbstractSystem { if (this._inTransaction) return; // Don't backup edits mid-transaction if (!this._mutex.locked()) return; // Another browser tab owns the history - const storage = context.systems.storage; const json = this.toJSON(); if (json) { - // status will be `true` if the backup succeeded - const status = storage.setItem(this._backupKey(), json); - if (status !== this._backupStatus) { - this._backupStatus = status; - this.emit('backupstatuschange', this._backupStatus); - } + this.context.systems.database.put('backups', { id: this._backupKey(), data: json }) + .then(() => { + // Backup saved successfully + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('Failed to save backup:', error); + }); } } @@ -1210,20 +1228,30 @@ export class EditSystem extends AbstractSystem { * - The user chooses to "Restore my changes" from the restore screen */ restoreBackup() { - this._canRestoreBackup = false; - - if (!this._mutex.locked()) return; // another browser tab owns the history - - const context = this.context; - const storage = context.systems.storage; - const json = storage.getItem(this._backupKey()); - if (json) { - context.resetAsync() - .then(() => this.fromJSONAsync(json)); - } + this.context.systems.database.get('backups', this._backupKey()) + .then((backup) => { + if (backup) { + return this.fromJSONAsync(backup.data) + .then(() => { + // Reset flags and emit events + this._canRestoreBackup = false; + this._inTransition = false; + this._inTransaction = false; + + // Emit events to ensure the system is aware of the restored state + this.emit('stagingchange', this._fullDifference); + this.emit('stablechange', this._fullDifference); + }); + } + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('Failed to restore backup:', error); + }); } + /** * clearBackup * Remove any backup stored in localStorage. @@ -1233,18 +1261,14 @@ export class EditSystem extends AbstractSystem { * - A changeset is inflight, we remove it to prevent the user from restoring duplicate edits */ clearBackup() { - this._canRestoreBackup = false; - this.deferredBackup.cancel(); - - if (!this._mutex.locked()) return; // another browser tab owns the history - - const storage = this.context.systems.storage; - storage.removeItem(this._backupKey()); - - // clear the changeset metadata associated with the saved history - storage.removeItem('comment'); - storage.removeItem('hashtags'); - storage.removeItem('source'); + this.context.systems.database.delete('backups', this._backupKey()) + .then(() => { + // Backup cleared successfully + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('Failed to clear backup:', error); + }); } diff --git a/modules/core/StorageSystem.js b/modules/core/StorageSystem.js index 901126f2c..da9484086 100644 --- a/modules/core/StorageSystem.js +++ b/modules/core/StorageSystem.js @@ -2,8 +2,7 @@ import { AbstractSystem } from './AbstractSystem.js'; /** * `StorageSystem` is a wrapper around `window.localStorage` - * It is used to store user preferences (good) - * and the user's edit history (not good) + * It is used to store user preferences * * n.b.: `localStorage` is a _synchronous_ API. * We should add another system for wrapping `indexedDB`, diff --git a/modules/core/index.js b/modules/core/index.js index 1ce7654eb..046774891 100644 --- a/modules/core/index.js +++ b/modules/core/index.js @@ -14,6 +14,7 @@ import { PhotoSystem } from './PhotoSystem.js'; import { PresetSystem } from './PresetSystem.js'; import { RapidSystem } from './RapidSystem.js'; import { StorageSystem } from './StorageSystem.js'; +import { DatabaseSystem } from './DatabaseSystem.js'; import { StyleSystem } from './StyleSystem.js'; import { UiSystem } from './UiSystem.js'; import { UploaderSystem } from './UploaderSystem.js'; @@ -35,6 +36,7 @@ export { PresetSystem, RapidSystem, StorageSystem, + DatabaseSystem, StyleSystem, UiSystem, UploaderSystem, @@ -60,6 +62,7 @@ systems.available.set('photos', PhotoSystem); systems.available.set('presets', PresetSystem); systems.available.set('rapid', RapidSystem); systems.available.set('storage', StorageSystem); +systems.available.set('database', DatabaseSystem); systems.available.set('styles', StyleSystem); systems.available.set('ui', UiSystem); systems.available.set('uploader', UploaderSystem); diff --git a/package-lock.json b/package-lock.json index be5a5c656..0174165d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rapideditor/rapid", - "version": "2.5.1", + "version": "2.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rapideditor/rapid", - "version": "2.5.1", + "version": "2.5.2", "license": "ISC", "dependencies": { "@mapbox/geojson-area": "^0.2.2",