[CalendarServer-changes] [11917] CalendarServer/trunk/contrib

source_changes at macosforge.org source_changes at macosforge.org
Wed Mar 12 11:17:23 PDT 2014


Revision: 11917
          http://trac.calendarserver.org//changeset/11917
Author:   cdaboo at apple.com
Date:     2013-11-08 13:59:31 -0800 (Fri, 08 Nov 2013)
Log Message:
-----------
webpoll prototype/demo webapp.

Added Paths:
-----------
    CalendarServer/trunk/contrib/webpoll/
    CalendarServer/trunk/contrib/webpoll/Makefile
    CalendarServer/trunk/contrib/webpoll/README.txt
    CalendarServer/trunk/contrib/webpoll/caldavd-test-webpoll.plist
    CalendarServer/trunk/contrib/webpoll/webapp/
    CalendarServer/trunk/contrib/webpoll/webapp/css/
    CalendarServer/trunk/contrib/webpoll/webapp/css/webpoll.css
    CalendarServer/trunk/contrib/webpoll/webapp/index.html
    CalendarServer/trunk/contrib/webpoll/webapp/js/
    CalendarServer/trunk/contrib/webpoll/webapp/js/caldav.js
    CalendarServer/trunk/contrib/webpoll/webapp/js/jcal.js
    CalendarServer/trunk/contrib/webpoll/webapp/js/utils.js
    CalendarServer/trunk/contrib/webpoll/webapp/js/webpoll.js

Added: CalendarServer/trunk/contrib/webpoll/Makefile
===================================================================
--- CalendarServer/trunk/contrib/webpoll/Makefile	                        (rev 0)
+++ CalendarServer/trunk/contrib/webpoll/Makefile	2013-11-08 21:59:31 UTC (rev 11917)
@@ -0,0 +1,36 @@
+##
+# Copyright (c) 2013 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+
+webpoll:
+	curl http://code.jquery.com/jquery-2.0.3.js -o webapp/js/jquery-2.0.3.js
+	curl http://code.jquery.com/ui/1.10.3/jquery-ui.js -o webapp/js/jquery-ui-1.10.3.js
+	curl https://raw.github.com/douglascrockford/JSON-js/master/json2.js -o webapp/js/json2.js
+	curl http://trentrichardson.com/examples/timepicker/jquery-ui-timepicker-addon.js -o webapp/js/datetimepicker.js
+	
+	curl http://trentrichardson.com/examples/timepicker/jquery-ui-timepicker-addon.css -o webapp/css/datetimepicker.css
+	curl http://jqueryui.com/resources/download/jquery-ui-themes-1.10.3.zip -o /tmp/jquery-ui-themes-1.10.3.zip
+	unzip /tmp/jquery-ui-themes-1.10.3.zip jquery-ui-themes-1.10.3/themes/cupertino/* -d /tmp
+	mv /tmp/jquery-ui-themes-1.10.3/themes/cupertino webapp/css
+	rm -rf /tmp/jquery-ui-themes-1.10.3
+
+clean:
+	rm -f webapp/js/jquery-2.0.3.js
+	rm -f webapp/js/jquery-ui-1.10.3.js
+	rm -f webapp/js/json2.js
+	rm -f webapp/js/datetimepicker.js
+	
+	rm -f webapp/css/datetimepicker.css
+	rm -rf webapp/css/cupertino

Added: CalendarServer/trunk/contrib/webpoll/README.txt
===================================================================
--- CalendarServer/trunk/contrib/webpoll/README.txt	                        (rev 0)
+++ CalendarServer/trunk/contrib/webpoll/README.txt	2013-11-08 21:59:31 UTC (rev 11917)
@@ -0,0 +1,29 @@
+WebPoll Package for CalendarServer
+==================================
+
+*** IMPORTANT: this a prototype/demo and is not intended for production use
+ 
+WebPoll is a set of webbrowser javascript files that implement a prototype webapp for
+a client that supports the new iCalendar VPOLL component for doing "consensus" scheduling
+- i.e., a scheduling process where users can vote on which of a series of potential events
+are best for them. The VPOLL technology has been developed by CalConnect to open up new
+methods of scheduling with iCalendar that allows for not only polls, but potentially
+booking systems and much more.
+
+The WebPoll webapp uses jQuery and jQueryUI (which have to be downloaded separately). It
+uses AJAX calls to make CalDAV queries to the server hosting the webapp. The CalDAV server
+must support jCal (iCalendar-in-JSON) format calendar data.
+
+Use with CalendarServer
+=======================
+
+Use "make webpoll" in this directory to download all the relevant dependencies.
+
+In the CalendarServer directory do "./run -f ./contrib/webpoll/caldavd-test-webpoll.plist" to run the server.
+
+In a browser navigate to "/webpoll".
+
+Using the webapp
+================
+
+TBD

Added: CalendarServer/trunk/contrib/webpoll/caldavd-test-webpoll.plist
===================================================================
--- CalendarServer/trunk/contrib/webpoll/caldavd-test-webpoll.plist	                        (rev 0)
+++ CalendarServer/trunk/contrib/webpoll/caldavd-test-webpoll.plist	2013-11-08 21:59:31 UTC (rev 11917)
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+    Copyright (c) 2006-2009 Apple Inc. All rights reserved.
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+  -->
+
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+
+    <!-- Import a parent config before this one -->
+    <key>ImportConfig</key>
+    <string>./conf/caldavd-test.plist</string>
+
+    <!-- Child aliases -->
+    <key>Aliases</key>
+    <array>
+      <dict>
+        <key>url</key>
+        <string>/webpoll</string>
+        <key>path</key>
+        <string>./contrib/webpoll/webapp</string>
+      </dict>
+    </array>
+
+  </dict>
+</plist>

Added: CalendarServer/trunk/contrib/webpoll/webapp/css/webpoll.css
===================================================================
--- CalendarServer/trunk/contrib/webpoll/webapp/css/webpoll.css	                        (rev 0)
+++ CalendarServer/trunk/contrib/webpoll/webapp/css/webpoll.css	2013-11-08 21:59:31 UTC (rev 11917)
@@ -0,0 +1,310 @@
+ at CHARSET "UTF-8";
+
+html,body {
+	font: 80% "Trebuchet MS", sans-serif;
+}
+
+/* Main Panel settings */
+#main-panel {
+	position: absolute;
+	left: 0px;
+	top: 0px;
+	right: 0px;
+	bottom: 0px;
+}
+
+/* Title Text */
+#title {
+	font: 14pt "Trebuchet MS", sans-serif;
+	position: absolute;
+	margin-top: 5px;
+	margin-left: 20px;
+	height: 32px;
+	left: 0px;
+	top: 0px;
+}
+
+#loading {
+	font: 14pt "Trebuchet MS", sans-serif;
+	position: absolute;
+	margin-top: 5px;
+	margin-right: 20px;
+	height: 20px;
+	width: 180px;
+	right: 0px;
+	top: 0px;
+}
+
+#progressbar {
+	position: absolute;
+	width: 100px;
+	height: 20px;
+	top: 0px;
+	right: 0px;
+}
+
+/* Sidebar Panel */
+#sidepanel {
+	position: absolute;
+	width: 210px;
+	left: 5px;
+	top: 32px;
+	bottom: 0px;
+	border-right-style:solid;
+	border-width: 1px;
+	border-color:#ccc;
+}
+
+#sidebar {
+	position: absolute;
+	left: 0px;
+	right: 2px;
+	top: 0px;
+	bottom: 20px;
+}
+
+.sidebar-title {
+	position: relative;
+	font: 12pt "Trebuchet MS", sans-serif;
+}
+
+#sidebar-new-poll-count, #sidebar-vote-poll-count {
+	position: absolute;
+	background-color:#3366ff;
+	font: 8pt "Trebuchet MS", sans-serif;
+	color:#fff;
+	text-align: center;
+	border:2px solid;
+	border-color: #fff;
+	border-radius: 30px;
+	right: 10px;
+	padding-top: 2px;
+	padding-bottom: 2px;
+	padding-left: 5px;
+	padding-right: 5px;
+}
+
+/* Refresh Button */
+#refresh {
+	position: absolute;
+	right: 5px;
+	bottom: 4px;
+}
+
+/* Detail Panel */
+#detail {
+	position: absolute;
+	left: 220px;
+	right: 5px;
+	top: 32px;
+	bottom: 0px;
+}
+
+#detail-nocontent {
+	position: absolute;
+	left: 50px;
+	top: 50px;
+	font-size: 2.0em;
+}
+
+#editpoll-description {
+	position: absolute;
+	left: 4px;
+	right: 0px;
+	top: 0px;
+	height: 32px;
+}
+
+#editpoll-title-edit-panel {
+	float: left;
+	font-size: 1.5em;
+}
+
+#editpoll-title-panel {
+	float: left;
+	font-size: 1.5em;
+}
+
+#editpoll-organizer-panel {
+	position: relative;
+	float: left;
+	left: 20px;
+	font-size: 1.5em;
+}
+
+#editpoll-status-panel {
+	position: relative;
+	float: left;
+	left: 40px;
+	font-size: 1.5em;
+}
+
+#editpoll-title, #editpoll-organizer, #editpoll-status {
+	font-weight: bold;
+}
+
+#editpoll-details {
+	position: absolute;
+	left: 0px;
+	right: 0px;
+	top: 28px;
+}
+
+#sidebar-owned, #editpoll-tabs {
+	margin-bottom: 4px;
+}
+
+#editpoll-delete {
+	position: relative;
+	left: 10px;
+}
+
+#editpoll-autofill {
+	position: relative;
+	left: 50px;
+}
+
+.event,.voter {
+	position: relative;
+	background-color:#eee;
+	box-shadow: 5px 5px 2px #888;
+	margin: 8px;
+	padding: 4px;
+}
+
+.edit-datetime {
+	margin: 4px;
+}
+
+.edit-datetime label, .edit-voter label {
+	display: inline-block;
+	width: 40px;
+}
+
+.voter-address {
+	width: 250px;
+}
+
+.input-remove {
+	position: absolute;
+	right: 4px;
+	bottom: 4px;
+}
+
+.active-voter {
+	font-weight:bold;
+}
+
+#editpoll-resulttable {
+	font: 11pt "Trebuchet MS", sans-serif;
+	border-collapse:collapse;
+	border: 1px solid #ccc;
+}
+
+#editpoll-resulttable thead, #editpoll-resulttable tfoot {
+	text-align:right;	
+}
+
+#editpoll-resulttable td {
+	border: 1px solid #ccc;	
+	padding: 4px;	
+}
+
+#editpoll-resulttable tfoot {
+	border: 2px solid;	
+}
+
+.response-btns, .response-btns label {
+	height: 24px;	
+}
+
+#winner-text {
+	position: relative;
+}
+
+#winner-icon-left {
+	position: absolute;
+	left: 0px;
+	top: 50%;
+	margin-top: -8px;
+}
+
+#winner-icon-right {
+	position: absolute;
+	right: 0px;
+	top: 50%;
+	margin-top: -8px;
+}
+
+.center-td {
+	text-align:center;	
+}
+
+.poll-winner-td {
+	background-color:#D9D9D9;	
+}
+
+.best-td {
+	background-color:#CCCCFF;	
+}
+
+.ok-td {
+	background-color:#CCE6CC;	
+}
+
+.maybe-td {
+	background-color:#FFFFCC;	
+}
+
+.no-td {
+	background-color:#FFCCCC;	
+}
+
+.no-response-td {
+	background-color:white;	
+}
+
+.no-close .ui-dialog-titlebar-close {
+  display: none;
+}
+
+#hover-grid {
+	position: relative;
+	width: 100%;
+	margin: 10px;
+	border-collapse:collapse;
+	border: 1px solid #ccc;
+}
+
+#hover-grid tr {
+	position: relative;
+	height: 30px;
+}
+
+#hover-grid td {
+	border: 1px solid #ccc;
+	padding: 4px;
+}
+
+#hover-grid .hover-grid-td-time {
+	width: 70px;
+	text-align: right;
+}
+
+.hover-event {
+	position: absolute;
+	font: 8pt "Trebuchet MS", sans-serif;
+	left: 81px;
+	width: 250px;
+	padding-top: 2px;
+	padding-bottom: 2px;
+	padding-left: 5px;
+	padding-right: 5px;
+}
+
+#response-key {
+	position: relative;
+	width: 100px;
+	top: 20px;
+}
+

