wikimedia/mediawiki-core

View on GitHub
resources/src/mediawiki.editRecovery/storage.js

Summary

Maintainability
A
1 hr
Test Coverage
/*!
 * Common indexedDB-access methods, only for use by the ResourceLoader modules this directory.
 */

const config = require( './config.json' );
const dbName = mw.config.get( 'wgDBname' ) + '_editRecovery';
const editRecoveryExpiry = config.EditRecoveryExpiry;
const objectStoreName = 'unsaved-page-data';

var db = null;

// TODO: Document Promise objects as native promises, not jQuery ones.

/**
 * @ignore
 * @return {jQuery.Promise} Promise which resolves on success
 */
function openDatabaseLocal() {
    return new Promise( ( resolve, reject ) => {
        const schemaNumber = 3;
        const openRequest = window.indexedDB.open( dbName, schemaNumber );
        openRequest.addEventListener( 'upgradeneeded', upgradeDatabase );
        openRequest.addEventListener( 'success', ( event ) => {
            db = event.target.result;
            resolve();
        } );
        openRequest.addEventListener( 'error', ( event ) => {
            reject( 'EditRecovery error: ' + event.target.error );
        } );
    } );
}

/**
 * @private
 * @param {Object} versionChangeEvent
 */
function upgradeDatabase( versionChangeEvent ) {
    const keyPathParts = [ 'pageName', 'section' ];
    var objectStore;

    db = versionChangeEvent.target.result;
    if ( !db.objectStoreNames.contains( objectStoreName ) ) {
        // ObjectStore does not yet exist, create it.
        objectStore = db.createObjectStore( objectStoreName, { keyPath: keyPathParts } );
    } else {
        // ObjectStore exists, but needs to be upgraded.
        objectStore = versionChangeEvent.target.transaction.objectStore( objectStoreName );
    }

    // Create indexes if they don't exist.
    if ( !objectStore.indexNames.contains( 'pageName-section' ) ) {
        objectStore.createIndex( 'pageName-section', keyPathParts, { unique: true } );
    }
    if ( !objectStore.indexNames.contains( 'expiry' ) ) {
        objectStore.createIndex( 'expiry', 'expiry' );
    }

    // Delete old indexes.
    if ( objectStore.indexNames.contains( 'lastModified' ) ) {
        objectStore.deleteIndex( 'lastModified' );
    }
    if ( objectStore.indexNames.contains( 'expiryDate' ) ) {
        objectStore.deleteIndex( 'expiryDate' );
    }
}

/**
 * Load data relating to a specific page and section
 *
 * @ignore
 * @param {string} pageName The current page name (with underscores)
 * @param {string|null} section The section ID, or null if the whole page is being edited
 * @return {jQuery.Promise} Promise which resolves with the page data on success, or rejects with an error message.
 */
function loadData( pageName, section ) {
    return new Promise( ( resolve, reject ) => {
        if ( !db ) {
            reject( 'DB not opened' );
        }
        const transaction = db.transaction( objectStoreName, 'readonly' );
        const key = [ pageName, section || '' ];
        const findExisting = transaction
            .objectStore( objectStoreName )
            .get( key );
        findExisting.addEventListener( 'success', () => {
            resolve( findExisting.result );
        } );
    } );
}

function loadAllData() {
    return new Promise( ( resolve, reject ) => {
        if ( !db ) {
            reject( 'DB not opened' );
        }
        const transaction = db.transaction( objectStoreName, 'readonly' );
        const requestAll = transaction
            .objectStore( objectStoreName )
            .getAll();
        requestAll.addEventListener( 'success', () => {
            resolve( requestAll.result );
        } );
    } );
}

/**
 * Save data for a specific page and section
 *
 * @ignore
 * @param {string} pageName The current page name (with underscores)
 * @param {string|null} section The section ID, or null if the whole page is being edited
 * @param {Object} pageData The page data to save
 * @return {jQuery.Promise} Promise which resolves on success, or rejects with an error message.
 */
function saveData( pageName, section, pageData ) {
    return new Promise( ( resolve, reject ) => {
        if ( !db ) {
            reject( 'DB not opened' );
        }

        // Add indexed fields.
        pageData.pageName = pageName;
        pageData.section = section || '';
        pageData.expiry = getExpiryDate( editRecoveryExpiry );

        const transaction = db.transaction( objectStoreName, 'readwrite' );
        const objectStore = transaction.objectStore( objectStoreName );

        const request = objectStore.put( pageData );
        request.addEventListener( 'success', ( event ) => {
            resolve( event );
        } );
        request.addEventListener( 'error', ( event ) => {
            reject( 'Error saving data: ' + event.target.errorCode );
        } );
    } );
}

/**
 * Delete data relating to a specific page
 *
 * @ignore
 * @param {string} pageName The current page name (with underscores)
 * @param {string|null} section The section ID, or null if the whole page is being edited
 * @return {jQuery.Promise} Promise which resolves on success, or rejects with an error message.
 */
function deleteData( pageName, section ) {
    return new Promise( ( resolve, reject ) => {
        if ( !db ) {
            reject( 'DB not opened' );
        }

        const transaction = db.transaction( objectStoreName, 'readwrite' );
        const objectStore = transaction.objectStore( objectStoreName );

        const request = objectStore.openCursor();

        request.addEventListener( 'success', ( event ) => {
            const cursor = event.target.result;
            if ( cursor ) {
                const key = cursor.key;
                const deleteSection = ( !section && key[ 1 ] === '' ) || section === key[ 1 ];
                if ( key[ 0 ] === pageName && deleteSection ) {
                    objectStore.delete( key );
                }
                cursor.continue();
            } else {
                resolve();
            }
        } );

        request.addEventListener( 'error', () => {
            reject( 'Error opening cursor' );
        } );
    } );
}

/**
 * Returns the date diff seconds in the future
 *
 * @ignore
 * @param {number} diff Seconds in the future
 * @return {number} Timestamp of diff days in the future
 */
function getExpiryDate( diff ) {
    return ( Date.now() / 1000 ) + diff;
}

/**
 * Delete expired data
 *
 * @ignore
 * @return {jQuery.Promise} Promise which resolves on success, or rejects with an error message.
 */
function deleteExpiredData() {
    return new Promise( ( resolve, reject ) => {
        if ( !db ) {
            reject( 'DB not opened' );
        }

        const transaction = db.transaction( objectStoreName, 'readwrite' );
        const objectStore = transaction.objectStore( objectStoreName );
        const expiry = objectStore.index( 'expiry' );
        const now = Date.now() / 1000;

        const expired = expiry.getAll( IDBKeyRange.upperBound( now, true ) );

        expired.addEventListener( 'success', ( event ) => {
            const cursors = event.target.result;
            if ( cursors.length > 0 ) {
                const deletions = [];
                cursors.forEach( ( cursor ) => {
                    deletions.push( deleteData( cursor.pageName, cursor.section ) );
                } );
                Promise.all( deletions ).then( resolve );
            } else {
                resolve();
            }
        } );

        expired.addEventListener( 'error', () => {
            reject( 'Error getting filtered data' );
        } );
    } );
}

/**
 * Close database
 *
 * @ignore
 */
function closeDatabase() {
    if ( db ) {
        db.close();
    }
}

module.exports = {
    openDatabase: openDatabaseLocal,
    closeDatabase,
    loadData,
    loadAllData,
    saveData,
    deleteData,
    deleteExpiredData
};