From 97172e28b314017bb568be2eae69b938a369560c Mon Sep 17 00:00:00 2001 From: benweet Date: Thu, 27 Jul 2017 09:23:54 +0100 Subject: [PATCH] added localDbSvc --- package.json | 3 + src/components/App.vue | 37 --- src/components/NavigationBar.vue | 38 ++++ src/icons/FormatBold.vue | 2 +- src/icons/FormatItalic.vue | 2 +- src/icons/FormatListBulleted.vue | 2 +- src/icons/FormatListNumbers.vue | 2 +- src/icons/FormatQuoteClose.vue | 2 +- src/icons/FormatSize.vue | 2 +- src/icons/FormatStrikethrough.vue | 2 +- src/services/localDbSvc.js | 363 ++++++++++++++++++++++++++++++ src/store/index.js | 5 +- yarn.lock | 65 +++++- 13 files changed, 474 insertions(+), 51 deletions(-) create mode 100644 src/services/localDbSvc.js diff --git a/package.json b/package.json index c075aad1..92f71bef 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "dependencies": { "bezier-easing": "^1.1.0", "clunderscore": "^1.0.3", + "debug": "^2.6.8", "diff-match-patch": "^1.0.0", + "indexeddbshim": "^3.0.4", "katex": "^0.7.1", "markdown-it": "^8.3.1", "markdown-it-abbr": "^1.0.4", @@ -35,6 +37,7 @@ "babel-eslint": "^7.1.1", "babel-loader": "^6.2.10", "babel-plugin-transform-runtime": "^6.22.0", + "babel-polyfill": "^6.23.0", "babel-preset-env": "^1.3.2", "babel-preset-stage-2": "^6.22.0", "babel-register": "^6.22.0", diff --git a/src/components/App.vue b/src/components/App.vue index 0e5217fe..242f2b73 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -104,41 +104,4 @@ textarea { } } } - -$r: 9px; -$d: $r * 2; -$t: 1500ms; - -.spinner { - width: $d; - height: $d; - display: block; - position: relative; - border: $d/10 solid currentColor; - border-radius: 50%; - - &::before, - &::after { - content: ""; - position: absolute; - display: block; - width: $d/10; - background-color: currentColor; - border-radius: ($d/10)/2; - transform-origin: 50% 0; - } - - &::before { - height: $r - $d/5; - left: $r - $d/10 - $d/20; - top: 50%; - animation: spin $t linear infinite; - } -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} diff --git a/src/components/NavigationBar.vue b/src/components/NavigationBar.vue index e49fab48..8eb6cfce 100644 --- a/src/components/NavigationBar.vue +++ b/src/components/NavigationBar.vue @@ -206,4 +206,42 @@ export default { margin: 10px 5px 0; color: rgba(255, 255, 255, 0.33); } + +$r: 9px; +$d: $r * 2; +$b: $d/10; +$t: 1500ms; + +.spinner { + width: $d; + height: $d; + display: block; + position: relative; + border: $b solid currentColor; + border-radius: 50%; + + &::before, + &::after { + content: ""; + position: absolute; + display: block; + width: $b; + background-color: currentColor; + border-radius: $b * 0.5; + transform-origin: 50% 0; + } + + &::before { + height: $r * 0.5; + left: $r - $b * 1.5; + top: 50%; + animation: spin $t linear infinite; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/icons/FormatBold.vue b/src/icons/FormatBold.vue index 8cd78707..10a8fe84 100644 --- a/src/icons/FormatBold.vue +++ b/src/icons/FormatBold.vue @@ -1,5 +1,5 @@ diff --git a/src/icons/FormatItalic.vue b/src/icons/FormatItalic.vue index de698f89..7a59b58a 100644 --- a/src/icons/FormatItalic.vue +++ b/src/icons/FormatItalic.vue @@ -1,5 +1,5 @@ diff --git a/src/icons/FormatListBulleted.vue b/src/icons/FormatListBulleted.vue index dbdb0a01..01290473 100644 --- a/src/icons/FormatListBulleted.vue +++ b/src/icons/FormatListBulleted.vue @@ -1,5 +1,5 @@ diff --git a/src/icons/FormatListNumbers.vue b/src/icons/FormatListNumbers.vue index 6bd088ba..a7f93f5f 100644 --- a/src/icons/FormatListNumbers.vue +++ b/src/icons/FormatListNumbers.vue @@ -1,5 +1,5 @@ diff --git a/src/icons/FormatQuoteClose.vue b/src/icons/FormatQuoteClose.vue index 3001e74f..e7cb7177 100644 --- a/src/icons/FormatQuoteClose.vue +++ b/src/icons/FormatQuoteClose.vue @@ -1,5 +1,5 @@ diff --git a/src/icons/FormatSize.vue b/src/icons/FormatSize.vue index 77e03dd8..5bbf4667 100644 --- a/src/icons/FormatSize.vue +++ b/src/icons/FormatSize.vue @@ -1,5 +1,5 @@ diff --git a/src/icons/FormatStrikethrough.vue b/src/icons/FormatStrikethrough.vue index 50d794a3..c5f7f10d 100644 --- a/src/icons/FormatStrikethrough.vue +++ b/src/icons/FormatStrikethrough.vue @@ -1,5 +1,5 @@ diff --git a/src/services/localDbSvc.js b/src/services/localDbSvc.js new file mode 100644 index 00000000..e64237a2 --- /dev/null +++ b/src/services/localDbSvc.js @@ -0,0 +1,363 @@ +import 'babel-polyfill'; +import 'indexeddbshim'; +import utils from './utils'; + +let indexedDB = window.indexedDB; +const localStorage = window.localStorage; +const dbVersion = 1; + +// Use the shim on Safari or if indexedDB is not available +if (window.shimIndexedDB && (!indexedDB || (navigator.userAgent.indexOf('Chrome') === -1 && navigator.userAgent.indexOf('Safari') !== -1))) { + indexedDB = window.shimIndexedDB; +} + +const deletedMarkerMaxAge = 1000; + +function identity(value) { + return value; +} + +function makeStore(storeName, schemaParameter) { + const schema = { + ...schemaParameter, + updated: 'int', + }; + + const schemaKeys = Object.keys(schema); + const schemaKeysLen = schemaKeys.length; + const complexKeys = []; + let complexKeysLen = 0; + const attributeCheckers = {}; + const attributeReaders = {}; + const attributeWriters = {}; + + class Dao { + constructor(id, skipInit) { + this.id = id || utils.uid(); + if (!skipInit) { + const fakeItem = {}; + for (let i = 0; i < schemaKeysLen; i += 1) { + attributeReaders[schemaKeys[i]](fakeItem, this); + } + this.$dirty = true; + } + } + } + + function createDao(id) { + return new Dao(id); + } + + Object.keys(schema).forEach((key) => { + const value = schema[key]; + const storedValueKey = `_${key}`; + let defaultValue = value.default === undefined ? '' : value.default; + let serializer = value.serializer || identity; + let parser = value.parser || identity; + if (value === 'int') { + defaultValue = 0; + } else if (value === 'object') { + defaultValue = 'null'; + parser = JSON.parse; + serializer = JSON.stringify; + } + + attributeReaders[key] = (dbItem, dao) => { + dao[storedValueKey] = dbItem[key] || defaultValue; + }; + attributeWriters[key] = (dbItem, dao) => { + const storedValue = dao[storedValueKey]; + if (storedValue && storedValue !== defaultValue) { + dbItem[key] = storedValue; + } + }; + + function getter() { + return this[storedValueKey]; + } + + function setter(param) { + const val = param || defaultValue; + if (this[storedValueKey] === val) { + return false; + } + this[storedValueKey] = val; + this.$dirty = true; + return true; + } + + if (key === 'updated') { + Object.defineProperty(Dao.prototype, key, { + get: getter, + set: (value) => { + if (setter.call(this, value)) { + this.$dirtyUpdated = true; + } + } + }) + } else if (value === 'string' || value === 'int') { + Object.defineProperty(Dao.prototype, key, { + get: getter, + set: setter + }) + } else if (![64, 128] // Handle string64 and string128 + .cl_some(function (length) { + if (value === 'string' + length) { + Object.defineProperty(Dao.prototype, key, { + get: getter, + set: function (value) { + if (value && value.length > length) { + value = value.slice(0, length) + } + setter.call(this, value) + } + }) + return true + } + }) + ) { + // Other types go to complexKeys list + complexKeys.push(key) + complexKeysLen++ + + // And have complex readers/writers + attributeReaders[key] = function (dbItem, dao) { + const storedValue = dbItem[key] + if (!storedValue) { + storedValue = defaultValue + } + dao[storedValueKey] = storedValue + dao[key] = parser(storedValue) + } + attributeWriters[key] = function (dbItem, dao) { + const storedValue = serializer(dao[key]) + dao[storedValueKey] = storedValue + if (storedValue && storedValue !== defaultValue) { + dbItem[key] = storedValue + } + } + + // Checkers are only for complex types + attributeCheckers[key] = function (dao) { + return serializer(dao[key]) !== dao[storedValueKey] + } + } + }) + + const lastTx = 0 + const storedSeqs = Object.create(null) + + function readDbItem(item, daoMap) { + const dao = daoMap[item.id] || new Dao(item.id, true) + if (!item.updated) { + delete storedSeqs[item.id] + if (dao.updated) { + delete daoMap[item.id] + return true + } + return + } + if (storedSeqs[item.id] === item.seq) { + return + } + storedSeqs[item.id] = item.seq + for (const i = 0; i < schemaKeysLen; i++) { + attributeReaders[schemaKeys[i]](item, dao) + } + dao.$dirty = false + dao.$dirtyUpdated = false + daoMap[item.id] = dao + return true + } + + function getPatch(tx, cb) { + let resetMap; + + // We may have missed some deleted markers + if (lastTx && tx.txCounter - lastTx > deletedMarkerMaxAge) { + // Delete all dirty daos, user was asleep anyway... + resetMap = true + // And retrieve everything from DB + lastTx = 0 + } + + const hasChanged = !lastTx + const store = tx.objectStore(storeName) + const index = store.index('seq') + const range = $window.IDBKeyRange.lowerBound(lastTx, true) + const items = [] + const itemsToDelete = [] + index.openCursor(range).onsuccess = function (event) { + const cursor = event.target.result + if (!cursor) { + itemsToDelete.cl_each(function (item) { + store.delete(item.id) + }) + items.length && debug('Got ' + items.length + ' ' + storeName + ' items') + // Return a patch, to apply changes later + return cb(function (daoMap) { + if (resetMap) { + Object.keys(daoMap).cl_each(function (key) { + delete daoMap[key] + }) + storedSeqs = Object.create(null) + } + items.cl_each(function (item) { + hasChanged |= readDbItem(item, daoMap) + }) + return hasChanged + }) + } + const item = cursor.value + items.push(item) + // Remove old deleted markers + if (!item.updated && tx.txCounter - item.seq > deletedMarkerMaxAge) { + itemsToDelete.push(item) + } + cursor.continue() + } + } + + function writeAll(daoMap, tx) { + lastTx = tx.txCounter + const store = tx.objectStore(storeName) + + // Remove deleted daos + const storedIds = Object.keys(storedSeqs) + const storedIdsLen = storedIds.length + for (const i = 0; i < storedIdsLen; i++) { + const id = storedIds[i] + if (!daoMap[id]) { + // Put a deleted marker to notify other tabs + store.put({ + id: id, + seq: lastTx + }) + delete storedSeqs[id] + } + } + + // Put changes + const daoIds = Object.keys(daoMap) + const daoIdsLen = daoIds.length + for (i = 0; i < daoIdsLen; i++) { + const dao = daoMap[daoIds[i]] + const dirty = dao.$dirty + for (const j = 0; !dirty && j < complexKeysLen; j++) { + dirty |= attributeCheckers[complexKeys[j]](dao) + } + if (dirty) { + if (!dao.$dirtyUpdated) { + // Force update the `updated` attribute + dao.updated = Date.now() + } + const item = { + id: daoIds[i], + seq: lastTx + } + for (j = 0; j < schemaKeysLen; j++) { + attributeWriters[schemaKeys[j]](item, dao) + } + debug('Put ' + storeName + ' item') + store.put(item) + storedSeqs[item.id] = item.seq + dao.$dirty = false + dao.$dirtyUpdated = false + } + } + } + + return { + getPatch, + writeAll, + createDao, + Dao, + }; +} + +class Connection { + constructor() { + this.getTxCbs = []; + + // Init connexion + const request = indexedDB.open('classeur-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; + function createStore(name) { + const store = eventDb.createObjectStore(name, { keyPath: 'id' }); + store.createIndex('seq', 'seq', { unique: false }); + } + + // 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: + [ + 'contents', + 'files', + 'folders', + 'objects', + 'app', + ].forEach(createStore); + 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 store = tx.objectStore('app'); + const request = store.get('txCounter'); + request.onsuccess = () => { + tx.txCounter = request.result ? request.result.value : 0; + tx.txCounter += 1; + store.put({ + id: 'txCounter', + value: tx.txCounter, + }); + cb(tx); + }; + } +} + +class LocalDbStorage { + init(store) { + this.store = store; + store.subscribe((mutation, state) => { + console.log(mutation, state); + }); + this.connection = new Connection(); + } +} + +export default LocalDbStorage; diff --git a/src/store/index.js b/src/store/index.js index 93497307..48e1e8d4 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -4,11 +4,14 @@ import Vuex from 'vuex'; import files from './modules/files'; import layout from './modules/layout'; import editor from './modules/editor'; +import LocalDbStorage from '../services/localDbSvc'; Vue.use(Vuex); const debug = process.env.NODE_ENV !== 'production'; +const localDbStorage = new LocalDbStorage(); + const store = new Vuex.Store({ modules: { files, @@ -16,7 +19,7 @@ const store = new Vuex.Store({ editor, }, strict: debug, - plugins: debug ? [createLogger()] : [], + plugins: [_store => localDbStorage.init(_store)].concat(debug ? [createLogger()] : []), }); export default store; diff --git a/yarn.lock b/yarn.lock index e6ac9058..58375dd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -115,6 +115,10 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +argsarray@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/argsarray/-/argsarray-0.0.1.tgz#6e7207b4ecdb39b0af88303fa5ae22bda8df61cb" + arr-diff@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" @@ -692,6 +696,14 @@ babel-plugin-transform-strict-mode@^6.24.1: babel-runtime "^6.22.0" babel-types "^6.24.1" +babel-polyfill@6.23.0, babel-polyfill@^6.23.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.23.0.tgz#8364ca62df8eafb830499f699177466c3b03499d" + dependencies: + babel-runtime "^6.22.0" + core-js "^2.4.0" + regenerator-runtime "^0.10.0" + babel-preset-env@^1.3.2: version "1.5.1" resolved "https://registry.yarnpkg.com/babel-preset-env/-/babel-preset-env-1.5.1.tgz#d2eca6af179edf27cdc305a84820f601b456dd0b" @@ -1587,7 +1599,7 @@ debug@2.6.7: dependencies: ms "2.0.0" -debug@^2.1.1, debug@^2.2.0, debug@^2.6.0: +debug@^2.1.1, debug@^2.2.0, debug@^2.6.0, debug@^2.6.8: version "2.6.8" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc" dependencies: @@ -2933,6 +2945,10 @@ ignore@^3.2.0: version "3.3.3" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.3.tgz#432352e57accd87ab3110e82d3fea0e47812156d" +immediate@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.2.3.tgz#d140fa8f614659bd6541233097ddaac25cdd991c" + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -2947,6 +2963,13 @@ indent-string@^2.1.0: dependencies: repeating "^2.0.0" +indexeddbshim@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/indexeddbshim/-/indexeddbshim-3.0.4.tgz#b4d9a4fb3fce7532b66e19790a15692f9b2c7b56" + dependencies: + babel-polyfill "6.23.0" + websql "https://github.com/brettz9/node-websql#configurable" + indexes-of@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" @@ -3701,10 +3724,6 @@ markdown-it-footnote@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/markdown-it-footnote/-/markdown-it-footnote-3.0.1.tgz#7f3730747cacc86e2fe0bf8a17a710f34791517a" -markdown-it-mathjax@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/markdown-it-mathjax/-/markdown-it-mathjax-2.0.0.tgz#ae2b4f4c5c719a03f9e475c664f7b2685231d9e9" - markdown-it-pandoc-renderer@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/markdown-it-pandoc-renderer/-/markdown-it-pandoc-renderer-1.1.3.tgz#77f1a5b488c5460ab1e1dcbcfcc07d12674f7a3a" @@ -3903,6 +3922,10 @@ nan@^2.3.0, nan@^2.3.2: version "2.6.2" resolved "https://registry.yarnpkg.com/nan/-/nan-2.6.2.tgz#e4ff34e6c95fdfb5aecc08de6596f43605a7db45" +nan@~2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.4.0.tgz#fb3c59d45fe4effe215f0b890f8adf6eb32d2232" + natives@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/natives/-/natives-1.1.0.tgz#e9ff841418a6b2ec7a495e939984f78f163e6e31" @@ -4007,7 +4030,7 @@ node-libs-browser@^2.0.0: util "^0.10.3" vm-browserify "0.0.4" -node-pre-gyp@^0.6.29: +node-pre-gyp@^0.6.29, node-pre-gyp@~0.6.31: version "0.6.36" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.36.tgz#db604112cb74e0d477554e9b505b17abddfab786" dependencies: @@ -4044,6 +4067,10 @@ node-sass@^4.5.3: sass-graph "^2.1.1" stdout-stream "^1.4.0" +noop-fn@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/noop-fn/-/noop-fn-1.0.0.tgz#5f33d47f13d2150df93e0cb036699e982f78ffbf" + "nopt@2 || 3", nopt@~3.0.1: version "3.0.6" resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" @@ -4735,6 +4762,10 @@ postcss@^6.0.1: source-map "^0.5.6" supports-color "^3.2.3" +pouchdb-collections@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pouchdb-collections/-/pouchdb-collections-1.0.1.tgz#fe63a17da977611abef7cb8026cb1a9553fd8359" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -5401,6 +5432,13 @@ sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" +sqlite3@^3.1.3: + version "3.1.8" + resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-3.1.8.tgz#4cbcf965d8b901d1b1015cbc7fc415aae157dfaa" + dependencies: + nan "~2.4.0" + node-pre-gyp "~0.6.31" + sshpk@^1.7.0: version "1.13.0" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.13.0.tgz#ff2a3e4fd04497555fed97b39a0fd82fafb3a33c" @@ -5756,6 +5794,10 @@ tiny-emitter@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.0.tgz#bad327adb1804b42a231afa741532bd884cd09ad" +tiny-queue@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/tiny-queue/-/tiny-queue-0.2.1.tgz#25a67f2c6e253b2ca941977b5ef7442ef97a6046" + to-arraybuffer@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" @@ -6141,6 +6183,17 @@ webpack@^2.6.1: webpack-sources "^0.2.3" yargs "^6.0.0" +"websql@https://github.com/brettz9/node-websql#configurable": + version "0.4.4" + resolved "https://github.com/brettz9/node-websql#c9828a34c92eced64858fc19151ec099fd60e8dd" + dependencies: + argsarray "^0.0.1" + immediate "^3.2.2" + noop-fn "^1.0.0" + pouchdb-collections "^1.0.1" + sqlite3 "^3.1.3" + tiny-queue "^0.2.1" + whet.extend@~0.9.9: version "0.9.9" resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"