Added: CalendarServer/trunk/contrib/webpoll/webapp/index.html
===================================================================
--- CalendarServer/trunk/contrib/webpoll/webapp/index.html	                        (rev 0)
+++ CalendarServer/trunk/contrib/webpoll/webapp/index.html	2013-11-08 21:59:31 UTC (rev 11917)
@@ -0,0 +1,110 @@
+<!doctype html>
+<html lang="us">
+<head>
+	<meta charset="utf-8">
+	<title>WebPoll</title>
+	<link href="css/cupertino/jquery-ui-1.10.3.css" rel="stylesheet">
+	<link href="css/datetimepicker.css" rel="stylesheet">
+	<link href="css/webpoll.css" rel="stylesheet">
+	<script src="js/jquery-2.0.3.js"></script>
+	<script src="js/jquery-ui-1.10.3.js"></script>
+	<script src="js/datetimepicker.js"></script>
+	<script src="js/json2.js"></script>
+	<script src="js/utils.js"></script>
+	<script src="js/jcal.js"></script>
+	<script src="js/caldav.js"></script>
+	<script src="js/webpoll.js"></script>
+</head>
+<body>
+
+<div id="main-panel">
+
+<div id="title">WebPoll</div>
+
+<div id="loading">
+Loading
+<div id="progressbar"></div>
+</div>
+
+<!-- Sidebar Panel-->
+<div id="sidepanel">
+	<div id="sidebar">
+		<h3 class="sidebar-title"><span>Your Polls</span><span id="sidebar-new-poll-count">0</span></h3>
+		<div>
+			<ul id="sidebar-owned"></ul>
+			<button id="sidebar-new-poll">New Poll</button>
+		</div>
+		<h3 class="sidebar-title"><span>Polls to Vote On<span id="sidebar-vote-poll-count">0</span></span></h3>
+		<div>
+			<ul id="sidebar-voter"></ul>
+		</div>
+	</div>
+	<div id="refresh"><button id="refresh-btn">Refresh</button></div>
+</div>
+
+<!-- Detail Panel -->
+<div id="detail">
+	<div id="detail-nocontent" class="ui-widget">Select a poll in the side-bar to view its details.</div>
+	<div id="editpoll">
+		<div id="editpoll-description">
+			<div id="editpoll-title-edit-panel" class="ui-widget">
+				<label for="editpoll-title-edit">Title: </label>
+				<input id="editpoll-title-edit" />
+			</div>
+			<div id="editpoll-title-panel" class="ui-widget">Title: <span id="editpoll-title"></span></div>
+			<div id="editpoll-organizer-panel" class="ui-widget">Organizer: <span id="editpoll-organizer"></span></div>
+			<div id="editpoll-status-panel" class="ui-widget">Status: <span id="editpoll-status"></span></div>
+		</div>
+		<div id="editpoll-details">
+			<div id="editpoll-tabs">
+				<ul>
+					<li id="editpoll-events-tab"><a href="#editpoll-events">Events</a></li>
+					<li id="editpoll-voters-tab"><a href="#editpoll-voters">Voters</a></li>
+					<li><a href="#editpoll-results">Results</a></li>
+				</ul>
+				<div id="editpoll-events">
+					<div id="editpoll-eventlist"></div>
+					<button id="editpoll-addevent">Add Event</button>
+				</div>
+				<div id="editpoll-voters">
+					<div id="editpoll-voterlist"></div>
+					<button id="editpoll-addvoter">Add Voter</button>
+				</div>
+				<div id="editpoll-results">
+					<table id="editpoll-resulttable">
+						<thead>
+							<tr></tr>
+							<tr></tr>
+							<tr></tr>
+						</thead>
+						<tbody>
+						</tbody>
+						<tfoot>
+							<tr></tr>
+							<tr></tr>
+						</tfoot>
+					</table>
+				</div>
+			</div>
+			<button id="editpoll-save">Save</button>
+			<button id="editpoll-cancel">Cancel</button>
+			<button id="editpoll-done">Done</button>
+			<button id="editpoll-delete">Delete Poll</button>
+			<button id="editpoll-autofill">Auto Fill</button>
+			<div id="response-key" class="ui-widget">
+				Possible Responses:
+				<ul id="response-menu">
+					<li><a href="#"><span class="ui-icon ui-icon-close"></span>No</a></li>
+					<li><a href="#"><span class="ui-icon ui-icon-help"></span>Maybe</a></li>
+					<li><a href="#"><span class="ui-icon ui-icon-check"></span>Ok</a></li>
+					<li><a href="#"><span class="ui-icon ui-icon-circle-check"></span>Best</a></li>
+				</ul>
+			</div>
+		</div>
+	</div>
+</div>
+
+</div>
+
+</body>
+</html>


Property changes on: CalendarServer/trunk/contrib/webpoll/webapp/index.html
___________________________________________________________________
Added: svn:executable
   + *

