Parcourir la source

things are running again

Adam Shaw il y a 8 ans
Parent
commit
8785c65eef
46 fichiers modifiés avec 2631 ajouts et 1855 suppressions
  1. 24 13
      src.json
  2. 18 15
      src/Calendar.business.js
  3. 29 81
      src/Calendar.constraints.js
  4. 210 0
      src/Calendar.events-api.js
  5. 46 1
      src/Calendar.js
  6. 1 1
      src/Calendar.options.js
  7. 0 755
      src/EventManager.js
  8. 10 10
      src/agenda/AgendaView.js
  9. 2 2
      src/basic/BasicView.js
  10. 5 5
      src/common/ChronoComponent.js
  11. 43 43
      src/common/Grid.events.js
  12. 34 38
      src/common/View.js
  13. 231 0
      src/gcal/GcalEventSource.js
  14. 0 180
      src/gcal/gcal.js
  15. 26 0
      src/gcal/intro.js
  16. 2 0
      src/gcal/outro.js
  17. 2 0
      src/models/ComponentFootprint.js
  18. 0 150
      src/models/EventDateMutation.js
  19. 0 155
      src/models/EventDefinition.js
  20. 0 125
      src/models/EventDefinitionCollection.js
  21. 0 29
      src/models/EventInstance.js
  22. 0 125
      src/models/EventInstanceGroup.js
  23. 355 0
      src/models/EventManager.js
  24. 0 76
      src/models/EventMutation.js
  25. 355 0
      src/models/EventPeriod.js
  26. 39 0
      src/models/UnzonedRange.js
  27. 80 0
      src/models/event-source/ArrayEventSource.js
  28. 123 0
      src/models/event-source/EventSource.js
  29. 29 0
      src/models/event-source/EventSourceParser.js
  30. 56 0
      src/models/event-source/FuncEventSource.js
  31. 139 0
      src/models/event-source/JsonFeedEventSource.js
  32. 25 13
      src/models/event/EventDateProfile.js
  33. 187 0
      src/models/event/EventDef.js
  34. 157 0
      src/models/event/EventDefDateMutation.js
  35. 108 0
      src/models/event/EventDefMutation.js
  36. 16 0
      src/models/event/EventDefParser.js
  37. 1 0
      src/models/event/EventFootprint.js
  38. 46 0
      src/models/event/EventInstance.js
  39. 26 0
      src/models/event/EventInstanceGroup.js
  40. 1 0
      src/models/event/EventRange.js
  41. 80 0
      src/models/event/EventRangeGroup.js
  42. 12 0
      src/models/event/EventStartEndMixin.js
  43. 19 14
      src/models/event/RecurringEventDef.js
  44. 26 14
      src/models/event/SingleEventDef.js
  45. 64 6
      src/util.js
  46. 4 4
      tasks/lint.js

+ 24 - 13
src.json

@@ -42,23 +42,32 @@
     "Calendar.toolbar.js",
     "Calendar.business.js",
     "Calendar.constraints.js",
+    "Calendar.events-api.js",
     "defaults.js",
     "locale.js",
     "Header.js",
     "models/UnzonedRange.js",
     "models/ComponentFootprint.js",
-    "models/EventFootprint.js",
-    "models/EventRange.js",
-    "models/EventDateProfile.js",
-    "models/EventDateMutation.js",
-    "models/EventMutation.js",
-    "models/EventInstance.js",
-    "models/EventDefinition.js",
-    "models/SingleEventDefinition.js",
-    "models/RecurringEventDefinition.js",
-    "models/EventDefinitionCollection.js",
-    "models/EventInstanceGroup.js",
-    "EventManager.js",
+    "models/EventManager.js",
+    "models/EventPeriod.js",
+    "models/event/EventDefParser.js",
+    "models/event/EventDef.js",
+    "models/event/SingleEventDef.js",
+    "models/event/RecurringEventDef.js",
+    "models/event/EventInstance.js",
+    "models/event/EventInstanceGroup.js",
+    "models/event/EventStartEndMixin.js",
+    "models/event/EventDateProfile.js",
+    "models/event/EventRangeGroup.js",
+    "models/event/EventRange.js",
+    "models/event/EventFootprint.js",
+    "models/event/EventDefMutation.js",
+    "models/event/EventDefDateMutation.js",
+    "models/event-source/EventSource.js",
+    "models/event-source/EventSourceParser.js",
+    "models/event-source/ArrayEventSource.js",
+    "models/event-source/FuncEventSource.js",
+    "models/event-source/JsonFeedEventSource.js",
     "basic/BasicView.js",
     "basic/MonthView.js",
     "basic/config.js",
@@ -79,6 +88,8 @@
     "common/print.css"
   ],
   "gcal.js": [
-    "gcal/gcal.js"
+    "gcal/intro.js",
+    "gcal/GcalEventSource.js",
+    "gcal/outro.js"
   ]
 }

+ 18 - 15
src/Calendar.business.js

