<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[11917] CalendarServer/trunk/contrib</title>
</head>
<body>
<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; }
#msg dl a { font-weight: bold}
#msg dl a:link { color:#fc3; }
#msg dl a:active { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta">
<dt>Revision</dt> <dd><a href="http://trac.calendarserver.org//changeset/11917">11917</a></dd>
<dt>Author</dt> <dd>cdaboo@apple.com</dd>
<dt>Date</dt> <dd>2013-11-08 13:59:31 -0800 (Fri, 08 Nov 2013)</dd>
</dl>
<h3>Log Message</h3>
<pre>webpoll prototype/demo webapp.</pre>
<h3>Added Paths</h3>
<ul>
<li>CalendarServer/trunk/contrib/webpoll/</li>
<li><a href="#CalendarServertrunkcontribwebpollMakefile">CalendarServer/trunk/contrib/webpoll/Makefile</a></li>
<li><a href="#CalendarServertrunkcontribwebpollREADMEtxt">CalendarServer/trunk/contrib/webpoll/README.txt</a></li>
<li><a href="#CalendarServertrunkcontribwebpollcaldavdtestwebpollplist">CalendarServer/trunk/contrib/webpoll/caldavd-test-webpoll.plist</a></li>
<li>CalendarServer/trunk/contrib/webpoll/webapp/</li>
<li>CalendarServer/trunk/contrib/webpoll/webapp/css/</li>
<li><a href="#CalendarServertrunkcontribwebpollwebappcsswebpollcss">CalendarServer/trunk/contrib/webpoll/webapp/css/webpoll.css</a></li>
<li><a href="#CalendarServertrunkcontribwebpollwebappindexhtml">CalendarServer/trunk/contrib/webpoll/webapp/index.html</a></li>
<li>CalendarServer/trunk/contrib/webpoll/webapp/js/</li>
<li><a href="#CalendarServertrunkcontribwebpollwebappjscaldavjs">CalendarServer/trunk/contrib/webpoll/webapp/js/caldav.js</a></li>
<li><a href="#CalendarServertrunkcontribwebpollwebappjsjcaljs">CalendarServer/trunk/contrib/webpoll/webapp/js/jcal.js</a></li>
<li><a href="#CalendarServertrunkcontribwebpollwebappjsutilsjs">CalendarServer/trunk/contrib/webpoll/webapp/js/utils.js</a></li>
<li><a href="#CalendarServertrunkcontribwebpollwebappjswebpolljs">CalendarServer/trunk/contrib/webpoll/webapp/js/webpoll.js</a></li>
</ul>
</div>
<div id="patch">
<h3>Diff</h3>
<a id="CalendarServertrunkcontribwebpollMakefile"></a>
<div class="addfile"><h4>Added: CalendarServer/trunk/contrib/webpoll/Makefile (0 => 11917)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/contrib/webpoll/Makefile         (rev 0)
+++ CalendarServer/trunk/contrib/webpoll/Makefile        2013-11-08 21:59:31 UTC (rev 11917)
</span><span class="lines">@@ -0,0 +1,36 @@
</span><ins>+##
+# 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
</ins></span></pre></div>
<a id="CalendarServertrunkcontribwebpollREADMEtxt"></a>
<div class="addfile"><h4>Added: CalendarServer/trunk/contrib/webpoll/README.txt (0 => 11917)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/contrib/webpoll/README.txt         (rev 0)
+++ CalendarServer/trunk/contrib/webpoll/README.txt        2013-11-08 21:59:31 UTC (rev 11917)
</span><span class="lines">@@ -0,0 +1,29 @@
</span><ins>+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
</ins></span></pre></div>
<a id="CalendarServertrunkcontribwebpollcaldavdtestwebpollplist"></a>
<div class="addfile"><h4>Added: CalendarServer/trunk/contrib/webpoll/caldavd-test-webpoll.plist (0 => 11917)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -0,0 +1,39 @@
</span><ins>+<?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>
</ins></span></pre></div>
<a id="CalendarServertrunkcontribwebpollwebappcsswebpollcss"></a>
<div class="addfile"><h4>Added: CalendarServer/trunk/contrib/webpoll/webapp/css/webpoll.css (0 => 11917)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -0,0 +1,310 @@
</span><ins>+@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;
+}
+
</ins></span></pre></div>
<a id="CalendarServertrunkcontribwebpollwebappindexhtml"></a>
<div class="addfile"><h4>Added: CalendarServer/trunk/contrib/webpoll/webapp/index.html (0 => 11917)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -0,0 +1,110 @@
</span><ins>+<!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>
</ins><span class="cx">Property changes on: CalendarServer/trunk/contrib/webpoll/webapp/index.html
</span><span class="cx">___________________________________________________________________
</span></span></pre></div>
<a id="svnexecutable"></a>
<div class="addfile"><h4>Added: svn:executable</h4></div>
<a id="CalendarServertrunkcontribwebpollwebappjscaldavjs"></a>
<div class="addfile"><h4>Added: CalendarServer/trunk/contrib/webpoll/webapp/js/caldav.js (0 => 11917)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -0,0 +1,1028 @@
</span><ins>+/**
+##
+# 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);
+                }
+        }
+}
</ins></span></pre></div>
<a id="CalendarServertrunkcontribwebpollwebappjsjcaljs"></a>
<div class="addfile"><h4>Added: CalendarServer/trunk/contrib/webpoll/webapp/js/jcal.js (0 => 11917)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -0,0 +1,478 @@
</span><ins>+/**
+##
+# 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;
+}
</ins></span></pre></div>
<a id="CalendarServertrunkcontribwebpollwebappjsutilsjs"></a>
<div class="addfile"><h4>Added: CalendarServer/trunk/contrib/webpoll/webapp/js/utils.js (0 => 11917)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -0,0 +1,211 @@
</span><ins>+/**
+##
+# 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;
+        };
+}
</ins></span></pre></div>
<a id="CalendarServertrunkcontribwebpollwebappjswebpolljs"></a>
<div class="addfile"><h4>Added: CalendarServer/trunk/contrib/webpoll/webapp/js/webpoll.js (0 => 11917)</h4>
<pre class="diff"><span>
<span class="info">--- 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)
</span><span class="lines">@@ -0,0 +1,956 @@
</span><ins>+/**
+##
+# 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();
+                        }
+                );
+        });
+}
+
</ins></span></pre>
</div>
</div>
</body>
</html>