flashpoint
Version: 
Angular bindings for Fireproof. Replaces AngularFire.
2,034 lines (1,397 loc) • 65.5 kB
JavaScript
/*! flashpoint 5.0.1, © 2015 J2H2 Inc. MIT License.
 * https://github.com/casetext/flashpoint
 */
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD. Register as an anonymous module.
    define(['angular', 'firebase', 'fireproof'], factory);
  } else if (typeof exports === 'object') {
    // Node. Does not work with strict CommonJS, but
    // only CommonJS-like environments that support module.exports,
    // like Node.
    factory(require('angular'), require('firebase'), require('fireproof'));
  } else {
    // Browser globals (root is window)
    factory(root.angular, root.Firebase, root.Fireproof);
  }
}(this, function (angular, Firebase, Fireproof) {
  'use strict';
    
  /**
   * @ngdoc module
   * @name flashpoint
   */
  angular.module('flashpoint', []);
  
  
  angular.module('flashpoint')
  .directive('firebase', ["$animate", function($animate) {
  
    /**
     * @ngdoc directive
     * @name firebase
     * @description Wires Firebase into an Angular application.
     *
     * The `firebase` directive is an easy way to make the Firebase controller available
     * to enclosing scope, where it is exposed as `fp`.
     *
     * @restrict A
     * @element ANY
     * @scope true
     * @param {expression} firebase Full URL to the Firebase, like
     * `https://my-firebase.firebaseio.com`. Interpolatable.
     */
  
    function firebasePreLink(scope, el, attrs, fp) {
  
      var attached, attachedUrl;
  
      var attachToController = function(url) {
  
        if (attached && url === attachedUrl) {
          // already attached to this path, no action necessary
          return;
        } else if (typeof url !== 'string' || url === '') {
          // no way to attach
          return;
        }
  
        fp.attachFirebase(url);
        attached = true;
        attachedUrl = url;
  
      };
  
      attrs.$observe('firebase', attachToController);
  
      scope.$watch('fp.connected', function(connected) {
  
        if (connected === true) {
          $animate.setClass(el, 'fp-connected', 'fp-disconnected');
        } else if (connected === false) {
          $animate.setClass(el, 'fp-disconnected', 'fp-connected');
        } else {
          $animate.setClass(el, '', 'fp-connected fp-disconnected');
        }
  
      });
  
      scope.$watch('fp.auth', function(auth) {
  
        if (auth === undefined) {
          $animate.setClass(el, '', 'fp-unauthenticated fp-authenticated');
        } else if (auth === null) {
          $animate.setClass(el, 'fp-unauthenticated', 'fp-authenticated');
        } else {
          $animate.setClass(el, 'fp-authenticated', 'fp-unauthenticated');
        }
  
      });
  
      scope.$watch('fp.authError', function(authError) {
  
        if (authError) {
          $animate.addClass(el, 'fp-auth-error');
        } else {
          $animate.removeClass(el, 'fp-auth-error');
        }
  
      });
  
      scope.$watch('fp.accountError', function(accountError) {
  
        if (accountError) {
          $animate.addClass(el, 'fp-account-error');
        } else {
          $animate.removeClass(el, 'fp-account-error');
        }
  
      });
  
    }
  
  
    return {
  
      restrict: 'A',
      controller: 'FirebaseCtl',
      controllerAs: 'fp',
      priority: 1000,
      scope: true,
      link: {
        pre: firebasePreLink
      }
  
    };
  
  }]);
  
  
  
  angular.module('flashpoint')
  .directive('fpBindChildren', ["$animate", "$compile", "_fpGetRef", function($animate, $compile, _fpGetRef) {
  
    /**
     * @ngdoc directive
     * @name fpBindChildren
     * @description Binds DOM elements to the children of a Firebase path.
     *
     * @restrict A
     * @element ANY
     * @param {expression} fpBindChildren The annotated path to the children to use.
     * @param {expression} query An expression that evaluates to a Firebase query.
     */
  
    function fpBindChildrenCompile($templateEl) {
  
      // search the template for fp-child-repeat, that's what we'll repeat on each child
      var template = $templateEl[0].querySelector('[fp-child-repeat]'),
        placeholder = angular.element(document.createElement('fp-child-repeat-placeholder'));
  
      if (template) {
  
        template = angular.element(template);
        template.after(placeholder);
        template.remove();
  
      } else {
        throw new Error('No fp-child-repeat was found in your fp-bind-children!');
      }
  
      return function fpBindChildrenLink(scope, el, attrs, fp) {
  
        var startPlaceholder = angular.element(document.createComment('fp-child-repeat start')),
          endPlaceholder = angular.element(document.createComment('fp-child-repeat end')),
          oldPlaceholder = el.find('fp-child-repeat-placeholder');
  
        oldPlaceholder.after(endPlaceholder);
        oldPlaceholder.after(startPlaceholder);
        oldPlaceholder.remove();
  
        var query,
          els = {},
          cancelAttachListener;
  
        scope.$children = [];
  
  
        function find(key) {
  
          // FIXME(goldibex): yes, I know this is linear time
          for (var i = 0; i < scope.$children.length; i++) {
  
            if (scope.$children[i].key() === key) {
              return i;
            }
  
          }
  
          return -1;
  
        }
  
  
        function _attach() {
  
          if (cancelAttachListener) {
            cancelAttachListener();
          }
  
          cancelAttachListener = scope.$watch(attrs.fpBindChildren, function(newQueryStr) {
  
            _detach();
  
            if (newQueryStr) {
  
              try {
  
                query = _fpGetRef(fp.root, newQueryStr);
  
                query.on('child_added', onAdded, onError);
                query.on('child_removed', onRemoved, onError);
                query.on('child_moved', onMoved, onError);
                query.on('child_changed', onChanged, onError);
  
              } catch(e) {
                onError(e);
              }
  
            }
  
          });
  
        }
  
  
        function _detach() {
  
          $animate.removeClass(el, 'fp-bind-children-error');
  
          if (query) {
  
            query.off('child_added', onAdded);
            query.off('child_removed', onRemoved);
            query.off('child_moved', onMoved);
            query.off('child_changed', onChanged);
  
          }
  
          query = null;
  
          scope.$children.forEach(function(child) {
  
            var deadEl = els[child.key()];
            deadEl.remove();
  
          });
  
          scope.$children.length = 0;
  
        }
  
        function onAdded(snap, prevKey) {
  
          var position = find(prevKey)+1;
          scope.$children.splice(position, 0, snap);
  
          var clone = template.clone(),
            cloneScope = scope.$new(),
            cloneFn = $compile(clone);
  
          // make sure the children know about flashpoint
          cloneFn(cloneScope, null, {
            transcludeControllers: {
              firebase: { instance: fp }
            }
          });
  
          var previousSibling = els[prevKey] || startPlaceholder;
  
          cloneScope.$key = snap.key();
          cloneScope.$value = snap.val();
          cloneScope.$priority = snap.getPriority();
          cloneScope.$index = position;
  
          els[snap.key()] = angular.element(clone);
  
          $animate.enter(clone, el.parent(), previousSibling);
  
        }
  
        function onRemoved(snap) {
  
          $animate.leave(els[snap.key()]);
  
          var position = find(snap.key());
          scope.$children.splice(position, 1);
  
          $animate.leave(els[snap.key()])
          .then(function() {
            els[snap.key()] = null;
          });
  
        }
  
        function onMoved(snap, prevKey) {
  
          var oldPosition = find(snap.key());
          scope.$children.splice(oldPosition, 1);
  
          var newPosition = find(prevKey) + 1;
          scope.$children.splice(newPosition, 0, snap);
  
          els[snap.key()].scope().$key = snap.key();
          els[snap.key()].scope().$value = snap.val();
          els[snap.key()].scope().$priority = snap.getPriority();
          els[snap.key()].scope().$index = newPosition+1;
  
          $animate.move(els[snap.key()], el.parent(), els[prevKey]);
  
        }
  
        function onChanged(snap) {
  
          var position = find(snap.key());
          scope.$children.splice(position, 1, snap);
  
          els[snap.key()].scope().$key = snap.key();
          els[snap.key()].scope().$value = snap.val();
          els[snap.key()].scope().$priority = snap.getPriority();
  
          $animate.addClass(els[snap.key()], 'fp-bind-children-changed')
          .then(function() {
            $animate.removeClass(els[snap.key()], 'fp-bind-children-changed');
          });
  
        }
  
        function onError(err) {
  
          scope.$error = err;
          $animate.addClass(el, 'fp-bind-children-error');
  
        }
  
        var onAttachListener = fp.onAttach(_attach);
        var onDetachListener = fp.onDetach(function() {
  
          if (cancelAttachListener) {
            cancelAttachListener();
            cancelAttachListener = null;
          }
  
          _detach();
  
        });
  
        scope.$on('$destroy', function() {
  
          // remove the comment nodes
          startPlaceholder.remove();
          endPlaceholder.remove();
          if (cancelAttachListener) {
            cancelAttachListener();
          }
          _detach();
  
          fp.offAttach(onAttachListener);
          fp.offDetach(onDetachListener);
  
        });
  
      };
  
    }
  
    return {
      restrict: 'A',
      priority: 900,
      require: '^firebase',
      compile: fpBindChildrenCompile
    };
  
  }]);
  
  
  angular.module('flashpoint')
  .directive('fpFeed', ["$q", "FPFeed", function($q, FPFeed) {
  
    /**
     * @ngdoc directive
     * @name fpFeed
     * @description Gathers objects from a variety of Firebase locations into a single array.
     *
     * The `fpFeed` directive queries a set of Firebase locations that you specify
     * and pools all the retrieved values into a single list, available on scope as $page.
     *
     * @restrict A
     * @element ANY
     *
     * @param {expression} fpFeed An expression that evaluates to an array of absolute
     * paths in Firebase you wish to query, for instance, `["feeds/" + username, "feeds/firehose"]`.
     *
     * @param {expression} feedTransform An optional expression to transform a feed object
     * into another linked object. It receives the
     * special variables `$object` and `$root` and should return
     * either the transformed object or a promise that resolves to the transformed object.
     *
     * @param {expression} feedFilter an optional expression run over each new feed object.
     * It should return true if the object should be kept, false if not.
     * Receives the special variables:
     * - `$object`: the object under consideration.
     * - `$index`: the proposed index of the new object.
     * - `$newItems`: the set of all items about to be appended to the feed.
     * - `$items`: the set of all items currently in the feed.
     *
     * @param {expression} feedSort an optional expression to be evaluated to sort the set of
     * new objects before they're appended to the feed.
     * Receives the special variables:
     * - `$a` or `$left`: the left-hand object in the comparison
     * - `$b` or `$right`: the right-hand object in the comparison
     * Defaults to sorting by key.
     */
  
    function fpFeedLink(scope, el, attrs, fp) {
  
      var onAttachListener = fp.onAttach(function(root) {
  
        scope.$feed = new FPFeed(scope.$eval(attrs.fpFeed), function(ref, start) {
  
          if (attrs.feedQuery) {
            return scope.$eval(attrs.feedQuery, { $ref: ref, $start: start });
          } else {
            throw new Error('fp-feed requires feed-query to be set');
          }
  
        });
  
        scope.$feed.setTransform(function(object, root) {
  
          if (attrs.feedTransform) {
  
            return scope.$eval(attrs.feedTransform, {
              '$object': object,
              '$root': root
            });
          } else {
            return object;
          }
  
        });
  
        scope.$feed.setFilter(function(object, index, newItems, items) {
  
          if (attrs.feedFilter) {
            return scope.$eval(attrs.feedFilter, {
              '$object': object,
              '$index': index,
              '$newItems': newItems,
              '$items': items
            });
          } else {
            return function() { return true; };
          }
  
        });
  
        scope.$feed.setSort(function(a, b) {
  
          if (attrs.feedSort) {
  
            return scope.$eval(attrs.feedSort, {
              '$a': a,
              '$b': b,
              '$left': a,
              '$right': b
            });
  
          } else {
            return a.ref().toString().localeCompare(b.ref().toString());
          }
  
        });
  
        scope.$feed.connect(root);
  
      });
  
      var onDetachListener = fp.onDetach(function() {
  
        if (scope.$feed) {
          scope.$feed.disconnect();
        }
  
      });
  
      scope.$on('$destroy', function() {
  
        fp.offAttach(onAttachListener);
        fp.offDetach(onDetachListener);
  
        scope.$feed.disconnect();
  
      });
  
    }
  
    return {
      require: '^firebase',
      link: fpFeedLink
    };
  
  }]);
  
  
  angular.module('flashpoint')
  .constant('FP_DEFAULT_PAGE_SIZE', 3)
  .directive('fpPage', ["FP_DEFAULT_PAGE_SIZE", "FPPage", function(FP_DEFAULT_PAGE_SIZE, FPPage) {
  
    /**
     * @ngdoc directive
     * @name fpPage
     * @description Pages over the children of a given Firebase location.
     *
     * The `fpPage` directive makes it easy to page over the static children of a
     * given Firebase location. Since, as the Firebase docs describe, the operation of
     * paging over data that changes in real-time is not well-defined, this directive
     * should only be used with Firebase data that changes rarely or not at all.
     * After instantiation, scope will have a variable `$page` of type {link Page}
     * that you can use.
     *
     * @restrict A
     * @element ANY
     *
     * @param {expression} fpPage An expression that evaluates to a Firebase query to
     * be used to retrieve items. Some special functions are provided:
     * - `$orderByPriority(path, size)`: order the children of `path` by priority,
     * and provide at most `size` objects per page.
     * - `$orderByKey(path, size)`: order the children of `path` by key,
     * and provide at most `size` objects per page.
     * - `$orderByValue(path, size)`: order the children of `path` by priority,
     * and provide at most `size` objects per page.
     * - `$orderByValue(path, child, size)`: order the children of `path` by the value of child `child`,
     * and provide at most `size` objects per page.
     * @see {Page}
     */
  
    function fpPageLink(scope, el, attrs, fp) {
  
      function $orderByPriority(path, size) {
  
        size = parseInt(size);
        if (isNaN(size)) {
          size = FP_DEFAULT_PAGE_SIZE;
        }
  
        return function(root, last) {
  
          var query = root.child(path).orderByPriority();
  
          if (last) {
            return query.startAt(last.getPriority(), last.key()).limitToFirst(size+1);
          } else {
            return query.limitToFirst(size);
          }
  
        };
  
      }
  
  
      function $orderByKey(path, size) {
  
        size = parseInt(size);
        if (isNaN(size)) {
          size = FP_DEFAULT_PAGE_SIZE;
        }
  
        return function(root, last) {
  
          var query = root.child(path).orderByKey();
  
          if (last) {
            return query.startAt(last.key()).limitToFirst(size+1);
          } else {
            return query.limitToFirst(size);
          }
  
        };
  
      }
  
  
      function $orderByValue(path, size) {
  
        size = parseInt(size);
        if (isNaN(size)) {
          size = FP_DEFAULT_PAGE_SIZE;
        }
  
        return function(root, last) {
  
          var query = root.child(path).orderByValue();
  
          if (last) {
            return query.startAt(last.val(), last.key()).limitToFirst(size+1);
          } else {
            return query.limitToFirst(size);
          }
  
        };
  
      }
  
  
      function $orderByChild(path, child, size) {
  
        size = parseInt(size);
        if (isNaN(size)) {
          size = FP_DEFAULT_PAGE_SIZE;
        }
  
        return function(root, last) {
  
          var query = root.child(path).orderByChild(child);
  
          if (last) {
  
            var lastVal = last.val();
  
            if (angular.isObject(lastVal)) {
              lastVal = lastVal[child];
            } else {
              lastVal = null;
            }
  
            return query.startAt(lastVal, last.key()).limitToFirst(size+1);
  
          } else {
            return query.limitToFirst(size);
          }
  
        };
  
      }
  
      var orderingMethods = {
        $orderByPriority: $orderByPriority,
        $orderByKey: $orderByKey,
        $orderByChild: $orderByChild,
        $orderByValue: $orderByValue
      };
  
      var onAttachListener = fp.onAttach(function(root) {
  
        scope.$page = new FPPage(scope.$eval(attrs.fpPage, orderingMethods));
        scope.$page.connect(root);
  
      });
  
      var onDetachListener = fp.onDetach(function() {
  
        if (scope.$page) {
          scope.$page.disconnect();
        }
  
      });
  
      scope.$on('$destroy', function() {
  
        fp.offAttach(onAttachListener);
        fp.offDetach(onDetachListener);
  
        scope.$page.disconnect();
  
      });
  
    }
  
    return {
      link: fpPageLink,
      restrict: 'A',
      priority: 750,
      require: '^firebase'
    };
  
  }]);
  
  
  angular.module('flashpoint')
  .directive('onAuth', function() {
  
    /**
     * @ngdoc directive
     * @name onAuth
     * @description Evaluates an Angular expression on changes in authentication status.
     *
     * The `onAuth` directive hooks into Firebase's `onAuth` expression and evaluates
     * the expression you supply every time authentication status against your Firebase
     * changes. This is useful for managing login state. It supplies the special variable
     * `$auth` to your expression.
     *
     * @restrict A
     * @element ANY
     */
  
    function onAuthPreLink(scope, el, attrs, fp) {
  
      function authHandler(authData) {
  
        if (attrs.onAuth) {
          scope.$eval(attrs.onAuth, { $auth: authData });
        }
  
      }
  
      fp.onAttach(function(root) {
        root.onAuth(authHandler);
      });
  
      fp.onDetach(function(root) {
  
        if (root) {
          root.offAuth(authHandler);
        }
  
      });
  
    }
  
    return {
      priority: 750,
      require: '^firebase',
      restrict: 'A',
      link: {
        pre: onAuthPreLink
      }
  
    };
  
  });
  
  
  angular.module('flashpoint')
  .directive('onConnect', function() {
  
    /**
     * @ngdoc directive
     * @name onConnect
     * @description Evaluates the given expression on successfully establishing a Firebase connection.
     * Must be supplied together with `firebase`.
     *
     * The `onConnect` directive evaluates the expression you supply whenever the
     * connection to the Firebase is re-established.
     *
     * @restrict A
     * @element ANY
     */
  
    return {
      require: 'firebase',
      link: fpOnConnectLink
    };
  
  });
  
  function fpOnConnectLink(scope, el, attrs, fp) {
  
    var cancel;
  
    var attachListener = fp.onAttach(function() {
  
      cancel = scope.$watch('fp.connected', function(connected) {
  
        if (connected === true) {
          scope.$eval(attrs.onConnect);
        }
  
      });
  
    });
  
    var detachListener = fp.onDetach(function() {
  
      if (cancel) {
        cancel();
        cancel = null;
      }
  
    });
  
    scope.$on('$destroy', function() {
  
      fp.offAttach(attachListener);
      fp.offDetach(detachListener);
  
    });
  
  
  }
  
  
  angular.module('flashpoint')
  .directive('onDisconnect', ["$q", "$log", "fpValidatePath", function($q, $log, fpValidatePath) {
  
    /**
     * @ngdoc directive
     * @name onDisconnect
     * @description Sets a Firebase onDisconnect hook. Must be supplied together with `firebase`.
     *
     * Firebase provides a way to make changes to a database in case the user disconnects,
     * known as "onDisconnect". The `onDisconnect` directive exposes onDisconnect
     * to Angular expressions.
     *
     * The `onDisconnect` expression adds the behavior that when you _detach_ from a Firebase,
     * the expression is also evaluated.
     *
     * NB: `onDisconnect` IS NOT EVALUATED WHEN THE FIREBASE ACTUALLY DISCONNECTS!
     * Instead, it's the equivalent of telling Firebase, "Hey, if you don't hear back
     * from me in a while, do this operation for me." The expression actually gets
     * evaluated right after a successful connection to Firebase.
     *
     * The supplied expression gets access to the special functions `$set`,
     * `$update`, `$setWithPriority`, and `$remove`, all of which behave identically
     * to their counterparts in Firebase using Flashpoint syntax.
     * For instance, ```on-disconnect="$remove('online-users', $auth.name)"```.
     *
     * @restrict A
     * @element ANY
     */
  
    function onDisconnectLink(scope, el, attrs, fp) {
  
      var disconnects = {};
  
      var onDisconnectError = function(err) {
  
        $log.debug('onDisconnect: error evaluating "' + attrs.onDisconnect +
          '": ' + err.code);
  
        if (attrs.onDisconnectError) {
          scope.$eval(attrs.onDisconnectError, { $error: err });
        }
  
      };
  
      var getDisconnectContext = function(root) {
  
        return {
  
          $set: function() {
  
            var args = Array.prototype.slice.call(arguments, 0),
              data = args.pop(),
              path = fpValidatePath(args);
  
            if (path) {
  
              disconnects[path] = true;
              return root.child(path).onDisconnect().set(data)
              .catch(onDisconnectError);
  
            } else {
              return $q.reject(new Error('Invalid path'));
            }
  
          },
  
          $update: function() {
  
            var args = Array.prototype.slice.call(arguments, 0),
              data = args.pop(),
              path = fpValidatePath(args);
  
            if (path) {
  
              disconnects[path] = true;
              return root.child(path).onDisconnect().update(data)
              .catch(onDisconnectError);
  
            } else {
              return $q.reject(new Error('Invalid path'));
            }
  
          },
  
          $setWithPriority: function() {
  
            var args = Array.prototype.slice.call(arguments, 0),
              priority = args.pop(),
              data = args.pop(),
              path = fpValidatePath(args);
  
            if (path) {
  
              disconnects[path] = true;
              return root.child(path).onDisconnect().setWithPriority(data, priority)
              .catch(onDisconnectError);
  
            } else {
              return $q.reject(new Error('Invalid path'));
            }
  
          },
  
          $remove: function() {
  
            var args = Array.prototype.slice.call(arguments, 0),
              path = fpValidatePath(args);
  
            if (path) {
  
              disconnects[path] = true;
              return root.child(path).onDisconnect().remove()
              .catch(onDisconnectError);
  
            } else {
              return $q.reject(new Error('Invalid path'));
            }
  
          }
  
        };
  
      };
  
      var liveContext = {
        $set: fp.set.bind(fp),
        $remove: fp.remove.bind(fp),
        $setWithPriority: fp.setWithPriority.bind(fp),
        $update: fp.update.bind(fp)
      };
  
  
      var attachListener = fp.onAttach(function(root) {
  
        // attach disconnect to this Firebase
        scope.$eval(attrs.onDisconnect, getDisconnectContext(root));
  
      });
  
  
      var detachListener = fp.onDetach(function(root) {
  
        for (var disconnectPath in disconnects) {
  
          // cancel the disconnect expression, then actually run it
          // (because detaching is effectively disconnecting)
          root.child(disconnectPath).onDisconnect().cancel();
  
          scope.$eval(attrs.onDisconnect, liveContext);
  
        }
  
        disconnects = {};
  
      });
  
      scope.$on('$destroy', function() {
        fp.offAttach(attachListener);
        fp.offDetach(detachListener);
      });
  
    }
  
    return {
      require: 'firebase',
      restrict: 'A',
      link: onDisconnectLink
    };
  
  }]);
  
  
  angular.module('flashpoint')
  .filter('orderByKey', function() {
  
    return function orderByKey(path) {
  
      if (angular.isArray(path)) {
        return path.map(orderByKey);
      } else if (angular.isString(path)) {
        return path + '.orderByKey';
      } else {
        return null;
      }
  
    };
  
  })
  .filter('orderByValue', function() {
  
    return function orderByValue(path) {
  
      if (angular.isArray(path)) {
        return path.map(orderByValue);
      } else if (angular.isString(path)) {
        return path + '.orderByValue';
      } else {
        return null;
      }
  
    };
  
  })
  .filter('orderByPriority', function() {
  
    return function orderByPriority(path) {
  
      if (angular.isArray(path)) {
        return path.map(orderByPriority);
      } else if (angular.isString(path)) {
        return path + '.orderByPriority';
      } else {
        return null;
      }
  
    };
  
  })
  .filter('orderByChild', function() {
  
  
    return function orderByChild(path, child) {
  
      if (angular.isArray(path)) {
        return path.map(function(pathItem) {
          return orderByChild(pathItem, child);
        });
      } else if (angular.isString(path) && angular.isString(child) && child.length > 0) {
        return path + '.orderByChild:' + JSON.stringify(child);
      } else {
        return null;
      }
  
    };
  
  })
  .filter('startAt', function() {
  
    return function startAt(path, startAtValue, startAtKey) {
  
      if (angular.isArray(path)) {
  
        return path.map(function(pathItem) {
          return startAt(pathItem, startAtValue, startAtKey);
        });
  
      } else {
  
        if (startAtValue === undefined) {
          return null;
        } else {
          path += '.startAt:' + JSON.stringify(startAtValue);
        }
  
        if (angular.isString(startAtKey) || angular.isNumber(startAtKey)) {
          return path + ':' + JSON.stringify(startAtKey);
        } else {
          return path;
        }
  
      }
  
    };
  
  })
  .filter('endAt', function() {
  
    return function endAt(path, endAtValue, endAtKey) {
  
      if (angular.isArray(path)) {
        return path.map(function(pathItem) {
          return endAt(pathItem, endAtValue, endAtKey);
        });
      } else {
  
        if (endAtValue === undefined) {
          return null;
        } else {
          path += '.endAt:' + JSON.stringify(endAtValue);
        }
  
        if (angular.isString(endAtKey) || angular.isNumber(endAtKey)) {
          return path + ':' + JSON.stringify(endAtKey);
        } else {
          return path;
        }
  
      }
  
    };
  
  })
  .filter('limitToFirst', function() {
  
    return function limitToFirst(path, limitToFirstQuantity) {
  
      limitToFirstQuantity = parseInt(limitToFirstQuantity);
  
      if (isNaN(limitToFirstQuantity)) {
        return null;
      } else if (angular.isArray(path)) {
  
        return path.map(function(pathItem) {
          return limitToFirst(pathItem, limitToFirstQuantity);
        });
  
      } else {
        return path + '.limitToFirst:' + JSON.stringify(limitToFirstQuantity);
      }
  
    };
  
  })
  .filter('limitToLast', function() {
  
    return function limitToLast(path, limitToLastQuantity) {
  
      limitToLastQuantity = parseInt(limitToLastQuantity);
  
      if (isNaN(limitToLastQuantity)) {
        return null;
      } else if (angular.isArray(path)) {
  
        return path.map(function(pathItem) {
          return limitToLast(pathItem, limitToLastQuantity);
        });
  
      } else {
        return path + '.limitToLast:' + JSON.stringify(limitToLastQuantity);
      }
  
    };
  
  })
  .constant('_fpGetRef', function _fpGetRef(root, path) {
  
    if (angular.isArray(path)) {
  
      return path.map(function(pathItem) {
        return _fpGetRef(root, pathItem);
      });
  
    } else if (angular.isString(path)) {
  
      var params = path.split(/\./g),
        query = root.child(params.shift());
  
      return params.reduce(function(query, part) {
  
        var name = part.split(':')[0],
          args = part.split(':').slice(1).map(function(item) { return JSON.parse(item); });
  
        switch(name) {
        case 'orderByKey':
        case 'orderByValue':
        case 'orderByPriority':
          return query[name]();
        case 'orderByChild':
        case 'startAt':
        case 'endAt':
        case 'limitToFirst':
        case 'limitToLast':
          return query[name].apply(query, args);
        }
  
      }, query);
  
    } else {
      return null;
    }
  
  });
  
  
  angular.module('flashpoint')
  .factory('FPFeed', ["$q", function($q) {
  
    function FPFeed(paths, queryFn) {
  
      if (arguments.length < 2) {
        throw new Error('FPFeed expects at least 2 arguments, got ' + arguments.length);
      }
  
      if (!angular.isArray(paths)) {
        paths = [paths];
      }
  
  
      this._queryFn = queryFn;
  
      this._positions = paths.reduce(function(obj, path) {
  
        obj[path] = undefined;
        return obj;
  
      }, {});
      this._presence = {};
      this.items = [];
  
    }
  
    FPFeed.prototype._transformFn = function(obj) {
      return obj;
    };
  
    FPFeed.prototype._filterFn = function() {
      return true;
    };
  
    FPFeed.prototype.setTransform = function(fn) {
      this._transformFn = fn;
      return this;
    };
  
    FPFeed.prototype.setSort = function(fn) {
      this._sortFn = fn;
      return this;
    };
  
    FPFeed.prototype.setFilter = function(fn) {
      this._filterFn = fn;
      return this;
    };
  
    FPFeed.prototype.more = function() {
  
      var self = this;
  
      if (!self._morePromise) {
  
        self._morePromise = $q.all(Object.keys(self._positions).map(function(path) {
  
          return self._queryFn(self.root.child(path), self._positions[path])
          .then(function(snap) {
  
            var promises = [];
            snap.forEach(function(feedEntry) {
  
              if (!self._presence.hasOwnProperty(feedEntry.ref().toString())) {
  
                self._presence[feedEntry.ref().toString()] = true;
                promises.push($q.when(self._transformFn(feedEntry, self.root)));
                self._positions[path] = feedEntry;
  
              }
  
            });
  
            return $q.all(promises);
  
          });
  
        }))
        .then(function(feedResultsArrays) {
  
          var allResults = feedResultsArrays
          .reduce(function(allResults, feedResultArray) {
            return allResults.concat(feedResultArray);
          }, [])
          .filter(function(object, index, array) {
            return self._filterFn(object, index, array, self.items);
          });
  
          if (self._sortFn) {
            allResults.sort(self._sortFn);
          }
  
          allResults.forEach(function(result) {
            self.items.push(result);
          });
  
          self._morePromise = null;
  
        });
  
      }
  
      return self._morePromise;
  
    };
  
    FPFeed.prototype.connect = function(root) {
  
      this.disconnect();
      this.root = root;
      return this.more();
  
    };
  
    FPFeed.prototype.disconnect = function() {
  
      this._positions = Object.keys(this._positions).reduce(function(obj, path) {
  
        obj[path] = undefined;
        return obj;
  
      }, {});
      this._presence = {};
      this.items.length = 0;
  
    };
  
    return FPFeed;
  
  }]);
  
  
  angular.module('flashpoint')
  .factory('FPListenerSet', function() {
  
    function FPListenerSet(root, scope) {
  
      var self = this,
        scrubbingListeners = false;
  
      self.watchers = {};
      self.liveWatchers = {};
      self.values = {};
      self.priorities = {};
      self.errors = {};
  
      self.root = root;
      self.scope = scope;
  
      function scrubListeners() {
  
        var newWatchers = {};
  
        for (var path in self.watchers) {
  
          if (self.watchers[path] && self.liveWatchers[path]) {
            newWatchers[path] = self.watchers[path];
          } else {
            self.remove(path);
          }
  
        }
  
        // as of now, nothing is alive.
        self.watchers = newWatchers;
        self.liveWatchers = {};
        scrubbingListeners = false;
  
      }
  
      self.scope.$watch(function() {
  
        // after each scope cycle, sweep out any "orphaned" listeners, i.e.,
        // ones we previously connected but don't need anymore.
  
        if (!scrubbingListeners) {
  
          scrubbingListeners = true;
          scope.$$postDigest(scrubListeners);
  
        }
  
      });
  
    }
  
    FPListenerSet.prototype.add = function(path) {
  
      var self = this;
  
      self.liveWatchers[path] = true;
  
      if (!self.watchers[path]) {
  
        self.watchers[path] = self.root.child(path)
        .on('value', function(snap) {
  
          self.errors[path] = null;
          self.values[path] = snap.val();
          self.priorities[path] = snap.getPriority();
          self.scope.$evalAsync();
  
        }, function(err) {
  
          self.liveWatchers[path] = false;
          self.watchers[path] = null;
          self.errors[path] = err;
          self.values[path] = null;
          self.priorities[path] = null;
          self.scope.$evalAsync();
  
        });
  
      }
  
    };
  
  
    FPListenerSet.prototype.has = function(path) {
      return this.watchers.hasOwnProperty(path);
    };
  
  
    FPListenerSet.prototype.remove = function(path) {
  
      if (this.watchers[path]) {
  
        // disconnect this watcher, it doesn't exist anymore.
        if (this.watchers[path].disconnect) {
          this.watchers[path].disconnect();
        } else {
          this.root.child(path).off('value', this.watchers[path]);
        }
  
        // clear all values associated with the watcher
        this.values[path] = null;
        this.errors[path] = null;
        this.priorities[path] = null;
        this.watchers[path] = null;
  
      }
  
    };
  
  
    FPListenerSet.prototype.clear = function() {
  
      for (var path in this.watchers) {
        this.remove(path);
      }
  
      this.watchers = {};
  
    };
  
    return FPListenerSet;
  
  });
  
  
  angular.module('flashpoint')
  .factory('FPPage', ["$q", function($q) {
  
    function FPPage(pagingFn) {
  
      this._pagingFn = pagingFn;
      this._pages = [];
      this._presence = {};
  
      this.items = [];
  
      this.keys = [];
      this.priorities = [];
      this.values = [];
  
  
      this.disconnect();
  
    }
  
  
    FPPage.prototype.connect = function(root) {
  
      this.disconnect();
  
      this.root = root;
      return this.next();
  
    };
  
  
    FPPage.prototype.disconnect = function() {
  
      this.root = null;
      this._pages.length = 0;
  
      this.items.length = 0;
      this.keys.length = 0;
      this.values.length = 0;
      this.priorities.length = 0;
  
      this.number = 0;
      this._presence = {};
  
    };
  
  
    FPPage.prototype._handleSnap = function(snap) {
  
      var newFPPage = [],
        presence = this._presence;
  
      snap.forEach(function(child) {
  
        if (!presence.hasOwnProperty(child.key()) ) {
  
          presence[child.key()] = true;
          newFPPage.push(child);
  
        }
  
      });
  
      if (newFPPage.length > 0) {
        this._pages.push(newFPPage);
        this._setFPPage(this._pages.length);
      } else {
        this._lastFPPage = this.number;
      }
  
      this._currentOperation = null;
  
    };
  
  
    FPPage.prototype.next = function() {
  
      if (!this.hasNext()) {
  
        // nothing to set.
        return $q.when();
  
      } else if (this._pages.length > this.number) {
  
        // we already have the page, just copy its contents into this.items
        this._setFPPage(this.number+1);
        return $q.when();
  
      } else if (this._currentOperation) {
        return this._currentOperation;
      } else if (this._pages.length === 0) {
  
        this._currentOperation = this._pagingFn(this.root, null)
        .then(this._handleSnap.bind(this));
  
        return this._currentOperation;
  
      } else if (this._pages[this._pages.length-1].length > 0) {
  
        var lastFPPage = this._pages[this._pages.length-1];
  
        this._currentOperation = this._pagingFn(this.root, lastFPPage[lastFPPage.length-1])
        .then(this._handleSnap.bind(this));
  
        return this._currentOperation;
  
      } else {
        return $q.when();
      }
  
    };
  
  
    FPPage.prototype.hasNext = function() {
      return this.hasOwnProperty('root') && this._lastFPPage !== this.number;
    };
  
  
    FPPage.prototype.hasPrevious = function() {
      return this.hasOwnProperty('root') && this.number > 1;
    };
  
  
    FPPage.prototype.previous = function() {
  
      if (this.hasPrevious()) {
        this._setFPPage(this.number-1);
      }
  
      return $q.when();
  
    };
  
  
    FPPage.prototype.reset = function() {
      return this.connect(this.root);
    };
  
  
    FPPage.prototype._setFPPage = function(pageNumber) {
  
      this.items.length = 0;
      this.keys.length = 0;
      this.values.length = 0;
      this.priorities.length = 0;
  
      this._pages[pageNumber-1].forEach(function(item) {
  
        this.items.push(item);
        this.keys.push(item.key());
        this.values.push(item.val());
        this.priorities.push(item.getPriority());
  
      }, this);
  
      this.number = pageNumber;
  
    };
  
  
    return FPPage;
  
  }]);
  
  
  angular.module('flashpoint')
  .factory('Firebase', function() {
  
  /**
   * @ngdoc service
   * @name Firebase
   * @description The Firebase class.
   *
   * The Firebase library exposes this on window by default, but using Angular DI
   * allows it to be mocked or modified if you wish.
   *
   * NB: You should not use this service yourself! Instead, use the firebase
   * directive and write your own directives to require it, then access its
   * `root` Firebase reference.
   *
   * @see {@link FirebaseCtl#cleanup}
   */
  
    return Firebase;
  
  })
  .factory('ServerValue', ["Firebase", function(Firebase) {
  
    /**
     * @ngdoc service
     * @name ServerValue
     * @description The object ordinarily discovered on `Firebase.ServerValue`.
     *
     * Available for convenience.
     * @see {@link Firebase}
     */
  
    return Firebase.ServerValue;
  
  }])
  .factory('Fireproof', ["$rootScope", "$q", function($rootScope, $q) {
  
    /**
     * @ngdoc service
     * @name Fireproof
     * @description The Fireproof class, properly configured for use in Angular.
     *
     * "Properly configured" means that $rootScope.$evalAsync is used for nextTick and
     * Angular's $q is used for promises).
     *
     * NB: You should not use this service yourself! Instead, use the firebase
     * directive and write your own directives to require it, then access its
     * `root` Firebase reference.
     */
  
    Fireproof.setNextTick(function(fn) {
      $rootScope.$evalAsync(fn);
    });
    Fireproof.bless($q);
  
    return Fireproof;
  
  }]);
  
  
  angular.module('flashpoint')
  .factory('fpValidatePath', function() {
  
    function fpValidatePath(pathParts) {
  
      // check the arguments
      var path = pathParts.join('/');
  
      if (pathParts.length === 0 || path === '' ||
        pathParts.indexOf(null) !== -1 || pathParts.indexOf(undefined) !== -1) {
  
        // if any one of them is null/undefined, this is not a valid path
        return null;
  
      } else {
        return path;
      }
  
    }
  
    return fpValidatePath;
  
  });
  
  
  function FirebaseCtl(
    $scope,
    $q,
    Firebase,
    Fireproof,
    fpValidatePath,
    FPListenerSet) {
  
    /**
     * @ngdoc type
     * @name FirebaseCtl
     * @module flashpoint
     * @description The core controller responsible for binding
     * Firebase data into Angular.
     *
     * @property {Firebase} root The root of the instantiated Firebase store.
     *
     * @property {Boolean} connected The state of the network connection to Firebase.
     * This will be:
     * - `true`, if there is a good network connection to Firebase
     * - `false`, if the connection to Firebase is interrupted or not available
     * - `undefined` if the connection state is not known
     *
     * @property {Object} auth The authentication data from Firebase. This will be:
     * - `null`, if the user is not authenticated
     * - `undefined`, if the authentication state is not yet known
     * - an `Object`, containing information about the currently-authenticated user
     *
     * @property {Error} authError The error reported by the most recent attempt to
     * authenticate to Firebase, or `null` otherwise.
     *
     * @property {Error} accountError The error reported by the most recent attempt
     * to perform an account-related action on Firebase, or `null` otherwise.
     *
     * @property {Boolean} accountChanging True if an account-changing action
     * (password reset, user delete, etc.) is in progress, false otherwise.
     *
     * @property {Boolean} authenticating True if an authentication attempt is
     * in progress, false otherwise.
     */
  
    var self = this;
  
    var _attachListeners = [],
      _detachListeners = [];
  
    self.auth = null;
    self.authError = null;
    self.accountError = null;
    self.authenticating = false;
    self.accountChanging = false;
  
    function authHandler(authData) {
  
      if (self.listenerSet) {
        self.listenerSet.clear();
      }
      self.auth = authData;
  
      $scope.$evalAsync();
  
    }
  
  
    function connectedListener(snap) {
  
      self.connected = snap.val();
      $scope.$evalAsync();
  
    }
  
  
    function authPassHandler(auth) {
  
      self.authenticating = false;
      self.authError = null;
      return auth;
  
    }
  
  
    function authErrorHandler(err) {
  
      self.authenticating = true;
      self.authError = err;
      return $q.reject(err);
  
    }
  
  
    function accountPassHandler() {
  
      self.accountChanging = false;
      self.accountError = null;
  
    }
  
  
    function accountErrorHandler(err) {
  
      self.accountChanging = false;
      self.accountError = err;
      return $q.reject(err);
  
    }
  
  
    /**
     * @ngdoc method
     * @name FirebaseCtl#detachFirebase
     * @description Removes and detaches all connections to Firebase used by
     * this controller.
     */
    self.detachFirebase = function() {
  
      // detach all watchers
      if (self.listenerSet) {
  
        self.listenerSet.clear();
        delete self.listenerSet;
  
      }
  
      delete self.connected;
  
      self.auth = null;
      self.authError = null;
      self.accountError = null;
      self.authenticating = false;
      self.accountChanging = false;
  
      if (self.root) {
  
        // detach any remaining listeners here.
        self.root.offAuth(authHandler);
        self.root.child('.info/connected').off('value', connectedListener);
        self.root.off();
  
        _detachListeners.forEach(function(listener) {
          listener(self.root);
        });
  
        // remove the actual root object itself, as it's now invalid.
        delete self.root;
  
      }
  
      $scope.$evalAsync();
  
    };
  
  
    self.onDetach = function(fn) {
  
      _detachListeners.push(fn);
  
      if (!self.root) {
        fn();
      }
  
      return fn;
  
    };
  
  
    self.offDetach = function(fn) {
      _detachListeners.splice(_detachListeners.indexOf(fn), 1);
    };
  
    /**
     * @ngdoc method
     * @name FirebaseCtl#attachFirebase
     * @description Connects to the specified Firebase.
     * @param {string} url The full URL of the Firebase to connect to.
     */
    self.attachFirebase = function(url) {
  
      // if we already have a root, make sure to clean it up first
      if (self.root) {
        self.detachFirebase();
      }
  
      self.root = new Fireproof(new Firebase(url));
  
      self.listenerSet = new FPListenerSet(self.root, $scope);
      self.root.onAuth(authHandler);
  
      // maintain knowledge of connection status
      // we assume, optimistically, that we're connected initially
      self.connected = true;
      self.root.child('.info/connected')
      .on('value', connectedListener);
  
      _attachListeners.forEach(function(listener) {
        listener(self.root);
      });
  
    };
  
    self.onAttach = function(fn) {
  
      _attachListeners.push(fn);
  
      if (self.root) {
        fn(self.root);
      }
  
      return fn;
  
    };
  
  
    self.offAttach = function(fn) {
      _attachListeners.splice(_attachListeners.indexOf(fn), 1);
    };
  
  
    /**
     * @ngdoc method
     * @name FirebaseCtl#goOffline
     * @description Disables the connection to the remote Firebase server. NOTE:
     * this method affects _all_ FirebaseCtl instances on the page.
     * @see Firebase.goOffline
     */
    self.goOffline = function() {
      Firebase.goOffline();
    };
  
  
    /**
     * @ngdoc method
     * @name FirebaseCtl#goOnline
     * @description Enables the connection to the remote Firebase server. NOTE:
     * this method affects _all_ FirebaseCtl instances on the page.
     * @see Firebase.goOnline
     */
    self.goOnline = function() {
      Firebase.goOnline();
    };
  
  
    /**
     * @ngdoc method
     * @name FirebaseCtl#unauth
     * @description Unauthenticates (i.e., logs out) the Firebase connection.
     * @see Fireproof#unauth
     */
    self.unauth = function() {
  
      self.authError = null;
      self.accountError = null;
  
      self.root.unauth();
  
    };
  
  
    /**
     * @ngdoc method
     * @name FirebaseCtl#authWithCustomToken
     * @description Authenticates using a custom token or Firebase secret.
     * @param {String} token The token to authenticate with.
     * @returns {Promise} that resolves on success and rejects on error.
     * @see Fireproof#authWithCustomToken
     */
    self.authWithCustomToken = function(token) {
  
      self.authenticating = true;
  
      return self.root.authWithCustomToken(token)
      .then(authPassHandler, authErrorHandler);
  
    };
  
  
    /**
     * @ngdoc method
     * @name FirebaseCtl#authAnonymously
     * @description Authenticates using a new, temporary guest account.
     * @param {Object} options
     * @returns {Promise} that resolves on success and rejects on error.
     * @see Fireproof#authAnonymously
     */
    self.authAnonymously = function(options) {
  
      self.authenticating = true;
  
      return self.root.authAnonymously(null, options)
      .then(authPassHandler, authErrorHandler);
  
    };
  
  
    /**
     * @ngdoc method
     * @name FirebaseCtl#authWithPassword
     * @description Authenticates using an email / password combination.
     * @param {String} email
     * @param {String} password
     * @returns {Promise} that resolves on success and rejects on error.
     * @see Fireproof#authWithPassword
     */
    self.authWithPassword = function(email, password) {
  
      self.authenticating = true;
  
      return self.root.authWithPassword({ email: email, password: password })
      .then(authPassHandler, authErrorHandler);
  
    };
  
  
    /**
     * @ngdoc method
     * @name FirebaseCtl#authWithOAuthPopup
     * @description Authenticates using a popup-based OAuth flow.
     * @param {String} provider
     * @param {Object} options
     * @returns {Promise} that resolves on success and rejects on error.
     * @see Fireproof#authWithOAuthPopup
     */
    self.authWithOAuthPopup = function(provider, options) {
  
      self.authenticating = true;
  
      return self.root.authWithOAuthPopup(provider, null, options)
      .then(authPassHandler, authErrorHandler);
  
    };
  
  
    /**
     * @ngdoc method
     * @name FirebaseCtl#authWithOAuthToken
     * @description Authenticates using OAuth access tokens or credentials.
     * @param {String} provider
     * @param {Object} credentials
     * @param {Object} options
     * @returns {Promise} that resolves on success and rejects on error.
     * @see Fireproof#authWithOAuthToken
     */
    self.authWithOAuthToken = function(provider, credentials, options) {
  
      self.authenticating = true;
  
      return self.root.authWithOAuthToken(provider, credentials, null, options)
      .then(authPassHandler, authErrorHandler);
  
    };
  
  
    /**
     * @ngdoc method
     * @name FirebaseCtl#createUser
     * @description Creates a new user account using