import 'babel-polyfill'; import 'indexeddbshim'; import debug from 'debug'; import store from '../store'; const dbg = debug('stackedit:localDbSvc'); let indexedDB = window.indexedDB; const localStorage = window.localStorage; const dbVersion = 1; const dbStoreName = 'objects'; // Use the shim on Safari or when indexedDB is not available if (window.shimIndexedDB && (!indexedDB || (navigator.userAgent.indexOf('Chrome') === -1 && navigator.userAgent.indexOf('Safari') !== -1))) { indexedDB = window.shimIndexedDB; } function getStorePrefixFromType(type) { // Return `files` for type `file`, `folders` for type `folder`, etc... const prefix = `${type}s`; return store.state[prefix] && prefix; } const deletedMarkerMaxAge = 1000; class Connection { constructor() { this.getTxCbs = []; // Init connexion const request = indexedDB.open('stackedit-db', dbVersion); request.onerror = () => { throw new Error("Can't connect to IndexedDB."); }; request.onsuccess = (event) => { this.db = event.target.result; localStorage.localDbVersion = this.db.version; // Safari does not support onversionchange this.db.onversionchange = () => window.location.reload(); this.getTxCbs.forEach(cb => this.createTx(cb)); this.getTxCbs = null; }; request.onupgradeneeded = (event) => { const eventDb = event.target.result; const oldVersion = event.oldVersion || 0; // We don't use 'break' in this switch statement, // the fall-through behaviour is what we want. /* eslint-disable no-fallthrough */ switch (oldVersion) { case 0: { // Create store const dbStore = eventDb.createObjectStore(dbStoreName, { keyPath: 'id', }); dbStore.createIndex('tx', 'tx', { unique: false, }); } default: } /* eslint-enable no-fallthrough */ }; } createTx(cb) { if (!this.db) { this.getTxCbs.push(cb); return; } // If DB version has changed (Safari support) if (parseInt(localStorage.localDbVersion, 10) !== this.db.version) { window.location.reload(); return; } const tx = this.db.transaction(this.db.objectStoreNames, 'readwrite'); tx.onerror = (evt) => { dbg('Rollback transaction', evt); }; const dbStore = tx.objectStore(dbStoreName); const request = dbStore.get('txCounter'); request.onsuccess = () => { tx.txCounter = request.result ? request.result.tx : 0; tx.txCounter += 1; dbStore.put({ id: 'txCounter', tx: tx.txCounter, }); cb(tx); }; } } export default { lastTx: 0, updatedMap: Object.create(null), connection: new Connection(), sync() { return new Promise((resolve) => { const storeItemMap = {}; [ store.state.contents, store.state.files, ].forEach(moduleState => Object.assign(storeItemMap, moduleState.itemMap)); this.connection.createTx((tx) => { this.readAll(storeItemMap, tx, () => { this.writeAll(storeItemMap, tx); resolve(); }); }); }); }, readAll(storeItemMap, tx, cb) { let resetMap; // We may have missed some deleted markers if (this.lastTx && tx.txCounter - this.lastTx > deletedMarkerMaxAge) { // Delete all dirty store items (user was asleep anyway...) resetMap = true; // And retrieve everything from DB this.lastTx = 0; } const dbStore = tx.objectStore(dbStoreName); const index = dbStore.index('tx'); const range = window.IDBKeyRange.lowerBound(this.lastTx, true); const items = []; const itemsToDelete = []; index.openCursor(range).onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const item = cursor.value; items.push(item); // Remove old deleted markers if (!item.updated && tx.txCounter - item.tx > deletedMarkerMaxAge) { itemsToDelete.push(item); } cursor.continue(); } else { itemsToDelete.forEach((item) => { dbStore.delete(item.id); }); if (items.length) { dbg(`Got ${items.length} items`); } if (resetMap) { Object.keys(storeItemMap).forEach((id) => { delete storeItemMap[id]; }); this.updatedMap = Object.create(null); } items.forEach(item => this.readDbItem(item, storeItemMap)); cb(); } }; }, writeAll(storeItemMap, tx) { this.lastTx = tx.txCounter; const dbStore = tx.objectStore(dbStoreName); // Remove deleted store items const storedIds = Object.keys(this.updatedMap); const storedIdsLen = storedIds.length; for (let i = 0; i < storedIdsLen; i += 1) { const id = storedIds[i]; if (!storeItemMap[id]) { // Put a deleted marker to notify other tabs dbStore.put({ id, tx: this.lastTx, }); delete this.updatedMap[id]; } } // Put changes const storeItemIds = Object.keys(storeItemMap); const storeItemIdsLen = storeItemIds.length; for (let i = 0; i < storeItemIdsLen; i += 1) { const storeItem = storeItemMap[storeItemIds[i]]; // Store object has changed if (this.updatedMap[storeItem.id] !== storeItem.updated) { const item = { ...storeItem, tx: this.lastTx, }; dbg('Putting 1 item'); dbStore.put(item); this.updatedMap[item.id] = item.updated; } } }, readDbItem(dbItem, storeItemMap) { const existingStoreItem = storeItemMap[dbItem.id]; if (!dbItem.updated) { delete this.updatedMap[dbItem.id]; if (existingStoreItem) { const prefix = getStorePrefixFromType(existingStoreItem.type); if (prefix) { delete storeItemMap[existingStoreItem.id]; // Remove object from the store store.commit(`${prefix}/deleteItem`, existingStoreItem.id); } } } else if (this.updatedMap[dbItem.id] !== dbItem.updated) { this.updatedMap[dbItem.id] = dbItem.updated; storeItemMap[dbItem.id] = dbItem; // Put object in the store const prefix = getStorePrefixFromType(dbItem.type); store.commit(`${prefix}/setItem`, dbItem); } }, };