Skip to content

Instantly share code, notes, and snippets.

@jonjenkins
Created May 6, 2011 04:58
Show Gist options
  • Save jonjenkins/958466 to your computer and use it in GitHub Desktop.
Save jonjenkins/958466 to your computer and use it in GitHub Desktop.
Stuffy
var Stuffy = function (options) {
if (typeof jQuery == 'undefined') throw 'Please include jQuery (http://www.jquery.com) on your page since Stuffy uses its .ajax methods to retrieve JSON';
if (eval('typeof dateFormat') == 'undefined') throw 'Please include the dateFormat plugin (1.2.3+) since Stuffy uses it to format datetime masks';
if (!'localStorage' in window || window['localStorage'] == null) throw 'Please make sure your browser supports localStorage since Stuffy uses it to track progress';
for (var i in StuffyHelpers) {
this[i] = StuffyHelpers[i];
}
this.init(options);
}
Stuffy.prototype = {
init: function (options) {
var merge = this.merge;
// adapters/structure poached from lawnchair (thanks!)
var adapters = { // TODO: add indexedDB (FF4 only for now?)
'webkit': window.WebkitSQLiteAdapter
};
// publicly set properties
this.adapter = options.adapter ? options.adapter : 'webkit'; // adapters for stuffable data stores
this.adapterObj = this.adapter ? new adapters[this.adapter](options) : new WebkitSQLiteAdapter(options); // adapter object
this.jsonUri = merge(null, options.jsonUri); // json source
this.jsonPath = merge(null, options.jsonPath); // relative path to append to jsonUri (e.g. /json, note: trailing / will be auto-appended between jsonPath & jsonUri)
this.table = merge(null, options.table); // table name desired in data store, should be overridden when not oData (can derive table name from __metadata.type)
this.json = merge([], options.json); // array that holds json if this.saveJson = true, many nodes for oData w/pages
this.saveJson = merge(false, options.saveJson); // to fill this.json?
this.maskDate = merge("yyyy-mm-dd'T'HH:MM:ss", options.maskDate); // uses dateTime plugin to provide an easily sortable date mask
this.oData = merge(false, options.oData); // is the json source oData, will be set during .stash if oData is found (uses __metadata.type as test)
this.getPages = merge(1, options.getPages); // for oData, how many __next pages to follow, -1 = get all
this.jsonFieldMappings = merge([], options.jsonFieldMappings); // field mappings allow json field names to be overridden in db, and also blank cols to be appended (use FieldMap object in array)
this.onlyMappedFields = merge(false, options.onlyMappedFields); // only create db from mapped fields and identity?
this.jsonMethod = merge('jsonp', options.jsonMethod); // jsonp for remote data sources, uses json if local detected in this.jsonUri
this.jsonCallback = merge('callback', options.jsonCallback); // function name used by $.ajax upon data retrieval success
this.jsonDateFields = merge(null, options.jsonDateFields); // field1,field2 format used to know which fields should be converted via maskDate
this.callbackStart = merge(null, options.callbackStart); // can be used for visuals, etc. $('#indicator').css('display', 'block');
this.callbackEnd = merge(null, options.callbackEnd); // can be used for visuals, etc. $('#indicator').css('display', 'none');
this.outCount = merge(null, options.outCount); // html id used by jQuery to update total installed record count (i.e. $(this.outCount).html(count);)
this.debug = merge(false, options.debug); // output data transactions (apart from append transactions)
this.debugAppend = merge(false, options.debugAppend); // output append data transactions
// privately set properties, all use _ prefix
this._created = false; // was table created, used to track during multi-page loads (e.g. oData)
this._gotPages = 0; // total pages retrieved from JSON (e.g. oData)
this._records = 0; // total records retrieved from JSON and installed
this._oDataType = null; // populated from __metadata.type
this._oDataNextUri = null; // __next value stored here prior to next this.jsonUri retrieval
// default error handler
this.onError = function (err) { console.error(err); };
if (typeof options == 'object')
if ('onError' in options)
if (typeof options.onError == 'function')
this.onError = options.onError;
},
parse: function (data) {
var d, type, next;
var merge = this.merge;
try {
if (data.d.results) {
d = data.d.results;
next = (data.d.__next != undefined) ? data.d.__next : null;
}
else if (data.d) {
d = data.d;
next = (data.__next != undefined) ? data.__next : null;
}
else
d = data;
type = d[0].__metadata.type; // null if not odata
}
catch (err) {
d = data;
}
this.oData = (type) ? true : false;
this._oDataType = type;
this._oDataNextUri = this.fixOdataUri(next);
this.table = (this.oData) ? merge(type.split('.')[type.split('.').length - 1], this.table) : this.table;
if (d.length !== undefined && this.table)
return d;
else
return null;
},
fixOdataUri: function (uri) {
if (typeof uri == 'string') {
var delim = (uri.indexOf('?') == -1) ? '?' : '&';
if (uri.indexOf('$format') == -1) {
uri += delim + '$format=json';
delim = '&';
}
if (uri.indexOf('$callback') == -1)
uri += delim + '$callback=callback';
}
return uri;
},
stash: function (options, callback) {
options = this.reinit(this, options, 'jsonUri');
if (this.jsonUri == null) throw 'A JSON URI is required in order to retrieve content';
// reset trackers
localStorage['_stuffyTxRemaining'] = 0; // used to manage callback schedule
localStorage['_stuffyTxRemainingPrior'] = 0;
localStorage['_stuffyDelayCount'] = 0;
if (this.callbackStart) eval(callbackStart);
var merge = this.merge;
if (this.jsonUri.indexOf('http://') == -1) { // local resource
this.jsonMethod = 'json';
this.jsonCallback = '';
}
if (this.oData) // append extra querystring parameters if needed
this.jsonUri = this.fixOdataUri(this.jsonUri);
var uriFinal = (this.jsonPath) ? this.jsonPath + '/' + this.jsonUri : this.jsonUri;
if (this.debug) console.log('GET: ' + uriFinal);
$.ajax({
url: uriFinal,
cache: false,
dataType: this.jsonMethod,
context: this,
jsonpCallback: this.jsonCallback,
success: function (data) {
this._gotPages++;
var d = this.parse(data);
var hasAllPages = false;
if (d) {
if (this.saveJson) this.json.push(d);
if (this._oDataNextUri && (this._gotPages < this.getPages || this.getPages == -1)) { // -1 means get all pages
options.jsonUri = this._oDataNextUri;
this.stash(options, callback);
}
else
hasAllPages = true;
this.adapterObj.save(this, d, hasAllPages, callback);
}
else {
console.warn('Stuffy firing callback, no data found at ' + uriFinal);
eval(callback);
}
},
error: function (jqXHR, textStatus, errorThrown) {
if (errorThrown == 'callback was not called') { // try as oData that was missing parameters
options.jsonUri = this.fixOdataUri(this.jsonUri);
this.stash(options, callback);
}
else
this.onError(errorThrown);
}
});
}
}
// timer, delay method for pending tx
var stuffyTimerDone;
function stuffyCheckDone(callback, callbackEnd) {
if (localStorage['_stuffyTxRemaining'] == 0) {
if (callback) {
if (callbackEnd) eval(callbackEnd);
console.log('Stuffy firing callback, done');
eval(callback);
}
return true;
}
else {
localStorage['_stuffyDelayCount']++;
console.warn('Stuffy delaying callback, ' + localStorage['_stuffyTxRemaining'] + ' pending tx');
}
var delay = 1000;
if (localStorage['_stuffyDelayCount'] == 5) // increase delay
delay = 3000;
else if (localStorage['_stuffyDelayCount'] > 5 && localStorage['_stuffyTxRemainingPrior'] == localStorage['_stuffyTxRemaining']) { // open tx not decreasing
if (callback) {
if (callbackEnd) eval(callbackEnd);
console.error('Stuffy firing callback, incomplete tx');
eval(callback);
}
return false;
}
localStorage['_stuffyTxRemainingPrior'] = localStorage['_stuffyTxRemaining'];
stuffyTimerDone = setTimeout('stuffyCheckDone(\'' + callback + '\', \'' + callbackEnd + '\')', delay);
}
// local data storage interfaces
var WebkitSQLiteAdapter = function (options) {
for (var i in StuffyHelpers) {
this[i] = StuffyHelpers[i];
}
this.init(options);
};
// used for sqlite overrides, optional
var FieldMap = function (jsonField, dbCol, dbType) {
this.JsonField = jsonField; // field as provided in json
this.DbCol = dbCol; // field that should be created in db (col)
this.DbType = dbType; // type of col (e.g. TEXT, INTEGER, etc. for sqlite)
}
WebkitSQLiteAdapter.prototype = {
init: function (options) {
var merge = this.merge;
options = (typeof arguments[0] == 'string') ? { name: options} : options;
// default properties
this.name = merge('Stuffy', options.name); // database name
this.version = merge('1.0', options.version); // database version (used in web sqlite protocol, see w3c spec--although now in impasse)
this.display = merge('Local database', options.display); // description
if (options.size) options.size = options.size * 1024 * 1024; // db size: n * 1024 * 1024 = n MB
this.size = merge(5 * 1024 * 1024, options.size);
this.db = merge(null, options.db); // database object
this.colIdentity = merge('row_id', options.colIdentity); // primary key/identity: add standard key if not overridden
this.colsInteger = merge('id,order', options.colsInteger); // json fieldname suffices in a,b delimited format that will be typed as integer cols
this.colsIndex = merge(null, options.colsIndex); // sqlite index named tableindex created on these cols in a,b colname delimited format
this.identityFormat = merge('INTEGER PRIMARY KEY AUTOINCREMENT', options.identityFormat); // default sqlite dbtype for identity col
// private properties
this.colList = ''; // db col list used during INSERT statement
this.colTypeList = ''; // db col list + types used during CREATE statement (could include blank cols via fieldmappings)
// default error handler
this.onError = function (tx, err) { console.error(err); };
if (typeof options == 'object')
if ('onError' in options)
if (typeof options.onError == 'function')
this.onError = options.onError;
if (!window.openDatabase)
throw ('Stuffy, "This browser does not support sqlite storage"');
if (!WebkitSQLiteAdapter.globaldb) WebkitSQLiteAdapter.globaldb = openDatabase(this.name, this.version, this.display, this.size);
this.db = WebkitSQLiteAdapter.globaldb;
var me = this; // context changes in tx
this.db.transaction(function (tx) {
localStorage['_stuffyTxRemaining']++;
tx.executeSql('CREATE TABLE IF NOT EXISTS _stuffyInventory (id NVARCHAR(32) UNIQUE PRIMARY KEY, tableName TEXT, cols TEXT, timestamp TEXT)', [], function (results) {
localStorage['_stuffyTxRemaining']--;
}, me.onError);
});
},
save: function (s, d, done, callback) {
if (!s._created)
this.create(s, d, done, callback);
else {
localStorage['_stuffyTxRemaining'] = parseInt(localStorage['_stuffyTxRemaining']) + d.length; // += fails since localStorage is text (though ++ and -- work!)
this.insert(s, d, done, callback);
}
},
done: function (s, d, done, callback) {
if (s.debug) console.log('Page complete, current record count: ' + s._records + ', done? ' + done);
if (done)
stuffyCheckDone(callback || '', s.callbackEnd || ''); // null/undefined won't work when passed in function
},
insert: function (s, d, done, callback) {
var me = this; // context changes in tx
this.db.transaction(function (tx) {
var sql;
for (var r in d) {
var valList = '';
for (var c = 0, l = s.jsonFieldMappings.length; c < l; c++) {
var fieldMap = s.jsonFieldMappings[c];
if (d[r][fieldMap.JsonField] == null && fieldMap.DbType == 'INTEGER')
val = 0;
else if (d[r][fieldMap.JsonField] == null)
val = '';
else
val = d[r][fieldMap.JsonField].toString().replace(/'/g, '\'\'');
if (s.jsonDateFields) {
var arrJsonDateFields = s.jsonDateFields.split(',');
for (var j = 0, la = arrJsonDateFields.length; j < la; j++)
if (fieldMap.JsonField.toLowerCase() == arrJsonDateFields[j].toLowerCase())
val = dateFormat(me.parseGmtDate(me.parseJsonDate(val)), s.maskDate);
}
delim = (fieldMap.DbType.indexOf('INTEGER') > -1) ? '' : '\'';
valList += (c == 0) ? delim + val + delim : ', ' + delim + val + delim;
}
if (s.debugAppend) console.log('INSERT INTO ' + s.table + ' (' + me.colList + ') VALUES (' + valList + ');');
tx.executeSql('INSERT INTO ' + s.table + ' (' + me.colList + ') VALUES (' + valList + ');', [], function (results) { localStorage['_stuffyTxRemaining']--; }, me.onError);
}
});
s._records += d.length;
if (s.outCount) $(s.outCount).html(s._records);
this.done(s, d, done, callback);
},
create: function (s, d, done, callback) {
var me = this; // context changes in tx
this.db.transaction(function (tx) {
localStorage['_stuffyTxRemaining']++;
if (s.debug) console.log('DROP TABLE IF EXISTS ' + s.table);
tx.executeSql('DROP TABLE IF EXISTS ' + s.table, [], function (results) {
// retain open transaction
if (s.debug) console.log('DELETE FROM _stuffyInventory WHERE tableName = ?');
tx.executeSql('DELETE FROM _stuffyInventory WHERE tableName = ?', [s.table], function (results) {
localStorage['_stuffyTxRemaining']--;
}, me.onError);
}, me.onError);
});
var hasIdentity = false;
var fmNoMatch = []; // remove mappings with no match in json
var fmClone = s.jsonFieldMappings.slice(0); // break byref, preserve originals
for (var i = 0, l = fmClone.length; i < l; i++) {
var hasMatch = false;
if (fmClone[i].JsonField) // blank by definition
for (var col in d[0])
if (col.indexOf('__') == -1 && (typeof d[0][col] != 'object' || !d[0][col]))
if (col == fmClone[i].JsonField)
hasMatch = true;
if (!hasMatch) {
fmNoMatch.push(fmClone[i]);
this.removeByValFromArray(s.jsonFieldMappings, fmClone[i].DbCol, 'DbCol');
}
}
for (var col in d[0]) {
if (!this.getByValFromArray(fmNoMatch, col, 'DbCol') && col.indexOf('__') == -1 && (typeof d[0][col] != 'object' || !d[0][col]) && ((!s.onlyMappedFields) || (s.onlyMappedFields && this.getByValFromArray(fmClone, col, 'JsonField')))) { // null is OK
var fm = (s.jsonFieldMappings) ? this.getByValFromArray(s.jsonFieldMappings, col, 'JsonField') : new FieldMap(col, col, null);
if (!fm) fm = new FieldMap(col, col, null);
else {
col = fm.DbCol; // override json fieldname with user-defined in case different
this.removeByValFromArray(s.jsonFieldMappings, col, 'DbCol'); // remove to add in-order/find DbType
}
this.colList += (this.colList) ? ',' + col : col;
if (!fm.DbType)
if (col.toLowerCase() == this.colIdentity.toLowerCase()) {
fm.DbType = this.identityFormat;
hasIdentity = true;
}
else {
var isIntCol = false;
var arrColsInteger = (this.colsInteger) ? this.colsInteger.split(',') : null;
if (arrColsInteger)
for (var i = 0, l = arrColsInteger.length; i < l; i++)
if (col.length > arrColsInteger[i].length)
if (col.substring(col.length - arrColsInteger[i].length).toLowerCase() == arrColsInteger[i].toLowerCase())
isIntCol = true;
if (isIntCol && typeof d[0][col] == 'number') fm.DbType = 'INTEGER';
else fm.DbType = 'TEXT';
}
// end assign DbType
this.colTypeList += (this.colTypeList) ? ',' + col + ' ' + fm.DbType : col + ' ' + fm.DbType;
s.jsonFieldMappings.push(fm);
}
}
if (this.colIdentity && !hasIdentity) // force identity column if specified
this.colTypeList += (this.colTypeList) ? ',' + this.colIdentity + ' ' + this.identityFormat : this.colIdentity + ' ' + this.identityFormat;
for (var i in fmNoMatch)
this.colTypeList += (this.colTypeList) ? ',' + fmNoMatch[i].DbCol + ' ' + fmNoMatch[i].DbType : s.jsonFieldMappings[i].DbCol + ' ' + s.jsonFieldMappings[i].DbType;
this.db.transaction(function (tx) {
localStorage['_stuffyTxRemaining']++;
if (s.debug) console.log('CREATE TABLE IF NOT EXISTS ' + s.table + ' (' + me.colTypeList + ')');
tx.executeSql('CREATE TABLE IF NOT EXISTS ' + s.table + ' (' + me.colTypeList + ')', [], function (results) {
// retain open transaction
tx.executeSql('INSERT INTO _stuffyInventory (id, tableName, cols, timestamp) VALUES (?, ?, ?, ?)', [me.uuid(), s.table, me.colTypeList, dateFormat(me.now(), s.maskDate)], function (results) {
localStorage['_stuffyTxRemaining']--;
}, me.onError);
}, me.onError);
});
if (this.colsIndex) { // create indexes
this.db.transaction(function (tx) {
localStorage['_stuffyTxRemaining']++;
if (s.debug) console.log('CREATE INDEX ' + s.table + 'Index ON ' + s.table + ' (' + me.colsIndex + ');');
tx.executeSql('CREATE INDEX ' + s.table + 'Index ON ' + s.table + ' (' + me.colsIndex + ');', [], function (results) {
localStorage['_stuffyTxRemaining']--;
}, me.onError);
});
}
s._created = true;
this.save(s, d, done, callback);
}
}
var StuffyHelpers = {
merge: function (defaultOption, userOption) {
return (userOption == undefined || userOption == null) ? defaultOption : userOption;
},
now: function () {
return new Date();
},
// based on Robert Kieffer's randomUUID.js at http://www.broofa.com
uuid: function (len, radix) {
var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
var uuid = [];
radix = radix || chars.length;
if (len) {
for (var i = 0; i < len; i++) uuid[i] = chars[0 | Math.random() * radix];
} else {
// rfc4122, version 4 form
var r;
// rfc4122 requires these characters
uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
uuid[14] = '4';
// fill in random data, at i == 19 set the high bits of clock sequence as per rfc4122, sec. 4.1.5
for (var i = 0; i < 36; i++) {
if (!uuid[i]) {
r = 0 | Math.random() * 16;
uuid[i] = chars[(i == 19) ? (r & 0x3) | 0x8 : r];
}
}
}
return uuid.join('');
},
getByValFromArray: function (arr, val, matchOn) {
for (i = 0, l = arr.length; i < l; i++)
if (val == arr[i][matchOn])
return arr[i];
return null;
},
removeByValFromArray: function (arr, val, matchOn) {
for (i = 0, l = arr.length; i < l; i++)
if (val == arr[i][matchOn]) {
arr.splice(i, 1);
return true; // return on match
}
return false;
},
parseGmtDate: function (gmtDate, mask) {
return dateFormat(gmtDate, mask, gmtDate); // integrates offset
},
parseJsonDate: function (jsonDate) {
return (jsonDate == null) ? '' : eval(jsonDate.replace(/\/Date\((.*?)\)\//gi, "new Date($1)"));
},
reinit: function (me, options, optionWhenString) { // me = this byref
options || (options = {})
if (typeof options == 'object') {
for (var option in options)
me[option] = options[option];
}
else if (typeof options == 'string') {
me[optionWhenString] = options;
var optobj = {};
optobj[optionWhenString] = options;
options = optobj; // rebuild as object
}
return options;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment