Stackedit/src/services/localDbSvc.js

390 lines
12 KiB
JavaScript

import 'babel-polyfill';
import 'indexeddbshim/dist/indexeddbshim';
import FileSaver from 'file-saver';
import utils from './utils';
import store from '../store';
import welcomeFile from '../data/welcomeFile.md';
const indexedDB = window.indexedDB;
const dbVersion = 1;
const dbVersionKey = `${utils.workspaceId}/localDbVersion`;
const dbStoreName = 'objects';
const exportBackup = utils.queryParams.exportBackup;
if (!indexedDB) {
throw new Error('Your browser is not supported. Please upgrade to the latest version.');
}
const deleteMarkerMaxAge = 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[dbVersionKey] = this.db.version; // Safari does not support onversionchange
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 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 */
};
}
/**
* 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 });
}
// If DB version has changed (Safari support)
if (parseInt(localStorage[dbVersionKey], 10) !== this.db.version) {
return window.location.reload();
}
// 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 hashMap = {};
utils.types.forEach((type) => {
hashMap[type] = Object.create(null);
});
const contentTypes = {
content: true,
contentState: true,
syncedContent: true,
};
const localDbSvc = {
lastTx: 0,
hashMap,
connection: new Connection(),
/**
* 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.
*/
sync() {
return new Promise((resolve, reject) => {
this.connection.createTx((tx) => {
this.readAll(tx, (storeItemMap) => {
this.writeAll(storeItemMap, tx);
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.lastTx;
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);
}
});
this.lastTx = lastTx;
cb(storeItemMap);
}
};
},
/**
* Write all changes from the store since previous transaction.
*/
writeAll(storeItemMap, tx) {
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.keys(storeItemMap).forEach((id) => {
const storeItem = storeItemMap[id];
// 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 existingStoreItem = storeItemMap[dbItem.id];
if (!dbItem.hash) {
// DB item is a delete marker
delete this.hashMap[dbItem.type][dbItem.id];
if (existingStoreItem) {
// Remove item from the store
store.commit(`${existingStoreItem.type}/deleteItem`, existingStoreItem.id);
delete storeItemMap[existingStoreItem.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 (existingStoreItem || !contentTypes[dbItem.type] || exportBackup) {
// 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.
*/
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
*/
unloadContents() {
return this.sync()
.then(() => {
// Keep only last opened files in memory
const lastOpenedFileIds = new Set(store.getters['data/lastOpenedIds']);
Object.keys(contentTypes).forEach((type) => {
store.getters[`${type}/items`].forEach((item) => {
const [fileId] = item.id.split('/');
if (!lastOpenedFileIds.has(fileId)) {
// Remove item from the store
store.commit(`${type}/deleteItem`, item.id);
}
});
});
});
},
/**
* Drop the database
*/
removeDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase('stackedit-db');
request.onerror = reject;
request.onsuccess = resolve;
})
.then(() => {
localStorage.removeItem(dbVersionKey);
window.location.reload();
}, () => store.dispatch('notification/error', 'Could not delete local database.'));
},
};
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');
const ifNoId = cb => (obj) => {
if (obj.id) {
return obj;
}
return cb();
};
// Load the DB on boot
localDbSvc.sync()
.then(() => {
if (exportBackup) {
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;
}
// Set the ready flag
store.commit('setReady');
// If app was last opened 7 days ago and synchronization is off
if (!store.getters['data/loginToken'] &&
(utils.lastOpened + utils.cleanTrashAfter < Date.now())
) {
// Clean files
store.getters['file/items']
.filter(file => file.parentId === 'trash') // If file is in the trash
.forEach(file => store.dispatch('deleteFile', file.id));
}
// watch file changing
store.watch(
() => store.getters['file/current'].id,
() => Promise.resolve(store.getters['file/current'])
// If current file has no ID, get the most recent file
.then(ifNoId(() => store.getters['file/lastOpened']))
// If still no ID, create a new file
.then(ifNoId(() => store.dispatch('createFile', {
name: 'Welcome file',
text: welcomeFile,
})))
.then((currentFile) => {
// Fix current file ID
if (store.getters['file/current'].id !== currentFile.id) {
store.commit('file/setCurrentId', currentFile.id);
// Wait for the next watch tick
return null;
}
return Promise.resolve()
// Load contentState from DB
.then(() => localDbSvc.loadContentState(currentFile.id))
// Load syncedContent from DB
.then(() => localDbSvc.loadSyncedContent(currentFile.id))
// Load content from DB
.then(() => localDbSvc.loadItem(`${currentFile.id}/content`))
.then(
// Success, set last opened file
() => store.dispatch('data/setLastOpenedId', currentFile.id),
(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;
},
);
})
.catch((err) => {
console.error(err); // eslint-disable-line no-console
store.dispatch('notification/error', err);
}),
{
immediate: true,
});
});
// Sync local DB periodically
utils.setInterval(() => localDbSvc.sync(), 1000);
export default localDbSvc;