Added: CalendarServer/trunk/contrib/webpoll/webapp/js/caldav.js
===================================================================
--- CalendarServer/trunk/contrib/webpoll/webapp/js/caldav.js	                        (rev 0)
+++ CalendarServer/trunk/contrib/webpoll/webapp/js/caldav.js	2013-11-08 21:59:31 UTC (rev 11917)
@@ -0,0 +1,1028 @@
+/**
+##
+# Copyright (c) 2013 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+*/
+
+/**
+ * Classes to model a CalDAV service using XHR requests.
+ */
+
+// Do an AJAX request
+function Ajax(params) {
+	$.extend(params, {
+		processData : false,
+		crossDomain: true,
+		username: gSession.auth,
+		password: gSession.auth
+	})
+	return $.ajax(params);
+}
+
+// A generic PROPFIND request
+function Propfind(url, depth, props) {
+	var nsmap = {};
+	addNamespace("D", nsmap);
+
+	var propstr = addElements(props, nsmap);
+
+	return Ajax({
+		url : url,
+		type : "PROPFIND",
+		contentType : "application/xml; charset=utf-8",
+		headers : {
+			"Prefer" : "return=minimal",
+			"Depth" : depth
+		},
+		data : '<?xml version="1.0" encoding="utf-8" ?>' + '<D:propfind' + buildXMLNS(nsmap) + '>' + '<D:prop>' + propstr + '</D:prop>' + '</D:propfind>',
+	});
+}
+
+// A calendar-query REPORT request for VPOLLs only
+function PollQueryReport(url, props) {
+	var nsmap = {};
+	addNamespace("D", nsmap);
+	addNamespace("C", nsmap);
+
+	var propstr = addElements(props, nsmap);
+	propstr += '<C:calendar-data content-type="application/calendar+json" />';
+
+	return Ajax({
+		url : url,
+		type : "REPORT",
+		contentType : "application/xml; charset=utf-8",
+		headers : {
+			"Prefer" : "return=minimal",
+			"Depth" : "0"
+		},
+		data : '<?xml version="1.0" encoding="utf-8" ?>' +
+			'<C:calendar-query' + buildXMLNS(nsmap) + '>' +
+				'<D:prop>' + propstr + '</D:prop>' +
+				'<C:filter>' +
+					'<C:comp-filter name="VCALENDAR">' +
+						'<C:comp-filter name="VPOLL" />' +
+					'</C:comp-filter>' +
+				'</C:filter>' +
+			'</C:calendar-query>',
+	});
+}
+
+// A calendar-query REPORT request for VEVENTs in time-range, expanded
+function TimeRangeExpandedSummaryQueryReport(url, start, end) {
+	var nsmap = {};
+	addNamespace("D", nsmap);
+	addNamespace("C", nsmap);
+
+	return Ajax({
+		url : url,
+		type : "REPORT",
+		contentType : "application/xml; charset=utf-8",
+		headers : {
+			"Prefer" : "return=minimal",
+			"Depth" : "0"
+		},
+		data : '<?xml version="1.0" encoding="utf-8" ?>' +
+			'<C:calendar-query' + buildXMLNS(nsmap) + '>' +
+				'<D:prop>' +
+				'<C:calendar-data content-type="application/calendar+json">' +
+					'<C:expand start="' + jcaldate.jsDateToiCal(start) + '" end="' + jcaldate.jsDateToiCal(end) + '"/>' +
+					'<C:comp name="VCALENDAR">' + 
+						'<C:allprop/>' +
+						'<C:comp name="VEVENT">' +
+							'<C:prop name="UID"/><C:prop name="DTSTART"/><C:prop name="DTEND"/><C:prop name="DURATION"/><C:prop name="SUMMARY"/>' +
+						'</C:comp>' +
+					'</C:comp>' +
+				'</C:calendar-data>' +
+				'</D:prop>' +
+				'<C:filter>' +
+					'<C:comp-filter name="VCALENDAR">' +
+						'<C:comp-filter name="VEVENT">' +
+							'<C:time-range start="' + jcaldate.jsDateToiCal(start) + '" end="' + jcaldate.jsDateToiCal(end) + '"/>' +
+						'</C:comp-filter>' +
+					'</C:comp-filter>' +
+				'</C:filter>' +
+			'</C:calendar-query>'
+	});
+}
+
+// A freebusy POST request
+function Freebusy(url, fbrequest) {
+	return Ajax({
+		url : url,
+		type : "POST",
+		contentType : "application/calendar+json; charset=utf-8",
+		data : fbrequest.toString(),
+	});
+}
+
+// A calendar-user-search REPORT request
+function UserSearchReport(url, text) {
+	var nsmap = {};
+	addNamespace("D", nsmap);
+	addNamespace("C", nsmap);
+	addNamespace("CS", nsmap);
+
+	return Ajax({
+		url : url,
+		type : "REPORT",
+		contentType : "application/xml; charset=utf-8",
+		headers : {
+			"Depth" : "0"
+		},
+		data : '<?xml version="1.0" encoding="utf-8" ?>'
+			+ '<CS:calendarserver-principal-search context="user"' + buildXMLNS(nsmap) + '>' + '<CS:search-token>' + xmlEncode(text) + '</CS:search-token>'
+			+ '<CS:limit><CS:nresults>20</CS:nresults></CS:limit>' + '<D:prop>' + '<D:displayname />' + '<C:calendar-user-address-set />' + '</D:prop>'
+			+ '</CS:calendarserver-principal-search>',
+	});
+}
+
+// Multistatus response processing
+MultiStatusResponse = function(response, parent_url) {
+	this.response = response;
+	this.parentURL = parent_url;
+}
+
+// Get property text value from the overall multistatus
+MultiStatusResponse.prototype.getPropertyText = function(prop) {
+	return this._getPropertyText($(this.response), prop, "D:multistatus/D:response/D:propstat/D:prop/");
+}
+
+// Get property href text value from the overall multistatus
+MultiStatusResponse.prototype.getPropertyHrefTextList = function(prop) {
+	return this._getPropertyHrefTextList($(this.response), prop, "D:multistatus/D:response/D:propstat/D:prop/");
+}
+
+// Get property text value from the specified response node
+MultiStatusResponse.prototype.getResourcePropertyText = function(response_node, prop) {
+	return this._getPropertyText(response_node, prop, "D:propstat/D:prop/");
+}
+
+// Get property href text value from the specified response node
+MultiStatusResponse.prototype.getResourcePropertyHrefTextList = function(response_node, prop) {
+	return this._getPropertyHrefTextList(response_node, prop, "D:propstat/D:prop/");
+}
+
+// Get property text value from the specified node
+MultiStatusResponse.prototype._getPropertyText = function(node, prop, prefix) {
+	return getElementText(node, prefix + prop);
+}
+
+// Get all property href text values as an array from the specified node
+MultiStatusResponse.prototype._getPropertyHrefTextList = function(node, prop, prefix) {
+	var items = findElementPath(node, prefix + prop + "/D:href");
+	if (items.length == 0) {
+		return null;
+	} else {
+		var results = []
+		$.each(items, function(index, item) {
+			results.push(item.text());
+		});
+		return results;
+	}
+}
+
+// Apply specified function to each response (other than the parent)
+MultiStatusResponse.prototype.doToEachChildResource = function(doIt) {
+	var items = findElementPath($(this.response), "D:multistatus/D:response");
+	var msr = this;
+	$.each(items, function(index, item) {
+		var href = getElementText(item, "D:href");
+		if (!compareURLs(href, msr.parentURL)) {
+			doIt(href, item);
+		}
+	});
+}
+
+// Schedule response processing
+ScheduleResponse = function(response) {
+	this.response = response;
+}
+
+// Apply specified function to each recipient response
+ScheduleResponse.prototype.doToEachRecipient = function(doIt) {
+	var items = findElementPath($(this.response), "C:schedule-response/C:response");
+	$.each(items, function(index, item) {
+		doIt(getElementText(item, "C:recipient/D:href"), item);
+	});
+}
+
+// A CalDAV session for a specific principal
+CalDAVSession = function(user) {
+	this.currentPrincipal = null;
+	//this.host = "http://172.16.105.104:8080/ucaldav";
+	//this.host = "https://cyrus.local:8543";
+	this.host = "";
+	
+	if (user === undefined) {
+		this.auth = null;
+	} else {
+		this.auth = user;
+	}
+}
+
+// Setup session
+CalDAVSession.prototype.init = function(whenDone) {
+	this.currentUserPropfind(whenDone);
+}
+
+gWellKnown = "/.well-known/caldav";
+//gWellKnown = "/";
+
+// Discover current principal from /.well-known/caldav, then load the principal data
+CalDAVSession.prototype.currentUserPropfind = function(whenDone) {
+	var session = this;
+	Propfind(joinURLs(this.host, gWellKnown), "0", [
+		"D:current-user-principal"
+	]).done(function(response) {
+		var msr = new MultiStatusResponse(response, gWellKnown);
+		var href = msr.getPropertyText("D:current-user-principal/D:href");
+		if (href == null) {
+			alert("Could not determine current user.");
+		} else {
+			// Load the principal
+			session.currentPrincipal = new CalDAVPrincipal(href);
+			session.currentPrincipal.init(whenDone);
+		}
+	}).fail(function(jqXHR, status, error) {
+		alert(status + error);
+	});
+}
+
+// Search for calendar users matching a string
+CalDAVSession.prototype.calendarUserSearch = function(item, whenDone) {
+	UserSearchReport(joinURLs(this.host, "/principals/"), item).done(function(response) {
+		var msr = new MultiStatusResponse(response, "/principals/");
+		var results = [];
+		msr.doToEachChildResource(function(url, response_node) {
+			var cn = msr.getResourcePropertyText(response_node, "D:displayname");
+			var cuaddr = CalDAVPrincipal.bestCUAddress(msr.getResourcePropertyHrefTextList(response_node, "C:calendar-user-address-set"));
+			if (cuaddr) {
+				results.push((cn ? cn + " " : "") + "<" + cuaddr + ">");
+			}
+		});
+
+		if (whenDone) {
+			whenDone(results.sort());
+		}
+	}).fail(function(jqXHR, status, error) {
+		alert(status + error);
+	});
+}
+
+// Represents a calendar user on the server
+CalDAVPrincipal = function(url) {
+	this.url = url;
+	this.cn = null;
+	this.home_url = null;
+	this.inbox_url = null;
+	this.outbox_url = null;
+	this.calendar_user_addresses = [];
+	this.default_address = null;
+	this.poll_calendars = [];
+	this.event_calendars = [];
+}
+
+// Return the best calendar user address from the set. Prefer mailto over urn over anything else.
+CalDAVPrincipal.bestCUAddress = function(cuaddress_set) {
+	var results = $.grep(cuaddress_set, function(cuaddr, index) {
+		return cuaddr.startsWith("mailto:");
+	});
+	if (results.length == 0) {
+		results = $.grep(cuaddress_set, function(cuaddr, index) {
+			return cuaddr.startsWith("urn:uuid:");
+		});
+	}
+
+	if (results.length != 0) {
+		return results[0];
+	}
+	return null;
+}
+
+// Load principal details for this user, then load all the calendars
+CalDAVPrincipal.prototype.init = function(whenDone) {
+		// Get useful properties
+	var principal = this;
+	Propfind(joinURLs(gSession.host, principal.url), "0", [
+		"D:displayname", "C:calendar-home-set", "C:schedule-inbox-URL", "C:schedule-outbox-URL", "C:calendar-user-address-set"
+	]).done(function(response) {
+		var msr = new MultiStatusResponse(response, principal.url);
+		principal.cn = msr.getPropertyText("D:displayname");
+		principal.home_url = msr.getPropertyText("C:calendar-home-set/D:href");
+		principal.inbox_url = msr.getPropertyText("C:schedule-inbox-URL/D:href");
+		principal.outbox_url = msr.getPropertyText("C:schedule-outbox-URL/D:href");
+		principal.calendar_user_addresses = msr.getPropertyHrefTextList("C:calendar-user-address-set");
+		// Load the calendars
+		principal.loadCalendars(whenDone);
+	}).fail(function(jqXHR, status, error) {
+		alert(status + error);
+	});
+}
+
+// For a reload of all calendar data
+CalDAVPrincipal.prototype.refresh = function(whenDone) {
+	this.poll_calendars = [];
+	this.event_calendars = [];
+	this.loadCalendars(whenDone);
+}
+
+// The most suitable calendar user address for the user
+CalDAVPrincipal.prototype.defaultAddress = function() {
+	if (!this.default_address) {
+		this.default_address = CalDAVPrincipal.bestCUAddress(this.calendar_user_addresses);
+	}
+	return this.default_address;
+}
+
+// Indicate whether the specified calendar-user-address matches the current user
+CalDAVPrincipal.prototype.matchingAddress = function(cuaddr) {
+	return this.calendar_user_addresses.indexOf(cuaddr) != -1;
+}
+
+// Load all VPOLL and VEVENT capable calendars for this user
+CalDAVPrincipal.prototype.loadCalendars = function(whenDone) {
+	var this_principal = this;
+	Propfind(joinURLs(gSession.host, this.home_url), "1", [
+		"D:resourcetype", "D:displayname", "D:add-member", "C:supported-calendar-component-set"
+	]).done(function(response) {
+		var msr = new MultiStatusResponse(response, this_principal.home_url);
+		msr.doToEachChildResource(function(url, response_node) {
+			if (!hasElementPath(response_node, "D:propstat/D:prop/D:resourcetype/D:collection")
+				|| !hasElementPath(response_node, "D:propstat/D:prop/D:resourcetype/C:calendar"))
+				return;
+
+			// Separate out support for VPOLL and VEVENT
+			var comps = findElementPath(response_node, "D:propstat/D:prop/C:supported-calendar-component-set/C:comp");
+			var has_vpoll = true;
+			var has_vevent = true;
+			if (comps.length != 0) {
+				has_vpoll = false;
+				has_vevent = false;
+				$.each(comps, function(index, comp) {
+					if (comp.attr("name") == "VPOLL") {
+						has_vpoll = true;
+					}
+					if (comp.attr("name") == "VEVENT") {
+						has_vevent = true;
+					}
+				});
+			}
+
+			// Build the calendar and assign to appropriate arrays
+			var cal = new CalendarCollection(url);
+			cal.displayname = getElementText(response_node, "D:propstat/D:prop/D:resourcetype/D:displayname")
+			if (!cal.displayname) {
+				cal.displayname = basenameURL(url);
+			}
+			cal.addmember = getElementText(response_node, "D:propstat/D:prop/D:add-member/D:href")
+			if (has_vpoll) {
+				this_principal.poll_calendars.push(cal);
+			}
+			if (has_vevent) {
+				this_principal.event_calendars.push(cal);
+			}
+		});
+		// Load the resources from all VPOLL calendars
+		this_principal.loadResources(whenDone);
+	}).fail(function(jqXHR, status, error) {
+		alert(status + error);
+	});
+}
+
+// Start loading all VPOLL resources
+CalDAVPrincipal.prototype.loadResources = function(whenDone) {
+	var principal = this;
+	var process = [].concat(principal.poll_calendars);
+	process.reverse();
+	this.loadCalendarResources(whenDone, process);
+}
+
+// Iteratively load all resources from VPOLL calendars
+CalDAVPrincipal.prototype.loadCalendarResources = function(whenDone, process) {
+	var this_principal = this;
+	var calendar = process.pop();
+	calendar.loadResources(function() {
+		if (process.length != 0) {
+			this_principal.loadCalendarResources(whenDone, process);
+		} else {
+			this_principal.addResources(whenDone);
+		}
+	});
+}
+
+// After all resources are loaded, add each VPOLL to view controller
+CalDAVPrincipal.prototype.addResources = function(whenDone) {
+
+	// Scan each resource to see if it is organized or not
+	$.each(this.poll_calendars, function(index1, calendar) {
+		$.each(calendar.resources, function(index2, resource) {
+			var poll = new Poll(resource);
+			gViewController.addPoll(poll);
+		})
+	});
+	if (whenDone) {
+		whenDone();
+	}
+}
+
+// Do a freebusy query for the specified user for the specified time range and indicate whether busy or not
+CalDAVPrincipal.prototype.isBusy = function(user, start, end, whenDone) {
+	var fbrequest = jcal.newCalendar();
+	fbrequest.newProperty("method", "REQUEST");
+	var fb = fbrequest.newComponent("vfreebusy", true);
+	fb.newProperty(
+		"organizer",
+		this.defaultAddress(),
+		{ "cn" : this.cn },
+		"cal-address"
+	);
+	fb.newProperty(
+		"attendee",
+		user,
+		{},
+		"cal-address"
+	);
+	fb.newProperty(
+		"dtstart",
+		jcaldate.jsDateTojCal(start),
+		{},
+		"date-time"
+	);
+	fb.newProperty(
+		"dtend",
+		jcaldate.jsDateTojCal(end),
+		{},
+		"date-time"
+	);
+
+	Freebusy(
+		joinURLs(gSession.host, this.outbox_url),
+		fbrequest
+  	).done(function(response) {
+  		var sched = new ScheduleResponse(response);
+  		var result = null;
+  		sched.doToEachRecipient(function(url, response_node) {
+  			var caldata = getElementText(response_node, "C:calendar-data");
+  			if (caldata) {
+  				caldata = jcal.fromString(caldata);
+  				if (caldata.mainComponent().name() == "vfreebusy") {
+  					// Any FREEBUSY property means busy sometime during the requested period
+  					result = caldata.mainComponent().hasProperty("freebusy")
+  				}
+  			}
+  		});
+  		
+  		if (whenDone) {
+  			whenDone(result)
+  		}
+  	}).fail(function(jqXHR, status, error) {
+  		alert(status + error);
+  	});
+}
+
+// Get a summary of events for the specified time-range
+CalDAVPrincipal.prototype.eventsForTimeRange = function(start, end, whenDone) {
+	var this_principal = this;
+	TimeRangeExpandedSummaryQueryReport(
+		joinURLs(gSession.host, this.event_calendars[0].url), start, end
+	).done(function(response) {
+		var results = []
+		var msr = new MultiStatusResponse(response, this_principal.event_calendars[0].url);
+		msr.doToEachChildResource(function(url, response_node) {
+			var caldata = jcal.fromString(msr.getResourcePropertyText(response_node, "C:calendar-data"));
+			results.push(new CalendarObject(caldata));
+		});
+		if (whenDone) {
+			whenDone(results);
+		}
+		
+	}).fail(function(jqXHR, status, error) {
+  		alert(status + error);
+  	});
+}
+
+// A calendar collection on the server
+CalendarCollection = function(url) {
+	this.url = url;
+	this.displayname = null;
+	this.addmember = null;
+	this.resources = [];
+}
+
+// Load a calendar's VPOLL resources
+CalendarCollection.prototype.loadResources = function(whenDone) {
+	var calendar = this;
+	PollQueryReport(joinURLs(gSession.host, calendar.url), [
+		"D:getetag"
+	]).done(function(response) {
+		var msr = new MultiStatusResponse(response, calendar.url);
+		msr.doToEachChildResource(function(url, response_node) {
+			var etag = msr.getResourcePropertyText(response_node, "D:getetag");
+			var caldata = jcal.fromString(msr.getResourcePropertyText(response_node, "C:calendar-data"));
+			calendar.resources.push(new CalendarResource(calendar, url, etag, caldata));
+		});
+		if (whenDone) {
+			whenDone();
+		}
+	}).fail(function(jqXHR, status, error) {
+		alert(status + error);
+	});
+}
+
+// A calendar resource object
+CalendarResource = function(calendar, url, etag, data) {
+	this.calendar = calendar;
+	this.url = url;
+	this.etag = etag;
+	this.object = (data instanceof CalendarObject ? data : new CalendarObject(data));
+}
+
+// Create a brand new poll and add to the default calendar
+CalendarResource.newPoll = function(title) {
+	// Add to calendar
+	resource = new CalendarResource(gSession.currentPrincipal.poll_calendars[0], null, null, CalendarPoll.newPoll(title));
+	resource.calendar.resources.push(resource);
+	return resource;
+}
+
+// Save this resource to the server - might be brand new or an update
+CalendarResource.prototype.saveResource = function(whenDone) {
+
+	if (!this.object.changed())
+		return;
+
+	// Always set to accepted
+	if (this.object.mainComponent().data.name() == "vpoll") {
+		this.object.mainComponent().acceptInvite();
+	}
+
+	if (!this.url) {
+		if (this.calendar.addmember) {
+			// Do POST;add-member
+			Ajax({
+				context : this,
+				url : joinURLs(gSession.host, this.calendar.addmember),
+				type : "POST",
+				contentType : "application/calendar+json; charset=utf-8",
+				headers : {
+					"Prefer" : "return=representation",
+					"Accept" : "application/calendar+json"
+				},
+				data : this.object.toString(),
+			}).done(function(response, textStatus, jqXHR) {
+				// Get Content-Location header as new url
+				this.url = jqXHR.getResponseHeader("Content-Location");
+
+				// Check for returned data and ETag
+				this.etag = jqXHR.getResponseHeader("Etag");
+				this.object = new CalendarObject(response);
+				
+				if (whenDone) {
+					whenDone();
+				}
+
+			}).fail(function(jqXHR, status, error) {
+				alert(status + error);
+			});
+
+			return;
+		}
+
+		// Have to PUT a new resource
+		this.url = joinURLs(this.calendar.url, this.data.getComponent("vpoll").getPropertyText("uid") + ".ics");
+	}
+
+	// Do conditional PUT
+	Ajax({
+		context : this,
+		url : joinURLs(gSession.host, this.url),
+		type : "PUT",
+		contentType : "application/calendar+json; charset=utf-8",
+		headers : {
+			"Prefer" : "return=representation",
+			"Accept" : "application/calendar+json"
+		},
+		data : this.object.toString(),
+	}).done(function(response, textStatus, jqXHR) {
+		// Check for returned data and ETag
+		this.etag = jqXHR.getResponseHeader("Etag");
+		this.object = new CalendarObject(response);
+		
+		if (whenDone) {
+			whenDone();
+		}
+
+	}).fail(function(jqXHR, status, error) {
+		alert(status + error);
+	});
+}
+
+// Remove this resource from the server
+CalendarResource.prototype.removeResource = function(whenDone) {
+	if (!this.url) {
+		if (whenDone) {
+			whenDone();
+		}
+		return;
+	}
+	Ajax({
+		context : this,
+		url : joinURLs(gSession.host, this.url),
+		type : "DELETE",
+	}).done(function(response) {
+		var index = this.calendar.resources.indexOf(this);
+		this.calendar.resources.splice(index, 1);
+		if (whenDone) {
+			whenDone();
+		}
+	}).fail(function(jqXHR, status, error) {
+		alert(status + error);
+	});
+}
+
+// A generic container for an iCalendar component
+CalendarComponent = function(caldata, parent) {
+	this.data = (caldata instanceof jcal ? caldata : new jcal(caldata));
+	this.parent = parent;
+}
+
+// Maintain a registry of component types so the right class can be created when parsing
+CalendarComponent.createComponentType = {}
+
+CalendarComponent.registerComponentType = function(name, cls) {
+	CalendarComponent.createComponentType[name] = cls;
+}
+
+CalendarComponent.buildComponentType = function(caldata, parent) {
+	return new CalendarComponent.createComponentType[caldata.name()](caldata, parent);
+}
+
+CalendarComponent.prototype.duplicate = function(parent) {
+	if (parent === undefined) {
+		parent = this.parent;
+	}
+	return CalendarComponent.buildComponentType(this.data.duplicate(), parent);
+}
+
+CalendarComponent.prototype.toString = function() {
+	return this.data.toString();
+}
+
+// Tell component whether it has changed or not
+CalendarComponent.prototype.changed = function(value) {
+	if (value === undefined) {
+		return this.parent ? this.parent.changed() : false;
+	} else {
+		if (this.parent) {
+			this.parent.changed(value);
+		}
+	}
+}
+
+CalendarComponent.prototype.uid = function() {
+	return this.data.getPropertyValue("uid");
+}
+
+// Indicate whether this object is owned by the current user.
+CalendarComponent.prototype.isOwned = function() {
+	return gSession.currentPrincipal.matchingAddress(this.organizer());
+}
+
+CalendarComponent.prototype.organizer = function() {
+	return this.data.getPropertyValue("organizer");
+}
+
+CalendarComponent.prototype.organizerDisplayName = function() {
+	return new CalendarUser(this.data.getProperty("organizer"), this).nameOrAddress();
+}
+
+CalendarComponent.prototype.status = function() {
+	return this.data.getPropertyValue("status");
+}
+
+CalendarComponent.prototype.summary = function(value) {
+	if (value === undefined) {
+		return this.data.getPropertyValue("summary");
+	} else {
+		if (this.summary() != value) {
+			this.data.updateProperty("summary", value);
+			this.changed(true);
+		}
+	}
+}
+
+CalendarComponent.prototype.dtstart = function(value) {
+	if (value === undefined) {
+		return jcaldate.jCalTojsDate(this.data.getPropertyValue("dtstart"));
+	} else {
+		if (this.dtstart() - value !== 0) {
+			this.data.updateProperty("dtstart", jcaldate.jsDateTojCal(value), {}, "date-time");
+			this.changed(true);
+		}
+	}
+}
+
+CalendarComponent.prototype.dtend = function(value) {
+	if (value === undefined) {
+		var dtend = this.data.getPropertyValue("dtend");
+		if (dtend === null) {
+			var offset = 0;
+			var duration = this.data.getPropertyValue("duration");
+			if (duration === null) {
+				dtend = new Date(this.dtstart().getTime());
+				dtend.setHours(0, 0, 0, 0);
+				dtend.setDate(dtend.getDate() + 1);
+			} else {
+				offset = jcalduration.parseText(duration).getTotalSeconds() * 1000;
+				dtend = new Date(this.dtstart().getTime() + offset);
+			}
+		} 
+		return jcaldate.jCalTojsDate(dtend);
+	} else {
+		if (this.dtend() - value !== 0) {
+			this.data.updateProperty("dtend", jcaldate.jsDateTojCal(value), {}, "date-time");
+			this.data.removeProperties("duration");
+			this.changed(true);
+		}
+	}
+}
+
+CalendarComponent.prototype.pollitemid = function(value) {
+	if (value === undefined) {
+		return this.data.getPropertyValue("poll-item-id");
+	} else {
+		if (this.politemid() != value) {
+			this.data.updateProperty("poll-item-id", value);
+			this.changed(true);
+		}
+	}
+}
+
+CalendarComponent.prototype.voter_responses = function() {
+	var voter_results = {}
+	$.each(this.data.properties("voter"), function(index, voter) {
+		voter_results[voter[3]] = parseInt(voter[1]["response"]);
+	});
+	return voter_results;
+}
+
+// Change active user's response to this event
+CalendarComponent.prototype.changeVoterResponse = function(response) {
+	if (response !== null) {
+		var matches = $.grep(this.data.properties("voter"), function(voter, index) {
+			return gSession.currentPrincipal.matchingAddress(voter[3]);
+		});
+		if (matches.length == 1) {
+			new CalendarUser(matches[0], this).response(response.toString());
+		} else {
+			this.data.newProperty(
+				"voter",
+				gSession.currentPrincipal.defaultAddress(),
+				{ "response" : response.toString() },
+				"cal-address"
+			);
+			this.changed(true);
+		}
+	} else {
+		this.data.removePropertiesMatchingValue(function(propdata) {
+			return propdata[0] == "voter" && gSession.currentPrincipal.matchingAddress(propdata[3]); 
+		});
+	}
+}
+
+// A container class for VCALENDAR objects
+CalendarObject = function(caldata) {
+	CalendarComponent.call(this, caldata, null);
+	this._changed = false;
+}
+
+CalendarObject.prototype = new CalendarComponent();
+CalendarObject.prototype.constructor = CalendarObject;
+CalendarComponent.registerComponentType("vcalendar", CalendarObject);
+
+// This is the top-level object for changing tracking
+CalendarObject.prototype.changed = function(value) {
+	if (value === undefined) {
+		return this._changed;
+	} else {
+		this._changed = value;
+	}
+}
+
+// Get the main component type as one of our model classes
+CalendarObject.prototype.mainComponent = function() {
+	var main = this.data.mainComponent();
+	return new CalendarComponent.buildComponentType(main, this);
+}
+
+// A container class for VPOLL objects
+CalendarPoll = function(caldata, parent) {
+	CalendarComponent.call(this, caldata, parent);
+}
+
+CalendarPoll.prototype = new CalendarComponent();
+CalendarPoll.prototype.constructor = CalendarPoll;
+CalendarComponent.registerComponentType("vpoll",  CalendarPoll);
+
+// Create a brand new poll, defaulting various properties
+CalendarPoll.newPoll = function(title) {
+	var calendar = jcal.newCalendar();
+	var vpoll = calendar.newComponent("vpoll", true);
+	vpoll.newProperty("summary", title);
+	vpoll.newProperty("poll-mode", "BASIC");
+	vpoll.newProperty("poll-properties", ["DTSTART","DTEND"]);
+	vpoll.newProperty(
+		"organizer",
+		gSession.currentPrincipal.defaultAddress(),
+		{ "cn" : gSession.currentPrincipal.cn },
+		"cal-address"
+	);
+	vpoll.newProperty(
+		"voter",
+		gSession.currentPrincipal.defaultAddress(),
+		{
+			"cn" : gSession.currentPrincipal.cn,
+			"partstat" : "ACCEPTED"
+		},
+		"cal-address"
+	);
+
+	return new CalendarObject(calendar);
+}
+
+// Whether or not current user can make changes (depends on their role as owner too)
+CalendarPoll.prototype.editable = function() {
+	var status = this.status();
+	return status ? status == "IN-PROCESS" : true;
+}
+
+CalendarComponent.prototype.pollwinner = function(value) {
+	if (value === undefined) {
+		return this.data.getPropertyValue("poll-winner");
+	} else {
+		if (this.pollwinner() != value) {
+			this.data.updateProperty("poll-winner", value);
+			this.changed(true);
+		}
+	}
+}
+
+CalendarComponent.prototype.ispollwinner = function() {
+	var pollid = this.pollitemid();
+	return pollid === undefined ? false : pollid == this.parent.pollwinner();
+}
+
+// Get an array of child VEVENTs in the VPOLL
+CalendarPoll.prototype.events = function() {
+	var this_vpoll = this;
+	return $.map(this.data.components("vevent"), function(event, index) {
+		return new CalendarEvent(event, this_vpoll);
+	});
+}
+
+// Add a new VEVENT to the VPOLL
+CalendarPoll.prototype.addEvent = function(dtstart, dtend) {
+	this.changed(true);
+	var vevent = this.data.newComponent("vevent", true);
+	vevent.newProperty("dtstart", jcaldate.jsDateTojCal(dtstart), {}, "date-time");
+	vevent.newProperty("dtend", jcaldate.jsDateTojCal(dtend), {}, "date-time");
+	vevent.newProperty("summary", this.summary());
+	vevent.newProperty("poll-item-id", (this.data.components("vevent").length).toString());
+	vevent.newProperty(
+		"voter",
+		this.organizer(),
+		{"response" : "80"},
+		"cal-address"
+	);
+	return new CalendarEvent(vevent, this);
+}
+
+// Get an array of voters in the VPOLL
+CalendarPoll.prototype.voters = function() {
+	var this_vpoll = this;
+	return $.map(this.data.properties("voter"), function(voter) {
+		return new CalendarUser(voter, this_vpoll);
+	});
+}
+
+// Add a voter to the VPOLL
+CalendarPoll.prototype.addVoter = function() {
+	this.changed(true);
+	return new CalendarUser(this.data.newProperty("voter", "", {}, "cal-address"), this);
+}
+
+// Mark current user as accepted
+CalendarPoll.prototype.acceptInvite = function() {
+	if (!this.isOwned()) {
+		var voters = $.grep(this.data.properties("voter"), function(voter) {
+			return gSession.currentPrincipal.matchingAddress(voter[3]);
+		});
+		$.each(voters, function(index, voter) {
+			voter[1]["partstat"] = "ACCEPTED";
+			delete voter[1]["rsvp"];
+		})
+	}
+}
+
+// An actual VEVENT object we can manipulate
+CalendarEvent = function(caldata, parent) {
+	CalendarComponent.call(this, caldata, parent);
+}
+
+CalendarEvent.prototype = new CalendarComponent();
+CalendarEvent.prototype.constructor = CalendarEvent;
+CalendarComponent.registerComponentType("vevent", CalendarEvent);
+
+// Create this event as the poll winner
+CalendarEvent.prototype.pickAsWinner = function() {
+	
+	// Adjust VPOLL to mark winner and set status
+	var vpoll = this.parent;
+	vpoll.data.updateProperty("status", "CONFIRMED");
+	vpoll.data.newProperty("poll-winner", this.pollitemid());
+	vpoll.changed(true);
+
+	// Create the new event resource with voters mapped to attendees
+	var calendar = new CalendarObject(jcal.newCalendar());
+	var vevent = calendar.data.newComponent("vevent", true);
+	vevent.updateProperty("uid", this.uid());
+	vevent.copyProperty("summary", this.data);
+	vevent.copyProperty("dtstart", this.data);
+	vevent.copyProperty("dtend", this.data);
+	vevent.copyProperty("organizer", vpoll.data);
+	$.each(vpoll.data.properties("voter"), function(index, voter) {
+		var attendee = vevent.newProperty(
+			"attendee",
+			voter[3],
+			{},
+			"cal-address"
+		);
+		$.each(voter[1], function(key, value) {
+			if (key == "partstat") {
+				value = gSession.currentPrincipal.matchingAddress(voter[3]) ? "ACCEPTED" : "NEEDS-ACTION";
+			}
+			attendee[1][key] = value
+		});
+	});
+	calendar.changed(true);
+
+	return new CalendarResource(gSession.currentPrincipal.event_calendars[0], null, null, calendar);
+}
+
+// An iCalendar calendar user (ORGANIZER/ATTENDEE/VOTER) property
+CalendarUser = function(caldata, parent) {
+	this.data = caldata;
+	this.parent = parent;
+}
+
+// Get or set the user name and/or cu-address
+CalendarUser.prototype.addressDescription = function(value) {
+	if (value === undefined) {
+		var cn = this.data[1]["cn"] ? this.data[1]["cn"] + " " : "";
+		return addressDescription(cn, this.data[3]);
+	} else {
+		if (this.addressDescription() != value) {
+			var splits = splitAddressDescription(value);
+			if (splits[0]) {
+				this.data[1]["cn"] = splits[0];
+			} else {
+				delete this.data[1]["cn"];
+			}
+			this.data[3] = splits[1];
+			this.parent.changed(true);
+		}
+	}
+}
+
+// Get a suitable display string for this user
+CalendarUser.prototype.nameOrAddress = function() {
+	return this.data[1]["cn"] ? this.data[1]["cn"] : this.data[3];
+}
+
+CalendarUser.prototype.cuaddr = function() {
+	return this.data[3];
+}
+
+// Get or set the voter response
+CalendarUser.prototype.response = function(value) {
+	if (value === undefined) {
+		return this.data[1]["response"];
+	} else {
+		if (this.response != value) {
+			this.data[1]["response"] = value;
+			this.parent.changed(true);
+		}
+	}
+}

