import FileSaver from 'file-saver'; import utils from './utils'; import store from '../store'; import welcomeFile from '../data/welcomeFile.md'; import fileSvc from './fileSvc'; const dbVersion = 1; const dbStoreName = 'objects'; const { exportWorkspace } = utils.queryParams; const { silent } = utils.queryParams; const resetApp = utils.queryParams.reset; const deleteMarkerMaxAge = 1000; const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec const getDbName = (workspaceId) => { let dbName = 'stackedit-db'; if (workspaceId !== 'main') { dbName += `-${workspaceId}`; } return dbName; }; class Connection { constructor() { this.getTxCbs = []; // Make the DB name const workspaceId = store.getters['workspace/currentWorkspace'].id; this.dbName = getDbName(workspaceId); // Init connection const request = indexedDB.open(this.dbName, dbVersion); request.onerror = () => { throw new Error("Can't connect to IndexedDB."); }; request.onsuccess = (event) => { this.db = event.target.result; this.db.onversionchange = () => window.location.reload(); this.getTxCbs.forEach(({ onTx, onError }) => this.createTx(onTx, onError)); 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 behavior 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 */ }; } /** * Create a transaction asynchronously. */ createTx(onTx, onError) { // If DB is not ready, keep callbacks for later if (!this.db) { return this.getTxCbs.push({ onTx, onError }); } // Open transaction in read/write will prevent conflict with other tabs const tx = this.db.transaction(this.db.objectStoreNames, 'readwrite'); tx.onerror = onError; return onTx(tx); } } const contentTypes = { content: true, contentState: true, syncedContent: true, }; const hashMap = {}; utils.types.forEach((type) => { hashMap[type] = Object.create(null); }); const lsHashMap = Object.create(null); const localDbSvc = { lastTx: 0, hashMap, connection: null, /** * Sync data items stored in the localStorage. */ syncLocalStorage() { utils.localStorageDataIds.forEach((id) => { const key = `data/${id}`; // Skip reloading the layoutSettings if (id !== 'layoutSettings' || !lsHashMap[id]) { try { // Try to parse the item from the localStorage const storedItem = JSON.parse(localStorage.getItem(key)); if (storedItem.hash && lsHashMap[id] !== storedItem.hash) { // Item has changed, replace it in the store store.commit('data/setItem', storedItem); lsHashMap[id] = storedItem.hash; } } catch (e) { // Ignore parsing issue } } // Write item if different from stored one const item = store.state.data.lsItemMap[id]; if (item && item.hash !== lsHashMap[id]) { localStorage.setItem(key, JSON.stringify(item)); lsHashMap[id] = item.hash; } }); }, /** * Return a promise that will be resolved once the synchronization between the store and the * localDb will be finished. Effectively, open a transaction, then read and apply all changes * from the DB since the previous transaction, then write all the changes from the store. */ async sync() { return new Promise((resolve, reject) => { // Create the DB transaction this.connection.createTx((tx) => { // Look for DB changes and apply them to the store this.readAll(tx, (storeItemMap) => { // Persist all the store changes into the DB this.writeAll(storeItemMap, tx); // Sync localStorage this.syncLocalStorage(); // Done resolve(); }); }, () => reject(new Error('Local DB access error.'))); }); }, /** * Read and apply all changes from the DB since previous transaction. */ readAll(tx, cb) { let { lastTx } = this; const dbStore = tx.objectStore(dbStoreName); const index = dbStore.index('tx'); const range = window.IDBKeyRange.lowerBound(this.lastTx, true); const changes = []; index.openCursor(range).onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const item = cursor.value; if (item.tx > lastTx) { lastTx = item.tx; if (this.lastTx && item.tx - this.lastTx > deleteMarkerMaxAge) { // We may have missed some delete markers window.location.reload(); return; } } // Collect change changes.push(item); cursor.continue(); } else { const storeItemMap = { ...store.getters.allItemMap }; changes.forEach((item) => { this.readDbItem(item, storeItemMap); // If item is an old delete marker, remove it from the DB if (!item.hash && lastTx - item.tx > deleteMarkerMaxAge) { dbStore.delete(item.id); } }); fileSvc.ensureUniquePaths(); this.lastTx = lastTx; cb(storeItemMap); } }; }, /** * Write all changes from the store since previous transaction. */ writeAll(storeItemMap, tx) { if (silent) { // Skip writing to DB in silent mode return; } const dbStore = tx.objectStore(dbStoreName); const incrementedTx = this.lastTx + 1; // Remove deleted store items Object.keys(this.hashMap).forEach((type) => { // Remove this type only if file is deleted let checker = cb => id => !storeItemMap[id] && cb(id); if (contentTypes[type]) { // For content types, remove item only if file is deleted checker = cb => (id) => { if (!storeItemMap[id]) { const [fileId] = id.split('/'); if (!store.state.file.itemMap[fileId]) { cb(id); } } }; } Object.keys(this.hashMap[type]).forEach(checker((id) => { // Put a delete marker to notify other tabs dbStore.put({ id, type, tx: incrementedTx, }); delete this.hashMap[type][id]; this.lastTx = incrementedTx; // No need to read what we just wrote })); }); // Put changes Object.entries(storeItemMap).forEach(([, storeItem]) => { // Store object has changed if (this.hashMap[storeItem.type][storeItem.id] !== storeItem.hash) { const item = { ...storeItem, tx: incrementedTx, }; dbStore.put(item); this.hashMap[item.type][item.id] = item.hash; this.lastTx = incrementedTx; // No need to read what we just wrote } }); }, /** * Read and apply one DB change. */ readDbItem(dbItem, storeItemMap) { const storeItem = storeItemMap[dbItem.id]; if (!dbItem.hash) { // DB item is a delete marker delete this.hashMap[dbItem.type][dbItem.id]; if (storeItem) { // Remove item from the store store.commit(`${storeItem.type}/deleteItem`, storeItem.id); delete storeItemMap[storeItem.id]; } } else if (this.hashMap[dbItem.type][dbItem.id] !== dbItem.hash) { // DB item is different from the corresponding store item this.hashMap[dbItem.type][dbItem.id] = dbItem.hash; // Update content only if it exists in the store if (storeItem || !contentTypes[dbItem.type] || exportWorkspace) { // Put item in the store dbItem.tx = undefined; store.commit(`${dbItem.type}/setItem`, dbItem); storeItemMap[dbItem.id] = dbItem; } } }, /** * Retrieve an item from the DB and put it in the store. */ async loadItem(id) { // Check if item is in the store const itemInStore = store.getters.allItemMap[id]; if (itemInStore) { // Use deepCopy to freeze item return Promise.resolve(itemInStore); } return new Promise((resolve, reject) => { // Get the item from DB const onError = () => reject(new Error('Data not available.')); this.connection.createTx((tx) => { const dbStore = tx.objectStore(dbStoreName); const request = dbStore.get(id); request.onsuccess = () => { const dbItem = request.result; if (!dbItem || !dbItem.hash) { onError(); } else { this.hashMap[dbItem.type][dbItem.id] = dbItem.hash; // Put item in the store dbItem.tx = undefined; store.commit(`${dbItem.type}/setItem`, dbItem); resolve(dbItem); } }; }, () => onError()); }); }, /** * Unload from the store contents that haven't been opened recently */ async unloadContents() { await this.sync(); // Keep only last opened files in memory const lastOpenedFileIdSet = new Set(store.getters['data/lastOpenedIds']); Object.keys(contentTypes).forEach((type) => { store.getters[`${type}/items`].forEach((item) => { const [fileId] = item.id.split('/'); if (!lastOpenedFileIdSet.has(fileId)) { // Remove item from the store store.commit(`${type}/deleteItem`, item.id); } }); }); }, /** * Drop the database and clean the localStorage for the specified workspaceId. */ async removeWorkspace(id) { const workspaces = { ...store.getters['data/workspaces'], }; delete workspaces[id]; store.dispatch('data/setWorkspaces', workspaces); this.syncLocalStorage(); await new Promise((resolve, reject) => { const dbName = getDbName(id); const request = indexedDB.deleteDatabase(dbName); request.onerror = reject; request.onsuccess = resolve; }); localStorage.removeItem(`${id}/lastSyncActivity`); localStorage.removeItem(`${id}/lastWindowFocus`); }, /** * Create the connection and start syncing. */ async init() { // Reset the app if reset flag was passed if (resetApp) { await Promise.all(Object.keys(store.getters['data/workspaces']) .map(workspaceId => localDbSvc.removeWorkspace(workspaceId))); utils.localStorageDataIds.forEach((id) => { // Clean data stored in localStorage localStorage.removeItem(`data/${id}`); }); window.location.reload(); throw new Error('reload'); } // Create the connection this.connection = new Connection(); // Load the DB await localDbSvc.sync(); // If exportWorkspace parameter was provided if (exportWorkspace) { const backup = JSON.stringify(store.getters.allItemMap); const blob = new Blob([backup], { type: 'text/plain;charset=utf-8', }); FileSaver.saveAs(blob, 'StackEdit workspace.json'); return; } // Save welcome file content hash if not done already const hash = utils.hash(welcomeFile); const { welcomeFileHashes } = store.getters['data/localSettings']; if (!welcomeFileHashes[hash]) { store.dispatch('data/patchLocalSettings', { welcomeFileHashes: { ...welcomeFileHashes, [hash]: 1, }, }); } // If app was last opened 7 days ago and synchronization is off if (!store.getters['workspace/syncToken'] && (store.state.workspace.lastFocus + utils.cleanTrashAfter < Date.now()) ) { // Clean files store.getters['file/items'] .filter(file => file.parentId === 'trash') // If file is in the trash .forEach(file => fileSvc.deleteFile(file.id)); } // Enable sponsorship if (utils.queryParams.paymentSuccess) { window.location.hash = ''; // PaymentSuccess param is always on its own store.dispatch('modal/open', 'paymentSuccess') .catch(() => { /* Cancel */ }); const sponsorToken = store.getters['workspace/sponsorToken']; // Force check sponsorship after a few seconds const currentDate = Date.now(); if (sponsorToken && sponsorToken.expiresOn > currentDate - checkSponsorshipAfter) { store.dispatch('data/setGoogleToken', { ...sponsorToken, expiresOn: currentDate - checkSponsorshipAfter, }); } } // Sync local DB periodically utils.setInterval(() => localDbSvc.sync(), 1000); // watch current file changing store.watch( () => store.getters['file/current'].id, async () => { // See if currentFile is real, ie it has an ID const currentFile = store.getters['file/current']; // If current file has no ID, get the most recent file if (!currentFile.id) { const recentFile = store.getters['file/lastOpened']; // Set it as the current file if (recentFile.id) { store.commit('file/setCurrentId', recentFile.id); } else { // If still no ID, create a new file const newFile = await fileSvc.createFile({ name: 'Welcome file', text: welcomeFile, }, true); // Set it as the current file store.commit('file/setCurrentId', newFile.id); } } else { try { // Load contentState from DB await localDbSvc.loadContentState(currentFile.id); // Load syncedContent from DB await localDbSvc.loadSyncedContent(currentFile.id); // Load content from DB try { await localDbSvc.loadItem(`${currentFile.id}/content`); } catch (err) { // Failure (content is not available), go back to previous file const lastOpenedFile = store.getters['file/lastOpened']; store.commit('file/setCurrentId', lastOpenedFile.id); throw err; } // Set last opened file store.dispatch('data/setLastOpenedId', currentFile.id); // Cancel new discussion and open the gutter if file contains discussions store.commit( 'discussion/setCurrentDiscussionId', store.getters['discussion/nextDiscussionId'], ); } catch (err) { console.error(err); // eslint-disable-line no-console store.dispatch('notification/error', err); } } }, { immediate: true }, ); }, }; const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`) // Item does not exist, create it .catch(() => store.commit(`${type}/setItem`, { id: `${fileId}/${type}`, })); localDbSvc.loadSyncedContent = loader('syncedContent'); localDbSvc.loadContentState = loader('contentState'); export default localDbSvc;