@@ -1,6 +1,5 @@
 
 var BUSINESS_HOUR_EVENT_DEFAULTS = {
-	id: '_fcBusinessHours', // will relate events from different calls to expandEvent
 	start: '09:00',
 	end: '17:00',
 	dow: [ 1, 2, 3, 4, 5 ], // monday - friday
@@ -11,18 +10,22 @@ var BUSINESS_HOUR_EVENT_DEFAULTS = {
 
 // Return events objects for business hours within the current view.
 // Abuse of our event system :(
-Calendar.prototype.buildCurrentBusinessFootprints = function(wholeDay) {
-	var activeRange = this.getView().activeRange;
-	var eventInstances = this.buildBusinessInstances(
-		wholeDay,
-		this.opt('businessHours'),
-		activeRange.start,
-		activeRange.end
-	);
-
-	var eventRanges = this.eventInstancesToEventRanges(eventInstances);
-
-	return this.eventRangesToEventFootprints(eventRanges);
+Calendar.prototype.buildCurrentBusinessRanges = function(wholeDay) {
+	var eventPeriod = this.eventManager.currentPeriod;
+
+	if (eventPeriod) {
+		return new EventInstanceGroup(
+			this.buildBusinessInstances(
+				wholeDay,
+				this.opt('businessHours'),
+				eventPeriod.start,
+				eventPeriod.end
+			)
+		).buildRanges();
+	}
+	else {
+		return [];
+	}
 };
 
 
@@ -64,9 +67,9 @@ Calendar.prototype._buildBusinessInstances = function(wholeDay, rawDefs, ignoreN
 			fullRawDef.end = null;
 		}
 
-		eventDef = RecurringEventDefinition.parse(
+		eventDef = RecurringEventDef.parse(
 			fullRawDef,
-			{}, // dummy source
+			new EventSource(this), // dummy source
 			this // calendar
 		);
 

+ 29 - 81
src/Calendar.constraints.js

@@ -1,18 +1,16 @@
 
 Calendar.prototype.isEventInstanceGroupAllowed = function(eventInstanceGroup) {
-	var eventDef = eventInstanceGroup.getEventDef();
-	var eventRanges = eventInstanceGroup.buildEventRanges(null, this); // TODO: fix signature
-	var eventFootprints = this.eventRangesToEventFootprints(eventRanges);
+	var eventRangeGroup = eventInstanceGroup.buildRangeGroup();
+	var eventDef = eventRangeGroup.getEventDef();
+	var eventFootprints = this.eventRangesToEventFootprints(eventRangeGroup.eventRanges);
 	var i;
 
-	var constraintVal = eventDef.getConstraint(this);
-	var overlapVal = eventDef.getOverlap(this);
-
-	var peerEventDefs = this.getPeerEventDefs(eventDef);
-	var peerEventInstances = this.eventDefsToInstances(peerEventDefs);
-	var peerEventRanges = this.eventInstancesToEventRanges(peerEventInstances); // TODO: loop through pre-cached ranges instead?
+	var peerEventRanges = this.eventManager.getEventRangesWithoutId(eventDef.id);
 	var peerEventFootprints = this.eventRangesToEventFootprints(peerEventRanges);
 
+	var constraintVal = eventDef.getConstraint();
+	var overlapVal = eventDef.getOverlap();
+
 	for (i = 0; i < eventFootprints.length; i++) {
 		if (
 			!this.isFootprintAllowed(
@@ -32,9 +30,7 @@ Calendar.prototype.isEventInstanceGroupAllowed = function(eventInstanceGroup) {
 
 
 Calendar.prototype.isSelectionFootprintAllowed = function(componentFootprint) {
-	var peerEventDefs = this.eventDefCollection.eventDefs; // all
-	var peerEventInstances = this.eventDefsToInstances(peerEventDefs);
-	var peerEventRanges = this.eventInstancesToEventRanges(peerEventInstances); // TODO: loop through pre-cached ranges instead?
+	var peerEventRanges = this.eventManager.getEventRanges();
 	var peerEventFootprints = this.eventRangesToEventFootprints(peerEventRanges);
 
 	var selectAllowFunc;
@@ -120,38 +116,21 @@ Calendar.prototype.isFootprintWithinConstraints = function(componentFootprint, c
 
 
 Calendar.prototype.constraintValToFootprints = function(constraintVal, isAllDay) {
-	var eventDefs;
-	var eventDef;
-	var eventInstances;
-	var eventRanges;
-	var eventFootprints;
+	var eventRanges = [];
 
 	if (constraintVal === 'businessHours') {
-
-		eventFootprints = this.buildCurrentBusinessFootprints(isAllDay);
-
-		return eventFootprintsToComponentFootprints(eventFootprints);
+		eventRanges = this.buildCurrentBusinessRanges(isAllDay);
 	}
 	else if (typeof constraintVal === 'object') {
-
-		eventDef = parseEventInput(constraintVal, this);
-		eventInstances = this.eventDefToInstances(eventDef);
-		eventRanges = this.eventInstancesToEventRanges(eventInstances);
-		eventFootprints = this.eventRangesToEventFootprints(eventRanges);
-
-		return eventFootprintsToComponentFootprints(eventFootprints);
+		eventRanges = this.parseEventDefToEventRanges(constraintVal);
 	}
 	else if (constraintVal != null) { // an ID
-
-		eventDefs = this.eventDefCollection.getById(constraintVal);
-		eventInstances = this.eventDefsToInstances(eventDefs);
-		eventRanges = this.eventInstancesToEventRanges(eventInstances);
-		eventFootprints = this.eventRangesToEventFootprints(eventRanges);
-
-		return eventFootprintsToComponentFootprints(eventFootprints);
+		eventRanges = this.eventManager.getEventRangesWithId(constraintVal);
 	}
 
-	return [];
+	return eventFootprintsToComponentFootprints(
+		this.eventRangesToEventFootprints(eventRanges)
+	);
 };
 
 
@@ -159,23 +138,6 @@ Calendar.prototype.constraintValToFootprints = function(constraintVal, isAllDay)
 // ------------------------------------------------------------------------------------------------
 
 
-Calendar.prototype.getPeerEventDefs = function(subjectEventDef) {
-	var eventDefs = this.eventDefCollection.eventDefs;
-	var i, eventDef;
-	var unrelated = [];
-
-	for (i = 0; i < eventDefs.length; i++) {
-		eventDef = eventDefs[i];
-
-		if (eventDef.id !== subjectEventDef.id) {
-			unrelated.push(eventDef);
-		}
-	}
-
-	return unrelated;
-};
-
-
 Calendar.prototype.collectOverlapEventFootprints = function(peerEventFootprints, targetFootprint) {
 	var overlapEventFootprints = [];
 	var i;
@@ -222,7 +184,7 @@ function isOverlapEventInstancesAllowed(overlapEventFootprints, subjectEventInst
 
 	for (i = 0; i < overlapEventFootprints.length; i++) {
 		overlapEventInstance = overlapEventFootprints[i].eventInstance;
-		overlapEventDef = overlapEventInstance.eventDefinition;
+		overlapEventDef = overlapEventInstance.def;
 
 		// don't need to pass in calendar, because don't want to consider global eventOverlap property,
 		// because we already considered that earlier in the process.
@@ -255,35 +217,21 @@ function isOverlapEventInstancesAllowed(overlapEventFootprints, subjectEventInst
 // this more DRY.
 
 
-Calendar.prototype.eventDefsToInstances = function(eventDefs) {
-	var eventInstances = [];
-	var i;
+Calendar.prototype.parseEventDefToEventRanges = function(eventInput) {
+	var eventPeriod = this.eventManager.currentPeriod;
+	var eventDef = EventDefParser.parse(eventInput, this.eventManager.stickySource);
+	var instanceGroup;
 
-	for (i = 0; i < eventDefs.length; i++) {
-		eventInstances.push.apply(eventInstances, // append
-			this.eventDefToInstances(eventDefs[i])
+	if (eventPeriod && eventDef) {
+		instanceGroup = new EventInstanceGroup(
+			eventDef.buildInstances(eventPeriod.start, eventPeriod.end)
 		);
-	}
-
-	return eventInstances;
-};
-
-
-Calendar.prototype.eventDefToInstances = function(eventDef) {
-	var activeRange = this.getView().activeRange; // TODO: use EventManager's range?
 
-	return eventDef.buildInstances(activeRange.start, activeRange.end);
-};
-
-
-Calendar.prototype.eventInstancesToEventRanges = function(eventInstances) {
-	var group = new EventInstanceGroup(eventInstances);
-	var activeRange = this.getView().activeRange; // TODO: use EventManager's range?
-
-	return group.buildEventRanges(
-		new UnzonedRange(activeRange.start, activeRange.end),
-		this // calendar
-	);
+		return instanceGroup.buildRanges();
+	}
+	else {
+		return [];
+	}
 };
 
 
@@ -307,7 +255,7 @@ Calendar.prototype.eventRangeToEventFootprints = function(eventRange) {
 			eventRange.eventInstance,
 			new ComponentFootprint(
 				eventRange.dateRange,
-				eventRange.eventInstance.eventDateProfile.isAllDay()
+				eventRange.eventInstance.dateProfile.isAllDay()
 			)
 		)
 	];

+ 210 - 0
src/Calendar.events-api.js

@@ -0,0 +1,210 @@
+
+Calendar.mixin({
+
+	// Sources
+	// ------------------------------------------------------------------------------------
+
+
+	getEventSources: function() {
+		return this.eventManager.otherSources.slice(); // clone
+	},
+
+
+	getEventSourceById: function(id) {
+		return this.eventManager.getSourceById(
+			EventSource.normalizeId(id)
+		);
+	},
+
+
+	addEventSource: function(sourceInput) {
+		var source = EventSourceParser.parse(sourceInput, this);
+
+		if (source) {
+			this.eventManager.addSource(source);
+		}
+	},
+
+
+	removeEventSources: function(sourceQuery) {
+		if (sourceQuery == null) {
+			this.eventManager.removeAllSources();
+		}
+		else {
+			this.removeEventSource(sourceQuery);
+		}
+	},
+
+
+	removeEventSource: function(sourceQuery) { // can do multiple
+		var eventManager = this.eventManager;
+		var sources = eventManager.querySources(sourceQuery);
+		var i;
+
+		eventManager.freeze();
+
+		for (i = 0; i < sources.length; i++) {
+			eventManager.removeSource(sources[i]);
+		}
+
+		eventManager.thaw();
+	},
+
+
+	refetchEventSources: function(sourceQuery) {
+		var eventManager = this.eventManager;
+		var sources = eventManager.querySources(sourceQuery);
+		var i;
+
+		eventManager.freeze();
+
+		for (i = 0; i < sources.length; i++) {
+			eventManager.refetchSource(sources[i]);
+		}
+
+		eventManager.unfreeze();
+	},
+
+
+	// Events
+	// ------------------------------------------------------------------------------------
+
+
+	refetchEvents: function() {
+		this.eventManager.refetchAllSources();
+	},
+
+
+	// CHANGELOG: note how it does not return objects anymore
+	renderEvents: function(eventInputs, isSticky) {
+		this.eventManager.freeze();
+
+		for (var i = 0; i < eventInputs.length; i++) {
+			this.renderEvent(eventInputs[i], isSticky);
+		}
+
+		this.eventManager.unfreeze();
+	},
+
+
+	// CHANGELOG: note how it does not return objects anymore
+	renderEvent: function(eventInput, isSticky) {
+		var eventManager = this.eventManager;
+		var eventDef = EventDefParser.parse(
+			eventInput,
+			eventInput.source || eventManager.stickySource
+		);
+
+		if (eventDef) {
+			eventManager.addEventDef(eventDef, isSticky);
+		}
+	},
+
+
+	// TODO: improve. can do shortcut if given straight IDs
+	removeEvents: function(legacyQuery) {
+		var eventManager = this.eventManager;
+		var eventIds = this.queryEventIdsViaLegacy(legacyQuery);
+		var i;
+
+		eventManager.freeze();
+
+		for (i = 0; i < eventIds.length; i++) {
+			eventManager.removeEventsById(eventIds[i]);
+		}
+
+		eventManager.unfreeze();
+	},
+
+
+	// is a utility. not meant to be public
+	queryEventIdsViaLegacy: function(legacyQuery) {
+		var eventInstances = this.eventManager.getEventInstances();
+		var matchFunc = buildEventInstanceMatcher(legacyQuery);
+		var i;
+		var eventIdMap = {}; // to remove
+
+		for (i = 0; i < eventInstances.length; i++) {
+			if (matchFunc(eventInstances[i])) {
+				eventIdMap[
+					eventInstances[i].def.id
+				] = true;
+			}
+		}
+
+		return Object.keys(eventIdMap);
+	},
+
+
+	clientEvents: function(legacyQuery) {
+		var eventInstances = this.eventManager.getEventInstances();
+		var matchFunc = buildEventInstanceMatcher(legacyQuery);
+		var i;
+		var legacyInstances = [];
+
+		for (i = 0; i < eventInstances.length; i++) {
+			if (matchFunc(eventInstances[i])) {
+				legacyInstances.push(eventInstances[i].toLegacy()); // TODO: optimimze re-legacyifying
+			}
+		}
+
+		return legacyInstances;
+	},
+
+
+	updateEvents: function(eventPropsArray) {
+		this.eventManager.freeze();
+
+		for (var i = 0; i < eventPropsArray.length; i++) {
+			this.updateEvent(eventPropsArray[i]);
+		}
+
+		this.eventManager.unfreeze();
+	},
+
+
+	updateEvent: function(eventProps) {
+		var eventDef = this.eventManager.getEventDefByInternalId(eventProps._id);
+		var eventInstance;
+		var eventDefMutation;
+
+		if (eventDef instanceof SingleEventDef) {
+			eventInstance = eventDef.buildInstances()[0];
+
+			eventDefMutation = EventDefMutation.createFromRawProps(
+				eventInstance,
+				eventProps, // raw props
+				null // largeUnit -- who uses it?
+			);
+
+			this.eventManager.mutateEventsWithId(eventDef.id, eventDefMutation); // will release
+		}
+	}
+
+});
+
+
+function buildEventInstanceMatcher(legacyQuery) {
+	if (legacyQuery == null) {
+		return function() {
+			return true;
+		};
+	}
+	else if ($.isFunction(legacyQuery)) {
+		return function(eventInstance) {
+			return legacyQuery(eventInstance.toLegacy());
+		};
+	}
+	else if (legacyQuery) { // an event ID
+		legacyQuery += ''; // normalize to string
+
+		return function(eventInstance) {
+			return eventInstance.def.id === legacyQuery;
+		};
+	}
+	else {
+		return function() {
+			return false;
+		};
+	}
+}

+ 46 - 1
src/Calendar.js

@@ -20,6 +20,7 @@ var Calendar = FC.Calendar = Class.extend(EmitterMixin, {
 		this.initOptionsInternals(overrides);
 		this.initMomentInternals(); // needs to happen after options hash initialized
 		this.initCurrentDate();
+		this.initEventManager();
 
 		EventManager.call(this); // needs options immediately
 		this.initialize();
@@ -272,8 +273,52 @@ var Calendar = FC.Calendar = Class.extend(EmitterMixin, {
 
 	rerenderEvents: function() { // API method. destroys old events if previously rendered.
 		if (this.elementVisible()) {
-			this.reportEventChange(); // will re-trasmit events to the view, causing a rerender
+			this.view.flash('displayingEvents');
 		}
+	},
+
+
+	initEventManager: function() {
+		var _this = this;
+		var eventManager = new EventManager();
+		var rawSources = this.opt('eventSources') || [];
+		var singleRawSource = this.opt('events');
+
+		this.eventManager = eventManager;
+
+		if (singleRawSource) {
+			rawSources.unshift(singleRawSource);
+		}
+
+		eventManager.on('release', function(eventRangeGroups) {
+			_this.trigger('eventsReset', eventRangeGroups);
+		});
+
+		eventManager.freeze();
+		rawSources.forEach(function(rawSource) {
+			var source = EventSourceParser.parse(rawSource, _this);
+
+			if (source) {
+				eventManager.addSource(source);
+			}
+		});
+		eventManager.thaw();
+	},
+
+
+	requestEventRangeGroups: function(start, end) {
+		return this.eventManager.requestEventRangeGroups(
+			start,
+			end,
+			this.opt('timezone'),
+			this.opt('lazyFetching')
+		);
+	},
+
+
+	// hook for external libs to manipulate event properties upon creation.
+	// should manipulate the event in-place.
+	normalizeEvent: function(event) {
 	}
 
 });

+ 1 - 1
src/Calendar.options.js

@@ -73,7 +73,7 @@ Calendar.mixin({
 				return;
 			}
 			else if (optionName === 'timezone') {
-				this.rezoneArrayEventSources();
+				this.eventManager.rezoneEvents();
 				this.refetchEvents();
 				return;
 			}

+ 0 - 755
src/EventManager.js

@@ -1,755 +0,0 @@
-
-FC.sourceNormalizers = [];
-FC.sourceFetchers = [];
-
-var ajaxDefaults = {
-	dataType: 'json',
-	cache: false
-};
-
-var eventGUID = 1;
-
-
-function EventManager() { // assumed to be a calendar
-	var t = this;
-
-
-	// exports
-	t.requestEvents = requestEvents;
-	t.reportEventChange = reportEventChange;
-	t.isFetchNeeded = isFetchNeeded;
-	t.fetchEvents = fetchEvents;
-	t.fetchEventSources = fetchEventSources;
-	t.refetchEvents = refetchEvents;
-	t.refetchEventSources = refetchEventSources;
-	t.getEventSources = getEventSources;
-	t.getEventSourceById = getEventSourceById;
-	t.addEventSource = addEventSource;
-	t.removeEventSource = removeEventSource;
-	t.removeEventSources = removeEventSources;
-	t.updateEvent = updateEvent;
-	t.updateEvents = updateEvents;
-	t.renderEvent = renderEvent;
-	t.renderEvents = renderEvents;
-	t.removeEvents = removeEvents;
-	t.clientEvents = clientEvents;
-
-
-	// locals
-	var stickySource = { events: [] };
-	var sources = [ stickySource ];
-	var rangeStart, rangeEnd;
-	var pendingSourceCnt = 0; // outstanding fetch requests, max one per source
-	var cache = []; // holds events that have already been expanded
-	var eventDefCollection = new EventDefinitionCollection(t);
-	t.eventDefCollection = eventDefCollection;
-
-	var currentRenderRanges;
-
-
-	$.each(
-		(t.opt('events') ? [ t.opt('events') ] : []).concat(t.opt('eventSources') || []),
-		function(i, sourceInput) {
-			var source = buildEventSource(sourceInput);
-			if (source) {
-				sources.push(source);
-			}
-		}
-	);
-
-
-
-	function requestEvents(start, end) {
-		if (!t.opt('lazyFetching') || isFetchNeeded(start, end)) {
-			return fetchEvents(start, end);
-		}
-		else {
-			currentRenderRanges = eventDefCollection.buildRenderRanges(rangeStart, rangeEnd, t);
-			return Promise.resolve(currentRenderRanges);
-		}
-	}
-
-
-	function reportEventChange() {
-		currentRenderRanges = eventDefCollection.buildRenderRanges(rangeStart, rangeEnd, t);
-		t.trigger('eventsReset', currentRenderRanges);
-	}
-
-
-	t.getEventCache = function() {
-		return cache;
-	};
-
-
-
-	/* Fetching
-	-----------------------------------------------------------------------------*/
-
-
-	// start and end are assumed to be unzoned
-	function isFetchNeeded(start, end) {
-		return !rangeStart || // nothing has been fetched yet?
-			start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range?
-	}
-
-
-	function fetchEvents(start, end) {
-		rangeStart = start;
-		rangeEnd = end;
-		return refetchEvents();
-	}
-
-
-	// poorly named. fetches all sources with current `rangeStart` and `rangeEnd`.
-	function refetchEvents() {
-		return fetchEventSources(sources, 'reset');
-	}
-
-
-	// poorly named. fetches a subset of event sources.
-	function refetchEventSources(matchInputs) {
-		return fetchEventSources(getEventSourcesByMatchArray(matchInputs));
-	}
-
-
-	// expects an array of event source objects (the originals, not copies)
-	// `specialFetchType` is an optimization parameter that affects purging of the event cache.
-	function fetchEventSources(specificSources, specialFetchType) {
-		var i, source;
-
-		if (specialFetchType === 'reset') {
-			cache = [];
-			eventDefCollection.clear();
-		}
-		else if (specialFetchType !== 'add') {
-			cache = excludeEventsBySources(cache, specificSources);
-			eventDefCollection.clearBySource(specificSources);
-		}
-
-		for (i = 0; i < specificSources.length; i++) {
-			source = specificSources[i];
-
-			// already-pending sources have already been accounted for in pendingSourceCnt
-			if (source._status !== 'pending') {
-				pendingSourceCnt++;
-			}
-
-			source._fetchId = (source._fetchId || 0) + 1;
-			source._status = 'pending';
-		}
-
-		for (i = 0; i < specificSources.length; i++) {
-			source = specificSources[i];
-			tryFetchEventSource(source, source._fetchId);
-		}
-
-		if (pendingSourceCnt) {
-			return Promise.construct(function(resolve) {
-				t.one('eventsReceived', resolve);
-			});
-		}
-		else { // executed all synchronously, or no sources at all
-			return Promise.resolve(eventDefCollection.buildRenderRanges(rangeStart, rangeEnd, t));
-		}
-	}
-
-
-	// fetches an event source and processes its result ONLY if it is still the current fetch.
-	// caller is responsible for incrementing pendingSourceCnt first.
-	function tryFetchEventSource(source, fetchId) {
-		_fetchEventSource(source, function(eventInputs) {
-			var isArraySource = $.isArray(source.events);
-			var i, eventInput;
-			var eventDef;
-
-			if (
-				// is this the source's most recent fetch?
-				// if not, rely on an upcoming fetch of this source to decrement pendingSourceCnt
-				fetchId === source._fetchId &&
-				// event source no longer valid?
-				source._status !== 'rejected'
-			) {
-				source._status = 'resolved';
-
-				if (eventInputs) {
-
-					for (i = 0; i < eventInputs.length; i++) {
-						eventInput = eventInputs[i];
-
-						if (isArraySource) { // array sources have already been convert to Event Objects
-							eventDef = eventInput;
-						}
-						else {
-							eventDef = parseEventInput(eventInput, source, t);
-						}
-
-						if (eventDef) { // not invalid
-							eventDefCollection.add(eventDef);
-						}
-					}
-				}
-
-				decrementPendingSourceCnt();
-			}
-		});
-	}
-
-
-	function rejectEventSource(source) {
-		var wasPending = source._status === 'pending';
-
-		source._status = 'rejected';
-
-		if (wasPending) {
-			decrementPendingSourceCnt();
-		}
-	}
-
-
-	function decrementPendingSourceCnt() {
-		pendingSourceCnt--;
-		if (!pendingSourceCnt) {
-			reportEventChange(); // populates currentRenderRanges
-			t.trigger('eventsReceived', currentRenderRanges);
-		}
-	}
-
-
-	function _fetchEventSource(source, callback) {
-		var i;
-		var fetchers = FC.sourceFetchers;
-		var res;
-
-		for (i=0; i<fetchers.length; i++) {
-			res = fetchers[i].call(
-				t, // this, the Calendar object
-				source,
-				rangeStart.clone(),
-				rangeEnd.clone(),
-				t.opt('timezone'),
-				callback
-			);
-
-			if (res === true) {
-				// the fetcher is in charge. made its own async request
-				return;
-			}
-			else if (typeof res == 'object') {
-				// the fetcher returned a new source. process it
-				_fetchEventSource(res, callback);
-				return;
-			}
-		}
-
-		var events = source.events;
-		if (events) {
-			if ($.isFunction(events)) {
-				t.pushLoading();
-				events.call(
-					t, // this, the Calendar object
-					rangeStart.clone(),
-					rangeEnd.clone(),
-					t.opt('timezone'),
-					function(events) {
-						callback(events);
-						t.popLoading();
-					}
-				);
-			}
-			else if ($.isArray(events)) {
-				callback(events);
-			}
-			else {
-				callback();
-			}
-		}else{
-			var url = source.url;
-			if (url) {
-				var success = source.success;
-				var error = source.error;
-				var complete = source.complete;
-
-				// retrieve any outbound GET/POST $.ajax data from the options
-				var customData;
-				if ($.isFunction(source.data)) {
-					// supplied as a function that returns a key/value object
-					customData = source.data();
-				}
-				else {
-					// supplied as a straight key/value object
-					customData = source.data;
-				}
-
-				// use a copy of the custom data so we can modify the parameters
-				// and not affect the passed-in object.
-				var data = $.extend({}, customData || {});
-
-				var startParam = firstDefined(source.startParam, t.opt('startParam'));
-				var endParam = firstDefined(source.endParam, t.opt('endParam'));
-				var timezoneParam = firstDefined(source.timezoneParam, t.opt('timezoneParam'));
-
-				if (startParam) {
-					data[startParam] = rangeStart.format();
-				}
-				if (endParam) {
-					data[endParam] = rangeEnd.format();
-				}
-				if (t.opt('timezone') && t.opt('timezone') != 'local') {
-					data[timezoneParam] = t.opt('timezone');
-				}
-
-				t.pushLoading();
-				$.ajax($.extend({}, ajaxDefaults, source, {
-					data: data,
-					success: function(events) {
-						events = events || [];
-						var res = applyAll(success, this, arguments);
-						if ($.isArray(res)) {
-							events = res;
-						}
-						callback(events);
-					},
-					error: function() {
-						applyAll(error, this, arguments);
-						callback();
-					},
-					complete: function() {
-						applyAll(complete, this, arguments);
-						t.popLoading();
-					}
-				}));
-			}else{
-				callback();
-			}
-		}
-	}
-
-
-
-	/* Sources
-	-----------------------------------------------------------------------------*/
-
-
-	function addEventSource(sourceInput) {
-		var source = buildEventSource(sourceInput);
-		if (source) {
-			sources.push(source);
-			fetchEventSources([ source ], 'add'); // will eventually call reportEventChange
-		}
-	}
-
-
-	function buildEventSource(sourceInput) { // will return undefined if invalid source
-		var normalizers = FC.sourceNormalizers;
-		var source;
-		var i;
-		var eventDef;
-
-		if ($.isFunction(sourceInput) || $.isArray(sourceInput)) {
-			source = { events: sourceInput };
-		}
-		else if (typeof sourceInput === 'string') {
-			source = { url: sourceInput };
-		}
-		else if (typeof sourceInput === 'object') {
-			source = $.extend({}, sourceInput); // shallow copy
-		}
-
-		if (source) {
-
-			// TODO: repeat code, same code for event classNames
-			if (source.className) {
-				if (typeof source.className === 'string') {
-					source.className = source.className.split(/\s+/);
-				}
-				// otherwise, assumed to be an array
-			}
-			else {
-				source.className = [];
-			}
-
-			// for array sources, we convert to standard Event Objects up front
-			if ($.isArray(source.events)) {
-				source.origArray = source.events; // for removeEventSource
-				source.events = [];
-
-				for (i = 0; i < source.origArray.length; i++) {
-					eventDef = parseEventInput(
-						source.origArray[i],
-						source,
-						t // calendar
-					);
-
-					if (eventDef) { // not invalid
-						source.events.push(eventDef);
-					}
-				}
-			}
-
-			for (i = 0; i < normalizers.length; i++) {
-				normalizers[i].call(t, source);
-			}
-
-			return source;
-		}
-	}
-
-
-	function removeEventSource(matchInput) {
-		removeSpecificEventSources(
-			getEventSourcesByMatch(matchInput)
-		);
-	}
-
-
-	// if called with no arguments, removes all.
-	function removeEventSources(matchInputs) {
-		if (matchInputs == null) {
-			removeSpecificEventSources(sources, true); // isAll=true
-		}
-		else {
-			removeSpecificEventSources(
-				getEventSourcesByMatchArray(matchInputs)
-			);
-		}
-	}
-
-
-	function removeSpecificEventSources(targetSources, isAll) {
-		var i;
-
-		// cancel pending requests
-		for (i = 0; i < targetSources.length; i++) {
-			rejectEventSource(targetSources[i]);
-		}
-
-		if (isAll) { // an optimization
-			sources = [];
-			cache = [];
-			eventDefCollection.clear();
-		}
-		else {
-			// remove from persisted source list
-			sources = $.grep(sources, function(source) {
-				for (i = 0; i < targetSources.length; i++) {
-					if (source === targetSources[i]) {
-						return false; // exclude
-					}
-				}
-				return true; // include
-			});
-
-			cache = excludeEventsBySources(cache, targetSources);
-			eventDefCollection.clearBySource(targetSources);
-		}
-
-		reportEventChange();
-	}
-
-
-	function getEventSources() {
-		return sources.slice(1); // returns a shallow copy of sources with stickySource removed
-	}
-
-
-	function getEventSourceById(id) {
-		return $.grep(sources, function(source) {
-			return source.id && source.id === id;
-		})[0];
-	}
-
-
-	// like getEventSourcesByMatch, but accepts multple match criteria (like multiple IDs)
-	function getEventSourcesByMatchArray(matchInputs) {
-
-		// coerce into an array
-		if (!matchInputs) {
-			matchInputs = [];
-		}
-		else if (!$.isArray(matchInputs)) {
-			matchInputs = [ matchInputs ];
-		}
-
-		var matchingSources = [];
-		var i;
-
-		// resolve raw inputs to real event source objects
-		for (i = 0; i < matchInputs.length; i++) {
-			matchingSources.push.apply( // append
-				matchingSources,
-				getEventSourcesByMatch(matchInputs[i])
-			);
-		}
-
-		return matchingSources;
-	}
-
-
-	// matchInput can either by a real event source object, an ID, or the function/URL for the source.
-	// returns an array of matching source objects.
-	function getEventSourcesByMatch(matchInput) {
-		var i, source;
-
-		// given an proper event source object
-		for (i = 0; i < sources.length; i++) {
-			source = sources[i];
-			if (source === matchInput) {
-				return [ source ];
-			}
-		}
-
-		// an ID match
-		source = getEventSourceById(matchInput);
-		if (source) {
-			return [ source ];
-		}
-
-		return $.grep(sources, function(source) {
-			return isSourcesEquivalent(matchInput, source);
-		});
-	}
-
-
-	function isSourcesEquivalent(source1, source2) {
-		return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2);
-	}
-
-
-	function getSourcePrimitive(source) {
-		return (
-			(typeof source === 'object') ? // a normalized event source?
-				(source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive
-				null
-		) ||
-		source; // the given argument *is* the primitive
-	}
-
-
-	// util
-	// returns a filtered array without events that are part of any of the given sources
-	function excludeEventsBySources(specificEvents, specificSources) {
-		return $.grep(specificEvents, function(event) {
-			for (var i = 0; i < specificSources.length; i++) {
-				if (event.source === specificSources[i]) {
-					return false; // exclude
-				}
-			}
-			return true; // keep
-		});
-	}
-
-
-
-	/* Manipulation
-	-----------------------------------------------------------------------------*/
-
-
-	// Only ever called from the externally-facing API
-	function updateEvent(eventProps) {
-		updateEvents([ eventProps ]);
-	}
-
-
-	// Only ever called from the externally-facing API
-	function updateEvents(eventPropsArray) {
-		var i, eventProps;
-		var eventDef;
-		var eventInstance;
-		var eventMutation;
-
-		for (i = 0; i < eventPropsArray.length; i++) {
-			eventProps = eventPropsArray[i];
-
-			eventDef = eventDefCollection.getById(eventProps._id);
-			eventInstance = eventDef.buildInstances()[0];
-			eventMutation = EventMutation.createFromRawProps(
-				eventInstance,
-				eventProps, // raw props
-				null, // largeUnit -- who uses it?
-				t // calendar
-			);
-
-			eventMutation.mutateSingleEventDefinition(eventDef, t); // calendar=t
-		}
-
-		reportEventChange(); // reports event modifications (so we can redraw)
-	}
-
-
-	// CHANGELOG: note how it does not return objects anymore
-	// called from public API only
-	function renderEvent(eventInput, isSticky) {
-		renderEvents([ eventInput ], isSticky);
-	}
-
-
-	// CHANGELOG: note how it does not return objects anymore
-	// called from public API only
-	function renderEvents(eventInputs, isSticky) {
-		var i;
-		var eventDef;
-		var successCnt = 0;
-
-		for (i = 0; i < eventInputs.length; i++) {
-
-			eventDef = parseEventInput(eventInputs[i], stickySource, t);
-
-			if (eventDef) { // not invalid
-				addEventDef(eventDef, isSticky);
-				successCnt++;
-			}
-		}
-
-		if (successCnt) { // any new events rendered?
-			reportEventChange();
-		}
-	}
-
-
-	t.addEventDefAndRender = function(eventDef, isSticky) {
-		addEventDef(eventDef, isSticky);
-		reportEventChange();
-	};
-
-
-	function addEventDef(eventDef, isSticky) {
-		eventDefCollection.add(eventDef);
-
-		if (isSticky) {
-			// will cause the addition to persist
-			stickySource.events.push(eventDef);
-		}
-	}
-
-
-	function removeEvents(filter) {
-		var eventID;
-		var i;
-
-		if (filter == null) { // null or undefined. remove all events
-			filter = function() { return true; }; // will always match
-		}
-		else if (!$.isFunction(filter)) { // an event ID
-			eventID = filter + '';
-			filter = function(event) {
-				return event._id == eventID;
-			};
-		}
-
-		// Purge event(s) from our local cache
-		cache = $.grep(cache, filter, true); // inverse=true
-		eventDefCollection.clearByFilter(filter);
-
-		// Remove events from array sources.
-		// This works because they have been converted to official Event Objects up front.
-		// (and as a result, event._id has been calculated).
-		for (i=0; i<sources.length; i++) {
-			if ($.isArray(sources[i].events)) {
-				sources[i].events = $.grep(sources[i].events, filter, true);
-			}
-		}
-
-		reportEventChange();
-	}
-
-
-	function clientEvents(filter) {
-		if ($.isFunction(filter)) {
-			return $.grep(cache, filter);
-		}
-		else if (filter != null) { // not null, not undefined. an event ID
-			filter += '';
-			return $.grep(cache, function(e) {
-				return e._id == filter;
-			});
-		}
-		return cache; // else, return all
-	}
-
-
-	// Makes sure all array event sources have their internal event objects
-	// converted over to the Calendar's current timezone.
-	// TODO: operate on EventDefs
-	t.rezoneArrayEventSources = function() {
-		var i;
-		var eventDefs;
-		var j;
-
-		for (i = 0; i < sources.length; i++) {
-			eventDefs = sources[i].eventDefs;
-
-			if ($.isArray(eventDefs)) {
-
-				for (j = 0; j < eventDefs.length; j++) {
-					rezoneEventDates(eventDefs[j]);
-				}
-			}
-		}
-	};
-
-	function rezoneEventDates(eventDef) {
-		if (eventDef instanceof SingleEventDefinition) {
-
-			eventDef.start = t.moment(eventDef.start);
-
-			if (eventDef.end) {
-				eventDef.end = t.moment(eventDef.end);
-			}
-		}
-	}
-
-}
-
-
-Calendar.prototype.mutateEventsWithId = function(id, eventMutation) {
-	var eventDefs = this.eventDefCollection.getById(id);
-	var i;
-	var undoFuncs = [];
-
-	for (i = 0; i < eventDefs.length; i++) {
-		if (eventDefs[i] instanceof SingleEventDefinition) {
-			undoFuncs.push(
-				eventMutation.mutateSingleEventDefinition(
-					eventDefs[i],
-					this // calendar
-				)
-			);
-		}
-	}
-
-	return function() {
-		for (var i = 0; i < undoFuncs.length; i++) {
-			undoFuncs[i]();
-		}
-	};
-};
-
-
-// hook for external libs to manipulate event properties upon creation.
-// should manipulate the event in-place.
-Calendar.prototype.normalizeEvent = function(event) {
-};
-
-
-Calendar.prototype.buildMutatedEventInstanceGroup = function(eventId, eventMutation) {
-	var viewRange = this.getView().activeRange;
-	var defs = this.eventDefCollection.getById(eventId);
-	var i;
-	var defCopy;
-	var allInstances = [];
-
-	for (i = 0; i < defs.length; i++) {
-		defCopy = defs[i].clone();
-
-		if (defCopy instanceof SingleEventDefinition) {
-
-			eventMutation.mutateSingleEventDefinition(defCopy, this); // calendar=this
-
-			allInstances.push.apply(allInstances, // append
-				defCopy.buildInstances(viewRange.start, viewRange.end)
-			);
-		}
-	}
-
-	return new EventInstanceGroup(allInstances);
-};

+ 10 - 10
src/agenda/AgendaView.js

@@ -297,27 +297,27 @@ var AgendaView = FC.AgendaView = View.extend({
 
 
 	// Renders events onto the view and populates the View's segment array
-	renderEventRanges: function(eventRanges) {
-		var dayEventRanges = [];
-		var timedEventRanges = [];
+	renderEventRangeGroups: function(eventRangeGroups) {
+		var dayEventRangeGroups = [];
+		var timedEventRangeGroups = [];
 		var daySegs = [];
 		var timedSegs;
 		var i;
 
 		// separate the events into all-day and timed
-		for (i = 0; i < eventRanges.length; i++) {
-			if (eventRanges[i].eventInstance.eventDateProfile.isAllDay()) {
-				dayEventRanges.push(eventRanges[i]);
+		for (i = 0; i < eventRangeGroups.length; i++) {
+			if (eventRangeGroups[i].getEventInstance().dateProfile.isAllDay()) {
+				dayEventRangeGroups.push(eventRangeGroups[i]);
 			}
 			else {
-				timedEventRanges.push(eventRanges[i]);
+				timedEventRangeGroups.push(eventRangeGroups[i]);
 			}
 		}
 
 		// render the events in the subcomponents
-		timedSegs = this.timeGrid.renderEventRanges(timedEventRanges);
+		timedSegs = this.timeGrid.renderEventRangeGroups(timedEventRangeGroups);
 		if (this.dayGrid) {
-			daySegs = this.dayGrid.renderEventRanges(dayEventRanges);
+			daySegs = this.dayGrid.renderEventRangeGroups(dayEventRangeGroups);
 		}
 
 		// the all-day area is flexible and might have a lot of events, so shift the height
@@ -333,7 +333,7 @@ var AgendaView = FC.AgendaView = View.extend({
 	// A returned value of `true` signals that a mock "helper" event has been rendered.
 	renderDrag: function(eventRanges, seg) {
 		var isAllDay = eventRanges.length &&
-			eventRanges[0].eventInstance.eventDateProfile.isAllDay();
+			eventRanges[0].eventInstance.dateProfile.isAllDay();
 
 		if (!isAllDay) {
 			return this.timeGrid.renderDrag(eventRanges, seg);

+ 2 - 2
src/basic/BasicView.js

@@ -253,8 +253,8 @@ var BasicView = FC.BasicView = View.extend({
 
 
 	// Renders the given events onto the view and populates the segments array
-	renderEventRanges: function(eventRanges) {
-		this.dayGrid.renderEventRanges(eventRanges);
+	renderEventRangeGroups: function(eventRangeGroups) {
+		this.dayGrid.renderEventRangeGroups(eventRangeGroups);
 
 		// must compensate for events that overflow the row
 		// TODO: how will ChronoComponent handle this?

+ 5 - 5
src/common/ChronoComponent.js

@@ -166,17 +166,17 @@ var ChronoComponent = Model.extend({
 
 
 	// Renders the events onto the view.
-	renderEventRanges: function(eventRanges) {
-		this.callChildren('renderEventRanges', eventRanges);
+	renderEventRangeGroups: function(eventRangeGroups) {
+		this.callChildren('renderEventRangeGroups', eventRangeGroups);
 	},
 
 
 	// Removes event elements from the view.
-	unrenderEventRanges: function() {
-		this.callChildren('unrenderEventRanges');
+	unrenderEventRangeGroups: function() {
+		this.callChildren('unrenderEventRangeGroups');
 
 		// we DON'T need to call updateHeight() because
-		// a renderEventRanges() call always happens after this, which will eventually call updateHeight()
+		// a renderEventRangeGroups() call always happens after this, which will eventually call updateHeight()
 	},
 
 

+ 43 - 43
src/common/Grid.events.js

@@ -15,22 +15,22 @@ Grid.mixin({
 	segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs`
 
 
-	renderEventRanges: function(eventRanges) {
-		var i, eventRange;
+	renderEventRangeGroups: function(eventRangeGroups) {
+		var unzonedRange = new UnzonedRange(this.start, this.end);
+		var i, eventRangeGroup;
+		var eventRenderRanges;
 		var eventFootprints;
 		var eventSegs;
-		var eventRendering;
 		var bgSegs = [];
 		var fgSegs = [];
 
-		for (i = 0; i < eventRanges.length; i++) {
-			eventRange = eventRanges[i];
-			eventFootprints = this.eventRangeToEventFootprints(eventRange);
+		for (i = 0; i < eventRangeGroups.length; i++) {
+			eventRangeGroup = eventRangeGroups[i];
+			eventRenderRanges = eventRangeGroup.sliceRenderRanges(unzonedRange);
+			eventFootprints = this.eventRangesToEventFootprints(eventRenderRanges);
 			eventSegs = this.eventFootprintsToSegs(eventFootprints);
-			eventRendering = eventRange.eventInstance.eventDefinition.rendering;
 
-			// TODO: query up to event's source and calendar
-			if (eventRendering === 'background' || eventRendering === 'inverse-background') {
+			if (eventRangeGroup.getEventDef().hasBgRendering()) {
 				bgSegs.push.apply(bgSegs, // append
 					eventSegs
 				);
@@ -50,7 +50,7 @@ Grid.mixin({
 
 
 	// Unrenders all events currently rendered on the grid
-	unrenderEventRanges: function() {
+	unrenderEventRangeGroups: function() {
 		this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event
 		this.clearDragListeners();
 
@@ -141,7 +141,7 @@ Grid.mixin({
 	// FOR RENDERING
 	buildBusinessHourRanges: function(wholeDay, businessHours) {
 		var calendar = this.view.calendar;
-		var group;
+		var instanceGroup;
 
 		if (businessHours == null) {
 			// fallback
@@ -149,14 +149,14 @@ Grid.mixin({
 			businessHours = calendar.opt('businessHours');
 		}
 
-		group = new EventInstanceGroup(calendar.buildBusinessInstances(
+		instanceGroup = new EventInstanceGroup(calendar.buildBusinessInstances(
 			wholeDay,
 			businessHours,
 			this.start,
 			this.end
 		));
 
-		return group.buildRenderRanges(
+		return instanceGroup.buildRangeGroup().sliceRenderRanges(
 			new UnzonedRange(this.start, this.end),
 			calendar
 		);
@@ -311,7 +311,7 @@ Grid.mixin({
 		var event = seg.event;
 		var isDragging;
 		var mouseFollower; // A clone of the original element that will move with the mouse
-		var eventMutation;
+		var eventDefMutation;
 
 		if (this.segDragListener) {
 			return this.segDragListener;
@@ -363,10 +363,10 @@ Grid.mixin({
 				footprint = hit.component.getSafeHitFootprint(hit);
 
 				if (origFootprint && footprint) {
-					eventMutation = _this.computeEventDropMutation(origFootprint, footprint);
+					eventDefMutation = _this.computeEventDropMutation(origFootprint, footprint);
 
-					if (eventMutation) {
-						eventInstanceGroup = view.calendar.buildMutatedEventInstanceGroup(event._id, eventMutation);
+					if (eventDefMutation) {
+						eventInstanceGroup = view.calendar.eventManager.buildMutatedEventInstanceGroup(event._id, eventDefMutation);
 						isAllowed = _this.isEventInstanceGroupAllowed(eventInstanceGroup);
 					}
 					else {
@@ -378,13 +378,13 @@ Grid.mixin({
 				}
 
 				if (!isAllowed) {
-					eventMutation = null;
+					eventDefMutation = null;
 					disableCursor();
 				}
 
 				// if a valid drop location, have the subclass render a visual indication
 				if (
-					eventMutation &&
+					eventDefMutation &&
 					(dragHelperEls = view.renderDrag(
 						eventInstanceGroup.buildRenderRanges(
 							new UnzonedRange(_this.start, _this.end),
@@ -407,13 +407,13 @@ Grid.mixin({
 
 				if (isOrig) {
 					// needs to have moved hits to be a valid drop
-					eventMutation = null;
+					eventDefMutation = null;
 				}
 			},
 			hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
 				view.unrenderDrag(); // unrender whatever was done in renderDrag
 				mouseFollower.show(); // show in case we are moving out of all hits
-				eventMutation = null;
+				eventDefMutation = null;
 			},
 			hitDone: function() { // Called after a hitOut OR before a dragEnd
 				enableCursor();
@@ -422,15 +422,15 @@ Grid.mixin({
 				delete seg.component; // prevent side effects
 
 				// do revert animation if hasn't changed. calls a callback when finished (whether animation or not)
-				mouseFollower.stop(!eventMutation, function() {
+				mouseFollower.stop(!eventDefMutation, function() {
 					if (isDragging) {
 						view.unrenderDrag();
 						_this.segDragStop(seg, ev);
 					}
 
-					if (eventMutation) {
+					if (eventDefMutation) {
 						// no need to re-show original, will rerender all anyways. esp important if eventRenderWait
-						view.reportEventDrop(event, eventMutation, el, ev);
+						view.reportEventDrop(event, eventDefMutation, el, ev);
 					}
 					else {
 						view.showEvent(event);
@@ -495,7 +495,7 @@ Grid.mixin({
 		var forceAllDay = false;
 		var dateDelta;
 		var dateMutation;
-		var eventMutation;
+		var eventDefMutation;
 
 		if (startFootprint.isAllDay !== endFootprint.isAllDay) {
 			clearEnd = true;
@@ -511,16 +511,16 @@ Grid.mixin({
 
 		dateDelta = this.diffDates(date1, date0);
 
-		dateMutation = new EventDateMutation();
+		dateMutation = new EventDefDateMutation();
 		dateMutation.clearEnd = clearEnd;
 		dateMutation.forceTimed = forceTimed;
 		dateMutation.forceAllDay = forceAllDay;
 		dateMutation.dateDelta = dateDelta;
 
-		eventMutation = new EventMutation();
-		eventMutation.dateMutation = dateMutation;
+		eventDefMutation = new EventDefMutation();
+		eventDefMutation.dateMutation = dateMutation;
 
-		return eventMutation;
+		return eventDefMutation;
 	},
 
 
@@ -661,7 +661,7 @@ Grid.mixin({
 			}
 		}
 
-		eventDef = SingleEventDefinition.parse(
+		eventDef = SingleEventDef.parse(
 			$.extend({}, meta.eventProps, {
 				start: start,
 				end: end
@@ -733,7 +733,7 @@ Grid.mixin({
 						_this.computeEventEndResizeMutation(origHitFootprint, hitFootprint, event);
 
 					if (resizeMutation) {
-						eventInstanceGroup = calendar.buildMutatedEventInstanceGroup(event._id, resizeMutation);
+						eventInstanceGroup = calendar.eventManager.buildMutatedEventInstanceGroup(event._id, resizeMutation);
 						isAllowed = _this.isEventInstanceGroupAllowed(eventInstanceGroup);
 					}
 					else {
@@ -748,7 +748,7 @@ Grid.mixin({
 					resizeMutation = null;
 					disableCursor();
 				}
-				else if (!resizeMutation.isSomething()) {
+				else if (resizeMutation.isEmpty()) {
 					// no change. (FYI, event dates might have zones)
 					resizeMutation = null;
 				}
@@ -816,17 +816,17 @@ Grid.mixin({
 		);
 		var eventEnd = this.view.calendar.getEventEnd(event);
 		var dateMutation;
-		var eventMutation;
+		var eventDefMutation;
 
 		if (event.start.clone().add(startDelta) < eventEnd) {
 
-			dateMutation = new EventDateMutation();
+			dateMutation = new EventDefDateMutation();
 			dateMutation.startDelta = startDelta;
 
-			eventMutation = new EventMutation();
-			eventMutation.dateMutation = dateMutation;
+			eventDefMutation = new EventDefMutation();
+			eventDefMutation.dateMutation = dateMutation;
 
-			return eventMutation;
+			return eventDefMutation;
 		}
 
 		return false;
@@ -842,17 +842,17 @@ Grid.mixin({
 		);
 		var eventEnd = this.view.calendar.getEventEnd(event);
 		var dateMutation;
-		var eventMutation;
+		var eventDefMutation;
 
 		if (eventEnd.add(endDelta) > event.start) {
 
-			dateMutation = new EventDateMutation();
+			dateMutation = new EventDefDateMutation();
 			dateMutation.endDelta = endDelta;
 
-			eventMutation = new EventMutation();
-			eventMutation.dateMutation = dateMutation;
+			eventDefMutation = new EventDefMutation();
+			eventDefMutation.dateMutation = dateMutation;
 
-			return eventMutation;
+			return eventDefMutation;
 		}
 
 		return false;
@@ -1057,7 +1057,7 @@ Grid.mixin({
 				eventRange.eventInstance,
 				new ComponentFootprint(
 					eventRange.dateRange,
-					eventRange.eventInstance.eventDateProfile.isAllDay()
+					eventRange.eventInstance.dateProfile.isAllDay()
 				)
 			)
 		];

+ 34 - 38
src/common/View.js

@@ -254,8 +254,8 @@ var View = FC.View = ChronoComponent.extend({
 	// -----------------------------------------------------------------------------------------------------------------
 
 
-	fetchInitialEvents: function(dateProfile) {
-		return this.calendar.requestEvents(
+	fetchInitialEventRangeGroups: function(dateProfile) {
+		return this.calendar.requestEventRangeGroups(
 			dateProfile.activeRange.start,
 			dateProfile.activeRange.end
 		);
@@ -263,7 +263,7 @@ var View = FC.View = ChronoComponent.extend({
 
 
 	bindEventChanges: function() {
-		this.listenTo(this.calendar, 'eventsReset', this.resetEventRanges);
+		this.listenTo(this.calendar, 'eventsReset', this.resetEventRangeGroups);
 	},
 
 
@@ -272,22 +272,22 @@ var View = FC.View = ChronoComponent.extend({
 	},
 
 
-	setEventRanges: function(eventRanges) {
-		this.set('currentEventRanges', eventRanges);
+	setEventRangeGroups: function(eventRangeGroups) {
+		this.set('currentEventRangeGroups', eventRangeGroups);
 		this.set('hasEvents', true);
 	},
 
 
-	unsetEvents: function() {
-		this.unset('currentEventRanges');
+	unsetEventRangeGroups: function() {
+		this.unset('currentEventRangeGroups');
 		this.unset('hasEvents');
 	},
 
 
-	resetEventRanges: function(eventRanges) {
+	resetEventRangeGroups: function(eventRangeGroups) {
 		this.startBatchRender();
-		this.unsetEvents();
-		this.setEventRanges(eventRanges);
+		this.unsetEventRangeGroups();
+		this.setEventRangeGroups(eventRangeGroups);
 		this.stopBatchRender();
 	},
 
@@ -296,11 +296,11 @@ var View = FC.View = ChronoComponent.extend({
 	// -----------------------------------------------------------------------------------------------------------------
 
 
-	requestEventsRender: function(eventRanges) {
+	requestEventsRender: function(eventRangeGroups) {
 		var _this = this;
 
 		this.renderQueue.queue(function() {
-			_this.executeEventsRender(eventRanges);
+			_this.executeEventsRender(eventRangeGroups);
 		}, 'event', 'init');
 	},
 
@@ -614,9 +614,9 @@ var View = FC.View = ChronoComponent.extend({
 	// -----------------------------------------------------------------------------------------------------------------
 
 
-	executeEventsRender: function(eventRanges) {
+	executeEventsRender: function(eventRangeGroups) {
 
-		this.renderEventRanges(eventRanges);
+		this.renderEventRangeGroups(eventRangeGroups);
 		this.isEventsRendered = true;
 
 		this.onEventsRender();
@@ -630,7 +630,7 @@ var View = FC.View = ChronoComponent.extend({
 			this.destroyEvents(); // TODO: deprecate
 		}
 
-		this.unrenderEventRanges();
+		this.unrenderEventRangeGroups();
 		this.isEventsRendered = false;
 	},
 
@@ -710,16 +710,14 @@ var View = FC.View = ChronoComponent.extend({
 
 
 	reportEventDrop: function(legacyEvent, eventMutation, el, ev) {
-		var calendar = this.calendar;
-		var undoDataFunc = calendar.mutateEventsWithId(legacyEvent._id, eventMutation);
-
-		var undoFunc = function() {
-			undoDataFunc();
-			calendar.reportEventChange();
-		};
+		var undoFunc = this.calendar.eventManager
+			.mutateEventsWithId(
+				EventManager.normalizeId(legacyEvent._id),
+				eventMutation,
+				this.calendar
+			);
 
 		this.triggerEventDrop(legacyEvent, eventMutation.dateDelta, undoFunc, el, ev);
-		calendar.reportEventChange(); // will rerender events
 	},
 
 
@@ -739,7 +737,7 @@ var View = FC.View = ChronoComponent.extend({
 	reportExternalDrop: function(singleEventDef, isEvent, isSticky, el, ev, ui) {
 
 		if (isEvent) {
-			this.calendar.addEventDefAndRender(singleEventDef, isSticky);
+			this.calendar.eventManager.addEventDef(singleEventDef, isSticky);
 		}
 
 		this.triggerExternalDrop(singleEventDef, isEvent, el, ev, ui);
@@ -769,16 +767,14 @@ var View = FC.View = ChronoComponent.extend({
 
 	// Must be called when an event in the view has been resized to a new length
 	reportEventResize: function(legacyEvent, eventMutation, el, ev) {
-		var calendar = this.calendar;
-		var undoDataFunc = calendar.mutateEventsWithId(legacyEvent._id, eventMutation);
-
-		var undoFunc = function() {
-			undoDataFunc();
-			calendar.reportEventChange();
-		};
+		var undoFunc = this.calendar.eventManager
+			.mutateEventsWithId(
+				EventManager.normalizeId(legacyEvent._id),
+				eventMutation,
+				this.calendar
+			);
 
 		this.triggerEventResize(legacyEvent, eventMutation.endDelta, undoFunc, el, ev);
-		calendar.reportEventChange(); // will rerender events
 	},
 
 
@@ -937,22 +933,22 @@ View.watch('displayingDates', [ 'dateProfile' ], function(deps) {
 });
 
 
-View.watch('initialEventRanges', [ 'dateProfile' ], function(deps) {
-	return this.fetchInitialEvents(deps.dateProfile);
+View.watch('initialEventRangeGroups', [ 'dateProfile' ], function(deps) {
+	return this.fetchInitialEventRangeGroups(deps.dateProfile);
 });
 
 
-View.watch('bindingEvents', [ 'initialEventRanges' ], function(deps) {
-	this.setEventRanges(deps.initialEventRanges);
+View.watch('bindingEvents', [ 'initialEventRangeGroups' ], function(deps) {
+	this.setEventRangeGroups(deps.initialEventRangeGroups);
 	this.bindEventChanges();
 }, function() {
 	this.unbindEventChanges();
-	this.unsetEvents();
+	this.unsetEventRangeGroups();
 });
 
 
 View.watch('displayingEvents', [ 'displayingDates', 'hasEvents' ], function() {
-	this.requestEventsRender(this.get('currentEventRanges')); // if there were event mutations after initialEventRanges
+	this.requestEventsRender(this.get('currentEventRangeGroups')); // if there were event mutations after initialEventRangeGroups
 }, function() {
 	this.requestEventsUnrender();
 });

+ 231 - 0
src/gcal/GcalEventSource.js

@@ -0,0 +1,231 @@
+
+var GcalEventSource = EventSource.extend({
+
+	// TODO: eventually remove "googleCalendar" prefix (API-breaking)
+	googleCalendarId: null,
+	googleCalendarError: null, // optional function
+	ajaxSettings: null,
+
+
+	fetch: function(start, end, timezone) {
+		var _this = this;
+		var url = this.buildUrl();
+		var requestParams = this.buildRequestParams(start, end, timezone);
+		var ajaxSettings = this.ajaxSettings;
+		var onSuccess = ajaxSettings.success;
+
+		if (!requestParams) { // could have failed
+			return Promise.reject();
+		}
+
+		return Promise.construct(function(onResolve, onReject) {
+			$.ajax($.extend(
+				{}, // destination
+				JsonFeedEventSource.AJAX_DEFAULTS,
+				ajaxSettings,
+				{
+					url: url,
+					data: requestParams,
+					success: function(responseData) {
+						var rawEventDefs;
+						var successRes;
+
+						if (responseData.error) {
+							_this.reportError('Google Calendar API: ' + responseData.error.message, responseData.error.errors);
+							onReject();
+						}
+						else if (responseData.items) {
+							rawEventDefs = _this.gcalItemsToRawEventDefs(
+								responseData.items,
+								requestParams.timeZone
+							);
+
+							successRes = applyAll(
+								onSuccess,
+								this, // forward `this`
+								// call the success handler(s) and allow it to return a new events array
+								[ rawEventDefs ].concat(Array.prototype.slice.call(arguments, 1))
+							);
+
+							if ($.isArray(successRes)) {
+								rawEventDefs = successRes;
+							}
+
+							onResolve(_this.parseEventDefs(rawEventDefs));
+						}
+					}
+				}
+			));
+		});
+	},
+
+
+	gcalItemsToRawEventDefs: function(items, gcalTimezone) {
+		var _this = this;
+
+		return items.map(function(item) {
+			return _this.gcalItemToRawEventDef(item, gcalTimezone);
+		});
+	},
+
+
+	gcalItemToRawEventDef: function(item, gcalTimezone) {
+		var url = item.htmlLink || null;
+
+		// make the URLs for each event show times in the correct timezone
+		if (url && gcalTimezone) {
+			url = injectQsComponent(url, 'ctz=' + gcalTimezone);
+		}
+
+		return {
+			id: item.id,
+			title: item.summary,
+			start: item.start.dateTime || item.start.date, // try timed. will fall back to all-day
+			end: item.end.dateTime || item.end.date, // same
+			url: url,
+			location: item.location,
+			description: item.description
+		};
+	},
+
+
+	buildUrl: function() {
+		return GcalEventSource.API_BASE + '/' +
+			encodeURIComponent(this.googleCalendarId) +
+			'/events?callback=?'; // jsonp
+	},
+
+
+	buildRequestParams: function(start, end, timezone) {
+		var apiKey = this.googleCalendarApiKey || this.calendar.opt('googleCalendarApiKey');
+
+		if (!apiKey) {
+			this.reportError("Specify a googleCalendarApiKey. See http://fullcalendar.io/docs/google_calendar/");
+			return null;
+		}
+
+		// The API expects an ISO8601 datetime with a time and timezone part.
+		// Since the calendar's timezone offset isn't always known, request the date in UTC and pad it by a day on each
+		// side, guaranteeing we will receive all events in the desired range, albeit a superset.
+		// .utc() will set a zone and give it a 00:00:00 time.
+		if (!start.hasZone()) {
+			start = start.clone().utc().add(-1, 'day');
+		}
+		if (!end.hasZone()) {
+			end = end.clone().utc().add(1, 'day');
+		}
+
+		if (timezone === 'local') {
+			timezone = null;
+		}
+		else if (timezone) {
+			// when sending timezone names to Google, only accepts underscores, not spaces
+			timezone = timezone.replace(' ', '_');
+		}
+
+		return $.extend(
+			this.ajaxSettings.data || {},
+			{
+				key: apiKey,
+				timeMin: start.format(),
+				timeMax: end.format(),
+				timeZone: timezone,
+				singleEvents: true,
+				maxResults: 9999
+			}
+		);
+	},
+
+
+	reportError: function(message, apiErrorObjs) {
+		var calendar = this.calendar;
+		var calendarOnError = calendar.opt('googleCalendarError');
+		var errorObjs = apiErrorObjs || [ { message: message } ]; // to be passed into error handlers
+
+		if (this.googleCalendarError) {
+			this.googleCalendarError.apply(calendar, errorObjs);
+		}
+
+		if (calendarOnError) {
+			calendarOnError.apply(calendar, errorObjs);
+		}
+
+		// print error to debug console
+		FC.warn.apply(null, [ message ].concat(apiErrorObjs || []));
+	},
+
+
+	getPrimitive: function() {
+		return this.googleCalendarId;
+	}
+
+});
+
+
+GcalEventSource.API_BASE = 'https://www.googleapis.com/calendar/v3/calendars';
+
+
+GcalEventSource.parse = function(rawInput, calendar) {
+	var rawProps;
+	var url;
+	var googleCalendarId;
+	var source;
+
+	if (typeof rawInput === 'string') {
+		url = rawInput;
+		rawProps = {};
+	}
+	else if (typeof rawInput.url === 'string') {
+		rawProps = $.extend({}, rawInput); // clone
+		url = pluckProp(rawProps, 'url');
+		googleCalendarId = pluckProp(rawProps, 'googleCalendarId');
+	}
+
+	if (!googleCalendarId && url) {
+		googleCalendarId = parseGoogleCalendarId(url);
+	}
+
+	if (googleCalendarId) {
+		source = EventSource.parseAndPluck.call(this, rawProps, calendar);
+
+		source.googleCalendarId = googleCalendarId;
+		source.googleCalendarError = pluckProp(rawProps, 'googleCalendarError');
+		source.ajaxSettings = rawProps; // remainder
+
+		return source;
+	}
+};
+
+
+function parseGoogleCalendarId(url) {
+	var match;
+
+	// detect if the ID was specified as a single string.
+	// will match calendars like "[email protected]" in addition to person email calendars.
+	if (/^[^\/]+@([^\/\.]+\.)*(google|googlemail|gmail)\.com$/.test(url)) {
+		return url;
+	}
+	// try to scrape it out of a V1 or V3 API feed URL
+	else if (
+		(match = /^https:\/\/www.googleapis.com\/calendar\/v3\/calendars\/([^\/]*)/.exec(url)) ||
+		(match = /^https?:\/\/www.google.com\/calendar\/feeds\/([^\/]*)/.exec(url))
+	) {
+		return decodeURIComponent(match[1]);
+	}
+}
+
+
+// Injects a string like "arg=value" into the querystring of a URL
+function injectQsComponent(url, component) {
+	// inject it after the querystring but before the fragment
+	return url.replace(/(\?.*?)?(#|$)/, function(whole, qs, hash) {
+		return (qs ? qs + '&' : '?') + component + hash;
+	});
+}
+
+
+// expose
+
+EventSourceParser.registerClass(GcalEventSource);
+
+FC.GcalEventSource = GcalEventSource;

+ 0 - 180
src/gcal/gcal.js

@@ -1,180 +0,0 @@
-/*!
- * <%= title %> v<%= version %> Google Calendar Plugin
- * Docs & License: <%= homepage %>
- * (c) <%= copyright %>
- */
- 
-(function(factory) {
-	if (typeof define === 'function' && define.amd) {
-		define([ 'jquery' ], factory);
-	}
-	else if (typeof exports === 'object') { // Node/CommonJS
-		module.exports = factory(require('jquery'));
-	}
-	else {
-		factory(jQuery);
-	}
-})(function($) {
-
-
-var API_BASE = 'https://www.googleapis.com/calendar/v3/calendars';
-var FC = $.fullCalendar;
-var applyAll = FC.applyAll;
-
-
-FC.sourceNormalizers.push(function(sourceOptions) {
-	var googleCalendarId = sourceOptions.googleCalendarId;
-	var url = sourceOptions.url;
-	var match;
-
-	// if the Google Calendar ID hasn't been explicitly defined
-	if (!googleCalendarId && url) {
-
-		// detect if the ID was specified as a single string.
-		// will match calendars like "[email protected]" in addition to person email calendars.
-		if (/^[^\/]+@([^\/\.]+\.)*(google|googlemail|gmail)\.com$/.test(url)) {
-			googleCalendarId = url;
-		}
-		// try to scrape it out of a V1 or V3 API feed URL
-		else if (
-			(match = /^https:\/\/www.googleapis.com\/calendar\/v3\/calendars\/([^\/]*)/.exec(url)) ||
-			(match = /^https?:\/\/www.google.com\/calendar\/feeds\/([^\/]*)/.exec(url))
-		) {
-			googleCalendarId = decodeURIComponent(match[1]);
-		}
-
-		if (googleCalendarId) {
-			sourceOptions.googleCalendarId = googleCalendarId;
-		}
-	}
-
-
-	if (googleCalendarId) { // is this a Google Calendar?
-
-		// make each Google Calendar source uneditable by default
-		if (sourceOptions.editable == null) {
-			sourceOptions.editable = false;
-		}
-
-		// We want removeEventSource to work, but it won't know about the googleCalendarId primitive.
-		// Shoehorn it into the url, which will function as the unique primitive. Won't cause side effects.
-		// This hack is obsolete since 2.2.3, but keep it so this plugin file is compatible with old versions.
-		sourceOptions.url = googleCalendarId;
-	}
-});
-
-
-FC.sourceFetchers.push(function(sourceOptions, start, end, timezone) {
-	if (sourceOptions.googleCalendarId) {
-		return transformOptions(sourceOptions, start, end, timezone, this); // `this` is the calendar
-	}
-});
-
-
-function transformOptions(sourceOptions, start, end, timezone, calendar) {
-	var url = API_BASE + '/' + encodeURIComponent(sourceOptions.googleCalendarId) + '/events?callback=?'; // jsonp
-	var apiKey = sourceOptions.googleCalendarApiKey || calendar.opt('googleCalendarApiKey');
-	var success = sourceOptions.success;
-	var data;
-	var timezoneArg; // populated when a specific timezone. escaped to Google's liking
-
-	function reportError(message, apiErrorObjs) {
-		var errorObjs = apiErrorObjs || [ { message: message } ]; // to be passed into error handlers
-
-		// call error handlers
-		(sourceOptions.googleCalendarError || $.noop).apply(calendar, errorObjs);
-		(calendar.opt('googleCalendarError') || $.noop).apply(calendar, errorObjs);
-
-		// print error to debug console
-		FC.warn.apply(null, [ message ].concat(apiErrorObjs || []));
-	}
-
-	if (!apiKey) {
-		reportError("Specify a googleCalendarApiKey. See http://fullcalendar.io/docs/google_calendar/");
-		return {}; // an empty source to use instead. won't fetch anything.
-	}
-
-	// The API expects an ISO8601 datetime with a time and timezone part.
-	// Since the calendar's timezone offset isn't always known, request the date in UTC and pad it by a day on each
-	// side, guaranteeing we will receive all events in the desired range, albeit a superset.
-	// .utc() will set a zone and give it a 00:00:00 time.
-	if (!start.hasZone()) {
-		start = start.clone().utc().add(-1, 'day');
-	}
-	if (!end.hasZone()) {
-		end = end.clone().utc().add(1, 'day');
-	}
-
-	// when sending timezone names to Google, only accepts underscores, not spaces
-	if (timezone && timezone != 'local') {
-		timezoneArg = timezone.replace(' ', '_');
-	}
-
-	data = $.extend({}, sourceOptions.data || {}, {
-		key: apiKey,
-		timeMin: start.format(),
-		timeMax: end.format(),
-		timeZone: timezoneArg,
-		singleEvents: true,
-		maxResults: 9999
-	});
-
-	return $.extend({}, sourceOptions, {
-		googleCalendarId: null, // prevents source-normalizing from happening again
-		url: url,
-		data: data,
-		startParam: false, // `false` omits this parameter. we already included it above
-		endParam: false, // same
-		timezoneParam: false, // same
-		success: function(data) {
-			var events = [];
-			var successArgs;
-			var successRes;
-
-			if (data.error) {
-				reportError('Google Calendar API: ' + data.error.message, data.error.errors);
-			}
-			else if (data.items) {
-				$.each(data.items, function(i, entry) {
-					var url = entry.htmlLink || null;
-
-					// make the URLs for each event show times in the correct timezone
-					if (timezoneArg && url !== null) {
-						url = injectQsComponent(url, 'ctz=' + timezoneArg);
-					}
-
-					events.push({
-						id: entry.id,
-						title: entry.summary,
-						start: entry.start.dateTime || entry.start.date, // try timed. will fall back to all-day
-						end: entry.end.dateTime || entry.end.date, // same
-						url: url,
-						location: entry.location,
-						description: entry.description
-					});
-				});
-
-				// call the success handler(s) and allow it to return a new events array
-				successArgs = [ events ].concat(Array.prototype.slice.call(arguments, 1)); // forward other jq args
-				successRes = applyAll(success, this, successArgs);
-				if ($.isArray(successRes)) {
-					return successRes;
-				}
-			}
-
-			return events;
-		}
-	});
-}
-
-
-// Injects a string like "arg=value" into the querystring of a URL
-function injectQsComponent(url, component) {
-	// inject it after the querystring but before the fragment
-	return url.replace(/(\?.*?)?(#|$)/, function(whole, qs, hash) {
-		return (qs ? qs + '&' : '?') + component + hash;
-	});
-}
-
-
-});

+ 26 - 0
src/gcal/intro.js

@@ -0,0 +1,26 @@
+/*!
+ * <%= title %> v<%= version %> Google Calendar Plugin
+ * Docs & License: <%= homepage %>
+ * (c) <%= copyright %>
+ */
+ 
+(function(factory) {
+	if (typeof define === 'function' && define.amd) {
+		define([ 'jquery' ], factory);
+	}
+	else if (typeof exports === 'object') { // Node/CommonJS
+		module.exports = factory(require('jquery'));
+	}
+	else {
+		factory(jQuery);
+	}
+})(function($) {
+
+
+var FC = $.fullCalendar;
+var Promise = FC.Promise;
+var EventSource = FC.EventSource;
+var JsonFeedEventSource = FC.JsonFeedEventSource;
+var EventSourceParser = FC.EventSourceParser;
+var pluckProp = FC.pluckProp;
+var applyAll = FC.applyAll;

+ 2 - 0
src/gcal/outro.js

@@ -0,0 +1,2 @@
+
+});

+ 2 - 0
src/models/ComponentFootprint.js

@@ -4,11 +4,13 @@ var ComponentFootprint = Class.extend({
 	dateRange: null,
 	isAllDay: false,
 
+
 	constructor: function(dateRange, isAllDay) {
 		this.dateRange = dateRange;
 		this.isAllDay = isAllDay;
 	},
 
+
 	toLegacy: function() {
 		return this.dateRange.getRange();
 	}

+ 0 - 150
src/models/EventDateMutation.js

@@ -1,150 +0,0 @@
-
-var EventDateMutation = Class.extend({ // TODO: EventDefDateMutation
-
-	clearEnd: false,
-	forceTimed: false,
-	forceAllDay: false,
-	dateDelta: null,
-	startDelta: null,
-	endDelta: null,
-
-
-	mutateSingleEventDefinition: function(eventDef, calendar) {
-		var origStart = eventDef.start;
-		var origEnd = eventDef.end;
-		var start = origStart.clone();
-		var end = origEnd ? origEnd.clone() : null;
-
-		if (this.clearEnd) {
-			end = null;
-		}
-
-		if (this.forceTimed) {
-
-			if (!start.hasTime()) {
-				start.time(0);
-			}
-
-			if (end && !end.hasTime()) {
-				end.time(0);
-			}
-		}
-		else if (this.forceAllDay) {
-
-			if (start.hasTime()) {
-				start.stripTime();
-			}
-
-			if (end && end.hasTime()) {
-				end.stripTime();
-			}
-		}
-
-		if (this.dateDelta) {
-
-			start.add(this.dateDelta);
-
-			if (end) {
-				end.add(this.dateDelta);
-			}
-		}
-
-		// do this before adding startDelta to start,
-		// so we can work off of start
-		if (this.endDelta) {
-
-			if (!end) {
-				// eventDef better be a SingleEventDefinition!
-				end = calendar.getDefaultEventEnd(eventDef.isAllDay(), start);
-			}
-
-			end.add(this.endDelta);
-		}
-
-		if (this.startDelta) {
-			start.add(this.startDelta);
-		}
-
-		if (calendar.getIsAmbigTimezone()) {
-
-			if (start.hasTime() && (this.dateDelta || this.startDelta)) {
-				start.stripZone();
-			}
-
-			if (end && end.hasTime() && (this.dateDelta || this.endDelta)) {
-				end.stripZone();
-			}
-		}
-
-		eventDef.start = start;
-		eventDef.end = end;
-
-		return function() {
-			eventDef.start = origStart;
-			eventDef.end = origEnd;
-		};
-	},
-
-
-	isSomething: function() {
-		return this.clearEnd || this.forceTimed || this.forceAllDay ||
-			(this.dateDelta && this.dateDelta.valueOf()) ||
-			(this.startDelta && this.startDelta.valueOf()) ||
-			(this.endDelta && this.endDelta.valueOf());
-	}
-
-});
-
-
-EventDateMutation.createFromRawProps = function(eventInstance, newRawProps, largeUnit, calendar) {
-	var newEventDateProfile = new EventDateProfile(
-		calendar.moment(newRawProps.start),
-		newRawProps.end ? calendar.moment(newRawProps.end) : null
-	);
-
-	return EventDateMutation.createFromDiff(
-		eventInstance.eventDateProfile,
-		newEventDateProfile,
-		largeUnit
-	);
-};
-
-
-EventDateMutation.createFromDiff = function(profile1, profile2, largeUnit) {
-	var clearEnd = profile1.end && !profile2.end;
-	var forceTimed = profile1.isAllDay() && !profile2.isAllDay();
-	var forceAllDay = !profile1.isAllDay() && profile2.isAllDay();
-	var dateDelta;
-	var endDiff;
-	var endDelta;
-	var mutation;
-
-	// diffs the dates in the appropriate way, returning a duration
-	function diffDates(date1, date0) { // date1 - date0
-		if (largeUnit) {
-			return diffByUnit(date1, date0, largeUnit);
-		}
-		else if (profile2.isAllDay()) {
-			return diffDay(date1, date0);
-		}
-		else {
-			return diffDayTime(date1, date0);
-		}
-	}
-
-	dateDelta = diffDates(profile2.start, profile1.start);
-
-	if (profile2.end) {
-		endDiff = diffDates(profile2.end, profile1.getEnd());
-		endDelta = endDiff.subtract(dateDelta);
-	}
-
-	mutation = new EventDateMutation();
-	mutation.clearEnd = clearEnd;
-	mutation.forceTimed = forceTimed;
-	mutation.forceAllDay = forceAllDay;
-	mutation.dateDelta = dateDelta;
-	mutation.endDelta = endDelta;
-
-	return mutation;
-};

+ 0 - 155
src/models/EventDefinition.js

@@ -1,155 +0,0 @@
-
-var EventDefinition = Class.extend({
-
-	source: null,
-	id: null,
-	title: null,
-	rendering: null,
-	constraint: null,
-	overlap: null,
-	miscProps: null,
-	className: null, // an array. TODO: rename to className*s*
-
-
-	constructor: function(source) {
-		this.source = source;
-		this.miscProps = {};
-	},
-
-
-	buildInstances: function(start, end) {
-		// subclasses must implement
-	},
-
-
-	clone: function() {
-		var copy = new this.constructor();
-
-		copy.source = this.source;
-		copy.id = this.id;
-		copy.title = this.title;
-		copy.rendering = this.rendering;
-		copy.constraint = this.constraint;
-		copy.overlap = this.overlap;
-		copy.miscProps = $.extend({}, this.miscProps);
-
-		return copy;
-	},
-
-
-	getRendering: function() {
-		if (this.rendering != null) {
-			return this.rendering;
-		}
-		if (this.source) {
-			return this.source.rendering;
-		}
-	},
-
-
-	isInverseBgEvent: function() {
-		return this.getRendering() === 'inverse-background';
-	},
-
-
-	isBgEvent: function() {
-		var rendering = this.getRendering();
-
-		return rendering === 'inverse-background' || rendering === 'background';
-	},
-
-
-	getConstraint: function(calendar) {
-		if (this.constraint != null) {
-			return this.constraint;
-		}
-
-		if (this.source && this.source.constraint != null) {
-			return this.source.constraint;
-		}
-
-		return calendar.opt('eventConstraint');
-	},
-
-
-	getOverlap: function(calendar) {
-		if (this.overlap != null) {
-			return this.overlap;
-		}
-
-		if (this.source && this.source.overlap != null) {
-			return this.source.overlap;
-		}
-
-		if (calendar) {
-			return calendar.opt('eventOverlap');
-		}
-	}
-
-
-});
-
-
-EventDefinition.uuid = 0;
-EventDefinition.reservedPropMap = {};
-
-
-EventDefinition.addReservedProps = function(propArray) {
-	var map = {};
-	var i;
-
-	for (i = 0; i < propArray.length; i++) {
-		map[propArray[i]] = true;
-	}
-
-	// won't modify original object. don't want sideeffects on superclasses
-	this.reservedPropMap = $.extend({}, this.reservedPropMap, map);
-};
-
-
-EventDefinition.isReservedProp = function(propName) {
-	return this.reservedPropMap[propName] || false;
-};
-
-
-EventDefinition.parse = function(rawProps, source, calendar) {
-	var def = new this(source);
-	var propName;
-	var miscProps = {};
-	var className;
-
-	var calendarTransform = calendar.opt('eventDataTransform');
-	var sourceTransform = source.eventDataTransform;
-
-	if (calendarTransform) {
-		rawProps = calendarTransform(rawProps);
-	}
-	if (sourceTransform) {
-		rawProps = sourceTransform(rawProps);
-	}
-
-	className = rawProps.className || [];
-	if (typeof className === 'string') {
-		className = className.split(/\s+/);
-	}
-
-	def.id = rawProps.id || ('_fc' + (++EventDefinition.uuid));
-	def.title = rawProps.title || '';
-	def.rendering = rawProps.rendering || null;
-	def.constraint = rawProps.constraint;
-	def.overlap = rawProps.overlap;
-	def.className = className;
-
-	for (propName in rawProps) {
-		if (!this.isReservedProp(propName)) {
-			miscProps[propName] = rawProps[propName];
-		}
-	}
-
-	def.miscProps = miscProps;
-
-	return def;
-};
-
-
-EventDefinition.addReservedProps([ 'id', 'title', 'rendering', 'constraint', 'overlap' ]);

+ 0 - 125
src/models/EventDefinitionCollection.js

@@ -1,125 +0,0 @@
-
-var EventDefinitionCollection = Class.extend({
-
-	calendar: null,
-	eventDefs: null,
-	eventDefsById: null,
-
-	constructor: function(calendar) {
-		this.calendar = calendar;
-		this.eventDefs = [];
-		this.eventDefsById = {};
-	},
-
-	add: function(eventDef) {
-		var eventDefsById = this.eventDefsById;
-
-		this.eventDefs.push(eventDef);
-
-		(eventDefsById[eventDef.id] || (eventDefsById[eventDef.id] = []))
-			.push(eventDef);
-	},
-
-	getById: function(id) { // TODO: getArrayById
-		return this.eventDefsById[id];
-	},
-
-	clear: function() {
-		this.eventDefs = [];
-		this.eventDefsById = {};
-	},
-
-	clearBySource: function() {
-		// TODO
-	},
-
-	clearByFilter: function() {
-		// TODO
-	},
-
-	// TODO: make DRY with buildRenderRanges. REUSE same instanceGroups somehow
-	buildEventRanges: function(start, end, calendar) {
-		var renderRanges = [];
-		var instanceGroups = this.buildInstanceGroups(start, end);
-		var constraintRange = new UnzonedRange(start, end);
-		var i;
-
-		for (i = 0; i < instanceGroups.length; i++) {
-			renderRanges.push.apply(renderRanges, // append
-				instanceGroups[i].buildEventRanges(constraintRange, calendar)
-			);
-		}
-
-		return renderRanges;
-	},
-
-	buildRenderRanges: function(start, end, calendar) {
-		var renderRanges = [];
-		var instanceGroups = this.buildInstanceGroups(start, end);
-		var constraintRange = new UnzonedRange(start, end);
-		var i;
-
-		for (i = 0; i < instanceGroups.length; i++) {
-			renderRanges.push.apply(renderRanges, // append
-				instanceGroups[i].buildRenderRanges(constraintRange, calendar)
-			);
-		}
-
-		return renderRanges;
-	},
-
-	buildInstanceGroups: function(start, end) {
-		var eventInstanceGroups = [];
-		var eventDefsById = this.eventDefsById;
-		var eventId;
-		var relatedEventDefs;
-		var relatedEventInstances;
-		var i, eventDef;
-
-		for (eventId in eventDefsById) {
-			relatedEventDefs = eventDefsById[eventId];
-			relatedEventInstances = [];
-
-			for (i = 0; i < relatedEventDefs.length; i++) {
-				eventDef = relatedEventDefs[i];
-
-				relatedEventInstances.push.apply( // append
-					relatedEventInstances,
-					eventDef.buildInstances(start, end)
-				);
-			}
-
-			eventInstanceGroups.push(
-				new EventInstanceGroup(relatedEventInstances)
-			);
-		}
-
-		return eventInstanceGroups;
-	}
-
-});
-
-
-function parseEventInput(eventInput, source, calendar) {
-	var eventDef = (
-		isEventInputRecurring(eventInput) ?
-			RecurringEventDefinition :
-			SingleEventDefinition
-	).parse(eventInput, source, calendar);
-
-	if (eventDef) { // not invalid
-		calendar.normalizeEvent(eventDef);
-	}
-
-	return eventDef;
-}
-
-
-function isEventInputRecurring(eventInput) {
-	var start = eventInput.start || eventInput.date;
-	var end = eventInput.end;
-
-	return eventInput.dow ||
-		(isTimeString(start) || moment.isDuration(start)) ||
-		(end && (isTimeString(start) || moment.isDuration(start)));
-}

+ 0 - 29
src/models/EventInstance.js

@@ -1,29 +0,0 @@
-
-var EventInstance = Class.extend({
-
-	eventDefinition: null,
-	eventDateProfile: null,
-
-	constructor: function(eventDefinition, eventDateProfile) {
-		this.eventDefinition = eventDefinition;
-		this.eventDateProfile = eventDateProfile;
-	},
-
-	toLegacy: function() {
-		var def = this.eventDefinition;
-		var dateProfile = this.eventDateProfile;
-
-		return $.extend({}, def.miscProps, {
-			_id: def.id,
-			id: def.id,
-			title: def.title,
-			rendering: def.rendering,
-			start: dateProfile.start.clone(),
-			end: dateProfile.end ? dateProfile.end.clone() : null,
-			allDay: dateProfile.isAllDay(),
-			source: def.source,
-			className: def.className
-		});
-	}
-
-});

+ 0 - 125
src/models/EventInstanceGroup.js

@@ -1,125 +0,0 @@
-
-var EventInstanceGroup = Class.extend({
-
-	eventInstances: null,
-
-	constructor: function(eventInstances) {
-		this.eventInstances = eventInstances;
-	},
-
-	getEventDef: function() {
-		if (this.eventInstances.length) {
-			return this.eventInstances[0].eventDefinition;
-		}
-	},
-
-	isInverse: function() {
-		var eventDef = this.getEventDef();
-
-		return eventDef && eventDef.isInverseBgEvent();
-	},
-
-	buildRenderRanges: function(constraintRange, calendar) { // TODO: buildRenderableEventRanges ?
-		if (this.isInverse()) {
-			return this.buildInverseEventRanges(constraintRange, calendar);
-		}
-		else {
-			return this.buildEventRanges(constraintRange, calendar);
-		}
-	},
-
-	buildEventRanges: function(constraintRange, calendar) { // TODO: use this.source.calendar
-		var eventInstances = this.eventInstances;
-		var i, eventInstance;
-		var dateRange;
-		var eventRanges = [];
-
-		for (i = 0; i < eventInstances.length; i++) {
-			eventInstance = eventInstances[i];
-
-			dateRange = eventInstance.eventDateProfile.buildRange(calendar);
-
-			if (constraintRange) {
-				dateRange = dateRange.constrainTo(constraintRange);
-			}
-
-			if (dateRange) {
-				eventRanges.push(
-					new EventRange(eventInstance, dateRange)
-				);
-			}
-		}
-
-		return eventRanges;
-	},
-
-	buildInverseEventRanges: function(constraintRange, calendar) {
-		var dateRanges = this.buildDateRanges(constraintRange, calendar);
-		var ownerEventInstance = this.eventInstances[0];
-		var i;
-		var eventRanges = [];
-
-		dateRanges = invertDateRanges(dateRanges, constraintRange);
-
-		for (i = 0; i < dateRanges.length; i++) {
-			eventRanges.push(
-				new EventRange(ownerEventInstance, dateRanges[i])
-			);
-		}
-
-		return eventRanges;
-	},
-
-	buildDateRanges: function(constraintRange, calendar) {
-		var eventInstances = this.eventInstances;
-		var i, eventInstance;
-		var dateRange;
-		var dateRanges = [];
-
-		for (i = 0; i < eventInstances.length; i++) {
-			eventInstance = eventInstances[i];
-
-			dateRange = eventInstance.eventDateProfile.buildRange(calendar);
-			dateRange = dateRange.constrainTo(constraintRange);
-
-			if (dateRange) {
-				dateRanges.push(dateRange);
-			}
-		}
-
-		return dateRanges;
-	}
-
-});
-
-
-// will sort eventRanges in place
-function invertDateRanges(dateRanges, constraintRange) {
-	var invertedRanges = [];
-	var startMs = constraintRange.startMs; // the end of the previous range. the start of the new range
-	var i;
-	var dateRange;
-
-	// ranges need to be in order. required for our date-walking algorithm
-	dateRanges.sort(compareUnzonedRanges);
-
-	for (i = 0; i < dateRanges.length; i++) {
-		dateRange = dateRanges[i];
-
-		// add the span of time before the event (if there is any)
-		if (dateRange.startMs > startMs) { // compare millisecond time (skip any ambig logic)
-			invertedRanges.push(new UnzonedRange(startMs, dateRange.startMs));
-		}
-
-		if (dateRange.endMs > startMs) {
-			startMs = dateRange.endMs;
-		}
-	}
-
-	// add the span of time after the last event (if there is any)
-	if (startMs < constraintRange.endMs) { // compare millisecond time (skip any ambig logic)
-		invertedRanges.push(new UnzonedRange(startMs, constraintRange.endMs));
-	}
-
-	return invertedRanges;
-}

+ 355 - 0
src/models/EventManager.js

@@ -0,0 +1,355 @@
+
+var EventManager = Class.extend(EmitterMixin, ListenerMixin, {
+
+	currentPeriod: null,
+
+	stickySource: null,
+	otherSources: null, // does not include sticky source
+
+
+	constructor: function() {
+		this.otherSources = [];
+		this.stickySource = new ArrayEventSource();
+	},
+
+
+	requestEventRangeGroups: function(start, end, timezone, force) {
+		if (
+			force ||
+			!this.currentPeriod ||
+			!this.currentPeriod.isWithinRange(start, end)
+		) {
+			this.setPeriod( // will change this.currentPeriod
+				new EventPeriod(start, end, timezone)
+			);
+		}
+
+		return this.currentPeriod.whenReleased();
+	},
+
+
+	// Source Adding/Removing
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	addSource: function(eventSource) {
+		this.otherSources.push(eventSource);
+
+		if (this.currentPeriod) {
+			this.currentPeriod.requestSource(eventSource); // might release
+		}
+	},
+
+
+	removeSource: function(doomedSource) {
+		removeExact(this.otherSources, doomedSource);
+
+		if (this.currentPeriod) {
+			this.currentPeriod.purgeSource(doomedSource); // might release
+		}
+	},
+
+
+	removeAllSources: function() {
+		this.otherSources = [];
+
+		if (this.currentPeriod) {
+			this.currentPeriod.purgeAllSources(); // might release
+		}
+	},
+
+
+	// Source Refetching
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	refetchSource: function(eventSource) {
+		if (this.currentPeriod) {
+			this.currentPeriod.purgeSource(eventSource, true); // isSilent=true
+			this.currentPeriod.requestSource(eventSource);
+		}
+	},
+
+
+	refetchAllSources: function() {
+		if (this.currentPeriod) {
+			this.currentPeriod.purgeAllSources(true); // isSilent=true
+			this.currentPeriod.requestSources(this.getSources());
+		}
+	},
+
+
+	// Source Querying
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	getSources: function() {
+		return [ this.stickySource ].concat(this.otherSources);
+	},
+
+
+	// like getEventSourcesByMatch, but accepts multple match criteria (like multiple IDs)
+	querySources: function(matchInputs) {
+
+		// coerce into an array
+		if (!matchInputs) {
+			matchInputs = [];
+		}
+		else if (!$.isArray(matchInputs)) {
+			matchInputs = [ matchInputs ];
+		}
+
+		var matchingSources = [];
+		var i;
+
+		// resolve raw inputs to real event source objects
+		for (i = 0; i < matchInputs.length; i++) {
+			matchingSources.push.apply( // append
+				matchingSources,
+				this.querySourceMatch(matchInputs[i])
+			);
+		}
+
+		return matchingSources;
+	},
+
+
+	// matchInput can either by a real event source object, an ID, or the function/URL for the source.
+	// returns an array of matching source objects.
+	querySourceMatch: function(matchInput) {
+		var sources = this.otherSources;
+		var i, source;
+
+		// given an proper event source object
+		for (i = 0; i < sources.length; i++) {
+			source = sources[i];
+			if (source === matchInput) {
+				return [ source ];
+			}
+		}
+
+		// an ID match
+		source = this.getSourceById(EventSource.normalizeId(matchInput));
+		if (source) {
+			return [ source ];
+		}
+
+		return $.grep(sources, function(source) {
+			return isSourcesEquivalent(matchInput, source);
+		});
+	},
+
+
+	/*
+	ID assumed to already be normalized
+	*/
+	getSourceById: function(id) {
+		return $.grep(this.otherSources, function(source) {
+			return source.id && source.id === id;
+		})[0];
+	},
+
+
+	// Event-Period
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	setPeriod: function(eventPeriod) {
+		if (this.currentPeriod) {
+			this.unbindPeriod(this.currentPeriod);
+			this.currentPeriod = null;
+		}
+
+		this.currentPeriod = eventPeriod;
+		this.bindPeriod(eventPeriod);
+
+		eventPeriod.requestSources(this.getSources());
+	},
+
+
+	bindPeriod: function(eventPeriod) {
+		this.listenTo(eventPeriod, 'release', function(eventRangeGroups) {
+			this.trigger('release', eventRangeGroups);
+		});
+	},
+
+
+	unbindPeriod: function(eventPeriod) {
+		this.stopListeningTo(eventPeriod);
+	},
+
+
+	// Event Getting/Adding/Removing
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	addEventDef: function(eventDef, isSticky) {
+		if (isSticky) {
+			this.stickySource.addEventDef(eventDef);
+		}
+
+		if (this.currentPeriod) {
+			this.currentPeriod.addEventDef(eventDef); // might release
+		}
+	},
+
+
+	removeEventsById: function(eventId) {
+		this.getSources().forEach(function(eventSource) {
+			eventSource.removeEventsById(eventId);
+		});
+
+		if (this.currentPeriod) {
+			this.currentPeriod.removeEventsById(eventId); // might release
+		}
+	},
+
+
+	getEventDefByInternalId: function(internalId) {
+		var foundEventDef = null;
+
+		// TODO: somehow break after first match
+		// TODO: somehow cache
+		this.iterEventDefs(function(eventDef) {
+			if (eventDef.internalId === internalId) {
+				foundEventDef = eventDef;
+			}
+		});
+
+		return foundEventDef;
+	},
+
+
+	iterEventDefs: function(func) {
+		this.sources.each(function(source) {
+			if (source instanceof ArrayEventSource) {
+				source.iterEventDefs(func);
+			}
+		});
+
+		if (this.currentPeriod) {
+			this.currentPeriod.iterEventDefs(function(eventDef) {
+				if (!(eventDef.source instanceof ArrayEventSource)) {
+					func(eventDef);
+				}
+			});
+		}
+	},
+
+
+	// Event Mutating
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	rezoneEvents: function() {
+		this.iterEventDefs(function(eventDef) {
+			eventDef.rezone();
+		});
+	},
+
+
+	/*
+	Returns an undo function
+	*/
+	mutateEventsWithId: function(eventDefId, eventDefMutation) {
+		var undoFuncs = [];
+		var currentPeriod = this.currentPeriod;
+
+		// TODO: somehow use EventPeriod::getEventDefsById
+		this.iterEventDefs(function(eventDef) {
+			if (eventDef.id === eventDefId) {
+				if (eventDef instanceof SingleEventDef) {
+					undoFuncs.push(
+						eventDefMutation.mutateSingle(eventDef)
+					);
+				}
+			}
+		});
+
+		if (currentPeriod && undoFuncs.length) {
+			// EventManager is responsible for triggering release
+			currentPeriod.tryRelease();
+		}
+
+		return function() {
+			for (var i = 0; i < undoFuncs.length; i++) {
+				undoFuncs[i]();
+			}
+
+			if (currentPeriod && undoFuncs.length) {
+				// EventManager is responsible for triggering release
+				currentPeriod.tryRelease();
+			}
+		};
+	},
+
+
+	/*
+	copies and then mutates
+	*/
+	buildMutatedEventInstanceGroup: function(eventDefId, eventDefMutation) {
+		var eventDefs = this.getEventDefsById(eventDefId);
+		var i;
+		var defCopy;
+		var allInstances = [];
+
+		for (i = 0; i < eventDefs.length; i++) {
+			defCopy = eventDefs[i].clone();
+
+			if (defCopy instanceof SingleEventDef) {
+				eventDefMutation.mutateSingle(defCopy);
+
+				allInstances.push.apply(allInstances, // append
+					defCopy.buildInstances()
+				);
+			}
+		}
+
+		return new EventInstanceGroup(allInstances);
+	},
+
+
+	// Freezing
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	freeze: function() {
+		if (this.currentPeriod) {
+			this.currentPeriod.freeze();
+		}
+	},
+
+
+	thaw: function() {
+		if (this.currentPeriod) {
+			this.currentPeriod.thaw();
+		}
+	}
+
+});
+
+
+// Methods that straight-up query the current EventPeriod for an array of results.
+[
+	'getEventDefsById',
+	'getEventInstances',
+	'getEventRanges',
+	'getEventRangesWithId',
+	'getEventRangesWithoutId'
+].forEach(function(methodName) {
+
+	EventManager.prototype[methodName] = function() {
+		var currentPeriod = this.currentPeriod;
+
+		if (currentPeriod) {
+			return currentPeriod[methodName].apply(currentPeriod, arguments);
+		}
+
+		return [];
+	};
+});
+
+
+function isSourcesEquivalent(source1, source2) {
+	return source1 && source2 && source1.getPrimitive() == source2.getPrimitive();
+}

+ 0 - 76
src/models/EventMutation.js

@@ -1,76 +0,0 @@
-
-var EventMutation = Class.extend({ // TODO: EventDefMutation
-
-	newTitle: null,
-	newRendering: null,
-	additionalMiscProps: null,
-	dateMutation: null,
-
-
-	// will not provide an undo function
-	mutateSingleEventDefinition: function(eventDef, calendar) {
-		var origTitle = eventDef.title;
-		var origRendering = eventDef.rendering;
-		var origMiscProps = eventDef.miscProps;
-		var undoDateMutation;
-
-		if (this.newTitle != null) {
-			eventDef.title = this.newTitle;
-		}
-
-		if (this.newRendering != null) {
-			eventDef.rendering = this.newRendering;
-		}
-
-		$.extend({}, eventDef.miscProps, this.additionalMiscProps || {});
-
-		undoDateMutation = this.dateMutation.mutateSingleEventDefinition(
-			eventDef,
-			calendar
-		);
-
-		return function() {
-			eventDef.title = origTitle;
-			eventDef.rendering = origRendering;
-			eventDef.miscProps = origMiscProps;
-
-			undoDateMutation();
-		};
-	},
-
-
-	isSomething: function() {
-		return this.newTitle != null || this.newRendering != null || this.additionalMiscProps ||
-			(this.dateMutation && this.dateMutation.isSomething());
-	}
-
-});
-
-
-EventMutation.createFromRawProps = function(eventInstance, newRawProps, largeUnit, calendar) {
-	var newTitle = newRawProps.title;
-	var newRendering = newRawProps.rendering;
-	var additionalMiscProps = {};
-	var propName;
-	var dateMutation;
-	var eventMutation;
-
-	for (propName in newRawProps) {
-		if (!eventInstance.eventDefinition.isStandardProp(propName)) {
-			additionalMiscProps[propName] = newRawProps[propName];
-		}
-	}
-
-	dateMutation = EventDateMutation.createFromRawProps(
-		eventInstance, newRawProps, largeUnit, calendar
-	);
-
-	eventMutation = new EventMutation();
-	eventMutation.newTitle = newTitle;
-	eventMutation.newRendering = newRendering;
-	eventMutation.additionalMiscProps = additionalMiscProps;
-	eventMutation.dateMutation = dateMutation;
-
-	return eventMutation;
-};
-

+ 355 - 0
src/models/EventPeriod.js

@@ -0,0 +1,355 @@
+
+var EventPeriod = Class.extend(EmitterMixin, {
+
+	start: null,
+	end: null,
+	timezone: null,
+
+	requests: null,
+	pendingCnt: 0,
+
+	freezeDepth: 0,
+	stuntedReleaseCnt: 0,
+	releaseCnt: 0,
+
+	eventDefsById: null,
+	eventInstancesById: null,
+	eventRangesById: null,
+
+
+	constructor: function(start, end, timezone) {
+		this.start = start;
+		this.end = end;
+		this.timezone = timezone;
+		this.requests = [];
+		this.eventDefsById = {};
+		this.eventInstancesById = {};
+		this.eventRangesById = {};
+	},
+
+
+	isWithinRange: function(start, end) {
+		// TODO: use a range util function?
+		return !start.isBefore(this.start) && !end.isAfter(this.end);
+	},
+
+
+	// Requesting and Purging
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	requestSources: function(sources) {
+		this.freeze();
+
+		for (var i = 0; i < sources.length; i++) {
+			this.requestSource(sources[i]);
+		}
+
+		this.thaw();
+	},
+
+
+	requestSource: function(source) {
+		var _this = this;
+		var request = { source: source, status: 'pending' };
+
+		this.requests.push(request);
+		this.pendingCnt += 1;
+
+		source.fetch(this.start, this.end, this.timezone).then(function(eventDefs) {
+			if (request.status !== 'cancelled') {
+				_this.addEventDefs(eventDefs);
+				_this.pendingCnt--;
+				_this.tryRelease();
+			}
+		});
+	},
+
+
+	purgeSource: function(source, isSilent) {
+		var _this = this;
+
+		var removeCnt = removeMatching(this.requests, function(request) {
+			if (request.source === source) {
+				if (request.status === 'pending') {
+					_this.pendingCnt--; // removeEventBySource might trigger the release
+				}
+				request.status = 'cancelled';
+				return true; // remove from the array
+			}
+		});
+
+		if (removeCnt) {
+			this.removeEventsBySource(source, isSilent); // might release
+		}
+
+		return removeCnt;
+	},
+
+
+	purgeAllSources: function(isSilent) {
+		if (this.requests.length) {
+			this.requests.forEach(function(request) {
+				request.status = 'cancelled';
+			});
+
+			this.requests = [];
+			this.pendingCnt = 0;
+			this.removeAllEvents(isSilent); // might release
+		}
+	},
+
+
+	// Event Def/Instance/Range ADDING
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	addEventDefs: function(eventDefs) {
+		for (var i = 0; i < eventDefs.length; i++) {
+			this.addEventDef(eventDefs[i]);
+		}
+	},
+
+
+	addEventDef: function(eventDef) {
+		var eventDefsById = this.eventDefsById;
+		var eventDefId = eventDef.id;
+		var eventInstances = eventDef.buildInstances(this.start, this.end);
+		var i;
+
+		(eventDefsById[eventDefId] || (eventDefsById[eventDefId] = []))
+			.push(eventDef);
+
+		for (i = 0; i < eventInstances.length; i++) {
+			this.addEventInstance(eventInstances[i], eventDefId);
+		}
+	},
+
+
+	addEventInstance: function(eventInstance, eventDefId) {
+		var eventInstancesById = this.eventInstancesById;
+
+		(eventInstancesById[eventDefId] || (eventInstancesById[eventDefId] = []))
+			.push(eventInstance);
+
+		this.addEventRange(eventInstance.buildEventRange(), eventDefId);
+	},
+
+
+	addEventRange: function(eventRange, eventDefId) {
+		var eventRangesById = this.eventRangesById;
+
+		(eventRangesById[eventDefId] || (eventRangesById[eventDefId] = []))
+			.push(eventRange);
+
+		this.tryRelease();
+	},
+
+
+	// Event Def/Instance/Range REMOVING
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	removeEventsById: function(eventDefId) {
+		delete this.eventDefsById[eventDefId];
+		delete this.eventInstancesById[eventDefId];
+
+		if (eventDefId in this.eventRangesById) {
+			delete this.eventRangesById[eventDefId];
+
+			this.tryRelease();
+		}
+	},
+
+
+	removeEventsBySource: function(source, isSilent) {
+		var eventDefsById = this.eventDefsById;
+		var eventInstancesById = this.eventInstancesById;
+		var eventRangesById = this.eventRangesById;
+		var id;
+		var removeCnt = 0;
+
+		function matchEventDef(eventDef) {
+			return eventDef.source === source;
+		}
+
+		function matchEventInstance(eventInstance) {
+			return eventInstance.def.source === source;
+		}
+
+		function matchEventRange(eventRange) {
+			return eventRange.eventInstance.def.source === source;
+		}
+
+		for (id in eventDefsById) {
+			removeMatching(eventDefsById[id], matchEventDef);
+		}
+
+		for (id in eventInstancesById) {
+			removeMatching(eventInstancesById[id], matchEventInstance);
+		}
+
+		for (id in eventRangesById) {
+			removeCnt += removeMatching(eventRangesById[id], matchEventRange);
+		}
+
+		if (removeCnt && !isSilent) {
+			this.tryRelease();
+		}
+	},
+
+
+	removeAllEvents: function(isSilent) {
+		var hasAny = !$.isEmptyObject(this.eventRangesById);
+
+		this.eventDefsById = {};
+		this.eventInstancesById = {};
+		this.eventRangesById = {};
+
+		if (hasAny && !isSilent) {
+			this.tryRelease();
+		}
+	},
+
+
+	// Event Def/Instance/Range GETTING
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	getEventDefsById: function(eventDefId) {
+		return this.eventDefsById[eventDefId] || [];
+	},
+
+
+	iterEventDefs: function(func) {
+		var eventDefsById = this.eventDefId;
+		var id;
+		var eventDefs;
+		var i;
+
+		for (id in eventDefsById) {
+			eventDefs = eventDefsById[id];
+
+			for (i = 0; i < eventDefs.length; i++) {
+				func(eventDefs[i]);
+			}
+		}
+	},
+
+
+	getEventInstances: function() { // TODO: consider iterator
+		var eventInstancesById = this.eventInstancesById;
+		var allInstances = [];
+		var id;
+
+		for (id in eventInstancesById) {
+			allInstances.push.apply(allInstances, // append
+				eventInstancesById[id]
+			);
+		}
+
+		return allInstances;
+	},
+
+
+	getEventRanges: function() { // TODO: consider iterator
+		var eventRangesById = this.eventRangesById;
+		var matchingRanges = [];
+		var id;
+
+		for (id in eventRangesById) {
+			matchingRanges.push.apply(matchingRanges, // append
+				eventRangesById[id]
+			);
+		}
+
+		return matchingRanges;
+	},
+
+
+	getEventRangesWithId: function(eventDefId) {
+		return this.eventRangesById[eventDefId] || [];
+	},
+
+
+	getEventRangesWithoutId: function(eventDefId) { // TODO: consider iterator
+		var eventRangesById = this.eventRangesById;
+		var matchingRanges = [];
+		var id;
+
+		for (id in eventRangesById) {
+			if (id !== eventDefId) {
+				matchingRanges.push.apply(matchingRanges, // append
+					eventRangesById[id]
+				);
+			}
+		}
+
+		return matchingRanges;
+	},
+
+
+	// Releasing and Freezing
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	tryRelease: function() {
+		if (!this.pendingCnt) {
+			if (!this.freezeDepth) {
+				this.release();
+			}
+			else {
+				this.stuntedReleaseCnt++;
+			}
+		}
+	},
+
+
+	release: function() {
+		this.releaseCnt++;
+		// TODO: dont re-convert to rangegroups
+		this.trigger('release', hashToEventRangeGroups(this.eventRangesById));
+	},
+
+
+	whenReleased: function() {
+		var _this = this;
+
+		if (this.releaseCnt) {
+			// TODO: dont re-convert to rangegroups
+			return Promise.resolve(hashToEventRangeGroups(this.eventRangesById));
+		}
+		else {
+			return Promise.construct(function(onResolve) {
+				_this.one('release', onResolve);
+			});
+		}
+	},
+
+
+	freeze: function() {
+		if (!(this.freezeDepth++)) {
+			this.stuntedReleaseCnt = 0;
+		}
+	},
+
+
+	thaw: function() {
+		if (!(--this.freezeDepth) && this.stuntedReleaseCnt && !this.pendingCnt) {
+			this.release();
+		}
+	}
+
+});
+
+
+function hashToEventRangeGroups(hash) {
+	var eventRangeGroups = [];
+	var id;
+
+	for (id in hash) {
+		eventRangeGroups.push(new EventRangeGroup(hash[id]));
+	}
+
+	return eventRangeGroups;
+}

+ 39 - 0
src/models/UnzonedRange.js

@@ -51,6 +51,45 @@ var UnzonedRange = Class.extend({
 });
 
 
+/*
+SIDEEFFECT: will mutate eventRanges.
+Will return a new array result.
+*/
+function invertDateRanges(dateRanges, constraintRange) {
+	var invertedRanges = [];
+	var startMs = constraintRange.startMs; // the end of the previous range. the start of the new range
+	var i;
+	var dateRange;
+
+	// ranges need to be in order. required for our date-walking algorithm
+	dateRanges.sort(compareUnzonedRanges);
+
+	for (i = 0; i < dateRanges.length; i++) {
+		dateRange = dateRanges[i];
+
+		// add the span of time before the event (if there is any)
+		if (dateRange.startMs > startMs) { // compare millisecond time (skip any ambig logic)
+			invertedRanges.push(
+				new UnzonedRange(startMs, dateRange.startMs)
+			);
+		}
+
+		if (dateRange.endMs > startMs) {
+			startMs = dateRange.endMs;
+		}
+	}
+
+	// add the span of time after the last event (if there is any)
+	if (startMs < constraintRange.endMs) { // compare millisecond time (skip any ambig logic)
+		invertedRanges.push(
+			new UnzonedRange(startMs, constraintRange.endMs)
+		);
+	}
+
+	return invertedRanges;
+}
+
+
 function compareUnzonedRanges(range1, range2) {
 	return range1.startMs - range2.startMs; // earlier ranges go first
 }

+ 80 - 0
src/models/event-source/ArrayEventSource.js

@@ -0,0 +1,80 @@
+
+var ArrayEventSource = EventSource.extend({
+
+	rawEventDefs: null, // unparsed
+	eventDefs: null,
+
+
+	constructor: function() {
+		EventSource.apply(this, arguments); // super-constructor
+		this.eventDefs = []; // for if setRawEventDefs is never called
+	},
+
+
+	setRawEventDefs: function(rawEventDefs) {
+		this.rawEventDefs = rawEventDefs;
+		this.eventDefs = this.parseEventDefs(rawEventDefs);
+	},
+
+
+	/*
+	disregards given start/end arguments
+	*/
+	fetch: function() {
+		return Promise.resolve(this.eventDefs);
+	},
+
+
+	addEventDef: function(eventDef) {
+		this.eventDefs.push(eventDef);
+	},
+
+
+	iterEventDefs: function(func) {
+		this.eventDefs.forEach(func);
+	},
+
+
+	/*
+	eventDefId already normalized to a string
+	*/
+	removeEventsById: function(eventDefId) {
+		return removeMatching(this.eventDefs, function(eventDef) {
+			return eventDef.id === eventDefId;
+		});
+	},
+
+
+	getPrimitive: function() {
+		return this.rawEventDefs;
+	}
+
+});
+
+
+ArrayEventSource.parse = function(rawInput, calendar) {
+	var rawEventDefs;
+	var rawOtherProps;
+	var source;
+
+	if ($.isArray(rawInput)) {
+		rawEventDefs = rawInput;
+		rawOtherProps = {};
+	}
+	else if ($.isArray(rawInput.events)) {
+		rawOtherProps = $.extend({}, rawInput); // copy
+		rawEventDefs = pluckProp(rawOtherProps, 'events');
+	}
+
+	if (rawEventDefs) {
+		source = EventSource.parseAndPluck.call(this, rawOtherProps, calendar);
+		source.setRawEventDefs(rawEventDefs);
+
+		return source;
+	}
+};
+
+
+EventSourceParser.registerClass(ArrayEventSource);
+
+FC.ArrayEventSource = ArrayEventSource;

+ 123 - 0
src/models/event-source/EventSource.js

@@ -0,0 +1,123 @@
+
+var EventSource = Class.extend({
+
+	calendar: null,
+
+	id: null,
+	color: null,
+	backgroundColor: null,
+	borderColor: null,
+	textColor: null,
+	className: null, // array
+	editable: null,
+	startEditable: null,
+	durationEditable: null,
+	resourceEditable: null,
+	rendering: null,
+	overlap: null,
+	constraint: null,
+	allDayDefault: null,
+	eventDataTransform: null, // optional function
+
+
+	constructor: function(calendar) {
+		this.calendar = calendar;
+		this.className = [];
+	},
+
+
+	fetch: function(start, end, timezone) {
+		// subclasses must implement. must return a promise.
+	},
+
+
+	removeEventsById: function(eventDefId) {
+		// optional for subclasses to implement
+	},
+
+
+	/*
+	For compairing/matching
+	*/
+	getPrimitive: function(otherSource) {
+		// subclasses must implement
+	},
+
+
+	parseEventDefs: function(rawEventDefs) {
+		var i;
+		var eventDef;
+		var eventDefs = [];
+
+		for (i = 0; i < rawEventDefs.length; i++) {
+			eventDef = EventDefParser.parse(
+				rawEventDefs[i],
+				this // source
+			);
+
+			if (eventDef) {
+				eventDefs.push(eventDef);
+			}
+		}
+
+		return eventDefs;
+	}
+
+});
+
+
+EventSource.normalizeId = function(id) {
+	if (id) {
+		return String(id);
+	}
+
+	return null;
+};
+
+
+// Parsing
+// ---------------------------------------------------------------------------------------------------------------------
+
+
+EventSource.parse = function(rawInput, calendar) {
+	// subclasses must implement
+};
+
+
+EventSource.parseAndPluck = function(rawProps, calendar) {
+	var source = new this(calendar);
+	var members = pluckProps(rawProps, [
+		'id',
+		'color',
+		'backgroundColor',
+		'borderColor',
+		'textColor',
+		'className',
+		'editable',
+		'startEditable',
+		'durationEditable',
+		'resourceEditable',
+		'rendering',
+		'overlap',
+		'constraint',
+		'allDayDefault',
+		'eventDataTransform'
+	]);
+
+	// post-process some soon-to-be member variables
+	if (typeof members.className === 'string') {
+		members.className = members.className.split(/\s+/);
+	}
+	else if (!members.className) {
+		delete members.className; // don't overwrite the empty array
+	}
+	members.id = EventSource.normalizeId(members.id);
+
+	// apply the member variables
+	$.extend(source, members);
+
+	return source;
+};
+
+
+FC.EventSource = EventSource;

+ 29 - 0
src/models/event-source/EventSourceParser.js

@@ -0,0 +1,29 @@
+
+var EventSourceParser = {
+
+	sourceClasses: [],
+
+
+	registerClass: function(EventSourceClass) {
+		this.sourceClasses.push(EventSourceClass);
+	},
+
+
+	parse: function(rawInput, calendar) {
+		var sourceClasses = this.sourceClasses;
+		var i;
+		var eventSource;
+
+		for (i = 0; i < sourceClasses.length; i++) {
+			eventSource = sourceClasses[i].parse(rawInput, calendar);
+
+			if (eventSource) {
+				return eventSource;
+			}
+		}
+	}
+
+};
+
+
+FC.EventSourceParser = EventSourceParser;

+ 56 - 0
src/models/event-source/FuncEventSource.js

@@ -0,0 +1,56 @@
+
+var FuncEventSource = EventSource.extend({
+
+	func: null,
+
+
+	fetch: function(start, end, timezone) {
+		var _this = this;
+
+		return Promise.construct(function(onResolve) {
+			this.func.call(
+				this.calendar,
+				start.clone(),
+				end.clone(),
+				timezone,
+				function(rawEventDefs) {
+					onResolve(_this.parseEventDefs(rawEventDefs));
+				}
+			);
+		});
+	},
+
+
+	getPrimitive: function() {
+		return this.func;
+	}
+
+});
+
+
+FuncEventSource.parse = function(rawInput, calendar) {
+	var func;
+	var rawOtherProps;
+	var source;
+
+	if ($.isFunction(rawInput)) {
+		func = rawInput;
+		rawOtherProps = {};
+	}
+	else if ($.isFunction(rawInput.events)) {
+		rawOtherProps = $.extend({}, rawInput); // copy
+		func = pluckProp(rawOtherProps, 'events');
+	}
+
+	if (func) {
+		source = EventSource.parseAndPluck.call(this, rawOtherProps, calendar);
+		source.func = func;
+
+		return source;
+	}
+};
+
+
+EventSourceParser.registerClass(FuncEventSource);
+
+FC.FuncEventSource = FuncEventSource;

+ 139 - 0
src/models/event-source/JsonFeedEventSource.js

@@ -0,0 +1,139 @@
+
+var JsonFeedEventSource = EventSource.extend({
+
+	// these props must all be manually set before calling fetch
+	startParam: null,
+	endParam: null,
+	timezoneParam: null,
+	ajaxSettings: null,
+
+
+	fetch: function(start, end, timezone) {
+		var _this = this;
+		var ajaxSettings = this.ajaxSettings;
+		var onSuccess = ajaxSettings.success;
+		var onError = ajaxSettings.error;
+		var requestParams = this.buildRequestParams(start, end, timezone);
+
+		// todo: eventually handle the promise's then,
+		// don't intercept success/error
+		// tho will be a breaking API change
+
+		return Promise.construct(function(onResolve, onReject) {
+			$.ajax($.extend(
+				{}, // avoid mutation
+				JsonFeedEventSource.AJAX_DEFAULTS,
+				ajaxSettings, // should have a `url`
+				{
+					data: requestParams,
+					success: function(rawEventDefs) {
+						var callbackRes;
+
+						if (rawEventDefs) {
+							callbackRes = applyAll(onSuccess, this, arguments); // redirect `this`
+
+							if ($.isArray(callbackRes)) {
+								rawEventDefs = callbackRes;
+							}
+
+							onResolve(_this.parseEventDefs(rawEventDefs));
+						}
+						else {
+							onReject();
+						}
+					},
+					error: function() {
+						applyAll(onError, this, arguments); // redirect `this`
+						onReject();
+					}
+				}
+			));
+		});
+	},
+
+
+	buildRequestParams: function(start, end, timezone) {
+		var calendar = this.calendar;
+		var ajaxSettings = this.ajaxSettings;
+		var startParam, endParam, timezoneParam;
+		var customRequestParams;
+		var params = {};
+
+		startParam = this.startParam;
+		if (startParam == null) {
+			startParam = calendar.opt('startParam');
+		}
+
+		endParam = this.endParam;
+		if (endParam == null) {
+			endParam = calendar.opt('endParam');
+		}
+
+		timezoneParam = this.timezoneParam;
+		if (timezoneParam == null) {
+			timezoneParam = calendar.opt('timezoneParam');
+		}
+
+		// retrieve any outbound GET/POST $.ajax data from the options
+		if ($.isFunction(ajaxSettings.data)) {
+			// supplied as a function that returns a key/value object
+			customRequestParams = ajaxSettings.data();
+		}
+		else {
+			// probably supplied as a straight key/value object
+			customRequestParams = ajaxSettings.data || {};
+		}
+
+		$.extend(params, customRequestParams);
+
+		params[startParam] = start.format();
+		params[endParam] = end.format();
+
+		if (timezone && timezone !== 'local') {
+			params[timezoneParam] = timezone;
+		}
+
+		return params;
+	},
+
+
+	getPrimitive: function() {
+		return this.ajaxSettings.url;
+	}
+
+});
+
+
+JsonFeedEventSource.AJAX_DEFAULTS = {
+	dataType: 'json',
+	cache: false
+};
+
+
+JsonFeedEventSource.parse = function(rawInput, calendar) {
+	var rawProps;
+	var source;
+
+	if (typeof rawInput === 'string') {
+		rawProps =  { url: rawInput };
+	}
+	else if (typeof rawInput.url === 'string') {
+		rawProps = $.extend({}, rawInput); // copy
+	}
+
+	if (rawProps) {
+		source = EventSource.parseAndPluck.call(this, rawProps, calendar);
+
+		source.startParam = pluckProp(rawProps, 'startParam');
+		source.endParam = pluckProp(rawProps, 'endParam');
+		source.timezoneParam = pluckProp(rawProps, 'timezoneParam');
+		source.ajaxSettings = rawProps; // remainder
+
+		return source;
+	}
+};
+
+
+EventSourceParser.registerClass(JsonFeedEventSource);
+
+FC.JsonFeedEventSource = JsonFeedEventSource;

+ 25 - 13
src/models/EventDateProfile.js → src/models/event/EventDateProfile.js

@@ -1,18 +1,27 @@
 
-var EventDateProfile = Class.extend({
+var EventDateProfile = Class.extend(EventStartEndMixin, {
 
-	start: null,
-	end: null,
 
 	constructor: function(start, end) {
 		this.start = start;
 		this.end = end;
 	},
 
-	isAllDay: function() {
-		return !(this.start.hasTime() || (this.end && this.end.hasTime()));
+
+	/*
+	Needs a Calendar object
+	*/
+	buildRange: function(calendar) {
+		var startMs = this.start.clone().stripZone().valueOf();
+		var endMs = this.getEnd(calendar).stripZone().valueOf();
+
+		return new UnzonedRange(startMs, endMs);
 	},
 
+
+	/*
+	Needs a Calendar object
+	*/
 	getEnd: function(calendar) {
 		return this.end ?
 			this.end.clone() :
@@ -21,14 +30,17 @@ var EventDateProfile = Class.extend({
 				this.isAllDay(),
 				this.start
 			);
-	},
-
-	// calendar object needed to compute missing end dates
-	buildRange: function(calendar) {
-		var startMs = this.start.clone().stripZone().valueOf();
-		var endMs = this.getEnd(calendar).stripZone().valueOf();
-
-		return new UnzonedRange(startMs, endMs);
 	}
 
 });
+
+
+/*
+Needs a Calendar object
+*/
+EventDateProfile.parse = function(rawProps, calendar) {
+	return new EventDateProfile(
+		calendar.moment(rawProps.start),
+		rawProps.end ? calendar.moment(rawProps.end) : null
+	);
+};

+ 187 - 0
src/models/event/EventDef.js

@@ -0,0 +1,187 @@
+
+var EventDef = Class.extend({
+
+	source: null, // required
+
+	id: null, // normalized supplied ID
+	rawId: null, // unnormalized supplied ID
+	internalId: null, // internal ID. new ID for every definition
+
+	title: null,
+	rendering: null,
+	constraint: null,
+	overlap: null,
+	className: null, // an array. TODO: rename to className*s* (API breakage)
+	miscProps: null,
+
+
+	constructor: function(source) {
+		this.source = source;
+		this.miscProps = {};
+	},
+
+
+	buildInstances: function(start, end) {
+		// subclasses must implement
+	},
+
+
+	clone: function() {
+		var copy = new this.constructor(this.source);
+
+		copy.id = this.id;
+		copy.rawId = this.rawId;
+		copy.internalId = this.internalId;
+
+		copy.title = this.title;
+		copy.rendering = this.rendering;
+		copy.constraint = this.constraint;
+		copy.overlap = this.overlap;
+		copy.miscProps = $.extend({}, this.miscProps);
+
+		return copy;
+	},
+
+
+	hasInverseRendering: function() {
+		return this.getRendering() === 'inverse-background';
+	},
+
+
+	hasBgRendering: function() {
+		var rendering = this.getRendering();
+
+		return rendering === 'inverse-background' || rendering === 'background';
+	},
+
+
+	getRendering: function() {
+		if (this.rendering != null) {
+			return this.rendering;
+		}
+
+		return this.source.rendering;
+	},
+
+
+	getConstraint: function() {
+		if (this.constraint != null) {
+			return this.constraint;
+		}
+
+		if (this.source.constraint != null) {
+			return this.source.constraint;
+		}
+
+		return this.source.calendar.opt('eventConstraint');
+	},
+
+
+	getOverlap: function() {
+		if (this.overlap != null) {
+			return this.overlap;
+		}
+
+		if (this.source.overlap != null) {
+			return this.source.overlap;
+		}
+
+		return this.source.calendar.opt('eventOverlap');
+	}
+
+
+});
+
+
+EventDef.uuid = 0;
+
+
+// Reserved Properties
+// ---------------------------------------------------------------------------------------------------------------------
+
+
+EventDef.reservedPropMap = {};
+
+
+EventDef.addReservedProps = function(propNames) {
+	var map = {};
+	var i;
+
+	for (i = 0; i < propNames.length; i++) {
+		map[propNames[i]] = true;
+	}
+
+	// won't modify original object. don't want side-effects on superclasses
+	this.reservedPropMap = $.extend({}, this.reservedPropMap, map);
+};
+
+
+EventDef.isReservedProp = function(propName) {
+	return this.reservedPropMap[propName] || false;
+};
+
+
+EventDef.addReservedProps([ 'id', 'title', 'rendering', 'constraint', 'overlap' ]);
+
+
+// Parsing
+// ---------------------------------------------------------------------------------------------------------------------
+
+
+EventDef.parse = function(rawProps, source) {
+	var def = new this(source);
+	var className; // an array
+	var propName;
+	var miscProps = {};
+
+	var calendarTransform = source.calendar.opt('eventDataTransform');
+	var sourceTransform = source.eventDataTransform;
+
+	if (calendarTransform) {
+		rawProps = calendarTransform(rawProps);
+	}
+	if (sourceTransform) {
+		rawProps = sourceTransform(rawProps);
+	}
+
+	className = rawProps.className || [];
+	if (typeof className === 'string') {
+		className = className.split(/\s+/);
+	}
+
+	if (rawProps.id != null) {
+		def.id = EventDef.normalizeId(
+			(def.rawId = rawProps.id)
+		);
+	}
+	else {
+		def.id = def.rawId = EventDef.generateId();
+	}
+
+	def.internalId = String(EventDef.uuid++);
+	def.title = rawProps.title || '';
+	def.rendering = rawProps.rendering || null;
+	def.constraint = rawProps.constraint || null;
+	def.overlap = rawProps.overlap || null;
+	def.className = className;
+
+	for (propName in rawProps) {
+		if (!this.isReservedProp(propName)) {
+			miscProps[propName] = rawProps[propName];
+		}
+	}
+
+	def.miscProps = miscProps;
+
+	return def;
+};
+
+
+EventDef.normalizeId = function(id) {
+	return String(id);
+};
+
+
+EventDef.generateId = function() {
+	return '_fc' + (EventDef.uuid++);
+};

+ 157 - 0
src/models/event/EventDefDateMutation.js

@@ -0,0 +1,157 @@
+
+var EventDefDateMutation = Class.extend({
+
+	clearEnd: false,
+	forceTimed: false,
+	forceAllDay: false,
+	dateDelta: null,
+	startDelta: null,
+	endDelta: null,
+
+
+	/*
+	eventDef assumed to be a SingleEventDef.
+	returns an undo function.
+	*/
+	mutateSingle: function(eventDef) {
+
+		var calendar = eventDef.source.calendar;
+		var origStart = eventDef.start;
+		var origEnd = eventDef.end;
+		var start = origStart.clone();
+		var end = null;
+
+		if (!this.clearEnd && origEnd) {
+			end = origEnd.clone();
+		}
+
+		if (this.forceTimed) {
+
+			if (!start.hasTime()) {
+				start.time(0);
+			}
+
+			if (end && !end.hasTime()) {
+				end.time(0);
+			}
+		}
+		else if (this.forceAllDay) {
+
+			if (start.hasTime()) {
+				start.stripTime();
+			}
+
+			if (end && end.hasTime()) {
+				end.stripTime();
+			}
+		}
+
+		if (this.dateDelta) {
+
+			start.add(this.dateDelta);
+
+			if (end) {
+				end.add(this.dateDelta);
+			}
+		}
+
+		// do this before adding startDelta to start, so we can work off of start
+		if (this.endDelta) {
+
+			if (!end) {
+				end = calendar.getDefaultEventEnd(eventDef.isAllDay(), start);
+			}
+
+			end.add(this.endDelta);
+		}
+
+		if (this.startDelta) {
+			start.add(this.startDelta);
+		}
+
+		// clear timezone if any changes
+		if (calendar.getIsAmbigTimezone()) {
+
+			if (start.hasTime() && (this.dateDelta || this.startDelta)) {
+				start.stripZone();
+			}
+
+			if (end && end.hasTime() && (this.dateDelta || this.endDelta)) {
+				end.stripZone();
+			}
+		}
+
+		eventDef.start = start;
+		eventDef.end = end;
+
+		return function() {
+			eventDef.start = origStart;
+			eventDef.end = origEnd;
+		};
+	},
+
+
+	isEmpty: function() {
+		return !this.clearEnd &&
+			!this.forceTimed &&
+			!this.forceAllDay &&
+			(!this.dateDelta || !this.dateDelta.valueOf()) &&
+			(!this.startDelta || !this.startDelta.valueOf()) &&
+			(!this.endDelta || !this.endDelta.valueOf());
+	}
+
+});
+
+
+EventDefDateMutation.createFromRawProps = function(eventInstance, newRawProps, largeUnit) {
+	var newDateProfile = EventDateProfile.parse(
+		newRawProps,
+		eventInstance.def.source.calendar
+	);
+
+	return EventDefDateMutation.createFromDiff(
+		eventInstance.dateProfile,
+		newDateProfile,
+		largeUnit
+	);
+};
+
+
+EventDefDateMutation.createFromDiff = function(dateProfile0, dateProfile2, largeUnit) {
+	var clearEnd = dateProfile0.end && !dateProfile2.end;
+	var forceTimed = dateProfile0.isAllDay() && !dateProfile2.isAllDay();
+	var forceAllDay = !dateProfile0.isAllDay() && dateProfile2.isAllDay();
+	var dateDelta;
+	var endDiff;
+	var endDelta;
+	var mutation;
+
+	// subtracts the dates in the appropriate way, returning a duration
+	function subtractDates(date1, date0) { // date1 - date0
+		if (largeUnit) {
+			return diffByUnit(date1, date0, largeUnit); // poorly named
+		}
+		else if (dateProfile2.isAllDay()) {
+			return diffDay(date1, date0); // poorly named
+		}
+		else {
+			return diffDayTime(date1, date0); // poorly named
+		}
+	}
+
+	dateDelta = subtractDates(dateProfile2.start, dateProfile0.start);
+
+	if (dateProfile2.end) {
+		endDiff = subtractDates(dateProfile2.end, dateProfile0.getEnd());
+		endDelta = endDiff.subtract(dateDelta);
+	}
+
+	mutation = new EventDefDateMutation();
+	mutation.clearEnd = clearEnd;
+	mutation.forceTimed = forceTimed;
+	mutation.forceAllDay = forceAllDay;
+	mutation.dateDelta = dateDelta;
+	mutation.endDelta = endDelta;
+
+	return mutation;
+};

+ 108 - 0
src/models/event/EventDefMutation.js

@@ -0,0 +1,108 @@
+
+var EventDefMutation = Class.extend({
+
+	newTitle: null,
+	newRendering: null,
+	newConstraint: null,
+	newOverlap: null,
+	newClassName: null, // array or null
+
+	additionalMiscProps: null,
+	dateMutation: null,
+
+
+	/*
+	eventDef assumed to be a SingleEventDef.
+	returns an undo function.
+	*/
+	mutateSingle: function(eventDef) {
+		var origTitle = eventDef.title;
+		var origRendering = eventDef.rendering;
+		var origConstraint = eventDef.constraint;
+		var origOverlap = eventDef.overlap;
+		var origClassName = eventDef.className;
+		var origMiscProps = eventDef.miscProps;
+		var undoDateMutation;
+
+		if (this.newTitle != null) {
+			eventDef.title = this.newTitle;
+		}
+
+		if (this.newRendering != null) {
+			eventDef.rendering = this.newRendering;
+		}
+
+		if (this.newConstraint != null) {
+			eventDef.constraint = this.newConstraint;
+		}
+
+		if (this.newOverlap != null) {
+			eventDef.overlap = this.newOverlap;
+		}
+
+		if (this.newClassName != null) {
+			eventDef.className = this.newClassName;
+		}
+
+		if (this.additionalMiscProps != null) {
+			// create a new object, so that "orig" stays intact
+			eventDef.miscProps = $.extend({}, eventDef.miscProps, this.additionalMiscProps);
+		}
+
+		if (this.dateMutation) {
+			undoDateMutation = this.dateMutation.mutateSingle(eventDef);
+		}
+
+		return function() {
+			eventDef.title = origTitle;
+			eventDef.rendering = origRendering;
+			eventDef.constraint = origConstraint;
+			eventDef.overlap = origOverlap;
+			eventDef.className = origClassName;
+			eventDef.miscProps = origMiscProps;
+
+			if (undoDateMutation) {
+				undoDateMutation();
+			}
+		};
+	},
+
+
+	isEmpty: function() {
+		return this.newTitle == null &&
+			this.newRendering == null &&
+			this.newConstraint == null &&
+			this.newOverlap == null &&
+			this.newClassName == null &&
+			this.additionalMiscProps == null &&
+			(!this.dateMutation || this.dateMutation.isEmpty());
+	}
+
+});
+
+
+EventDefMutation.createFromRawProps = function(eventInstance, newRawProps, largeUnit) {
+	var additionalMiscProps = {};
+	var propName;
+	var dateMutation;
+	var defMutation;
+
+	for (propName in newRawProps) {
+		if (!eventInstance.def.isStandardProp(propName)) {
+			additionalMiscProps[propName] = newRawProps[propName];
+		}
+	}
+
+	dateMutation = EventDefDateMutation.createFromRawProps(eventInstance, newRawProps, largeUnit);
+
+	defMutation = new EventDefMutation();
+	defMutation.newTitle = newRawProps.title;
+	defMutation.newRendering = newRawProps.rendering;
+	defMutation.newConstraint = newRawProps.constraint;
+	defMutation.newOverlap = newRawProps.overlap;
+	defMutation.newClassName = newRawProps.className;
+	defMutation.additionalMiscProps = additionalMiscProps;
+	defMutation.dateMutation = dateMutation;
+
+	return defMutation;
+};

+ 16 - 0
src/models/event/EventDefParser.js

@@ -0,0 +1,16 @@
+
+var EventDefParser = {
+
+	parse: function(eventInput, source) {
+		if (
+			isTimeString(eventInput.start) || moment.isDuration(eventInput.start) ||
+			isTimeString(eventInput.end) || moment.isDuration(eventInput.end)
+		) {
+			return RecurringEventDef.parse(eventInput, source);
+		}
+		else {
+			return SingleEventDef.parse(eventInput, source);
+		}
+	}
+
+};

+ 1 - 0
src/models/EventFootprint.js → src/models/event/EventFootprint.js

@@ -4,6 +4,7 @@ var EventFootprint = Class.extend({
 	eventInstance: null,
 	componentFootprint: null,
 
+
 	constructor: function(eventInstance, componentFootprint) {
 		this.eventInstance = eventInstance;
 		this.componentFootprint = componentFootprint;

+ 46 - 0
src/models/event/EventInstance.js

@@ -0,0 +1,46 @@
+
+var EventInstance = Class.extend({
+
+	def: null, // EventDef
+	dateProfile: null, // EventDateProfile
+
+
+	constructor: function(def, dateProfile) {
+		this.def = def;
+		this.dateProfile = dateProfile;
+	},
+
+
+	buildEventRange: function() { // EventRange
+		return new EventRange(
+			this,
+			this.buildDateRange()
+		);
+	},
+
+
+	buildDateRange: function() { // UnzonedRange
+		return this.dateProfile.buildRange(
+			this.def.source.calendar
+		);
+	},
+
+
+	toLegacy: function() {
+		var def = this.def;
+		var dateProfile = this.dateProfile;
+
+		return $.extend({}, def.miscProps, {
+			_id: def.internalId,
+			id: def.rawId,
+			title: def.title,
+			rendering: def.rendering,
+			start: dateProfile.start.clone(),
+			end: dateProfile.end ? dateProfile.end.clone() : null,
+			allDay: dateProfile.isAllDay(),
+			source: def.source,
+			className: def.className // should clone?
+		});
+	}
+
+});

+ 26 - 0
src/models/event/EventInstanceGroup.js

@@ -0,0 +1,26 @@
+
+/*
+A group of related EventInstances. Assumed to all have the same ID.
+*/
+var EventInstanceGroup = Class.extend({
+
+	eventInstances: null, // EventInstance[]
+
+
+	constructor: function(eventInstances) {
+		this.eventInstances = eventInstances;
+	},
+
+
+	buildRanges: function() {
+		return this.eventInstances.map(function(instance) {
+			return instance.buildEventRange();
+		});
+	},
+
+
+	buildRangeGroup: function() {
+		return new EventRangeGroup(this.buildRanges());
+	}
+
+});

+ 1 - 0
src/models/EventRange.js → src/models/event/EventRange.js

@@ -4,6 +4,7 @@ var EventRange = Class.extend({
 	eventInstance: null,
 	dateRange: null,
 
+
 	constructor: function(eventInstance, dateRange) {
 		this.eventInstance = eventInstance;
 		this.dateRange = dateRange;

+ 80 - 0
src/models/event/EventRangeGroup.js

@@ -0,0 +1,80 @@
+
+var EventRangeGroup = Class.extend({
+
+	eventRanges: null,
+
+
+	constructor: function(eventRanges) {
+		this.eventRanges = eventRanges;
+	},
+
+
+	sliceRenderRanges: function(constraintRange) {
+		if (this.isInverse()) {
+			return this.sliceInverseRenderRanges(constraintRange);
+		}
+		else {
+			return this.sliceNormalRenderRanges(constraintRange);
+		}
+	},
+
+
+	sliceNormalRenderRanges: function(constraintRange) {
+		var wholeEventRanges = this.eventRanges;
+		var i, eventRange;
+		var slicedDateRange;
+		var slicedEventRanges = [];
+
+		for (i = 0; i < wholeEventRanges.length; i++) {
+			eventRange = wholeEventRanges[i];
+
+			slicedDateRange = eventRange.dateRange.constrainTo(constraintRange);
+
+			if (slicedDateRange) {
+				slicedEventRanges.push(
+					new EventRange(
+						eventRange.eventInstance,
+						slicedDateRange
+					)
+				);
+			}
+		}
+
+		return slicedEventRanges;
+	},
+
+
+	sliceInverseRenderRanges: function(constraintRange) {
+		var dateRanges = collectDateRangesFromEventRanges(this.eventRanges);
+		var ownerInstance = this.eventRanges[0].eventInstance;
+
+		dateRanges = invertDateRanges(dateRanges, constraintRange);
+
+		return dateRanges.map(function(dateRange) {
+			return new EventRange(ownerInstance, dateRange);
+		});
+	},
+
+
+	isInverse: function() {
+		return this.getEventDef().hasInverseRendering();
+	},
+
+
+	getEventDef: function() {
+		return this.getEventInstance().def;
+	},
+
+
+	getEventInstance: function() {
+		return this.eventRanges[0].eventInstance;
+	}
+
+});
+
+
+function collectDateRangesFromEventRanges(eventRanges) {
+	return eventRanges.map(function(eventRange) {
+		return eventRange.dateRange;
+	});
+}

+ 12 - 0
src/models/event/EventStartEndMixin.js

@@ -0,0 +1,12 @@
+
+var EventStartEndMixin = {
+
+	start: null,
+	end: null,
+
+
+	isAllDay: function() {
+		return !(this.start.hasTime() || (this.end && this.end.hasTime()));
+	}
+
+};

+ 19 - 14
src/models/RecurringEventDefinition.js → src/models/event/RecurringEventDef.js

@@ -1,15 +1,15 @@
 
-var RecurringEventDefinition = EventDefinition.extend({
+var RecurringEventDef = EventDef.extend({
 
-	startTime: null,
-	endTime: null,
-	dowHash: null,
+	startTime: null, // duration
+	endTime: null, // duration, or null
+	dowHash: null, // object hash, or null
 
 
 	buildInstances: function(start, end) {
 		var date = start.clone();
 		var instanceStart, instanceEnd;
-		var eventInstances = [];
+		var instances = [];
 
 		while (date.isBefore(end)) {
 
@@ -30,7 +30,7 @@ var RecurringEventDefinition = EventDefinition.extend({
 					instanceEnd = date.clone().time(this.endTime);
 				}
 
-				eventInstances.push(
+				instances.push(
 					new EventInstance(
 						this, // definition
 						new EventDateProfile(instanceStart, instanceEnd)
@@ -41,23 +41,24 @@ var RecurringEventDefinition = EventDefinition.extend({
 			date.add(1, 'days');
 		}
 
-		return eventInstances;
+		return instances;
 	},
 
 
-	setDow: function(dowArray) {
+	setDow: function(dowNumbers) {
+
 		if (!this.dowHash) {
 			this.dowHash = {};
 		}
 
-		for (var i = 0; i < dowArray.length; i++) {
-			this.dowHash[dowArray[i]] = true;
+		for (var i = 0; i < dowNumbers.length; i++) {
+			this.dowHash[dowNumbers[i]] = true;
 		}
 	},
 
 
 	clone: function() {
-		var def = EventDefinition.prototype.clone.call(this);
+		var def = EventDef.prototype.clone.call(this);
 
 		if (def.startTime) {
 			def.startTime = moment.duration(this.startTime);
@@ -77,11 +78,15 @@ var RecurringEventDefinition = EventDefinition.extend({
 });
 
 
-RecurringEventDefinition.addReservedProps([ 'start', 'end', 'dow' ]);
+RecurringEventDef.addReservedProps([ 'start', 'end', 'dow' ]);
+
+
+// Parsing
+// ---------------------------------------------------------------------------------------------------------------------
 
 
-RecurringEventDefinition.parse = function(rawProps) {
-	var def = EventDefinition.parse.apply(this, arguments); // a RecurringEventDefinition
+RecurringEventDef.parse = function(rawProps) {
+	var def = EventDef.parse.apply(this, arguments); // a RecurringEventDef
 
 	if (rawProps.start) {
 		def.startTime = moment.duration(rawProps.start);

+ 26 - 14
src/models/SingleEventDefinition.js → src/models/event/SingleEventDef.js

@@ -1,11 +1,11 @@
 
-var SingleEventDefinition = EventDefinition.extend({ // TODO: mix-in some of EventInstance's methods?
+var SingleEventDef = EventDef.extend(EventStartEndMixin, {
 
-	start: null,
-	end: null,
 
-
-	buildInstances: function() { // disregards start/end
+	/*
+	Will receive start/end params, but will be ignored.
+	*/
+	buildInstances: function() {
 		return [
 			new EventInstance(
 				this, // definition
@@ -16,7 +16,7 @@ var SingleEventDefinition = EventDefinition.extend({ // TODO: mix-in some of Eve
 
 
 	clone: function() {
-		var def = EventDefinition.prototype.clone.call(this);
+		var def = EventDef.prototype.clone.call(this);
 
 		def.start = this.start.clone();
 
@@ -28,21 +28,33 @@ var SingleEventDefinition = EventDefinition.extend({ // TODO: mix-in some of Eve
 	},
 
 
-	isAllDay: function() {
-		// TODO: make more DRY
-		return !(this.start.hasTime() || (this.end && this.end.hasTime()));
+	rezone: function() {
+		var calendar = this.source.calendar;
+
+		this.start = calendar.moment(this.start);
+
+		if (this.end) {
+			this.end = calendar.moment(this.end);
+		}
 	}
 
 });
 
 
-SingleEventDefinition.addReservedProps([ 'start', 'end', 'date' ]);
+SingleEventDef.addReservedProps([ 'start', 'end', 'date' ]);
+
+
+// Parsing
+// ---------------------------------------------------------------------------------------------------------------------
 
 
-SingleEventDefinition.parse = function(rawProps, source, calendar) {
-	var def = EventDefinition.parse.apply(this, arguments); // a SingleEventDefinition
+SingleEventDef.parse = function(rawProps, source) {
+	var def = EventDef.parse.apply(this, arguments); // a SingleEventDef
+	var calendar = source.calendar;
 	var start = calendar.moment(rawProps.start || rawProps.date); // 'date' is an alias
 	var end = rawProps.end ? calendar.moment(rawProps.end) : null;
+	var forcedAllDay;
+	var forceEventDuration;
 
 	if (!start.isValid()) {
 		return false;
@@ -52,7 +64,7 @@ SingleEventDefinition.parse = function(rawProps, source, calendar) {
 		end = null;
 	}
 
-	var forcedAllDay = rawProps.allDay;
+	forcedAllDay = rawProps.allDay;
 	if (forcedAllDay == null) {
 		forcedAllDay = source.allDayDefault;
 		if (forcedAllDay == null) {
@@ -73,7 +85,7 @@ SingleEventDefinition.parse = function(rawProps, source, calendar) {
 		}
 	}
 
-	var forceEventDuration = calendar.opt('forceEventDuration');
+	forceEventDuration = calendar.opt('forceEventDuration');
 	if (!end && forceEventDuration) {
 		end = calendar.getDefaultEventEnd(!start.hasTime(), start);
 	}

+ 64 - 6
src/util.js

@@ -885,12 +885,6 @@ function hasOwnProp(obj, name) {
 }
 
 
-// Is the given value a non-object non-function value?
-function isAtomic(val) {
-	return /undefined|null|boolean|number|string/.test($.type(val));
-}
-
-
 function applyAll(functions, thisObj, args) {
 	if ($.isFunction(functions)) {
 		functions = [ functions ];
@@ -906,6 +900,70 @@ function applyAll(functions, thisObj, args) {
 }
 
 
+function pluckProp(obj, propName) {
+	var res = null;
+
+	if (propName in obj) {
+		res = obj[propName];
+		delete obj[propName];
+	}
+
+	return res;
+}
+FC.pluckProp = pluckProp;
+
+
+function pluckProps(obj, propNames) {
+	var i, propName;
+	var res = {};
+
+	for (i = 0; i < propNames.length; i++) {
+		propName = propNames[i];
+
+		if (propName in obj) {
+			res[propName] = obj[propName];
+			delete obj[propName];
+		}
+	}
+
+	return res;
+}
+
+
+function removeMatching(array, testFunc) {
+	var removeCnt = 0;
+	var i;
+
+	while (i < array.length) {
+		if (testFunc(array[i])) { // truthy value means *remove*
+			array.splice(i, 1);
+		}
+		else {
+			i++;
+		}
+	}
+
+	return removeCnt;
+}
+
+
+function removeExact(array, exactVal) {
+	var removeCnt = 0;
+	var i;
+
+	while (i < array.length) {
+		if (array[i] === exactVal) {
+			array.splice(i, 1);
+		}
+		else {
+			i++;
+		}
+	}
+
+	return removeCnt;
+}
+
+
 function firstDefined() {
 	for (var i=0; i<arguments.length; i++) {
 		if (arguments[i] !== undefined) {

+ 4 - 4
tasks/lint.js

@@ -38,8 +38,8 @@ gulp.task('jshint:base', function() {
 gulp.task('jshint:browser', function() {
 	return gulp.src([
 			'src/**/*.js',
-			'!src/intro.js', // exclude
-			'!src/outro.js', // "
+			'!src/**/intro.js', // exclude
+			'!src/**/outro.js', // "
 			'locale/*.js',
 		])
 		.pipe(jshint(jshintBrowser))
@@ -78,8 +78,8 @@ gulp.task('jscs:relaxed', function() {
 			'*.js', // like gulpfile and root configs
 			'tasks/*.js',
 			'src/**/*.js',
-			'!src/intro.js', // exclude
-			'!src/outro.js', // "
+			'!src/**/intro.js', // exclude
+			'!src/**/outro.js', // "
 			'locale/*.js'
 		])
 		.pipe(jscs({