diff --git a/example/atm/index.html b/example/atm/index.html index d25a0a9..fcc0571 100644 --- a/example/atm/index.html +++ b/example/atm/index.html @@ -4,14 +4,14 @@ ATM State Machine Example - - - - - + + + + + - + diff --git a/example/atm/js/accounting.js b/example/atm/js/accounting.js new file mode 100644 index 0000000..d822c5d --- /dev/null +++ b/example/atm/js/accounting.js @@ -0,0 +1,398 @@ +/*! + * accounting.js v0.3.2 + * Copyright 2011, Joss Crowcroft + * + * Freely distributable under the MIT license. + * Portions of accounting.js are inspired or borrowed from underscore.js + * + * Full details and documentation: + * http://josscrowcroft.github.com/accounting.js/ + */ + +( function( root, undefined ) { + /* --- Setup --- */ + + // Create the local library object, to be exported or referenced globally later + var lib = {}; + + // Current version + lib.version = '0.3.2'; + + /* --- Exposed settings --- */ + + // The library's settings configuration object. Contains default parameters for + // currency and number formatting + lib.settings = { + currency: { + symbol: "$", // default currency symbol is '$' + format: "%s%v", // controls output: %s = symbol, %v = value (can be object, see docs) + decimal: ".", // decimal point separator + thousand: ",", // thousands separator + precision: 2, // decimal places + grouping: 3 // digit grouping (not implemented yet) + }, + number: { + precision: 0, // default precision on numbers is 0 + grouping: 3, // digit grouping (not implemented yet) + thousand: ",", + decimal: "." + } + }; + + /* --- Internal Helper Methods --- */ + + // Store reference to possibly-available ECMAScript 5 methods for later + var nativeMap = Array.prototype.map, + nativeIsArray = Array.isArray, + toString = Object.prototype.toString; + + /** + * Tests whether supplied parameter is a string + * from underscore.js + */ + function isString( obj ) { + return !!( obj === '' || ( obj && obj.charCodeAt && obj.substr ) ); + } + + /** + * Tests whether supplied parameter is a string + * from underscore.js, delegates to ECMA5's native Array.isArray + */ + function isArray( obj ) { + return nativeIsArray ? nativeIsArray( obj ) : toString.call( obj ) === '[object Array]'; + } + + /** + * Tests whether supplied parameter is a true object + */ + function isObject( obj ) { + return toString.call( obj ) === '[object Object]'; + } + + /** + * Extends an object with a defaults object, similar to underscore's _.defaults + * + * Used for abstracting parameter handling from API methods + */ + function defaults( object, defs ) { + var key; + object = object || {}; + defs = defs || {}; + // Iterate over object non-prototype properties: + for ( key in defs ) { + if ( defs.hasOwnProperty( key ) ) { + // Replace values with defaults only if undefined (allow empty/zero values): + if ( object[key] == null ) object[key] = defs[key]; + } + } + return object; + } + + /** + * Implementation of `Array.map()` for iteration loops + * + * Returns a new Array as a result of calling `iterator` on each array value. + * Defers to native Array.map if available + */ + function map( obj, iterator, context ) { + var results = [], i, j; + + if ( !obj ) return results; + + // Use native .map method if it exists: + if ( nativeMap && obj.map === nativeMap ) return obj.map( iterator, context ); + + // Fallback for native .map: + for ( i = 0, j = obj.length; i < j; i++ ) { + results[i] = iterator.call( context, obj[i], i, obj ); + } + return results; + } + + /** + * Check and normalise the value of precision (must be positive integer) + */ + function checkPrecision( val, base ) { + val = Math.round( Math.abs( val ) ); + return isNaN( val ) ? base : val; + } + + /** + * Parses a format string or object and returns format obj for use in rendering + * + * `format` is either a string with the default (positive) format, or object + * containing `pos` (required), `neg` and `zero` values (or a function returning + * either a string or object) + * + * Either string or format.pos must contain "%v" (value) to be valid + */ + function checkCurrencyFormat( format ) { + var defaults = lib.settings.currency.format; + + // Allow function as format parameter (should return string or object): + if ( typeof format === "function" ) format = format(); + + // Format can be a string, in which case `value` ("%v") must be present: + if ( isString( format ) && format.match( "%v" ) ) { + // Create and return positive, negative and zero formats: + return { + pos: format, + neg: format.replace( "-", "" ).replace( "%v", "-%v" ), + zero: format + }; + + // If no format, or object is missing valid positive value, use defaults: + } else if ( !format || !format.pos || !format.pos.match( "%v" ) ) { + // If defaults is a string, casts it to an object for faster checking next time: + return ( !isString( defaults ) ) ? defaults : lib.settings.currency.format = { + pos: defaults, + neg: defaults.replace( "%v", "-%v" ), + zero: defaults + }; + } + // Otherwise, assume format was fine: + return format; + } + + /* --- API Methods --- */ + + /** + * Takes a string/array of strings, removes all formatting/cruft and returns the raw float value + * alias: accounting.`parse(string)` + * + * Decimal must be included in the regular expression to match floats (default: "."), so if the number + * uses a non-standard decimal separator, provide it as the second argument. + * + * Also matches bracketed negatives (eg. "$ (1.99)" => -1.99) + * + * Doesn't throw any errors (`NaN`s become 0) but this may change in future + */ + var unformat = lib.unformat = lib.parse = function( value, decimal ) { + // Recursively unformat arrays: + if ( isArray( value ) ) { + return map( value, function( val ) { + return unformat( val, decimal ); + } ); + } + + // Fails silently (need decent errors): + value = value || 0; + + // Return the value as-is if it's already a number: + if ( typeof value === "number" ) return value; + + // Default decimal point is "." but could be set to eg. "," in opts: + decimal = decimal || "."; + + // Build regex to strip out everything except digits, decimal point and minus sign: + var regex = new RegExp( "[^0-9-" + decimal + "]", [ "g" ] ), + unformatted = parseFloat( + ( "" + value ) + .replace( /\((.*)\)/, "-$1" ) // replace bracketed values with negatives + .replace( regex, '' ) // strip out any cruft + .replace( decimal, '.' ) // make sure decimal point is standard + ); + + // This will fail silently which may cause trouble, let's wait and see: + return !isNaN( unformatted ) ? unformatted : 0; + }; + + /** + * Implementation of toFixed() that treats floats more like decimals + * + * Fixes binary rounding issues (eg. (0.615).toFixed(2) === "0.61") that present + * problems for accounting- and finance-related software. + */ + var toFixed = lib.toFixed = function( value, precision ) { + precision = checkPrecision( precision, lib.settings.number.precision ); + var power = Math.pow( 10, precision ); + + // Multiply up by precision, round accurately, then divide and use native toFixed(): + return ( Math.round( lib.unformat( value ) * power ) / power ).toFixed( precision ); + }; + + /** + * Format a number, with comma-separated thousands and custom precision/decimal places + * + * Localise by overriding the precision and thousand / decimal separators + * 2nd parameter `precision` can be an object matching `settings.number` + */ + var formatNumber = lib.formatNumber = function( number, precision, thousand, decimal ) { + // Resursively format arrays: + if ( isArray( number ) ) { + return map( number, function( val ) { + return formatNumber( val, precision, thousand, decimal ); + } ); + } + + // Clean up number: + number = unformat( number ); + + // Build options object from second param (if object) or all params, extending defaults: + var opts = defaults( + ( isObject( precision ) ? precision : { + precision: precision, + thousand: thousand, + decimal: decimal + } ), + lib.settings.number + ), + + // Clean up precision + usePrecision = checkPrecision( opts.precision ), + + // Do some calc: + negative = number < 0 ? "-" : "", + base = parseInt( toFixed( Math.abs( number || 0 ), usePrecision ), 10 ) + "", + mod = base.length > 3 ? base.length % 3 : 0; + + // Format the number: + return negative + ( mod ? base.substr( 0, mod ) + opts.thousand : "" ) + base.substr( mod ).replace( /(\d{3})(?=\d)/g, "$1" + opts.thousand ) + ( usePrecision ? opts.decimal + toFixed( Math.abs( number ), usePrecision ).split( '.' )[1] : "" ); + }; + + /** + * Format a number into currency + * + * Usage: accounting.formatMoney(number, symbol, precision, thousandsSep, decimalSep, format) + * defaults: (0, "$", 2, ",", ".", "%s%v") + * + * Localise by overriding the symbol, precision, thousand / decimal separators and format + * Second param can be an object matching `settings.currency` which is the easiest way. + * + * To do: tidy up the parameters + */ + var formatMoney = lib.formatMoney = function( number, symbol, precision, thousand, decimal, format ) { + // Resursively format arrays: + if ( isArray( number ) ) { + return map( number, function( val ) { + return formatMoney( val, symbol, precision, thousand, decimal, format ); + } ); + } + + // Clean up number: + number = unformat( number ); + + // Build options object from second param (if object) or all params, extending defaults: + var opts = defaults( + ( isObject( symbol ) ? symbol : { + symbol: symbol, + precision: precision, + thousand: thousand, + decimal: decimal, + format: format + } ), + lib.settings.currency + ), + + // Check format (returns object with pos, neg and zero): + formats = checkCurrencyFormat( opts.format ), + + // Choose which format to use for this value: + useFormat = number > 0 ? formats.pos : number < 0 ? formats.neg : formats.zero; + + // Return with currency symbol added: + return useFormat.replace( '%s', opts.symbol ).replace( '%v', formatNumber( Math.abs( number ), checkPrecision( opts.precision ), opts.thousand, opts.decimal ) ); + }; + + /** + * Format a list of numbers into an accounting column, padding with whitespace + * to line up currency symbols, thousand separators and decimals places + * + * List should be an array of numbers + * Second parameter can be an object containing keys that match the params + * + * Returns array of accouting-formatted number strings of same length + * + * NB: `white-space:pre` CSS rule is required on the list container to prevent + * browsers from collapsing the whitespace in the output strings. + */ + lib.formatColumn = function( list, symbol, precision, thousand, decimal, format ) { + if ( !list ) return []; + + // Build options object from second param (if object) or all params, extending defaults: + var opts = defaults( + ( isObject( symbol ) ? symbol : { + symbol: symbol, + precision: precision, + thousand: thousand, + decimal: decimal, + format: format + } ), + lib.settings.currency + ), + + // Check format (returns object with pos, neg and zero), only need pos for now: + formats = checkCurrencyFormat( opts.format ), + + // Whether to pad at start of string or after currency symbol: + padAfterSymbol = formats.pos.indexOf( "%s" ) < formats.pos.indexOf( "%v" ) ? true : false, + + // Store value for the length of the longest string in the column: + maxLength = 0, + + // Format the list according to options, store the length of the longest string: + formatted = map( list, function( val, i ) { + if ( isArray( val ) ) { + // Recursively format columns if list is a multi-dimensional array: + return lib.formatColumn( val, opts ); + } else { + // Clean up the value + val = unformat( val ); + + // Choose which format to use for this value (pos, neg or zero): + var useFormat = val > 0 ? formats.pos : val < 0 ? formats.neg : formats.zero, + + // Format this value, push into formatted list and save the length: + fVal = useFormat.replace( '%s', opts.symbol ).replace( '%v', formatNumber( Math.abs( val ), checkPrecision( opts.precision ), opts.thousand, opts.decimal ) ); + + if ( fVal.length > maxLength ) maxLength = fVal.length; + return fVal; + } + } ); + + // Pad each number in the list and send back the column of numbers: + return map( formatted, function( val, i ) { + // Only if this is a string (not a nested array, which would have already been padded): + if ( isString( val ) && val.length < maxLength ) { + // Depending on symbol position, pad after symbol or at index 0: + return padAfterSymbol ? val.replace( opts.symbol, opts.symbol + ( new Array( maxLength - val.length + 1 ).join( " " ) ) ) : ( new Array( maxLength - val.length + 1 ).join( " " ) ) + val; + } + return val; + } ); + }; + + /* --- Module Definition --- */ + + // Export accounting for CommonJS. If being loaded as an AMD module, define it as such. + // Otherwise, just add `accounting` to the global object + if ( typeof exports !== 'undefined' ) { + if ( typeof module !== 'undefined' && module.exports ) { + exports = module.exports = lib; + } + exports.accounting = lib; + } else if ( typeof define === 'function' && define.amd ) { + // Return the library as an AMD module: + define( [], function() { + return lib; + } ); + } else { + // Use accounting.noConflict to restore `accounting` back to its original value. + // Returns a reference to the library's `accounting` object; + // e.g. `var numbers = accounting.noConflict();` + lib.noConflict = ( function( oldAccounting ) { + return function() { + // Reset the value of the root's `accounting` variable: + root.accounting = oldAccounting; + // Delete the noConflict method: + lib.noConflict = undefined; + // Return reference to the library to re-assign it: + return lib; + }; + } )( root.accounting ); + + // Declare `fx` on the root (global/window) object: + root['accounting'] = lib; + } + + // Root will be `window` in browser or `global` on the server: +}( this ) ); diff --git a/example/atm/js/amplify.js b/example/atm/js/amplify.js new file mode 100644 index 0000000..5cb8656 --- /dev/null +++ b/example/atm/js/amplify.js @@ -0,0 +1,815 @@ +/*! + * Amplify 1.1.2 + * + * Copyright 2011 - 2013 appendTo LLC. (http://appendto.com/team) + * Dual licensed under the MIT or GPL licenses. + * http://appendto.com/open-source-licenses + * + * http://amplifyjs.com + */ +( function( global, undefined ) { + var slice = [].slice, + subscriptions = {}; + + var amplify = global.amplify = { + publish: function( topic ) { + if ( typeof topic !== "string" ) { + throw new Error( "You must provide a valid topic to publish." ); + } + + var args = slice.call( arguments, 1 ), + topicSubscriptions, + subscription, + length, + i = 0, + ret; + + if ( !subscriptions[ topic ] ) { + return true; + } + + topicSubscriptions = subscriptions[ topic ].slice(); + for ( length = topicSubscriptions.length; i < length; i++ ) { + subscription = topicSubscriptions[ i ]; + ret = subscription.callback.apply( subscription.context, args ); + if ( ret === false ) { + break; + } + } + return ret !== false; + }, + + subscribe: function( topic, context, callback, priority ) { + if ( typeof topic !== "string" ) { + throw new Error( "You must provide a valid topic to create a subscription." ); + } + + if ( arguments.length === 3 && typeof callback === "number" ) { + priority = callback; + callback = context; + context = null; + } + if ( arguments.length === 2 ) { + callback = context; + context = null; + } + priority = priority || 10; + + var topicIndex = 0, + topics = topic.split( /\s/ ), + topicLength = topics.length, + added; + for ( ; topicIndex < topicLength; topicIndex++ ) { + topic = topics[ topicIndex ]; + added = false; + if ( !subscriptions[ topic ] ) { + subscriptions[ topic ] = []; + } + + var i = subscriptions[ topic ].length - 1, + subscriptionInfo = { + callback: callback, + context: context, + priority: priority + }; + + for ( ; i >= 0; i-- ) { + if ( subscriptions[ topic ][ i ].priority <= priority ) { + subscriptions[ topic ].splice( i + 1, 0, subscriptionInfo ); + added = true; + break; + } + } + + if ( !added ) { + subscriptions[ topic ].unshift( subscriptionInfo ); + } + } + + return callback; + }, + + unsubscribe: function( topic, context, callback ) { + if ( typeof topic !== "string" ) { + throw new Error( "You must provide a valid topic to remove a subscription." ); + } + + if ( arguments.length === 2 ) { + callback = context; + context = null; + } + + if ( !subscriptions[ topic ] ) { + return; + } + + var length = subscriptions[ topic ].length, + i = 0; + + for ( ; i < length; i++ ) { + if ( subscriptions[ topic ][ i ].callback === callback ) { + if ( !context || subscriptions[ topic ][ i ].context === context ) { + subscriptions[ topic ].splice( i, 1 ); + + // Adjust counter and length for removed item + i--; + length--; + } + } + } + } +}; +}( this ) ); + +( function( amplify, undefined ) { + var store = amplify.store = function( key, value, options ) { + var type = store.type; + if ( options && options.type && options.type in store.types ) { + type = options.type; + } + return store.types[ type ]( key, value, options || {} ); +}; + + store.types = {}; + store.type = null; + store.addType = function( type, storage ) { + if ( !store.type ) { + store.type = type; + } + + store.types[ type ] = storage; + store[ type ] = function( key, value, options ) { + options = options || {}; + options.type = type; + return store( key, value, options ); + }; +}; + store.error = function() { + return "amplify.store quota exceeded"; +}; + + var rprefix = /^__amplify__/; + function createFromStorageInterface( storageType, storage ) { + store.addType( storageType, function( key, value, options ) { + var storedValue, parsed, i, remove, + ret = value, + now = ( new Date() ).getTime(); + + if ( !key ) { + ret = {}; + remove = []; + i = 0; + try { + // accessing the length property works around a localStorage bug + // in Firefox 4.0 where the keys don't update cross-page + // we assign to key just to avoid Closure Compiler from removing + // the access as "useless code" + // https://bugzilla.mozilla.org/show_bug.cgi?id=662511 + key = storage.length; + + while ( key = storage.key( i++ ) ) { + if ( rprefix.test( key ) ) { + parsed = JSON.parse( storage.getItem( key ) ); + if ( parsed.expires && parsed.expires <= now ) { + remove.push( key ); + } else { + ret[ key.replace( rprefix, "" ) ] = parsed.data; + } + } + } + while ( key = remove.pop() ) { + storage.removeItem( key ); + } + } catch ( error ) {} + return ret; + } + + // protect against name collisions with direct storage + key = "__amplify__" + key; + + if ( value === undefined ) { + storedValue = storage.getItem( key ); + parsed = storedValue ? JSON.parse( storedValue ) : { expires: -1 }; + if ( parsed.expires && parsed.expires <= now ) { + storage.removeItem( key ); + } else { + return parsed.data; + } + } else { + if ( value === null ) { + storage.removeItem( key ); + } else { + parsed = JSON.stringify( { + data: value, + expires: options.expires ? now + options.expires : null + } ); + try { + storage.setItem( key, parsed ); + // quota exceeded + } catch ( error ) { + // expire old data and try again + store[ storageType ](); + try { + storage.setItem( key, parsed ); + } catch ( error ) { + throw store.error(); + } + } + } + } + + return ret; + } ); +} + + // localStorage + sessionStorage + // IE 8+, Firefox 3.5+, Safari 4+, Chrome 4+, Opera 10.5+, iPhone 2+, Android 2+ + for ( var webStorageType in { localStorage: 1, sessionStorage: 1 } ) { + // try/catch for file protocol in Firefox and Private Browsing in Safari 5 + try { + // Safari 5 in Private Browsing mode exposes localStorage + // but doesn't allow storing data, so we attempt to store and remove an item. + // This will unfortunately give us a false negative if we're at the limit. + window[ webStorageType ].setItem( "__amplify__", "x" ); + window[ webStorageType ].removeItem( "__amplify__" ); + createFromStorageInterface( webStorageType, window[ webStorageType ] ); + } catch ( e ) {} + } + + // globalStorage + // non-standard: Firefox 2+ + // https://developer.mozilla.org/en/dom/storage#globalStorage + if ( !store.types.localStorage && window.globalStorage ) { + // try/catch for file protocol in Firefox + try { + createFromStorageInterface( "globalStorage", + window.globalStorage[ window.location.hostname ] ); + // Firefox 2.0 and 3.0 have sessionStorage and globalStorage + // make sure we default to globalStorage + // but don't default to globalStorage in 3.5+ which also has localStorage + if ( store.type === "sessionStorage" ) { + store.type = "globalStorage"; + } + } catch ( e ) {} + } + + // userData + // non-standard: IE 5+ + // http://msdn.microsoft.com/en-us/library/ms531424(v=vs.85).aspx + ( function() { + // IE 9 has quirks in userData that are a huge pain + // rather than finding a way to detect these quirks + // we just don't register userData if we have localStorage + if ( store.types.localStorage ) { + return; + } + + // append to html instead of body so we can do this from the head + var div = document.createElement( "div" ), + attrKey = "amplify"; + div.style.display = "none"; + document.getElementsByTagName( "head" )[ 0 ].appendChild( div ); + + // we can't feature detect userData support + // so just try and see if it fails + // surprisingly, even just adding the behavior isn't enough for a failure + // so we need to load the data as well + try { + div.addBehavior( "#default#userdata" ); + div.load( attrKey ); + } catch ( e ) { + div.parentNode.removeChild( div ); + return; + } + + store.addType( "userData", function( key, value, options ) { + div.load( attrKey ); + var attr, parsed, prevValue, i, remove, + ret = value, + now = ( new Date() ).getTime(); + + if ( !key ) { + ret = {}; + remove = []; + i = 0; + while ( attr = div.XMLDocument.documentElement.attributes[ i++ ] ) { + parsed = JSON.parse( attr.value ); + if ( parsed.expires && parsed.expires <= now ) { + remove.push( attr.name ); + } else { + ret[ attr.name ] = parsed.data; + } + } + while ( key = remove.pop() ) { + div.removeAttribute( key ); + } + div.save( attrKey ); + return ret; + } + + // convert invalid characters to dashes + // http://www.w3.org/TR/REC-xml/#NT-Name + // simplified to assume the starting character is valid + // also removed colon as it is invalid in HTML attribute names + key = key.replace( /[^\-._0-9A-Za-z\xb7\xc0-\xd6\xd8-\xf6\xf8-\u037d\u037f-\u1fff\u200c-\u200d\u203f\u2040\u2070-\u218f]/g, "-" ); + // adjust invalid starting character to deal with our simplified sanitization + key = key.replace( /^-/, "_-" ); + + if ( value === undefined ) { + attr = div.getAttribute( key ); + parsed = attr ? JSON.parse( attr ) : { expires: -1 }; + if ( parsed.expires && parsed.expires <= now ) { + div.removeAttribute( key ); + } else { + return parsed.data; + } + } else { + if ( value === null ) { + div.removeAttribute( key ); + } else { + // we need to get the previous value in case we need to rollback + prevValue = div.getAttribute( key ); + parsed = JSON.stringify( { + data: value, + expires: ( options.expires ? ( now + options.expires ) : null ) + } ); + div.setAttribute( key, parsed ); + } + } + + try { + div.save( attrKey ); + // quota exceeded + } catch ( error ) { + // roll the value back to the previous value + if ( prevValue === null ) { + div.removeAttribute( key ); + } else { + div.setAttribute( key, prevValue ); + } + + // expire old data and try again + store.userData(); + try { + div.setAttribute( key, parsed ); + div.save( attrKey ); + } catch ( error ) { + // roll the value back to the previous value + if ( prevValue === null ) { + div.removeAttribute( key ); + } else { + div.setAttribute( key, prevValue ); + } + throw store.error(); + } + } + return ret; + } ); +}() ); + + // in-memory storage + // fallback for all browsers to enable the API even if we can't persist data + ( function() { + var memory = {}, + timeout = {}; + + function copy( obj ) { + return obj === undefined ? undefined : JSON.parse( JSON.stringify( obj ) ); + } + + store.addType( "memory", function( key, value, options ) { + if ( !key ) { + return copy( memory ); + } + + if ( value === undefined ) { + return copy( memory[ key ] ); + } + + if ( timeout[ key ] ) { + clearTimeout( timeout[ key ] ); + delete timeout[ key ]; + } + + if ( value === null ) { + delete memory[ key ]; + return null; + } + + memory[ key ] = value; + if ( options.expires ) { + timeout[ key ] = setTimeout( function() { + delete memory[ key ]; + delete timeout[ key ]; + }, options.expires ); + } + + return value; + } ); +}() ); +}( this.amplify = this.amplify || {} ) ); + +/*global amplify*/ + +( function( amplify, undefined ) { + 'use strict'; + + function noop() {} + function isFunction( obj ) { + return ( {} ).toString.call( obj ) === "[object Function]"; +} + + function async( fn ) { + var isAsync = false; + setTimeout( function() { + isAsync = true; + }, 1 ); + return function() { + var that = this, + args = arguments; + if ( isAsync ) { + fn.apply( that, args ); + } else { + setTimeout( function() { + fn.apply( that, args ); + }, 1 ); + } + }; +} + + amplify.request = function( resourceId, data, callback ) { + // default to an empty hash just so we can handle a missing resourceId + // in one place + var settings = resourceId || {}; + + if ( typeof settings === "string" ) { + if ( isFunction( data ) ) { + callback = data; + data = {}; + } + settings = { + resourceId: resourceId, + data: data || {}, + success: callback + }; + } + + var request = { abort: noop }, + resource = amplify.request.resources[ settings.resourceId ], + success = settings.success || noop, + error = settings.error || noop; + + settings.success = async( function( data, status ) { + status = status || "success"; + amplify.publish( "request.success", settings, data, status ); + amplify.publish( "request.complete", settings, data, status ); + success( data, status ); + } ); + + settings.error = async( function( data, status ) { + status = status || "error"; + amplify.publish( "request.error", settings, data, status ); + amplify.publish( "request.complete", settings, data, status ); + error( data, status ); + } ); + + if ( !resource ) { + if ( !settings.resourceId ) { + throw "amplify.request: no resourceId provided"; + } + throw "amplify.request: unknown resourceId: " + settings.resourceId; + } + + if ( !amplify.publish( "request.before", settings ) ) { + settings.error( null, "abort" ); + return; + } + + amplify.request.resources[ settings.resourceId ]( settings, request ); + return request; +}; + + amplify.request.types = {}; + amplify.request.resources = {}; + amplify.request.define = function( resourceId, type, settings ) { + if ( typeof type === "string" ) { + if ( !( type in amplify.request.types ) ) { + throw "amplify.request.define: unknown type: " + type; + } + + settings.resourceId = resourceId; + amplify.request.resources[ resourceId ] = + amplify.request.types[ type ]( settings ); + } else { + // no pre-processor or settings for one-off types (don't invoke) + amplify.request.resources[ resourceId ] = type; + } +}; +}( amplify ) ); + +( function( amplify, $, undefined ) { + 'use strict'; + + var xhrProps = [ "status", "statusText", "responseText", "responseXML", "readyState" ], + rurlData = /\{([^\}]+)\}/g; + + amplify.request.types.ajax = function( defnSettings ) { + defnSettings = $.extend( { + type: "GET" + }, defnSettings ); + + return function( settings, request ) { + var xhr, handleResponse, + url = defnSettings.url, + abort = request.abort, + ajaxSettings = $.extend( true, {}, defnSettings, { data: settings.data } ), + aborted = false, + ampXHR = { + readyState: 0, + setRequestHeader: function( name, value ) { + return xhr.setRequestHeader( name, value ); + }, + getAllResponseHeaders: function() { + return xhr.getAllResponseHeaders(); + }, + getResponseHeader: function( key ) { + return xhr.getResponseHeader( key ); + }, + overrideMimeType: function( type ) { + return xhr.overrideMimeType( type ); + }, + abort: function() { + aborted = true; + try { + xhr.abort(); + // IE 7 throws an error when trying to abort + } catch ( e ) {} + handleResponse( null, "abort" ); + }, + success: function( data, status ) { + settings.success( data, status ); + }, + error: function( data, status ) { + settings.error( data, status ); + } + }; + + handleResponse = function( data, status ) { + $.each( xhrProps, function( i, key ) { + try { + ampXHR[ key ] = xhr[ key ]; + } catch ( e ) {} + } ); + // Playbook returns "HTTP/1.1 200 OK" + // TODO: something also returns "OK", what? + if ( /OK$/.test( ampXHR.statusText ) ) { + ampXHR.statusText = "success"; + } + if ( data === undefined ) { + // TODO: add support for ajax errors with data + data = null; + } + if ( aborted ) { + status = "abort"; + } + if ( /timeout|error|abort/.test( status ) ) { + ampXHR.error( data, status ); + } else { + ampXHR.success( data, status ); + } + // avoid handling a response multiple times + // this can happen if a request is aborted + // TODO: figure out if this breaks polling or multi-part responses + handleResponse = $.noop; + }; + + amplify.publish( "request.ajax.preprocess", + defnSettings, settings, ajaxSettings, ampXHR ); + + $.extend( ajaxSettings, { + isJSONP: function() { + return ( /jsonp/gi ).test( this.dataType ); + }, + cacheURL: function() { + if ( !this.isJSONP() ) { + return this.url; + } + + var callbackName = 'callback'; + + // possible for the callback function name to be overridden + if ( this.hasOwnProperty( 'jsonp' ) ) { + if ( this.jsonp !== false ) { + callbackName = this.jsonp; + } else { + if ( this.hasOwnProperty( 'jsonpCallback' ) ) { + callbackName = this.jsonpCallback; + } + } + } + + // search and replace callback parameter in query string with empty string + var callbackRegex = new RegExp( '&?' + callbackName + '=[^&]*&?', 'gi' ); + return this.url.replace( callbackRegex, '' ); + }, + success: function( data, status ) { + handleResponse( data, status ); + }, + error: function( _xhr, status ) { + handleResponse( null, status ); + }, + beforeSend: function( _xhr, _ajaxSettings ) { + xhr = _xhr; + ajaxSettings = _ajaxSettings; + var ret = defnSettings.beforeSend ? + defnSettings.beforeSend.call( this, ampXHR, ajaxSettings ) : true; + return ret && amplify.publish( "request.before.ajax", + defnSettings, settings, ajaxSettings, ampXHR ); + } + } ); + + // cache all JSONP requests + if ( ajaxSettings.cache && ajaxSettings.isJSONP() ) { + $.extend( ajaxSettings, { + cache: true + } ); + } + + $.ajax( ajaxSettings ); + + request.abort = function() { + ampXHR.abort(); + abort.call( this ); + }; + }; +}; + + amplify.subscribe( "request.ajax.preprocess", function( defnSettings, settings, ajaxSettings ) { + var mappedKeys = [], + data = ajaxSettings.data; + + if ( typeof data === "string" ) { + return; + } + + data = $.extend( true, {}, defnSettings.data, data ); + + ajaxSettings.url = ajaxSettings.url.replace( rurlData, function( m, key ) { + if ( key in data ) { + mappedKeys.push( key ); + return data[ key ]; + } + } ); + + // We delete the keys later so duplicates are still replaced + $.each( mappedKeys, function( i, key ) { + delete data[ key ]; + } ); + + ajaxSettings.data = data; +} ); + + amplify.subscribe( "request.ajax.preprocess", function( defnSettings, settings, ajaxSettings ) { + var data = ajaxSettings.data, + dataMap = defnSettings.dataMap; + + if ( !dataMap || typeof data === "string" ) { + return; + } + + if ( $.isFunction( dataMap ) ) { + ajaxSettings.data = dataMap( data ); + } else { + $.each( defnSettings.dataMap, function( orig, replace ) { + if ( orig in data ) { + data[ replace ] = data[ orig ]; + delete data[ orig ]; + } + } ); + ajaxSettings.data = data; + } +} ); + + var cache = amplify.request.cache = { + _key: function( resourceId, url, data ) { + data = url + data; + var length = data.length, + i = 0; + + /*jshint bitwise:false*/ + function chunk() { + return data.charCodeAt( i++ ) << 24 | + data.charCodeAt( i++ ) << 16 | + data.charCodeAt( i++ ) << 8 | + data.charCodeAt( i++ ) << 0; + } + + var checksum = chunk(); + while ( i < length ) { + checksum ^= chunk(); + } + /*jshint bitwise:true*/ + + return "request-" + resourceId + "-" + checksum; + }, + + _default: ( function() { + var memoryStore = {}; + return function( resource, settings, ajaxSettings, ampXHR ) { + // data is already converted to a string by the time we get here + var cacheKey = cache._key( settings.resourceId, + ajaxSettings.cacheURL(), ajaxSettings.data ), + duration = resource.cache; + + if ( cacheKey in memoryStore ) { + ampXHR.success( memoryStore[ cacheKey ] ); + return false; + } + var success = ampXHR.success; + ampXHR.success = function( data ) { + memoryStore[ cacheKey ] = data; + if ( typeof duration === "number" ) { + setTimeout( function() { + delete memoryStore[ cacheKey ]; + }, duration ); + } + success.apply( this, arguments ); + }; + }; + }() ) +}; + + if ( amplify.store ) { + $.each( amplify.store.types, function( type ) { + cache[ type ] = function( resource, settings, ajaxSettings, ampXHR ) { + var cacheKey = cache._key( settings.resourceId, + ajaxSettings.cacheURL(), ajaxSettings.data ), + cached = amplify.store[ type ]( cacheKey ); + + if ( cached ) { + ajaxSettings.success( cached ); + return false; + } + var success = ampXHR.success; + ampXHR.success = function( data ) { + amplify.store[ type ]( cacheKey, data, { expires: resource.cache.expires } ); + success.apply( this, arguments ); + }; + }; + } ); + cache.persist = cache[ amplify.store.type ]; + } + + amplify.subscribe( "request.before.ajax", function( resource ) { + var cacheType = resource.cache; + if ( cacheType ) { + // normalize between objects and strings/booleans/numbers + cacheType = cacheType.type || cacheType; + return cache[ cacheType in cache ? cacheType : "_default" ] + .apply( this, arguments ); + } +} ); + + amplify.request.decoders = { + // http://labs.omniti.com/labs/jsend + jsend: function( data, status, ampXHR, success, error ) { + if ( data.status === "success" ) { + success( data.data ); + } else if ( data.status === "fail" ) { + error( data.data, "fail" ); + } else if ( data.status === "error" ) { + delete data.status; + error( data, "error" ); + } else { + error( null, "error" ); + } + } +}; + + amplify.subscribe( "request.before.ajax", function( resource, settings, ajaxSettings, ampXHR ) { + var _success = ampXHR.success, + _error = ampXHR.error, + decoder = $.isFunction( resource.decoder ) ? + resource.decoder : + resource.decoder in amplify.request.decoders ? + amplify.request.decoders[ resource.decoder ] : + amplify.request.decoders._default; + + if ( !decoder ) { + return; + } + + function success( data, status ) { + _success( data, status ); + } + function error( data, status ) { + _error( data, status ); + } + ampXHR.success = function( data, status ) { + decoder( data, status, ampXHR, success, error ); + }; + ampXHR.error = function( data, status ) { + decoder( data, status, ampXHR, success, error ); + }; +} ); +}( amplify, jQuery ) ); diff --git a/example/atm/js/appConfig.js b/example/atm/js/appConfig.js index f7781ef..74617c2 100644 --- a/example/atm/js/appConfig.js +++ b/example/atm/js/appConfig.js @@ -2,7 +2,7 @@ var infuserDefault = infuser.defaults; infuser.defaults = $.extend( true, infuserDefault, { - templateUrl: "/example/atm/templates", + templateUrl: "/machina.js/example/atm/templates", bindingInstruction: function( template, model ) { return template( model ); }, diff --git a/example/atm/js/backbone.js b/example/atm/js/backbone.js new file mode 100644 index 0000000..45c64b6 --- /dev/null +++ b/example/atm/js/backbone.js @@ -0,0 +1,1293 @@ +// Backbone.js 0.9.1 + +// (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc. +// Backbone may be freely distributed under the MIT license. +// For all details and documentation: +// http://backbonejs.org + +( function() { + // Initial Setup + // ------------- + + // Save a reference to the global object (`window` in the browser, `global` + // on the server). + var root = this; + + // Save the previous value of the `Backbone` variable, so that it can be + // restored later on, if `noConflict` is used. + var previousBackbone = root.Backbone; + + // Create a local reference to slice/splice. + var slice = Array.prototype.slice; + var splice = Array.prototype.splice; + + // The top-level namespace. All public Backbone classes and modules will + // be attached to this. Exported for both CommonJS and the browser. + var Backbone; + if ( typeof exports !== 'undefined' ) { + Backbone = exports; + } else { + Backbone = root.Backbone = {}; + } + + // Current version of the library. Keep in sync with `package.json`. + Backbone.VERSION = '0.9.1'; + + // Require Underscore, if we're on the server, and it's not already present. + var _ = root._; + if ( !_ && ( typeof require !== 'undefined' ) ) _ = require( 'underscore' ); + + // For Backbone's purposes, jQuery, Zepto, or Ender owns the `$` variable. + var $ = root.jQuery || root.Zepto || root.ender; + + // Set the JavaScript library that will be used for DOM manipulation and + // Ajax calls (a.k.a. the `$` variable). By default Backbone will use: jQuery, + // Zepto, or Ender; but the `setDomLibrary()` method lets you inject an + // alternate JavaScript library (or a mock library for testing your views + // outside of a browser). + Backbone.setDomLibrary = function( lib ) { + $ = lib; + }; + + // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable + // to its previous owner. Returns a reference to this Backbone object. + Backbone.noConflict = function() { + root.Backbone = previousBackbone; + return this; + }; + + // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option + // will fake `"PUT"` and `"DELETE"` requests via the `_method` parameter and + // set a `X-Http-Method-Override` header. + Backbone.emulateHTTP = false; + + // Turn on `emulateJSON` to support legacy servers that can't deal with direct + // `application/json` requests ... will encode the body as + // `application/x-www-form-urlencoded` instead and will send the model in a + // form param named `model`. + Backbone.emulateJSON = false; + + // Backbone.Events + // ----------------- + + // A module that can be mixed in to *any object* in order to provide it with + // custom events. You may bind with `on` or remove with `off` callback functions + // to an event; trigger`-ing an event fires all callbacks in succession. + // + // var object = {}; + // _.extend(object, Backbone.Events); + // object.on('expand', function(){ alert('expanded'); }); + // object.trigger('expand'); + // + Backbone.Events = { + + // Bind an event, specified by a string name, `ev`, to a `callback` + // function. Passing `"all"` will bind the callback to all events fired. + on: function( events, callback, context ) { + var ev; + events = events.split( /\s+/ ); + var calls = this._callbacks || ( this._callbacks = {} ); + while ( ev = events.shift() ) { + // Create an immutable callback list, allowing traversal during + // modification. The tail is an empty object that will always be used + // as the next node. + var list = calls[ev] || ( calls[ev] = {} ); + var tail = list.tail || ( list.tail = list.next = {} ); + tail.callback = callback; + tail.context = context; + list.tail = tail.next = {}; + } + return this; + }, + + // Remove one or many callbacks. If `context` is null, removes all callbacks + // with that function. If `callback` is null, removes all callbacks for the + // event. If `ev` is null, removes all bound callbacks for all events. + off: function( events, callback, context ) { + var ev, calls, node; + if ( !events ) { + delete this._callbacks; + } else if ( calls = this._callbacks ) { + events = events.split( /\s+/ ); + while ( ev = events.shift() ) { + node = calls[ev]; + delete calls[ev]; + if ( !callback || !node ) continue; + // Create a new list, omitting the indicated event/context pairs. + while ( ( node = node.next ) && node.next ) { + if ( node.callback === callback && + ( !context || node.context === context ) ) continue; + this.on( ev, node.callback, node.context ); + } + } + } + return this; + }, + + // Trigger an event, firing all bound callbacks. Callbacks are passed the + // same arguments as `trigger` is, apart from the event name. + // Listening for `"all"` passes the true event name as the first argument. + trigger: function( events ) { + var event, node, calls, tail, args, all, rest; + if ( !( calls = this._callbacks ) ) return this; + all = calls['all']; + ( events = events.split( /\s+/ ) ).push( null ); + // Save references to the current heads & tails. + while ( event = events.shift() ) { + if ( all ) events.push( { next: all.next, tail: all.tail, event: event } ); + if ( !( node = calls[event] ) ) continue; + events.push( { next: node.next, tail: node.tail } ); + } + // Traverse each list, stopping when the saved tail is reached. + rest = slice.call( arguments, 1 ); + while ( node = events.pop() ) { + tail = node.tail; + args = node.event ? [ node.event ].concat( rest ) : rest; + while ( ( node = node.next ) !== tail ) { + node.callback.apply( node.context || this, args ); + } + } + return this; + } + + }; + + // Aliases for backwards compatibility. + Backbone.Events.bind = Backbone.Events.on; + Backbone.Events.unbind = Backbone.Events.off; + + // Backbone.Model + // -------------- + + // Create a new model, with defined attributes. A client id (`cid`) + // is automatically generated and assigned for you. + Backbone.Model = function( attributes, options ) { + var defaults; + attributes || ( attributes = {} ); + if ( options && options.parse ) attributes = this.parse( attributes ); + if ( defaults = getValue( this, 'defaults' ) ) { + attributes = _.extend( {}, defaults, attributes ); + } + if ( options && options.collection ) this.collection = options.collection; + this.attributes = {}; + this._escapedAttributes = {}; + this.cid = _.uniqueId( 'c' ); + if ( !this.set( attributes, { silent: true } ) ) { + throw new Error( "Can't create an invalid model" ); + } + delete this._changed; + this._previousAttributes = _.clone( this.attributes ); + this.initialize.apply( this, arguments ); + }; + + // Attach all inheritable methods to the Model prototype. + _.extend( Backbone.Model.prototype, Backbone.Events, { + + // The default name for the JSON `id` attribute is `"id"`. MongoDB and + // CouchDB users may want to set this to `"_id"`. + idAttribute: 'id', + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function() {}, + + // Return a copy of the model's `attributes` object. + toJSON: function() { + return _.clone( this.attributes ); + }, + + // Get the value of an attribute. + get: function( attr ) { + return this.attributes[attr]; + }, + + // Get the HTML-escaped value of an attribute. + escape: function( attr ) { + var html; + if ( html = this._escapedAttributes[attr] ) return html; + var val = this.attributes[attr]; + return this._escapedAttributes[attr] = _.escape( val == null ? '' : '' + val ); + }, + + // Returns `true` if the attribute contains a value that is not null + // or undefined. + has: function( attr ) { + return this.attributes[attr] != null; + }, + + // Set a hash of model attributes on the object, firing `"change"` unless + // you choose to silence it. + set: function( key, value, options ) { + var attrs, attr, val; + if ( _.isObject( key ) || key == null ) { + attrs = key; + options = value; + } else { + attrs = {}; + attrs[key] = value; + } + + // Extract attributes and options. + options || ( options = {} ); + if ( !attrs ) return this; + if ( attrs instanceof Backbone.Model ) attrs = attrs.attributes; + if ( options.unset ) for ( attr in attrs ) attrs[attr] = void 0; + + // Run validation. + if ( !this._validate( attrs, options ) ) return false; + + // Check for changes of `id`. + if ( this.idAttribute in attrs ) this.id = attrs[this.idAttribute]; + + var now = this.attributes; + var escaped = this._escapedAttributes; + var prev = this._previousAttributes || {}; + var alreadySetting = this._setting; + this._changed || ( this._changed = {} ); + this._setting = true; + + // Update attributes. + for ( attr in attrs ) { + val = attrs[attr]; + if ( !_.isEqual( now[attr], val ) ) delete escaped[attr]; + options.unset ? delete now[attr] : now[attr] = val; + if ( this._changing && !_.isEqual( this._changed[attr], val ) ) { + this.trigger( 'change:' + attr, this, val, options ); + this._moreChanges = true; + } + delete this._changed[attr]; + if ( !_.isEqual( prev[attr], val ) || ( _.has( now, attr ) != _.has( prev, attr ) ) ) { + this._changed[attr] = val; + } + } + + // Fire the `"change"` events, if the model has been changed. + if ( !alreadySetting ) { + if ( !options.silent && this.hasChanged() ) this.change( options ); + this._setting = false; + } + return this; + }, + + // Remove an attribute from the model, firing `"change"` unless you choose + // to silence it. `unset` is a noop if the attribute doesn't exist. + unset: function( attr, options ) { + ( options || ( options = {} ) ).unset = true; + return this.set( attr, null, options ); + }, + + // Clear all attributes on the model, firing `"change"` unless you choose + // to silence it. + clear: function( options ) { + ( options || ( options = {} ) ).unset = true; + return this.set( _.clone( this.attributes ), options ); + }, + + // Fetch the model from the server. If the server's representation of the + // model differs from its current attributes, they will be overriden, + // triggering a `"change"` event. + fetch: function( options ) { + options = options ? _.clone( options ) : {}; + var model = this; + var success = options.success; + options.success = function( resp, status, xhr ) { + if ( !model.set( model.parse( resp, xhr ), options ) ) return false; + if ( success ) success( model, resp ); + }; + options.error = Backbone.wrapError( options.error, model, options ); + return ( this.sync || Backbone.sync ).call( this, 'read', this, options ); + }, + + // Set a hash of model attributes, and sync the model to the server. + // If the server returns an attributes hash that differs, the model's + // state will be `set` again. + save: function( key, value, options ) { + var attrs, current; + if ( _.isObject( key ) || key == null ) { + attrs = key; + options = value; + } else { + attrs = {}; + attrs[key] = value; + } + + options = options ? _.clone( options ) : {}; + if ( options.wait ) current = _.clone( this.attributes ); + var silentOptions = _.extend( {}, options, { silent: true } ); + if ( attrs && !this.set( attrs, options.wait ? silentOptions : options ) ) { + return false; + } + var model = this; + var success = options.success; + options.success = function( resp, status, xhr ) { + var serverAttrs = model.parse( resp, xhr ); + if ( options.wait ) serverAttrs = _.extend( attrs || {}, serverAttrs ); + if ( !model.set( serverAttrs, options ) ) return false; + if ( success ) { + success( model, resp ); + } else { + model.trigger( 'sync', model, resp, options ); + } + }; + options.error = Backbone.wrapError( options.error, model, options ); + var method = this.isNew() ? 'create' : 'update'; + var xhr = ( this.sync || Backbone.sync ).call( this, method, this, options ); + if ( options.wait ) this.set( current, silentOptions ); + return xhr; + }, + + // Destroy this model on the server if it was already persisted. + // Optimistically removes the model from its collection, if it has one. + // If `wait: true` is passed, waits for the server to respond before removal. + destroy: function( options ) { + options = options ? _.clone( options ) : {}; + var model = this; + var success = options.success; + + var triggerDestroy = function() { + model.trigger( 'destroy', model, model.collection, options ); + }; + + if ( this.isNew() ) return triggerDestroy(); + options.success = function( resp ) { + if ( options.wait ) triggerDestroy(); + if ( success ) { + success( model, resp ); + } else { + model.trigger( 'sync', model, resp, options ); + } + }; + options.error = Backbone.wrapError( options.error, model, options ); + var xhr = ( this.sync || Backbone.sync ).call( this, 'delete', this, options ); + if ( !options.wait ) triggerDestroy(); + return xhr; + }, + + // Default URL for the model's representation on the server -- if you're + // using Backbone's restful methods, override this to change the endpoint + // that will be called. + url: function() { + var base = getValue( this.collection, 'url' ) || getValue( this, 'urlRoot' ) || urlError(); + if ( this.isNew() ) return base; + return base + ( base.charAt( base.length - 1 ) == '/' ? '' : '/' ) + encodeURIComponent( this.id ); + }, + + // **parse** converts a response into the hash of attributes to be `set` on + // the model. The default implementation is just to pass the response along. + parse: function( resp, xhr ) { + return resp; + }, + + // Create a new model with identical attributes to this one. + clone: function() { + return new this.constructor( this.attributes ); + }, + + // A model is new if it has never been saved to the server, and lacks an id. + isNew: function() { + return this.id == null; + }, + + // Call this method to manually fire a `"change"` event for this model and + // a `"change:attribute"` event for each changed attribute. + // Calling this will cause all objects observing the model to update. + change: function( options ) { + if ( this._changing || !this.hasChanged() ) return this; + this._changing = true; + this._moreChanges = true; + for ( var attr in this._changed ) { + this.trigger( 'change:' + attr, this, this._changed[attr], options ); + } + while ( this._moreChanges ) { + this._moreChanges = false; + this.trigger( 'change', this, options ); + } + this._previousAttributes = _.clone( this.attributes ); + delete this._changed; + this._changing = false; + return this; + }, + + // Determine if the model has changed since the last `"change"` event. + // If you specify an attribute name, determine if that attribute has changed. + hasChanged: function( attr ) { + if ( !arguments.length ) return !_.isEmpty( this._changed ); + return this._changed && _.has( this._changed, attr ); + }, + + // Return an object containing all the attributes that have changed, or + // false if there are no changed attributes. Useful for determining what + // parts of a view need to be updated and/or what attributes need to be + // persisted to the server. Unset attributes will be set to undefined. + // You can also pass an attributes object to diff against the model, + // determining if there *would be* a change. + changedAttributes: function( diff ) { + if ( !diff ) return this.hasChanged() ? _.clone( this._changed ) : false; + var val, changed = false, old = this._previousAttributes; + for ( var attr in diff ) { + if ( _.isEqual( old[attr], ( val = diff[attr] ) ) ) continue; + ( changed || ( changed = {} ) )[attr] = val; + } + return changed; + }, + + // Get the previous value of an attribute, recorded at the time the last + // `"change"` event was fired. + previous: function( attr ) { + if ( !arguments.length || !this._previousAttributes ) return null; + return this._previousAttributes[attr]; + }, + + // Get all of the attributes of the model at the time of the previous + // `"change"` event. + previousAttributes: function() { + return _.clone( this._previousAttributes ); + }, + + // Check if the model is currently in a valid state. It's only possible to + // get into an *invalid* state if you're using silent changes. + isValid: function() { + return !this.validate( this.attributes ); + }, + + // Run validation against a set of incoming attributes, returning `true` + // if all is well. If a specific `error` callback has been passed, + // call that instead of firing the general `"error"` event. + _validate: function( attrs, options ) { + if ( options.silent || !this.validate ) return true; + attrs = _.extend( {}, this.attributes, attrs ); + var error = this.validate( attrs, options ); + if ( !error ) return true; + if ( options && options.error ) { + options.error( this, error, options ); + } else { + this.trigger( 'error', this, error, options ); + } + return false; + } + + } ); + + // Backbone.Collection + // ------------------- + + // Provides a standard collection class for our sets of models, ordered + // or unordered. If a `comparator` is specified, the Collection will maintain + // its models in sort order, as they're added and removed. + Backbone.Collection = function( models, options ) { + options || ( options = {} ); + if ( options.comparator ) this.comparator = options.comparator; + this._reset(); + this.initialize.apply( this, arguments ); + if ( models ) this.reset( models, { silent: true, parse: options.parse } ); + }; + + // Define the Collection's inheritable methods. + _.extend( Backbone.Collection.prototype, Backbone.Events, { + + // The default model for a collection is just a **Backbone.Model**. + // This should be overridden in most cases. + model: Backbone.Model, + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function() {}, + + // The JSON representation of a Collection is an array of the + // models' attributes. + toJSON: function() { + return this.map( function( model ) { + return model.toJSON(); + } ); + }, + + // Add a model, or list of models to the set. Pass **silent** to avoid + // firing the `add` event for every new model. + add: function( models, options ) { + var i, index, length, model, cid, id, cids = {}, ids = {}; + options || ( options = {} ); + models = _.isArray( models ) ? models.slice() : [ models ]; + + // Begin by turning bare objects into model references, and preventing + // invalid models or duplicate models from being added. + for ( i = 0, length = models.length; i < length; i++ ) { + if ( !( model = models[i] = this._prepareModel( models[i], options ) ) ) { + throw new Error( "Can't add an invalid model to a collection" ); + } + if ( cids[cid = model.cid] || this._byCid[cid] || + ( ( ( id = model.id ) != null ) && ( ids[id] || this._byId[id] ) ) ) { + throw new Error( "Can't add the same model to a collection twice" ); + } + cids[cid] = ids[id] = model; + } + + // Listen to added models' events, and index models for lookup by + // `id` and by `cid`. + for ( i = 0; i < length; i++ ) { + ( model = models[i] ).on( 'all', this._onModelEvent, this ); + this._byCid[model.cid] = model; + if ( model.id != null ) this._byId[model.id] = model; + } + + // Insert models into the collection, re-sorting if needed, and triggering + // `add` events unless silenced. + this.length += length; + index = options.at != null ? options.at : this.models.length; + splice.apply( this.models, [ index, 0 ].concat( models ) ); + if ( this.comparator ) this.sort( { silent: true } ); + if ( options.silent ) return this; + for ( i = 0, length = this.models.length; i < length; i++ ) { + if ( !cids[( model = this.models[i] ).cid] ) continue; + options.index = i; + model.trigger( 'add', model, this, options ); + } + return this; + }, + + // Remove a model, or a list of models from the set. Pass silent to avoid + // firing the `remove` event for every model removed. + remove: function( models, options ) { + var i, l, index, model; + options || ( options = {} ); + models = _.isArray( models ) ? models.slice() : [ models ]; + for ( i = 0, l = models.length; i < l; i++ ) { + model = this.getByCid( models[i] ) || this.get( models[i] ); + if ( !model ) continue; + delete this._byId[model.id]; + delete this._byCid[model.cid]; + index = this.indexOf( model ); + this.models.splice( index, 1 ); + this.length--; + if ( !options.silent ) { + options.index = index; + model.trigger( 'remove', model, this, options ); + } + this._removeReference( model ); + } + return this; + }, + + // Get a model from the set by id. + get: function( id ) { + if ( id == null ) return null; + return this._byId[id.id != null ? id.id : id]; + }, + + // Get a model from the set by client id. + getByCid: function( cid ) { + return cid && this._byCid[cid.cid || cid]; + }, + + // Get the model at the given index. + at: function( index ) { + return this.models[index]; + }, + + // Force the collection to re-sort itself. You don't need to call this under + // normal circumstances, as the set will maintain sort order as each item + // is added. + sort: function( options ) { + options || ( options = {} ); + if ( !this.comparator ) throw new Error( 'Cannot sort a set without a comparator' ); + var boundComparator = _.bind( this.comparator, this ); + if ( this.comparator.length == 1 ) { + this.models = this.sortBy( boundComparator ); + } else { + this.models.sort( boundComparator ); + } + if ( !options.silent ) this.trigger( 'reset', this, options ); + return this; + }, + + // Pluck an attribute from each model in the collection. + pluck: function( attr ) { + return _.map( this.models, function( model ) { + return model.get( attr ); + } ); + }, + + // When you have more items than you want to add or remove individually, + // you can reset the entire set with a new list of models, without firing + // any `add` or `remove` events. Fires `reset` when finished. + reset: function( models, options ) { + models || ( models = [] ); + options || ( options = {} ); + for ( var i = 0, l = this.models.length; i < l; i++ ) { + this._removeReference( this.models[i] ); + } + this._reset(); + this.add( models, { silent: true, parse: options.parse } ); + if ( !options.silent ) this.trigger( 'reset', this, options ); + return this; + }, + + // Fetch the default set of models for this collection, resetting the + // collection when they arrive. If `add: true` is passed, appends the + // models to the collection instead of resetting. + fetch: function( options ) { + options = options ? _.clone( options ) : {}; + if ( options.parse === undefined ) options.parse = true; + var collection = this; + var success = options.success; + options.success = function( resp, status, xhr ) { + collection[options.add ? 'add' : 'reset']( collection.parse( resp, xhr ), options ); + if ( success ) success( collection, resp ); + }; + options.error = Backbone.wrapError( options.error, collection, options ); + return ( this.sync || Backbone.sync ).call( this, 'read', this, options ); + }, + + // Create a new instance of a model in this collection. Add the model to the + // collection immediately, unless `wait: true` is passed, in which case we + // wait for the server to agree. + create: function( model, options ) { + var coll = this; + options = options ? _.clone( options ) : {}; + model = this._prepareModel( model, options ); + if ( !model ) return false; + if ( !options.wait ) coll.add( model, options ); + var success = options.success; + options.success = function( nextModel, resp, xhr ) { + if ( options.wait ) coll.add( nextModel, options ); + if ( success ) { + success( nextModel, resp ); + } else { + nextModel.trigger( 'sync', model, resp, options ); + } + }; + model.save( null, options ); + return model; + }, + + // **parse** converts a response into a list of models to be added to the + // collection. The default implementation is just to pass it through. + parse: function( resp, xhr ) { + return resp; + }, + + // Proxy to _'s chain. Can't be proxied the same way the rest of the + // underscore methods are proxied because it relies on the underscore + // constructor. + chain: function() { + return _( this.models ).chain(); + }, + + // Reset all internal state. Called when the collection is reset. + _reset: function( options ) { + this.length = 0; + this.models = []; + this._byId = {}; + this._byCid = {}; + }, + + // Prepare a model or hash of attributes to be added to this collection. + _prepareModel: function( model, options ) { + if ( !( model instanceof Backbone.Model ) ) { + var attrs = model; + options.collection = this; + model = new this.model( attrs, options ); + if ( !model._validate( model.attributes, options ) ) model = false; + } else if ( !model.collection ) { + model.collection = this; + } + return model; + }, + + // Internal method to remove a model's ties to a collection. + _removeReference: function( model ) { + if ( this == model.collection ) { + delete model.collection; + } + model.off( 'all', this._onModelEvent, this ); + }, + + // Internal method called every time a model in the set fires an event. + // Sets need to update their indexes when models change ids. All other + // events simply proxy through. "add" and "remove" events that originate + // in other collections are ignored. + _onModelEvent: function( ev, model, collection, options ) { + if ( ( ev == 'add' || ev == 'remove' ) && collection != this ) return; + if ( ev == 'destroy' ) { + this.remove( model, options ); + } + if ( model && ev === 'change:' + model.idAttribute ) { + delete this._byId[model.previous( model.idAttribute )]; + this._byId[model.id] = model; + } + this.trigger.apply( this, arguments ); + } + + } ); + + // Underscore methods that we want to implement on the Collection. + var methods = [ 'forEach', 'each', 'map', 'reduce', 'reduceRight', 'find', + 'detect', 'filter', 'select', 'reject', 'every', 'all', 'some', 'any', + 'include', 'contains', 'invoke', 'max', 'min', 'sortBy', 'sortedIndex', + 'toArray', 'size', 'first', 'initial', 'rest', 'last', 'without', 'indexOf', + 'shuffle', 'lastIndexOf', 'isEmpty', 'groupBy' ]; + + // Mix in each Underscore method as a proxy to `Collection#models`. + _.each( methods, function( method ) { + Backbone.Collection.prototype[method] = function() { + return _[method].apply( _, [ this.models ].concat( _.toArray( arguments ) ) ); + }; + } ); + + // Backbone.Router + // ------------------- + + // Routers map faux-URLs to actions, and fire events when routes are + // matched. Creating a new one sets its `routes` hash, if not set statically. + Backbone.Router = function( options ) { + options || ( options = {} ); + if ( options.routes ) this.routes = options.routes; + this._bindRoutes(); + this.initialize.apply( this, arguments ); + }; + + // Cached regular expressions for matching named param parts and splatted + // parts of route strings. + var namedParam = /:\w+/g; + var splatParam = /\*\w+/g; + var escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g; + + // Set up all inheritable **Backbone.Router** properties and methods. + _.extend( Backbone.Router.prototype, Backbone.Events, { + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function() {}, + + // Manually bind a single named route to a callback. For example: + // + // this.route('search/:query/p:num', 'search', function(query, num) { + // ... + // }); + // + route: function( route, name, callback ) { + Backbone.history || ( Backbone.history = new Backbone.History ); + if ( !_.isRegExp( route ) ) route = this._routeToRegExp( route ); + if ( !callback ) callback = this[name]; + Backbone.history.route( route, _.bind( function( fragment ) { + var args = this._extractParameters( route, fragment ); + callback && callback.apply( this, args ); + this.trigger.apply( this, [ 'route:' + name ].concat( args ) ); + Backbone.history.trigger( 'route', this, name, args ); + }, this ) ); + return this; + }, + + // Simple proxy to `Backbone.history` to save a fragment into the history. + navigate: function( fragment, options ) { + Backbone.history.navigate( fragment, options ); + }, + + // Bind all defined routes to `Backbone.history`. We have to reverse the + // order of the routes here to support behavior where the most general + // routes can be defined at the bottom of the route map. + _bindRoutes: function() { + if ( !this.routes ) return; + var routes = []; + for ( var route in this.routes ) { + routes.unshift( [ route, this.routes[route] ] ); + } + for ( var i = 0, l = routes.length; i < l; i++ ) { + this.route( routes[i][0], routes[i][1], this[routes[i][1]] ); + } + }, + + // Convert a route string into a regular expression, suitable for matching + // against the current location hash. + _routeToRegExp: function( route ) { + route = route.replace( escapeRegExp, '\\$&' ) + .replace( namedParam, '([^\/]+)' ) + .replace( splatParam, '(.*?)' ); + return new RegExp( '^' + route + '$' ); + }, + + // Given a route, and a URL fragment that it matches, return the array of + // extracted parameters. + _extractParameters: function( route, fragment ) { + return route.exec( fragment ).slice( 1 ); + } + + } ); + + // Backbone.History + // ---------------- + + // Handles cross-browser history management, based on URL fragments. If the + // browser does not support `onhashchange`, falls back to polling. + Backbone.History = function() { + this.handlers = []; + _.bindAll( this, 'checkUrl' ); + }; + + // Cached regex for cleaning leading hashes and slashes . + var routeStripper = /^[#\/]/; + + // Cached regex for detecting MSIE. + var isExplorer = /msie [\w.]+/; + + // Has the history handling already been started? + var historyStarted = false; + + // Set up all inheritable **Backbone.History** properties and methods. + _.extend( Backbone.History.prototype, Backbone.Events, { + + // The default interval to poll for hash changes, if necessary, is + // twenty times a second. + interval: 50, + + // Get the cross-browser normalized URL fragment, either from the URL, + // the hash, or the override. + getFragment: function( fragment, forcePushState ) { + if ( fragment == null ) { + if ( this._hasPushState || forcePushState ) { + fragment = window.location.pathname; + var search = window.location.search; + if ( search ) fragment += search; + } else { + fragment = window.location.hash; + } + } + fragment = decodeURIComponent( fragment ); + if ( !fragment.indexOf( this.options.root ) ) fragment = fragment.substr( this.options.root.length ); + return fragment.replace( routeStripper, '' ); + }, + + // Start the hash change handling, returning `true` if the current URL matches + // an existing route, and `false` otherwise. + start: function( options ) { + // Figure out the initial configuration. Do we need an iframe? + // Is pushState desired ... is it available? + if ( historyStarted ) throw new Error( "Backbone.history has already been started" ); + this.options = _.extend( {}, { root: '/' }, this.options, options ); + this._wantsHashChange = this.options.hashChange !== false; + this._wantsPushState = !!this.options.pushState; + this._hasPushState = !!( this.options.pushState && window.history && window.history.pushState ); + var fragment = this.getFragment(); + var docMode = document.documentMode; + var oldIE = ( isExplorer.exec( navigator.userAgent.toLowerCase() ) && ( !docMode || docMode <= 7 ) ); + if ( oldIE ) { + this.iframe = $( '