forked from blikblum/nextbone
-
Notifications
You must be signed in to change notification settings - Fork 0
/
localstorage.js
313 lines (275 loc) · 8.61 KB
/
localstorage.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
import { sync, Collection } from './nextbone.js';
/** Generates 4 random hex digits
* @returns {string} 4 Random hex digits
*/
function s4() {
const rand = (1 + Math.random()) * 0x10000;
return (rand | 0).toString(16).substring(1);
}
/** Generate a pseudo-guid
* @returns {string} A GUID-like string.
*/
export function guid() {
return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
}
/** The default serializer for transforming your saved data to localStorage */
const defaultSerializer = {
/** Return a JSON-serialized string representation of item
* @param {Object} item - The encoded model data
* @returns {string} A JSON-encoded string
*/
serialize(item) {
return typeof item === 'object' && item ? JSON.stringify(item) : item;
},
/** Custom deserialization for data.
* @param {string} data - JSON-encoded string
* @returns {Object} The object result of parsing data
*/
deserialize(data) {
return JSON.parse(data);
}
};
function initializeData(instance, name, data) {
const records = [];
if (typeof data === 'function') data = data();
if (!Array.isArray(data)) data = [data];
const idAttribute =
instance instanceof Collection
? (instance.model || instance.constructor.model || {}).idAttribute || 'id'
: instance.idAttribute;
data.forEach(item => {
let id = item[idAttribute];
if (!id && id !== 0) {
item[idAttribute] = id = guid();
}
window.localStorage.setItem(`${name}-${id}`, JSON.stringify(item));
records.push(id);
});
window.localStorage.setItem(name, records.join(','));
}
export function bindLocalStorage(instance, name, { serializer, initialData } = {}) {
instance.localStorage = new LocalStorage(name, serializer);
let revision = revisionMap[name] || 0;
if (initialData && !(revision || window.localStorage.getItem(name))) {
initializeData(instance, name, initialData);
revisionMap[name] = ++revision;
}
}
const revisionMap = {};
/** LocalStorage proxy class for Backbone models.
* Usage:
* export const MyModel = Backbone.Model.extend({
* localStorage: new LocalStorage('MyModelName')
* });
*/
class LocalStorage {
constructor(name = '', serializer = defaultSerializer) {
this.name = name;
this.serializer = serializer;
}
/** Return the global localStorage variable
* @returns {Object} Local Storage reference.
*/
localStorage() {
return window.localStorage;
}
/** Returns the records associated with store
* @returns {Array} The records.
*/
getRecords() {
if (!this.records || revisionMap[this.name] !== this.revision) {
const store = this._getItem(this.name);
this.revision = revisionMap[this.name];
return (store && store.split(',')) || [];
}
return this.records;
}
/** Save the current status to localStorage
* @returns {undefined}
*/
save(records) {
this._setItem(this.name, records.join(','));
this.records = records;
let revision = revisionMap[this.name] || 0;
this.revision = revisionMap[this.name] = ++revision;
}
/** Add a new model with a unique GUID, if it doesn't already have its own ID
* @param {Model} model - The Backbone Model to save to LocalStorage
* @returns {Model} The saved model
*/
create(model) {
if (!model.id && model.id !== 0) {
model.id = guid();
model.set(model.idAttribute, model.id);
}
this._setItem(this._itemName(model.id), this.serializer.serialize(model));
const records = this.getRecords();
records.push(model.id.toString());
this.save(records);
return this.find(model);
}
/** Update an existing model in LocalStorage
* @param {Model} model - The model to update
* @returns {Model} The updated model
*/
update(model) {
this._setItem(this._itemName(model.id), this.serializer.serialize(model));
const modelId = model.id.toString();
const records = this.getRecords();
if (!records.includes(modelId)) {
records.push(modelId);
this.save(records);
}
return this.find(model);
}
/** Retrieve a model from local storage by model id
* @param {Model} model - The Backbone Model to lookup
* @returns {Model} The model from LocalStorage
*/
find(model) {
return this.serializer.deserialize(this._getItem(this._itemName(model.id)));
}
/** Return all models from LocalStorage
* @returns {Array} The array of models stored
*/
findAll() {
const records = this.getRecords();
return records
.map(id => this.serializer.deserialize(this._getItem(this._itemName(id))))
.filter(item => item != null);
}
/** Delete a model from `this.data`, returning it.
* @param {Model} model - Model to delete
* @returns {Model} Model removed from this.data
*/
destroy(model) {
this._removeItem(this._itemName(model.id));
const newRecords = this.getRecords().filter(id => id != model.id); // eslint-disable-line eqeqeq
this.save(newRecords);
return model;
}
/** Number of items in localStorage
* @returns {integer} - Number of items
*/
_storageSize() {
return window.localStorage.length;
}
/** Return the item from localStorage
* @param {string} name - Name to lookup
* @returns {string} Value from localStorage
*/
_getItem(name) {
return window.localStorage.getItem(name);
}
/** Return the item name to lookup in localStorage
* @param {integer} id - Item ID
* @returns {string} Item name
*/
_itemName(id) {
return `${this.name}-${id}`;
}
/** Proxy to the localStorage setItem value method
* @param {string} key - LocalStorage key to set
* @param {string} value - LocalStorage value to set
* @returns {undefined}
*/
_setItem(key, value) {
window.localStorage.setItem(key, value);
}
/** Proxy to the localStorage removeItem method
* @param {string} key - LocalStorage key to remove
* @returns {undefined}
*/
_removeItem(key) {
window.localStorage.removeItem(key);
}
}
/** Returns the localStorage attribute for a model
* @param {Model} model - Model to get localStorage
* @returns {Storage} The localstorage
*/
function getLocalStorage(model) {
return model.localStorage || (model.collection && model.collection.localStorage);
}
/** Override Backbone's `sync` method to run against localStorage
* @param {string} method - One of read/create/update/delete
* @param {Model} model - Backbone model to sync
* @param {Object} options - Options object, use `ajaxSync: true` to run the
* operation against the server in which case, options will also be passed into
* `jQuery.ajax`
* @returns {undefined}
*/
function localStorageSync(method, model, options) {
const store = getLocalStorage(model);
let resp, errorMessage;
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
try {
switch (method) {
case 'read':
resp = typeof model.id === 'undefined' ? store.findAll() : store.find(model);
break;
case 'create':
resp = store.create(model);
break;
case 'patch':
case 'update':
resp = store.update(model);
break;
case 'delete':
resp = store.destroy(model);
break;
}
} catch (error) {
if (error.code === 22 && store._storageSize() === 0) {
errorMessage = 'Private browsing is unsupported';
} else {
errorMessage = error.message;
}
}
if (resp && !errorMessage) {
resolve(resp);
} else {
reject(new Error(errorMessage || 'Record Not Found'));
}
return promise;
}
const previousSync = sync.handler;
/** Get the local or ajax sync call
* @param {Model} model - Model to sync
* @param {object} options - Options to pass, takes ajaxSync
* @returns {function} The sync method that will be called
*/
function getSyncMethod(model, options) {
const forceAjaxSync = options.ajaxSync;
const hasLocalStorage = getLocalStorage(model);
return !forceAjaxSync && hasLocalStorage ? localStorageSync : previousSync;
}
sync.handler = function localStorageSyncHandler(method, model, options = {}) {
const fn = getSyncMethod(model, options);
return fn.call(this, method, model, options);
};
const createClass = (ModelClass, name, options) => {
return class extends ModelClass {
constructor(...args) {
super(...args);
bindLocalStorage(this, name, options);
}
};
};
export const localStorage = (name, options) => ctorOrDescriptor => {
if (typeof ctorOrDescriptor === 'function') {
return createClass(ctorOrDescriptor, name, options);
}
const { kind, elements } = ctorOrDescriptor;
return {
kind,
elements,
finisher(ctor) {
return createClass(ctor, name, options);
}
};
};