Created
May 6, 2011 04:58
-
-
Save jonjenkins/958466 to your computer and use it in GitHub Desktop.
Stuffy
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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