Added: CalendarServer/trunk/contrib/webpoll/webapp/js/jcal.js
===================================================================
--- CalendarServer/trunk/contrib/webpoll/webapp/js/jcal.js	                        (rev 0)
+++ CalendarServer/trunk/contrib/webpoll/webapp/js/jcal.js	2013-11-08 21:59:31 UTC (rev 11917)
@@ -0,0 +1,478 @@
+/**
+##
+# Copyright (c) 2013 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+*/
+
+gProdID = "-//calendarserver.org//jcal v1//EN";
+
+jcalparser = {
+	PARSER_ALLOW : 0, 	// Pass the "suspect" data through to the object model
+	PARSER_IGNORE : 1, 	// Ignore the "suspect" data
+	PARSER_FIX: 2, 		// Fix (or if not possible ignore) the "suspect" data
+	PARSER_RAISE: 3 	// Raise an exception
+}
+
+// Some clients escape ":" - fix
+jcalparser.INVALID_COLON_ESCAPE_SEQUENCE = jcalparser.PARSER_FIX;
+
+// Other escape sequences - raise
+jcalparser.INVALID_ESCAPE_SEQUENCES = jcalparser.PARSER_RAISE;
+
+// Some client generate empty lines in the body of the data
+jcalparser.BLANK_LINES_IN_DATA = jcalparser.PARSER_FIX;
+
+// Some clients still generate vCard 2 parameter syntax
+jcalparser.VCARD_2_NO_PARAMETER_VALUES = jcalparser.PARSER_ALLOW;
+
+// Use this to fix v2 BASE64 to v3 ENCODING=b - only PARSER_FIX or PARSER_ALLOW
+jcalparser.VCARD_2_BASE64 = jcalparser.PARSER_FIX;
+
+// Allow DATE values when DATETIME specified (and vice versa)
+jcalparser.INVALID_DATETIME_VALUE = jcalparser.PARSER_FIX;
+
+// Allow slightly invalid DURATION values
+jcalparser.INVALID_DURATION_VALUE = jcalparser.PARSER_FIX;
+
+// Truncate over long ADR and N values
+jcalparser.INVALID_ADR_N_VALUES = jcalparser.PARSER_FIX;
+
+// REQUEST-STATUS values with \; as the first separator or single element
+jcalparser.INVALID_REQUEST_STATUS_VALUE = jcalparser.PARSER_FIX;
+
+// Remove \-escaping in URI values when parsing - only PARSER_FIX or PARSER_ALLOW
+jcalparser.BACKSLASH_IN_URI_VALUE = jcalparser.PARSER_FIX;
+
+
+jcal = function(caldata) {
+	this.caldata = caldata;
+}
+
+jcal.newCalendar = function() {
+	var calendar = new jcal(["vcalendar", [], []]);
+	calendar.newProperty("version", "2.0");
+	calendar.newProperty("prodid", gProdID);
+	return calendar;
+}
+
+jcal.fromString = function(data) {
+	return new jcal($.parseJSON(data));
+}
+
+jcal.prototype.toString = function() {
+	return JSON.stringify(this.caldata);
+}
+
+jcal.prototype.duplicate = function() {
+	return new jcal($.parseJSON(this.toString()));
+}
+
+jcal.prototype.name = function() {
+	return this.caldata[0];
+}
+
+// Return the primary (master) component
+// TODO: currently ignores recurrence - i.e. assumes one top-level component only
+jcal.prototype.mainComponent = function() {
+	var results = $.grep(this.caldata[2], function(component, index) {
+		return (component[0] != "vtimezone");
+	});
+	return (results.length == 1) ? new jcal(results[0]) : null;
+}
+
+jcal.prototype.newComponent = function(name, defaultProperties) {
+	var jcomp = new jcal([name.toLowerCase(), [], []]);
+	this.caldata[2].push(jcomp.caldata);
+	if (defaultProperties) {
+		// Add UID and DTSTAMP
+		jcomp.newProperty("uid", generateUUID());
+		jcomp.newProperty("dtstamp", new jcaldate().toString(), {}, "date-time");
+	}
+	return jcomp;
+}
+
+jcal.prototype.addComponent = function(component) {
+	this.caldata[2].push(component.caldata);
+	return component;
+}
+
+// Get one component 
+jcal.prototype.getComponent = function(name) {
+	name = name.toLowerCase();
+	var results = $.grep(this.caldata[2], function(component, index) {
+		return (component[0] == name);
+	});
+	return (results.length == 1) ? new jcal(results[0]) : null;
+}
+
+// Get all matching components 
+jcal.prototype.components = function(name) {
+	name = name.toLowerCase();
+	var results = $.grep(this.caldata[2], function(component, index) {
+		return (component[0] == name);
+	});
+	return $.map(results, function(component, index) {
+		return new jcal(component);
+	});
+}
+
+// Remove all matching components
+jcal.prototype.removeComponents = function(name) {
+	name = name.toLowerCase();
+	this.caldata[2] = $.grep(this.caldata[2], function(comp, index) {
+		return comp[0] != name;
+	});
+}
+
+jcal.prototype.newProperty = function(name, value, params, value_type) {
+	var prop = [name.toLowerCase(), params === undefined ? {} : params, value_type == undefined ? "text" : value_type];
+	if (value instanceof Array) {
+		$.each(value, function(index, single) {
+			prop.push(single);
+		});
+	} else {
+		prop.push(value);
+	}
+	this.caldata[1].push(prop);
+	return prop;
+}
+
+jcal.prototype.copyProperty = function(name, component) {
+	var propdata = component.getProperty(name);
+	this.caldata[1].push([propdata[0], propdata[1], propdata[2], propdata[3]]);
+	return propdata;
+}
+
+jcal.prototype.hasProperty = function(name) {
+	var result = false;
+	name = name.toLowerCase();
+	$.each(this.caldata[1], function(index, property) {
+		if (property[0] == name) {
+			result = true;
+			return false;
+		}
+	});
+	return result;
+}
+
+jcal.prototype.getProperty = function(name) {
+	var result = null;
+	name = name.toLowerCase();
+	$.each(this.caldata[1], function(index, property) {
+		if (property[0] == name) {
+			result = property;
+			return false;
+		}
+	});
+	return result;
+}
+
+jcal.prototype.getPropertyValue = function(name) {
+	var result = null;
+	name = name.toLowerCase();
+	$.each(this.caldata[1], function(index, propdata) {
+		if (propdata[0] == name) {
+			result = propdata[3];
+			return false;
+		}
+	});
+	return result;
+}
+
+jcal.prototype.updateProperty = function(name, value, params, value_type) {
+	if (params === undefined) {
+		params = {};
+	}
+	if (value_type === undefined) {
+		value_type = "text";
+	}
+	var props = this.properties(name);
+	if (props.length == 1) {
+		props[0][1] = params;
+		props[0][2] = value_type;
+		props[0][3] = value;
+		
+		return props[0];
+	} else if (props.length == 0) {
+		return this.newProperty(name, value, params, value_type);
+	}
+}
+
+jcal.prototype.properties = function(name) {
+	return $.grep(this.caldata[1], function(propdata, index) {
+		return propdata[0] == name;
+	});
+}
+
+jcal.prototype.removeProperties = function(name) {
+	name = name.toLowerCase();
+	this.caldata[1] = $.grep(this.caldata[1], function(propdata, index) {
+		return propdata[0] != name;
+	});
+}
+
+// Remove properties for which test() returns true
+jcal.prototype.removePropertiesMatching = function(test) {
+	name = name.toLowerCase();
+	this.caldata[1] = $.grep(this.caldata[1], function(propdata, index) {
+		return !test(propdata);
+	});
+}
+
+// Date/time utility functions
+jcaldate = function() {
+	this.date = new Date();
+	this.tzid = "utc";
+}
+
+jcaldate.jsDateTojCal = function(date) {
+	return date.toISOString().substr(0, 19) + "Z";
+}
+
+jcaldate.jsDateToiCal = function(date) {
+	return jcaldate.jsDateTojCal(date).replace(/\-/g, "").replace(/\:/g, "");
+}
+
+jcaldate.jCalTojsDate = function(value) {
+	var result = new Date(value);
+	result.setMilliseconds(0);
+	return result;
+}
+
+jcaldate.prototype.toString = function() {
+	return this.date.toISOString().substr(0, 19) + "Z";
+}
+
+// Duration utility functions
+jcalduration = function(duration) {
+	
+	if (duration === undefined) {
+		this.mForward = true
+	
+		this.mWeeks = 0
+		this.mDays = 0
+	
+		this.mHours = 0
+		this.mMinutes = 0
+		this.mSeconds = 0
+	} else {
+		this.setDuration(duration);		
+	}
+}
+
+jcalduration.parseText = function(data) {
+	var duration = new jcalduration();
+	duration.parse(data);
+	return duration;
+}
+
+jcalduration.prototype.getTotalSeconds = function() {
+	return (this.mForward ? 1 : -1) * (this.mSeconds + (this.mMinutes + (this.mHours + (this.mDays + (this.mWeeks * 7)) * 24) * 60) * 60);
+}
+
+jcalduration.prototype.setDuration = function(seconds) {
+	this.mForward = seconds >= 0;
+
+	var remainder = seconds;
+	if (remainder < 0) {
+		remainder = -remainder;
+	}
+
+	// Is it an exact number of weeks - if so use the weeks value, otherwise
+	// days, hours, minutes, seconds
+	if (remainder % (7 * 24 * 60 * 60) == 0) {
+		this.mWeeks = remainder / (7 * 24 * 60 * 60);
+		this.mDays = 0;
+
+		this.mHours = 0;
+		this.mMinutes = 0;
+		this.mSeconds = 0;
+	} else {
+		this.mSeconds = remainder % 60;
+		remainder -= this.mSeconds;
+		remainder /= 60;
+
+		this.mMinutes = remainder % 60;
+		remainder -= this.mMinutes;
+		remainder /= 60;
+
+		this.mHours = remainder % 24;
+		remainder -= this.mHours;
+
+		this.mDays = remainder / 24;
+
+		this.mWeeks = 0;
+	}
+}
+
+jcalduration.prototype.parse = function(data) {
+	// parse format ([+]/-) "P" (dur-date / dur-time / dur-week)
+	try {
+		var offset = 0;
+		var maxoffset = data.length;
+	
+		// Look for +/-
+		this.mForward = true;
+		if (data[offset] == '+') {
+			this.mForward = true;
+			offset += 1;
+		} else if (data[offset] == '-') {
+			this.mForward = false;
+			offset += 1;
+		}
+	
+		// Must have a 'P'
+		if (data[offset] != "P")
+			throw "Invalid duration";
+		offset += 1;
+	
+		// Look for time
+		if (data[offset] != "T") {
+			// Must have a number
+			var strnum = data.strtoul(offset);
+			var num = strnum.num;
+			offset = strnum.offset;
+	
+			// Now look at character
+			if (data[offset] == "W") {
+				// Have a number of weeks
+				this.mWeeks = num;
+				offset += 1;
+	
+				// There cannot be anything else after this so just exit
+				if (offset != maxoffset) {
+					if (ParserContext.INVALID_DURATION_VALUE != ParserContext.PARSER_RAISE)
+						return;
+					throw "Invalid duration";
+				}
+				return;
+			} else if (data[offset] == "D") {
+				// Have a number of days
+				this.mDays = num;
+				offset += 1;
+	
+				// Look for more data - exit if none
+				if (offset == maxoffset)
+					return;
+	
+				// Look for time - exit if none
+				if (data[offset] != "T")
+					throw "Invalid duration";
+			} else {
+				// Error in format
+				throw "Invalid duration";
+			}
+		}
+	
+		// Have time
+		offset += 1;
+	
+		// Strictly speaking T must always be followed by time values, but some clients
+		// send T with no additional text
+		if (offset == maxoffset) {
+			if (jcalparser.INVALID_DURATION_VALUE == jcalparser.PARSER_RAISE)
+				throw "Invalid duration";
+			else
+				return;
+		}
+		var strnum = data.strtoul(offset);
+		var num = strnum.num;
+		offset = strnum.offset;
+	
+		// Look for hour
+		if (data[offset] == "H") {
+			// Get hours
+			this.mHours = num;
+			offset += 1;
+	
+			// Look for more data - exit if none
+			if (offset == maxoffset)
+				return;
+	
+			// Parse the next number
+			strnum = data.strtoul(offset);
+			num = strnum.num;
+			offset = strnum.offset;
+		}
+	
+		// Look for minute
+		if (data[offset] == "M") {
+			// Get hours
+			this.mMinutes = num;
+			offset += 1;
+	
+			// Look for more data - exit if none
+			if (offset == maxoffset)
+				return;
+	
+			// Parse the next number
+			strnum = data.strtoul(offset);
+			num = strnum.num;
+			offset = strnum.offset;
+		}
+	
+		// Look for seconds
+		if (data[offset] == "S") {
+			// Get hours
+			this.mSeconds = num;
+			offset += 1;
+	
+			// No more data - exit
+			if (offset == maxoffset)
+				return;
+		}
+		
+		throw "Invalid duration";
+	} catch(err) {
+		throw "Invalid duration";
+	}
+}
+
+jcalduration.prototype.generate = function(self, os) {
+	var result = "";
+
+	if (!this.mForward && (this.mWeeks || this.mDays || this.mHours || this.mMinutes || this.mSeconds)) {
+		result += "-";
+	}
+	result += "P";
+
+	if (this.mWeeks != 0) {
+		result += this.mWeeks + "W";
+	} else {
+		if (this.mDays != 0) {
+			result += this.mDays + "D";
+		}
+
+		if (this.mHours != 0 || this.mMinutes != 0 || this.mSeconds != 0) {
+			result += "T";
+
+			if (this.mHours != 0) {
+				result += this.mHours + "H";
+			}
+
+			if ((this.mMinutes != 0) || ((this.mHours != 0) && (this.mSeconds != 0))) {
+				result += this.mMinutes + "M";
+			}
+
+			if (this.mSeconds != 0) {
+				result += this.mSeconds + "S";
+			}
+		} else if (this.mDays == 0) {
+			result += "T0S";
+		}
+	}
+	
+	return result;
+}

