Stackedit/src/services/localDbSvc.js
2017-12-10 23:49:20 +00:00

465 lines
15 KiB
JavaScript

import FileSaver from 'file-saver';
import utils from './utils';
import store from '../store';
import welcomeFile from '../data/welcomeFile.md';
const dbVersion = 1;
const dbVersionKey = `${utils.workspaceId}/localDbVersion`;
const dbStoreName = 'objects';
const exportBackup = utils.queryParams.exportBackup;
if (exportBackup) {
location.hash = '';
}
const deleteMarkerMaxAge = 1000;
const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec
class Connection {
constructor(dbName) {
this.getTxCbs = [];
// Init connection
const request = indexedDB.open(dbName, 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 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 });
}
// 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 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,
/**
* Create the connection and start syncing.
*/
init() {
// Create the connection
this.connection = new Connection(store.getters['data/dbName']);
// Load the DB
return localDbSvc.sync()
.then(() => {
// If exportBackup parameter was provided
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;
}
// Save welcome file content hash if not done already
const hash = utils.hash(welcomeFile);
const welcomeFileHashes = store.getters['data/localSettings'].welcomeFileHashes;
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['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));
}
// Enable sponsorship
if (utils.queryParams.paymentSuccess) {
location.hash = '';
store.dispatch('modal/paymentSuccess');
const loginToken = store.getters['data/loginToken'];
// Force check sponsorship after a few seconds
const currentDate = Date.now();
if (loginToken && loginToken.expiresOn > currentDate - checkSponsorshipAfter) {
store.dispatch('data/setGoogleToken', {
...loginToken,
expiresOn: currentDate - checkSponsorshipAfter,
});
}
}
// Sync local DB periodically
utils.setInterval(() => localDbSvc.sync(), 1000);
const ifNoId = cb => (obj) => {
if (obj.id) {
return obj;
}
return cb();
};
// watch current 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(
() => {
// Set last opened file
store.dispatch('data/setLastOpenedId', currentFile.id);
// Cancel new discussion
store.commit('discussion/setCurrentDiscussionId');
// Open the gutter if file contains discussions
store.commit('discussion/setCurrentDiscussionId',
store.getters['discussion/nextDiscussionId']);
},
(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 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.
*/
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.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.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 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 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
*/
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');
export default localDbSvc;