Added: CalendarServer/trunk/contrib/webpoll/webapp/js/utils.js
===================================================================
--- CalendarServer/trunk/contrib/webpoll/webapp/js/utils.js	                        (rev 0)
+++ CalendarServer/trunk/contrib/webpoll/webapp/js/utils.js	2013-11-08 21:59:31 UTC (rev 11917)
@@ -0,0 +1,211 @@
+/**
+##
+# Copyright (c) 2013 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+*/
+
+/**
+ * Utility classes.
+ */
+
+// XML processing utilities
+// Make it easier to use namespaces by allowing the following "NS:" prefixes on
+// element names
+var gNamespaceShortcuts = {
+	"D" : "DAV:",
+	"C" : "urn:ietf:params:xml:ns:caldav",
+	"CS" : "http://calendarserver.org/ns/"
+};
+
+// Add a shortcut namespace for use in an xmlns map
+function addNamespace(shortcut, xmlnsmap) {
+	xmlnsmap[gNamespaceShortcuts[shortcut]] = shortcut;
+}
+
+function buildXMLNS(xmlnsmap) {
+	var xmlnstr = "";
+	$.each(xmlnsmap, function(ns, nsprefix) {
+		xmlnstr += ' xmlns:' + nsprefix + '="' + ns + '"';
+	});
+	return xmlnstr;
+}
+
+function addElements(elements, xmlnsmap) {
+	var propstr = "";
+	$.each(elements, function(index, element) {
+		var segments = element.split(":");
+		addNamespace(segments[0], xmlnsmap);
+		propstr += '<' + element + ' />';
+	});
+	return propstr;
+}
+
+// Find XML elements matching the specified xpath
+function findElementPath(node, path) {
+	return findElementPathSegments(node, path.split("/"));
+}
+
+// Find XML elements matching the specified path segments
+function findElementPathSegments(root, segments) {
+	var elements = findElementNS(root, segments[0]);
+	if (segments.length == 1) {
+		return elements;
+	}
+	var results = [];
+	$.each(elements, function(index, name) {
+		var next = findElementPathSegments($(this), segments.slice(1));
+		$.each(next, function(index, item) {
+			results.push(item);
+		});
+	});
+	return results;
+}
+
+// Find immediate children of node matching the XML {NS}name
+function findElementNS(node, nsname) {
+	var segments = nsname.split(":");
+	var namespace = gNamespaceShortcuts[segments[0]];
+	var name = segments[1];
+	var results = [];
+	node.children(name).each(function() {
+		if (this.namespaceURI == namespace) {
+			results.push($(this));
+		}
+	});
+	return results;
+}
+
+// Get text of target element from an xpath
+function getElementText(node, path) {
+	var items = findElementPath(node, path);
+	return (items.length == 1) ? items[0].text() : null;
+}
+
+// Check for the existence of an element
+function hasElementPath(node, path) {
+	var elements = findElementPath(node, path);
+	return elements.length != 0;
+}
+
+function xmlEncode(text)
+{
+	return text.replace(/&(?!\w+([;\s]|$))/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
+}
+
+// URL helpers
+
+// Removing any trailing slash from a URL
+function removeTrailingSlash(url) {
+	return (url[url.length - 1] == "/") ? url.substr(0, url.length - 1) : url;
+}
+
+// Removing any trailing slash from a URL
+function removeLeadingSlash(url) {
+	return (url[0] == "/") ? url = url.substr(1) : url;
+}
+
+// Compare two URLs ignoring trailing slash
+function compareURLs(url1, url2) {
+	return removeTrailingSlash(url1) == removeTrailingSlash(url2);
+}
+
+// Join two URLs
+function joinURLs(url1, url2) {
+	return removeTrailingSlash(url1) + "/" + removeLeadingSlash(url2);
+}
+
+// Get last path segment
+function basenameURL(url) {
+	return removeTrailingSlash(url).split("/").pop();
+}
+
+// UUID
+
+function generateUUID() {
+	var result = "";
+	for ( var i = 0; i < 32; i++) {
+		if (i == 8 || i == 12 || i == 16 || i == 20)
+			result = result + '-';
+		result += Math.floor(Math.random() * 16).toString(16).toUpperCase();
+	}
+	return result;
+}
+
+// Addresses
+
+function addressDescription(cn, addr) {
+	return addr ? (cn ? cn + " " : "") + "<" + addr + ">" : "";
+}
+
+function splitAddressDescription(desc) {
+	results = ["", ""];
+	if (desc.indexOf("<") == -1) {
+		results[1] = desc;
+	} else {
+		var splits = desc.split("<");
+		results[0] = splits[0].substr(0, splits[0].length - 1);
+		results[1] = splits[1].substr(0, splits[1].length - 1);
+	}
+	
+	return results;
+}
+
+// JSString extensions
+
+if (typeof String.prototype.startsWith != 'function') {
+	String.prototype.startsWith = function(str) {
+		return this.slice(0, str.length) == str;
+	};
+}
+if (typeof String.prototype.endsWith != 'function') {
+	String.prototype.endsWith = function(str) {
+		return this.slice(-str.length) == str;
+	};
+}
+
+if (typeof String.prototype.strtoul != 'function') {
+
+	String.prototype.strtoul = function(offset) {
+		if (offset === undefined) {
+			offset = 0;
+		}
+		var matches = this.substring(offset).match(/^[0-9]+/);
+		if (matches.length == 1) {
+			return {
+				num : parseInt(matches[0]),
+				offset : offset + matches[0].length
+			}
+		} else {
+			throw "ValueError";
+		}
+	}
+}
+
+// JSArray extensions
+
+if (typeof Array.prototype.average != 'function') {
+	Array.prototype.average = function() {
+		return this.sum() / this.length;
+	};
+}
+if (typeof Array.prototype.sum != 'function') {
+	Array.prototype.sum = function() {
+		var result = 0;
+		$.each(this, function(index, num) {
+			result += num;
+		});
+		return result;
+	};
+}

Added: CalendarServer/trunk/contrib/webpoll/webapp/js/webpoll.js
===================================================================
--- CalendarServer/trunk/contrib/webpoll/webapp/js/webpoll.js	                        (rev 0)
+++ CalendarServer/trunk/contrib/webpoll/webapp/js/webpoll.js	2013-11-08 21:59:31 UTC (rev 11917)
@@ -0,0 +1,956 @@
+/**
+##
+# Copyright (c) 2013 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+*/
+
+// Globals
+var gSession = null;
+var gViewController = null;
+
+// Page load
+$(function() {
+
+	$("#progressbar").progressbar({
+		value : false
+	});
+	showLoading(true);
+
+    var params = {};
+    var ps = window.location.search.split(/\?|&/);
+    for (var i = 0; i < ps.length; i++) {
+          if (ps[i]) {
+                var p = ps[i].split(/=/);
+                params[p[0]] = p[1];
+          }
+    }
+
+    // Setup CalDAV session
+	gSession = new CalDAVSession(params.user);
+	gSession.init(function() {
+		$("#title").text($("#title").text() + " for User: " + gSession.currentPrincipal.cn);
+		gViewController.refreshed();
+	});
+
+	gViewController = new ViewController(gSession);
+});
+
+function showLoading(visible) {
+	if (visible) {
+		$("#progressbar").progressbar("enable");
+		$("#loading").show();
+	} else {
+		$("#progressbar").progressbar("disable");
+		$("#loading").hide();
+	}
+}
+
+// Handles all the view interactions
+ViewController = function(session) {
+	this.session = session;
+	this.ownedPolls = new PollList($("#sidebar-owned"), $("#sidebar-new-poll-count"));
+	this.voterPolls = new PollList($("#sidebar-voter"), $("#sidebar-vote-poll-count"));
+	this.activePoll = null;
+	this.isNewPoll = null;
+
+	this.init();
+}
+
+// Setup all the parts of the view
+ViewController.prototype.init = function() {
+	// Setup title area
+
+	var view = this;
+
+	// Setup sidebar UI widgets
+	$("#sidebar").accordion({
+		heightStyle : "content"
+	});
+	$("#sidebar-owned").menu({
+		select : function(event, ui) {
+			view.clickSelectPoll(event, ui, true);
+		}
+	});
+	$("#sidebar-new-poll").button({
+		icons : {
+			primary : "ui-icon-plusthick"
+		}
+	}).click(function() {
+		view.clickAddPoll();
+	});
+
+	$("#sidebar-voter").menu({
+		select : function(event, ui) {
+			view.clickSelectPoll(event, ui, false);
+		}
+	});
+
+	$("#refresh-btn").button({
+		icons : {
+			primary : "ui-icon-refresh"
+		}
+	}).click(function() {
+		view.clickRefresh();
+	});
+
+
+	// Detail Panel
+	this.editSetVisible(false);
+	$("#editpoll-title-edit").focus(function() {
+		$(this).select();
+	});
+	$("#editpoll-tabs").tabs({
+		beforeActivate : function(event, ui) {
+			view.showResults(event, ui);
+		}
+	});
+	$("#editpoll-save").button({
+		icons : {
+			primary : "ui-icon-check"
+		}
+	}).click(function() {
+		view.clickPollSave();
+	});
+	$("#editpoll-cancel").button({
+		icons : {
+			primary : "ui-icon-close"
+		}
+	}).click(function() {
+		view.clickPollCancel();
+	});
+	$("#editpoll-done").button({
+		icons : {
+			primary : "ui-icon-arrowreturnthick-1-w"
+		}
+	}).click(function() {
+		view.clickPollCancel();
+	});
+	$("#editpoll-delete").button({
+		icons : {
+			primary : "ui-icon-trash"
+		}
+	}).click(function() {
+		view.clickPollDelete();
+	});
+	$("#editpoll-autofill").button({
+		icons : {
+			primary : "ui-icon-gear"
+		}
+	}).click(function() {
+		view.clickPollAutofill();
+	});
+	$("#editpoll-addevent").button({
+		icons : {
+			primary : "ui-icon-plus"
+		}
+	}).click(function() {
+		view.clickAddEvent();
+	});
+	$("#editpoll-addvoter").button({
+		icons : {
+			primary : "ui-icon-plus"
+		}
+	}).click(function() {
+		view.clickAddVoter();
+	});
+	
+	$("#editpoll-autofill").hide();
+	$("#response-key").hide();
+	$("#response-menu").menu();
+}
+
+// Add a poll to the UI
+ViewController.prototype.addPoll = function(poll) {
+	if (poll.owned) {
+		this.ownedPolls.addPoll(poll)			
+	} else {
+		this.voterPolls.addPoll(poll)			
+	}
+}
+
+// Switching away from active poll
+ViewController.prototype.aboutToClosePoll = function() {
+	if (this.activePoll && this.activePoll.editing_poll.changed()) {
+		alert("Save or cancel the current poll changes first");
+		return false;
+	} else {
+		return true;
+	}
+}
+
+// Refresh the side bar - try to preserve currently selected item
+ViewController.prototype.clickRefresh = function() {
+	if (!this.aboutToClosePoll()) {
+		return;
+	}
+
+	var currentUID = this.activePoll ? this.activePoll.editing_poll.uid() : null;
+	var active_tab = $("#editpoll-tabs").tabs("option", "active");
+	if (this.activePoll) {
+		this.clickPollCancel();
+	}
+	showLoading(true);
+	this.ownedPolls.clearPolls();
+	this.voterPolls.clearPolls();
+	var this_view = this;
+	this.session.currentPrincipal.refresh(function() {
+		this_view.refreshed();
+		if (currentUID) {
+			this_view.selectPollByUID(currentUID);
+			$("#editpoll-tabs").tabs("option", "active", active_tab);
+			if (active_tab == 2) {
+				this.activePoll.buildResults();
+			}
+		}
+	});
+}
+
+// Add poll button clicked
+ViewController.prototype.clickAddPoll = function() {
+	if (!this.aboutToClosePoll()) {
+		return;
+	}
+
+	// Make sure edit panel is visible
+	this.activatePoll(new Poll(CalendarResource.newPoll("New Poll")));
+	this.isNewPoll = true;
+	$("#editpoll-title-edit").focus();
+}
+
+// A poll was selected
+ViewController.prototype.clickSelectPoll = function(event, ui, owner) {
+	if (!this.aboutToClosePoll()) {
+		return;
+	}
+
+	this.selectPoll(ui.item.index(), owner);
+}
+
+// Select a poll from the list based on its UID
+ViewController.prototype.selectPollByUID = function(uid) {
+	var result = this.ownedPolls.indexOfPollUID(uid);
+	if (result !== null) {
+		this.selectPoll(result, true);
+		return;
+	}
+	result = this.voterPolls.indexOfPollUID(uid);
+	if (result !== null) {
+		this.selectPoll(result, false);
+		return;
+	}
+}
+
+//A poll was selected
+ViewController.prototype.selectPoll = function(index, owner) {
+
+	// Make sure edit panel is visible
+	this.activatePoll(owner ? this.ownedPolls.polls[index] : this.voterPolls.polls[index]);
+	if (owner) {
+		$("#editpoll-title-edit").focus();
+	}
+}
+
+// Activate specified poll
+ViewController.prototype.activatePoll = function(poll) {
+	this.activePoll = poll;
+	this.activePoll.setPanel();
+	this.isNewPoll = false;
+	this.editSetVisible(true);
+}
+
+// Save button clicked
+ViewController.prototype.clickPollSave = function() {
+
+	// TODO: Actually save it to the server
+
+	this.activePoll.getPanel();
+	if (this.isNewPoll) {
+		this.ownedPolls.newPoll(this.activePoll);
+	} else {
+		this.activePoll.list.changePoll(this.activePoll);
+	}
+}
+
+// Cancel button clicked
+ViewController.prototype.clickPollCancel = function() {
+
+	// Make sure edit panel is visible
+	this.activePoll.closed();
+	this.activePoll = null;
+	this.isNewPoll = null;
+	this.editSetVisible(false);
+}
+
+// Delete button clicked
+ViewController.prototype.clickPollDelete = function() {
+
+	// TODO: Actually delete it on the server
+
+	this.activePoll.list.removePoll(this.activePoll);
+
+	// Make sure edit panel is visible
+	this.activePoll = null;
+	this.isNewPoll = null;
+	this.editSetVisible(false);
+}
+
+// Autofill button clicked
+ViewController.prototype.clickPollAutofill = function() {
+	this.activePoll.autoFill();
+}
+
+// Add event button clicked
+ViewController.prototype.clickAddEvent = function() {
+	this.activePoll.addEvent();
+}
+
+// Add voter button clicked
+ViewController.prototype.clickAddVoter = function() {
+	var panel = this.activePoll.addVoter();
+	panel.find(".voter-address").focus();
+}
+
+// Toggle display of poll details
+ViewController.prototype.editSetVisible = function(visible) {
+
+	if (visible) {
+		
+		if (this.isNewPoll) {
+			$("#editpoll-delete").hide();
+			$("#editpoll-tabs").tabs("disable", 2);
+		} else {
+			$("#editpoll-delete").show();
+			$("#editpoll-tabs").tabs("enable", 2);
+		}
+		if (this.activePoll.owned && this.activePoll.resource.object.mainComponent().editable()) {
+			$("#editpoll-title-panel").hide();
+			$("#editpoll-organizer-panel").hide();
+			$("#editpoll-status-panel").hide();
+			$("#editpoll-title-edit-panel").show();
+			$("#editpoll-tabs").tabs("enable", 0);
+			$("#editpoll-tabs").tabs("enable", 1);
+			$("#editpoll-tabs").tabs("option", "active", 0);
+			$("#response-key").hide();
+		} else {
+			$("#editpoll-title-edit-panel").hide();
+			$("#editpoll-title-panel").show();
+			$("#editpoll-organizer-panel").show();
+			$("#editpoll-status-panel").show();
+			$("#editpoll-tabs").tabs("option", "active", 2);
+			$("#editpoll-tabs").tabs("disable", 0);
+			$("#editpoll-tabs").tabs("disable", 1);
+			$("#response-key").toggle(this.activePoll.resource.object.mainComponent().editable());
+			this.activePoll.buildResults();
+		}
+		
+		$("#editpoll-save").toggle(this.activePoll.resource.object.mainComponent().editable());
+		$("#editpoll-cancel").toggle(this.activePoll.resource.object.mainComponent().editable());
+		$("#editpoll-done").toggle(!this.activePoll.resource.object.mainComponent().editable());
+		$("#editpoll-autofill").toggle(this.activePoll.resource.object.mainComponent().editable());
+
+		$("#detail-nocontent").hide();
+		$("#editpoll").show();
+	} else {
+		$("#editpoll").hide();
+		$("#detail-nocontent").show();
+	}
+}
+
+ViewController.prototype.refreshed = function() {
+	showLoading(false);
+	if (this.ownedPolls.polls.length == 0 && this.voterPolls.polls.length != 0) {
+		$("#sidebar").accordion("option", "active", 1);
+	} else {
+		$("#sidebar").accordion("option", "active", 0);
+	}
+}
+
+// Rebuild results panel each time it is selected
+ViewController.prototype.showResults = function(event, ui) {
+	if (ui.newPanel.selector == "#editpoll-results") {
+		this.activePoll.buildResults();
+	}
+	$("#editpoll-autofill").toggle(ui.newPanel.selector == "#editpoll-results");
+	$("#response-key").toggle(ui.newPanel.selector == "#editpoll-results");
+}
+
+// Maintains the list of editable polls and manipulates the DOM as polls are
+// added
+// and removed.
+PollList = function(menu, counter) {
+	this.polls = [];
+	this.menu = menu;
+	this.counter = counter;
+}
+
+// Add a poll to the UI.
+PollList.prototype.addPoll = function(poll) {
+	this.polls.push(poll);
+	poll.list = this;
+	this.menu.append('<li class="sidebar-list"><a href="#">' + poll.title() + '</a></li>');
+	this.menu.menu("refresh");
+	this.counter.text(this.polls.length);
+}
+
+// Add a poll to the UI and save its resource
+PollList.prototype.newPoll = function(poll) {
+	this.addPoll(poll);
+	poll.saveResource();
+	$("#editpoll-delete").show();
+}
+
+// Change a poll in the UI and save its resource
+PollList.prototype.changePoll = function(poll) {
+	var index = this.polls.indexOf(poll);
+	this.menu.find("a").eq(index).text(poll.title());
+	this.menu.menu("refresh");
+	poll.saveResource();
+}
+
+// Remove a poll resource and its UI
+PollList.prototype.removePoll = function(poll) {
+	var this_polllist = this;
+	poll.resource.removeResource(function() {
+		var index = this_polllist.polls.indexOf(poll);
+		this_polllist.polls.splice(index, 1);
+		this_polllist.menu.children("li").eq(index).remove();
+		this_polllist.menu.menu("refresh");
+		this_polllist.counter.text(this_polllist.polls.length);
+	});
+}
+
+PollList.prototype.indexOfPollUID = function(uid) {
+	var result = null;
+	$.each(this.polls, function(index, poll) {
+		if (poll.resource.object.mainComponent().uid() == uid) {
+			result = index;
+			return false;
+		}
+	});
+	return result;
+}
+
+// Remove all UI items
+PollList.prototype.clearPolls = function() {
+	this.menu.empty();
+	this.menu.menu("refresh");
+	this.polls = [];
+	this.counter.text(this.polls.length);
+}
+
+// An editable poll. It manipulates the DOM for editing a poll
+Poll = function(resource) {
+	this.resource = resource;
+	this.owned = this.resource.object.mainComponent().isOwned();
+	this.editing_object = null;
+	this.editing_poll = null;
+}
+
+Poll.prototype.title = function() {
+	return this.editing_poll ? this.editing_poll.summary() : this.resource.object.mainComponent().summary();
+}
+
+Poll.prototype.closed = function() {
+	this.editing_poll = null;
+}
+
+// Save the editable state
+Poll.prototype.saveResource = function(whenDone) {
+	// Only if it changed
+	if (this.editing_poll.changed()) {
+		this.resource.object = this.editing_object;
+		var this_poll = this;
+		this.resource.saveResource(function() {
+			// Reload from the resource as it might change after write to server
+			this_poll.editing_object = this_poll.resource.object.duplicate();
+			this_poll.editing_poll = this_poll.editing_object.mainComponent();
+			
+			if (whenDone) {
+				whenDone();
+			}
+		});
+	}
+}
+
+// Fill the UI with details of the poll
+Poll.prototype.setPanel = function() {
+	
+	var this_poll = this;
+	this.editing_object = this.resource.object.duplicate();
+	this.editing_poll = this.editing_object.mainComponent();
+
+	// Setup the details panel with this poll
+	$("#editpoll-title-edit").val(this.editing_poll.summary());
+	$("#editpoll-title").text(this.editing_poll.summary());
+	$("#editpoll-organizer").text(this.editing_poll.organizerDisplayName());
+	$("#editpoll-status").text(this.editing_poll.status());
+	$("#editpoll-eventlist").empty();
+	$.each(this.editing_poll.events(), function(index, event) {
+		this_poll.setEventPanel(this_poll.addEventPanel(), event);
+	});
+	$("#editpoll-voterlist").empty();
+	$.each(this.editing_poll.voters(), function(index, voter) {
+		this_poll.setVoterPanel(this_poll.addVoterPanel(), voter);
+	});
+}
+
+// Get poll details from the UI
+Poll.prototype.getPanel = function() {
+	var this_poll = this;
+
+	// Get values from the details panel
+	if (this.owned) {
+		this.editing_poll.summary($("#editpoll-title-edit").val());
+		
+		var events = this.editing_poll.events();
+		$("#editpoll-eventlist").children().each(function(index) {
+			this_poll.updateEventFromPanel($(this), events[index]);
+		});
+
+		var voters = this.editing_poll.voters();
+		$("#editpoll-voterlist").children().each(function(index) {
+			this_poll.updateVoterFromPanel($(this), voters[index]);
+		});
+	}
+}
+
+//Add a new event item in the UI
+Poll.prototype.addEventPanel = function() {
+
+	var ctr = $("#editpoll-eventlist").children().length + 1;
+	var idstart = "event-dtstart-" + ctr;
+	var idend = "event-dtend-" + ctr;
+
+	// Add new list item
+	var evt = '<div class="event">';
+	evt += '<div class="edit-datetime">';
+	evt += '<label for="' + idstart + '">Start: </label>';
+	evt += '<input type="text" id="' + idstart + '" class="event-dtstart"/>';
+	evt += '</div>';
+	evt += '<div class="edit-datetime">';
+	evt += '<label for="' + idend + '">End:   </label>';
+	evt += '<input type="text" id="' + idend + '" class="event-dtend" />';
+	evt += '</div>';
+	evt += '<button class="input-remove">Remove</button>';
+	evt += '</div>';
+	evt = $(evt).appendTo("#editpoll-eventlist");
+
+	evt.find(".event-dtstart").datetimepicker();
+	evt.find(".event-dtend").datetimepicker();
+	evt.find(".input-remove").button({
+		icons : {
+			primary : "ui-icon-close"
+		}
+	});
+
+	return evt;
+}
+
+// Update the UI for this event
+Poll.prototype.setEventPanel = function(panel, event) {
+	panel.find(".event-dtstart").datetimepicker("setDate", event.dtstart());
+	panel.find(".event-dtend").datetimepicker("setDate", event.dtend());
+	return panel;
+}
+
+// Get details of the event from the UI
+Poll.prototype.updateEventFromPanel = function(panel, event) {
+	event.summary($("#editpoll-title-edit").val());
+	event.dtstart(panel.find(".event-dtstart").datetimepicker("getDate"));
+	event.dtend(panel.find(".event-dtend").datetimepicker("getDate"));
+}
+
+// Add event button clicked
+Poll.prototype.addEvent = function() {
+	
+	var ctr = $("#editpoll-eventlist").children().length;
+	var dtstart = new Date();
+	dtstart.setDate(dtstart.getDate() + ctr);
+	dtstart.setHours(12, 0, 0, 0);
+	var dtend = new Date();
+	dtend.setDate(dtend.getDate() + ctr);
+	dtend.setHours(13, 0, 0, 0);
+
+	// Add new list item
+	var vevent = this.editing_poll.addEvent(dtstart, dtend);
+	return this.setEventPanel(this.addEventPanel(), vevent);
+}
+
+//Add a new voter item in the UI
+Poll.prototype.addVoterPanel = function() {
+
+	var ctr = $("#editpoll-voterlist").children().length + 1;
+	var idvoter = "voter-address-" + ctr;
+
+	// Add new list item
+	var vtr = '<div class="voter">';
+	vtr += '<div class="edit-voter">';
+	vtr += '<label for="' + idvoter + '">Voter: </label>';
+	vtr += '<input type="text" id="' + idvoter + '" class="voter-address"/>';
+	vtr += '</div>';
+	vtr += '<button class="input-remove">Remove</button>';
+	vtr += '</div>';
+	vtr = $(vtr).appendTo("#editpoll-voterlist");
+
+	vtr.find(".voter-address").autocomplete({
+		minLength : 3,
+		source : function(request, response) {
+			gSession.calendarUserSearch(request.term, function(results) {
+				response(results);
+			});
+		}
+	}).focus(function() {
+		$(this).select();
+	});
+	
+	vtr.find(".input-remove").button({
+		icons : {
+			primary : "ui-icon-close"
+		}
+	});
+
+	return vtr;
+}
+
+// Update UI for this voter
+Poll.prototype.setVoterPanel = function(panel, voter) {
+	panel.find(".voter-address").val(voter.addressDescription());
+	return panel;
+}
+
+// Get details of the voter from the UI
+Poll.prototype.updateVoterFromPanel = function(panel, voter) {
+	voter.addressDescription(panel.find(".voter-address").val());
+}
+
+// Add voter button clicked
+Poll.prototype.addVoter = function() {
+	// Add new list item
+	var voter = this.editing_poll.addVoter();
+	return this.setVoterPanel(this.addVoterPanel(), voter);
+}
+
+// Build the results UI based on the poll details
+Poll.prototype.buildResults = function() {
+	
+	var this_poll = this;
+	
+	// Sync with any changes from other panels
+	this.getPanel();
+
+	var event_details = this.editing_poll.events();
+	var voter_details = this.editing_poll.voters();
+
+	var thead = $("#editpoll-resulttable").children("thead").first();
+	var th_date = thead.children("tr").eq(0).empty();
+	var th_start = thead.children("tr").eq(1).empty();
+	var th_end = thead.children("tr").eq(2).empty();
+	var tf = $("#editpoll-resulttable").children("tfoot").first();
+	var tf_overall = tf.children("tr").first().empty();
+	var tf_commit = tf.children("tr").last().empty();
+	tf_commit.toggle(this.owned || !this_poll.editing_poll.editable());
+	var tbody = $("#editpoll-resulttable").children("tbody").first().empty();
+	$('<td>Date:</td>').appendTo(th_date);
+	$('<td>Start:</td>').appendTo(th_start);
+	$('<td>End:</td>').appendTo(th_end);
+	$('<td>Overall:</td>').appendTo(tf_overall);
+	$('<td />').appendTo(tf_commit);
+	$.each(event_details, function(index, event) {
+		var td_date = $('<td />').appendTo(th_date).text(event.dtstart().toDateString()).addClass("center-td");
+		var td_start = $('<td />').appendTo(th_start).text(event.dtstart().toLocaleTimeString());
+		var td_end = $('<td />').appendTo(th_end).text(event.dtend().toLocaleTimeString());
+		$('<td />').appendTo(tf_overall).addClass("center-td");
+		$('<td />').appendTo(tf_commit).addClass("center-td");
+		if (event.ispollwinner()) {
+			td_date.addClass("poll-winner-td");
+			td_start.addClass("poll-winner-td");
+			td_end.addClass("poll-winner-td");
+		}
+		td_date.hover(
+			function() {
+				this_poll.hoverDialogOpen(td_date, thead, event);
+			},
+			this_poll.hoverDialogClose
+		);
+	});
+	$.each(voter_details, function(index, voter) {
+		var active = gSession.currentPrincipal.matchingAddress(voter.cuaddr());
+		var tr = $("<tr/>").appendTo(tbody);
+		$("<td/>").appendTo(tr).text(voter.nameOrAddress());
+		$.each(event_details, function(index, event) {
+			var response = event.voter_responses()[voter.cuaddr()];
+			var td = $("<td />").appendTo(tr).addClass("center-td");
+			if (event.ispollwinner()) {
+				td.addClass("poll-winner-td");
+			}
+			if (active && this_poll.editing_poll.editable()) {
+				var radios = $('<div id="response-' + index + '" />').appendTo(td).addClass("response-btns");
+				$('<input type="radio" id="respond_no-' + index + '" name="response-' + index + '"/>').appendTo(radios);
+				$('<label for="respond_no-' + index + '"/>').appendTo(radios);
+				$('<input type="radio" id="respond_maybe-' + index + '" name="response-' + index + '"/>').appendTo(radios);
+				$('<label for="respond_maybe-' + index + '"/>').appendTo(radios);
+				$('<input type="radio" id="respond_ok-' + index + '" name="response-' + index + '"/>').appendTo(radios);
+				$('<label for="respond_ok-' + index + '"/>').appendTo(radios);
+				$('<input type="radio" id="respond_best-' + index + '" name="response-' + index + '"/>').appendTo(radios);
+				$('<label for="respond_best-' + index + '"/>').appendTo(radios);
+				radios.buttonset();
+				if (response !== undefined) {
+					if (response < 40) {
+						$('#respond_no-' + index).click();
+					} else if (response < 80) {
+						$('#respond_maybe-' + index).click();
+					} else if (response < 90) {
+						$('#respond_ok-' + index).click();
+					} else {
+						$('#respond_best-' + index).click();
+					}
+				}
+				$('#respond_no-' + index).button({
+					icons : {
+						primary : "ui-icon-close"
+					},
+					text: false
+				}).click(this_poll.clickResponse);
+				$('#respond_maybe-' + index).button({
+					icons : {
+						primary : "ui-icon-help"
+					},
+					text: false
+				}).click(this_poll.clickResponse);
+				$('#respond_ok-' + index).button({
+					icons : {
+						primary : "ui-icon-check"
+					},
+					text: false
+				}).click(this_poll.clickResponse);
+				$('#respond_best-' + index).button({
+					icons : {
+						primary : "ui-icon-circle-check"
+					},
+					text: false
+				}).click(this_poll.clickResponse);
+			} else {
+				td.text(this_poll.textForResponse(response)[0]);
+			}
+		});
+		if (active) {
+			tr.addClass("active-voter");
+		}
+	});
+
+	$.each(event_details, function(index, event) {
+		if (this_poll.editing_poll.editable()) {
+			$('<button id="winner-' + index + '">Pick Winner</button>').appendTo(tf_commit.children()[index + 1]).button({
+				icons : {
+					primary : "ui-icon-star"
+				},
+			}).click(this_poll.clickWinner);
+		} else {
+			if (event.ispollwinner()) {
+				$(tf_commit.children()[index + 1]).addClass("poll-winner-td");
+				$('<div id="winner-text"><span id="winner-icon-left" class="ui-icon ui-icon-star" />Winner<span id="winner-icon-right" class="ui-icon ui-icon-star" /></div>').appendTo(tf_commit.children()[index + 1]);
+			}
+		}
+	});
+
+	this.updateOverallResults();
+}
+
+Poll.prototype.textForResponse = function(response) {
+	var result = [];
+	if (response === undefined) {
+		result.push("No Response");
+		result.push("no-response-td");
+	} else if (response < 40) {
+		result.push("No");
+		result.push("no-td");
+	} else if (response < 80) {
+		result.push("Maybe");
+		result.push("maybe-td");
+	} else if (response < 90) {
+		result.push("Ok");
+		result.push("ok-td");
+	} else {
+		result.push("Best");
+		result.push("best-td");
+	}
+	return result;
+}
+
+Poll.prototype.clickResponse = function() {
+	var splits = $(this).attr("id").split("-");
+	var response_type = splits[0];
+	var index = parseInt(splits[1]);
+	var response = 0;
+	if (response_type == "respond_maybe") {
+		response = 50;
+	} else if (response_type == "respond_ok") {
+		response = 85;
+	} else if (response_type == "respond_best") {
+		response = 100;
+	}
+	
+	var event = gViewController.activePoll.editing_poll.events()[index];
+	event.changeVoterResponse(response);
+	gViewController.activePoll.updateOverallResults();
+}
+
+// A winner was chosen, make poll changes and create new event and save everything
+Poll.prototype.clickWinner = function() {
+	var splits = $(this).attr("id").split("-");
+	var event = gViewController.activePoll.editing_poll.events()[parseInt(splits[1])];
+	var new_resource = event.pickAsWinner();
+	new_resource.saveResource(function() {
+		gViewController.activePoll.saveResource(function() {
+			gViewController.activatePoll(gViewController.activePoll);
+		});
+	})
+}
+
+// Open the event time-range hover dialog 
+Poll.prototype.hoverDialogOpenClassic = function(td_date, thead, event) {
+	var dialog_div = $('<div id="hover-cal" />').appendTo(td_date).dialog({
+		dialogClass: "no-close",
+		position: { my: "left top", at: "right+20 top", of: thead },
+		show: "fade",
+		title: "Your Events for " + event.dtstart().toDateString(),
+		width: 400,
+	});
+	var start = new Date(event.dtstart().getTime() - 6 * 60 * 60 * 1000);
+	var end = new Date(event.dtend().getTime() + 6 * 60 * 60 * 1000);
+	gSession.currentPrincipal.eventsForTimeRange(
+		start,
+		end,
+		function(results) {
+			var text = "";
+			results = $.map(results, function(result) {
+				return result.mainComponent();
+			});
+			results.push(event);
+			results.sort(function(a, b) {
+				return a.dtstart().getTime() - b.dtstart().getTime();
+			});
+			var relative_offset = 10;
+			var last_end = null;
+			$.each(results, function(index, result) {
+				text = result.dtstart().toLocaleTimeString() + " - ";
+				text += result.dtend().toLocaleTimeString() + " : ";
+				text += result.summary();
+				if (last_end !== null && last_end.getTime() != result.dtstart().getTime()) {
+					relative_offset += 10;
+				}
+				last_end = result.dtend();
+				$('<div class="hover-event ui-corner-all" style="top:' + relative_offset + 'px"/>').appendTo(dialog_div).addClass(result.pollitemid() !== null ? "ui-state-active" : "ui-state-default").text(text);
+			});
+		}
+	);
+}
+
+// Open the event time-range hover dialog 
+Poll.prototype.hoverDialogOpenFancy = function(td_date, thead, event) {
+	var dialog_div = $('<div id="hover-cal" />').appendTo(td_date).dialog({
+		dialogClass: "no-close",
+		position: { my: "left top", at: "right+20 top", of: thead },
+		show: "fade",
+		title: "Your Events for " + event.dtstart().toDateString(),
+		width: 400,
+	});
+	
+	var start = new Date(event.dtstart().getTime() - 6 * 60 * 60 * 1000);
+	start.setMinutes(0, 0, 0);
+	var startHour = start.getHours();
+	var end = new Date(event.dtend().getTime() + 6 * 60 * 60 * 1000);
+	end.setMinutes(0, 0, 0);
+	var endHour = end.getHours();
+	
+	var grid = $('<table id="hover-grid" />').appendTo(dialog_div);
+	for(var i = startHour; i < endHour; i++) {
+		var text = i > 12 ? i - 12 +":00 pm" : i + ":00 am";
+		$('<tr><td class="hover-grid-td-time">' + text + '</td><td class="hover-grid-td-slot" /></tr>').appendTo(grid);
+	}
+	gSession.currentPrincipal.eventsForTimeRange(
+		start,
+		end,
+		function(results) {
+			results = $.map(results, function(result) {
+				return result.mainComponent();
+			});
+			results.push(event);
+			results.sort(function(a, b) {
+				return a.dtstart().getTime() - b.dtstart().getTime();
+			});
+			var last_dtend = null;
+			$.each(results, function(index, result) {
+				var top_offset = (result.dtstart().getHours() - startHour) * 30;
+				var height = ((result.dtend().getTime() - result.dtstart().getTime()) * 30) / (60 * 60 * 1000) - 6;
+				var styles = "top:" + top_offset + "px;height:" + height + "px";
+				if (last_dtend !== null && last_dtend > result.dtstart()) {
+					styles += ";left:206px;width:125px";
+				}
+				last_dtend = result.dtend();
+				$('<div class="hover-event ui-corner-all" style="' + styles + '" />').appendTo(grid).addClass(result.pollitemid() !== null ? "ui-state-active" : "ui-state-default").text(result.summary());
+			});
+		}
+	);
+}
+
+Poll.prototype.hoverDialogOpen = Poll.prototype.hoverDialogOpenFancy;
+
+// Close the event time-range hover dialog 
+Poll.prototype.hoverDialogClose = function() {
+	$("#hover-cal").dialog("close").remove();
+}
+
+Poll.prototype.updateOverallResults = function() {
+	var this_poll = this;
+	var event_details = this.editing_poll.events();
+	var voter_details = this.editing_poll.voters();
+	var tds = $("#editpoll-resulttable").children("tfoot").first().children("tr").first().children("td");
+
+	// Update overall items
+	$.each(event_details, function(index, event) {
+		var overall = [];
+		var responses = event.voter_responses();
+		$.each(voter_details, function(index, voter) {
+			var response = responses[voter.cuaddr()];
+			if (response !== undefined) {
+				overall.push(response);
+			}
+		});
+		var response_details = this_poll.textForResponse(overall.average());
+		var possible_classes = ["best-td", "ok-td", "maybe-td", "no-td", "no-response-td"];
+		possible_classes.splice(possible_classes.indexOf(response_details[1]), 1);
+		$(tds[index + 1]).text(response_details[0]);
+		$(tds[index + 1]).removeClass(possible_classes.join(" "));
+		$(tds[index + 1]).addClass(event.ispollwinner() ? "poll-winner-td" : response_details[1]);
+	});
+}
+
+Poll.prototype.autoFill = function() {
+	
+	var event_details = this.editing_poll.events();
+	$.each(event_details, function(index, event) {
+		// Freebusy
+		gSession.currentPrincipal.isBusy(
+			gSession.currentPrincipal.defaultAddress(),
+			event.dtstart(),
+			event.dtend(),
+			function(result) {
+				$((result ? "#respond_no-" : "#respond_ok-") + index).click();
+			}
+		);
+	});
+}
+
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20140312/3ae74e8d/attachment.html>


More information about the calendarserver-changes mailing list