4 commits - plugins/calendar plugins/libcalendaring plugins/libkolab plugins/tasklist

Thomas Brüderli bruederli at kolabsys.com
Thu Apr 17 17:49:13 CEST 2014


 plugins/calendar/calendar.php                                  |   21 -
 plugins/calendar/calendar_ui.js                                |   48 --
 plugins/calendar/drivers/database/SQL/mysql.initial.sql        |    3 
 plugins/calendar/drivers/database/SQL/postgres.initial.sql     |    3 
 plugins/calendar/drivers/database/SQL/sqlite.initial.sql       |    3 
 plugins/calendar/drivers/database/database_driver.php          |   63 +++
 plugins/calendar/drivers/kolab/SQL/mysql.initial.sql           |    4 
 plugins/calendar/drivers/kolab/SQL/mysql/2014041700.sql        |    1 
 plugins/calendar/drivers/kolab/SQL/postgres.initial.sql        |    2 
 plugins/calendar/drivers/kolab/kolab_driver.php                |   47 +-
 plugins/calendar/skins/classic/calendar.css                    |   38 ++
 plugins/calendar/skins/classic/images/delete.png               |binary
 plugins/calendar/skins/classic/images/plus.png                 |binary
 plugins/calendar/skins/classic/templates/eventedit.html        |   10 
 plugins/calendar/skins/larry/templates/eventedit.html          |   10 
 plugins/libcalendaring/libcalendaring.js                       |   80 ++++
 plugins/libcalendaring/libcalendaring.php                      |  181 +++++++---
 plugins/libcalendaring/libvcalendar.php                        |    7 
 plugins/libcalendaring/localization/en_US.inc                  |    3 
 plugins/libcalendaring/skins/larry/libcal.css                  |   39 ++
 plugins/libcalendaring/tests/libvcalendar.php                  |    3 
 plugins/libkolab/SQL/mysql/2014040900.sql                      |    8 
 plugins/libkolab/lib/kolab_format_event.php                    |    2 
 plugins/libkolab/lib/kolab_format_task.php                     |    2 
 plugins/libkolab/lib/kolab_format_xcal.php                     |    2 
 plugins/tasklist/drivers/database/tasklist_database_driver.php |   62 +++
 plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php       |   41 +-
 plugins/tasklist/skins/larry/templates/taskedit.html           |   10 
 plugins/tasklist/tasklist.js                                   |   40 --
 plugins/tasklist/tasklist.php                                  |   19 -
 30 files changed, 555 insertions(+), 197 deletions(-)

New commits:
commit 93d2b69bb9205ae0ef06848569b29498fbaaa254
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Apr 17 17:49:00 2014 +0200

    Refactored alarms in calendar and tasks to support multiple alarms. Moved redundant functions to libcalendaring

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index 2666d3c..ef39665 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -763,7 +763,7 @@ class calendar extends rcube_plugin
       case "new":
         // create UID for new event
         $event['uid'] = $this->generate_uid();
-        $this->prepare_event($event, $action);
+        $this->write_preprocess($event, $action);
         if ($success = $this->driver->new_event($event)) {
           $event['id'] = $event['uid'];
           $this->cleanup_event($event);
@@ -772,20 +772,20 @@ class calendar extends rcube_plugin
         break;
         
       case "edit":
-        $this->prepare_event($event, $action);
+        $this->write_preprocess($event, $action);
         if ($success = $this->driver->edit_event($event))
             $this->cleanup_event($event);
         $reload =  $success && ($event['recurrence'] || $event['_savemode'] || $event['_fromcalendar']) ? 2 : 1;
         break;
       
       case "resize":
-        $this->prepare_event($event, $action);
+        $this->write_preprocess($event, $action);
         $success = $this->driver->resize_event($event);
         $reload = $event['_savemode'] ? 2 : 1;
         break;
       
       case "move":
-        $this->prepare_event($event, $action);
+        $this->write_preprocess($event, $action);
         $success = $this->driver->move_event($event);
         $reload  = $success && $event['_savemode'] ? 2 : 1;
         break;
@@ -1327,8 +1327,10 @@ class calendar extends rcube_plugin
   private function _client_event($event, $addcss = false)
   {
     // compose a human readable strings for alarms_text and recurrence_text
-    if ($event['alarms'])
-      $event['alarms_text'] = libcalendaring::alarms_text($event['alarms']);
+    if ($event['valarms']) {
+      $event['alarms_text'] = libcalendaring::alarms_text($event['valarms']);
+      $event['valarms'] = libcalendaring::to_client_alarms($event['valarms']);
+    }
     if ($event['recurrence']) {
       $event['recurrence_text'] = $this->_recurrence_text($event['recurrence']);
       if ($event['recurrence']['UNTIL'])
@@ -1555,7 +1557,7 @@ class calendar extends rcube_plugin
   /**
    * Prepares new/edited event properties before save
    */
-  private function prepare_event(&$event, $action)
+  private function write_preprocess(&$event, $action)
   {
     // convert dates into DateTime objects in user's current timezone
     $event['start'] = new DateTime($event['start'], $this->timezone);
@@ -1584,6 +1586,11 @@ class calendar extends rcube_plugin
       }, $event['recurrence']['RDATE']);
     }
 
+    // convert the submitted alarm values
+    if ($event['valarms']) {
+      $event['valarms'] = libcalendaring::from_client_alarms($event['valarms']);
+    }
+
     $attachments = array();
     $eventid = 'cal:'.$event['id'];
     if (is_array($_SESSION[self::SESSION_KEY]) && $_SESSION[self::SESSION_KEY]['id'] == $eventid) {
diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js
index 04ec392..44681ba 100644
--- a/plugins/calendar/calendar_ui.js
+++ b/plugins/calendar/calendar_ui.js
@@ -81,7 +81,6 @@ function rcube_calendar_ui(settings)
     var date2unixtime = this.date2unixtime;
     var fromunixtime = this.fromunixtime;
     var parseISO8601 = this.parseISO8601;
-    var init_alarms_edit = this.init_alarms_edit;
 
 
     /***  private methods  ***/
@@ -311,7 +310,7 @@ function rcube_calendar_ui(settings)
       if (event.recurrence && event.recurrence_text)
         $('#event-repeat').show().children('.event-text').html(Q(event.recurrence_text));
       
-      if (event.alarms && event.alarms_text)
+      if (event.valarms && event.alarms_text)
         $('#event-alarm').show().children('.event-text').html(Q(event.alarms_text));
       
       if (calendar.name)
@@ -519,34 +518,10 @@ function rcube_calendar_ui(settings)
       else {
         allday.checked = false;
       }
-      
+
       // set alarm(s)
-      // TODO: support multiple alarm entries
-      if (event.alarms || action != 'new') {
-        if (typeof event.alarms == 'string')
-          event.alarms = event.alarms.split(';');
-        
-        var valarms = event.alarms || [''];
-        for (var alarm, i=0; i < valarms.length; i++) {
-          alarm = String(valarms[i]).split(':');
-          if (!alarm[1] && alarm[0]) alarm[1] = 'DISPLAY';
-          $('#eventedit select.edit-alarm-type').val(alarm[1]);
-          
-          if (alarm[0].match(/@(\d+)/)) {
-            var ondate = fromunixtime(parseInt(RegExp.$1));
-            $('#eventedit select.edit-alarm-offset').val('@');
-            $('#eventedit input.edit-alarm-date').val($.fullCalendar.formatDate(ondate, settings['date_format']));
-            $('#eventedit input.edit-alarm-time').val($.fullCalendar.formatDate(ondate, settings['time_format']));
-          }
-          else if (alarm[0].match(/([-+])(\d+)([MHD])/)) {
-            $('#eventedit input.edit-alarm-value').val(RegExp.$2);
-            $('#eventedit select.edit-alarm-offset').val(''+RegExp.$1+RegExp.$3);
-          }
-        }
-      }
-      // set correct visibility by triggering onchange handlers
-      $('#eventedit select.edit-alarm-type, #eventedit select.edit-alarm-offset').change();
-      
+      me.set_alarms_edit('#edit-alarms', action != 'new' && event.valarms && calendar.alarms ? event.valarms : []);
+
       // enable/disable alarm property according to backend support
       $('#edit-alarms')[(calendar.alarms ? 'show' : 'hide')]();
 
@@ -690,23 +665,12 @@ function rcube_calendar_ui(settings)
           sensitivity: sensitivity.val(),
           status: eventstatus.val(),
           recurrence: '',
-          alarms: '',
+          valarms: me.serialize_alarms('#edit-alarms'),
           attendees: event_attendees,
           deleted_attachments: rcmail.env.deleted_attachments,
           attachments: []
         };
 
-        // serialize alarm settings
-        // TODO: support multiple alarm entries
-        var alarm = $('#eventedit select.edit-alarm-type').val();
-        if (alarm) {
-          var val, offset = $('#eventedit select.edit-alarm-offset').val();
-          if (offset == '@')
-            data.alarms = '@' + date2unixtime(parse_datetime($('#eventedit input.edit-alarm-time').val(), $('#eventedit input.edit-alarm-date').val())) + ':' + alarm;
-          else if ((val = parseInt($('#eventedit input.edit-alarm-value').val())) && !isNaN(val) && val >= 0)
-            data.alarms = offset[0] + val + offset[1] + ':' + alarm;
-        }
-
         // uploaded attachments list
         for (var i in rcmail.env.attachments)
           if (i.match(/^rcmfile(.+)/))
@@ -3260,7 +3224,7 @@ function rcube_calendar_ui(settings)
         });
 
       // register events on alarm fields
-      init_alarms_edit('#eventedit');
+      me.init_alarms_edit('#edit-alarms');
 
       // toggle recurrence frequency forms
       $('#edit-recurrence-frequency').change(function(e){
diff --git a/plugins/calendar/drivers/database/SQL/mysql.initial.sql b/plugins/calendar/drivers/database/SQL/mysql.initial.sql
index c45b3f2..ed989be 100644
--- a/plugins/calendar/drivers/database/SQL/mysql.initial.sql
+++ b/plugins/calendar/drivers/database/SQL/mysql.initial.sql
@@ -3,12 +3,11 @@
  *
  * Plugin to add a calendar to Roundcube.
  *
- * @version @package_version@
  * @author Lazlo Westerhof
  * @author Thomas Bruederli
- * @url http://rc-calendar.lazlo.me
  * @licence GNU AGPL
  * @copyright (c) 2010 Lazlo Westerhof - Netherlands
+ * @copyright (c) 2014 Kolab Systems AG
  *
  **/
 
diff --git a/plugins/calendar/drivers/database/SQL/postgres.initial.sql b/plugins/calendar/drivers/database/SQL/postgres.initial.sql
index 007bbf2..21239c6 100644
--- a/plugins/calendar/drivers/database/SQL/postgres.initial.sql
+++ b/plugins/calendar/drivers/database/SQL/postgres.initial.sql
@@ -3,13 +3,12 @@
  *
  * Plugin to add a calendar to RoundCube.
  *
- * @version @package_version@
  * @author Lazlo Westerhof
  * @author Albert Lee
  * @author Aleksander Machniak <machniak at kolabsys.com>
- * @url http://rc-calendar.lazlo.me
  * @licence GNU AGPL
  * @copyright (c) 2010 Lazlo Westerhof - Netherlands
+ * @copyright (c) 2014 Kolab Systems AG
  *
  **/
 
diff --git a/plugins/calendar/drivers/database/SQL/sqlite.initial.sql b/plugins/calendar/drivers/database/SQL/sqlite.initial.sql
index 3d35907..078007d 100644
--- a/plugins/calendar/drivers/database/SQL/sqlite.initial.sql
+++ b/plugins/calendar/drivers/database/SQL/sqlite.initial.sql
@@ -3,13 +3,12 @@
  *
  * Plugin to add a calendar to Roundcube.
  *
- * @version @package_version@
  * @author Lazlo Westerhof
  * @author Thomas Bruederli
  * @author Albert Lee
- * @url http://rc-calendar.lazlo.me
  * @licence GNU AGPL
  * @copyright (c) 2010 Lazlo Westerhof - Netherlands
+ * @copyright (c) 2014 Kolab Systems AG
  *
  **/
 
diff --git a/plugins/calendar/drivers/database/database_driver.php b/plugins/calendar/drivers/database/database_driver.php
index 77e4951..b4de23b 100644
--- a/plugins/calendar/drivers/database/database_driver.php
+++ b/plugins/calendar/drivers/database/database_driver.php
@@ -459,10 +459,14 @@ class database_driver extends calendar_driver
     if (isset($event['allday'])) {
       $event['all_day'] = $event['allday'] ? 1 : 0;
     }
-    
+
     // compute absolute time to notify the user
     $event['notifyat'] = $this->_get_notification($event);
-    
+
+    if (is_array($event['valarms'])) {
+        $event['alarms'] = $this->serialize_alarms($event['valarms']);
+    }
+
     // process event attendees
     $_attendees = '';
     foreach ((array)$event['attendees'] as $attendee) {
@@ -484,10 +488,10 @@ class database_driver extends calendar_driver
    */
   private function _get_notification($event)
   {
-    if ($event['alarms'] && $event['start'] > new DateTime()) {
+    if ($event['valarms'] && $event['start'] > new DateTime()) {
       $alarm = libcalendaring::get_next_alarm($event);
 
-      if ($alarm['time'] && $alarm['action'] == 'DISPLAY')
+      if ($alarm['time'] && in_array($alarm['action'], $this->alarm_types))
         return date('Y-m-d H:i:s', $alarm['time']);
     }
 
@@ -877,7 +881,12 @@ class database_driver extends calendar_driver
     else {
       $event['attendees'] = array();
     }
-
+    
+    // decode serialized alarms
+    if ($event['alarms']) {
+      $event['valarms'] = $this->unserialize_alarms($event['alarms']);
+    }
+    
     unset($event['event_id'], $event['calendar_id'], $event['notifyat'], $event['all_day'], $event['_attachments']);
     return $event;
   }
@@ -1088,4 +1097,48 @@ class database_driver extends calendar_driver
     return $this->rc->db->affected_rows($query);
   }
 
+  /**
+   * Helper method to serialize the list of alarms into a string
+   */
+  private function serialize_alarms($valarms)
+  {
+      foreach ((array)$valarms as $i => $alarm) {
+          if ($alarm['trigger'] instanceof DateTime) {
+              $valarms[$i]['trigger'] = '@' . $alarm['trigger']->format('c');
+          }
+      }
+
+      return $valarms ? json_encode($valarms) : null;
+  }
+
+  /**
+   * Helper method to decode a serialized list of alarms
+   */
+  private function unserialize_alarms($alarms)
+  {
+      // decode json serialized alarms
+      if ($alarms && $alarms[0] == '[') {
+          $valarms = json_decode($alarms, true);
+          foreach ($valarms as $i => $alarm) {
+              if ($alarm['trigger'][0] == '@') {
+                  try {
+                      $valarms[$i]['trigger'] = new DateTime(substr($alarm['trigger'], 1));
+                  }
+                  catch (Exception $e) {
+                      unset($valarms[$i]);
+                  }
+              }
+          }
+      }
+      // convert legacy alarms data
+      else if (strlen($alarms)) {
+          list($trigger, $action) = explode(':', $alarms, 2);
+          if ($trigger = libcalendaring::parse_alaram_value($trigger)) {
+              $valarms = array(array('action' => $action, 'trigger' => $trigger[3] ?: $trigger[0]));
+          }
+      }
+
+      return $valarms;
+  }
+
 }
diff --git a/plugins/calendar/drivers/kolab/SQL/mysql.initial.sql b/plugins/calendar/drivers/kolab/SQL/mysql.initial.sql
index f10d902..88df960 100644
--- a/plugins/calendar/drivers/kolab/SQL/mysql.initial.sql
+++ b/plugins/calendar/drivers/kolab/SQL/mysql.initial.sql
@@ -7,11 +7,11 @@
  **/
 
 CREATE TABLE IF NOT EXISTS `kolab_alarms` (
-  `event_id` VARCHAR(255) NOT NULL,
+  `alarm_id` VARCHAR(255) NOT NULL,
   `user_id` int(10) UNSIGNED NOT NULL,
   `notifyat` DATETIME DEFAULT NULL,
   `dismissed` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0',
-  PRIMARY KEY(`event_id`),
+  PRIMARY KEY(`alarm_id`),
   CONSTRAINT `fk_kolab_alarms_user_id` FOREIGN KEY (`user_id`)
     REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE
 ) /*!40000 ENGINE=INNODB */;
diff --git a/plugins/calendar/drivers/kolab/SQL/mysql/2014041700.sql b/plugins/calendar/drivers/kolab/SQL/mysql/2014041700.sql
new file mode 100644
index 0000000..9175b55
--- /dev/null
+++ b/plugins/calendar/drivers/kolab/SQL/mysql/2014041700.sql
@@ -0,0 +1 @@
+ALTER TABLE `kolab_alarms` CHANGE `event_id` `alarm_id` VARCHAR(255) NOT NULL;
\ No newline at end of file
diff --git a/plugins/calendar/drivers/kolab/SQL/postgres.initial.sql b/plugins/calendar/drivers/kolab/SQL/postgres.initial.sql
index b869240..e3ef9aa 100644
--- a/plugins/calendar/drivers/kolab/SQL/postgres.initial.sql
+++ b/plugins/calendar/drivers/kolab/SQL/postgres.initial.sql
@@ -6,7 +6,7 @@
  **/
 
 CREATE TABLE IF NOT EXISTS kolab_alarms (
-  event_id character varying(255) NOT NULL,
+  alarm_id character varying(255) NOT NULL,
   user_id integer NOT NULL
         REFERENCES users (user_id) ON DELETE CASCADE ON UPDATE CASCADE,
   notifyat timestamp without time zone DEFAULT NULL,
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index caa4043..6058dfb 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -33,7 +33,7 @@ class kolab_driver extends calendar_driver
   public $freebusy = true;
   public $attachments = true;
   public $undelete = true;
-  public $alarm_types = array('DISPLAY');
+  public $alarm_types = array('DISPLAY','AUDIO');
   public $categoriesimmutable = true;
 
   private $rc;
@@ -834,7 +834,7 @@ class kolab_driver extends calendar_driver
     
     $time = $slot + $interval;
     
-    $events = array();
+    $candidates = array();
     $query = array(array('tags', '=', 'x-has-alarms'));
     foreach ($this->calendars as $cid => $calendar) {
       // skip calendars with alarms disabled
@@ -844,41 +844,48 @@ class kolab_driver extends calendar_driver
       foreach ($calendar->list_events($time, $time + 86400 * 365, null, 1, $query) as $e) {
         // add to list if alarm is set
         $alarm = libcalendaring::get_next_alarm($e);
-        if ($alarm && $alarm['time'] && $alarm['time'] <= $time && $alarm['action'] == 'DISPLAY') {
-          $id = $e['id'];
-          $events[$id] = $e;
-          $events[$id]['notifyat'] = $alarm['time'];
+        if ($alarm && $alarm['time'] && $alarm['time'] <= $time && in_array($alarm['action'], $this->alarm_types)) {
+          $id = $alarm['id'];  // use alarm-id as primary identifier
+          $candidates[$id] = array(
+            'id'       => $id,
+            'title'    => $e['title'],
+            'location' => $e['location'],
+            'start'    => $e['start'],
+            'end'      => $e['end'],
+            'notifyat' => $alarm['time'],
+            'action'   => $alarm['action'],
+          );
         }
       }
     }
 
     // get alarm information stored in local database
-    if (!empty($events)) {
-      $event_ids = array_map(array($this->rc->db, 'quote'), array_keys($events));
+    if (!empty($candidates)) {
+      $alarm_ids = array_map(array($this->rc->db, 'quote'), array_keys($candidates));
       $result = $this->rc->db->query(sprintf(
           "SELECT * FROM kolab_alarms
-           WHERE event_id IN (%s) AND user_id=?",
-           join(',', $event_ids),
+           WHERE alarm_id IN (%s) AND user_id=?",
+           join(',', $alarm_ids),
            $this->rc->db->now()
           ),
           $this->rc->user->ID
        );
 
       while ($result && ($e = $this->rc->db->fetch_assoc($result))) {
-        $dbdata[$e['event_id']] = $e;
+        $dbdata[$e['alarm_id']] = $e;
       }
     }
     
     $alarms = array();
-    foreach ($events as $id => $e) {
-      // skip dismissed
+    foreach ($candidates as $id => $alarm) {
+      // skip dismissed alarms
       if ($dbdata[$id]['dismissed'])
         continue;
       
       // snooze function may have shifted alarm time
-      $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $e['notifyat'];
+      $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $alarm['notifyat'];
       if ($notifyat <= $time)
-        $alarms[] = $e;
+        $alarms[] = $alarm;
     }
     
     return $alarms;
@@ -889,13 +896,13 @@ class kolab_driver extends calendar_driver
    *
    * @see calendar_driver::dismiss_alarm()
    */
-  public function dismiss_alarm($event_id, $snooze = 0)
+  public function dismiss_alarm($alarm_id, $snooze = 0)
   {
     // delete old alarm entry
     $this->rc->db->query(
       "DELETE FROM kolab_alarms
-       WHERE event_id=? AND user_id=?",
-       $event_id,
+       WHERE alarm_id=? AND user_id=?",
+       $alarm_id,
        $this->rc->user->ID
     );
 
@@ -904,9 +911,9 @@ class kolab_driver extends calendar_driver
 
     $query = $this->rc->db->query(
       "INSERT INTO kolab_alarms
-       (event_id, user_id, dismissed, notifyat)
+       (alarm_id, user_id, dismissed, notifyat)
        VALUES(?, ?, ?, ?)",
-      $event_id,
+      $alarm_id,
       $this->rc->user->ID,
       $snooze > 0 ? 0 : 1,
       $notifyat
diff --git a/plugins/calendar/skins/classic/calendar.css b/plugins/calendar/skins/classic/calendar.css
index 6c7ca14..40350fa 100644
--- a/plugins/calendar/skins/classic/calendar.css
+++ b/plugins/calendar/skins/classic/calendar.css
@@ -535,6 +535,44 @@ td.topalign {
 	vertical-align: top;
 }
 
+#eventedit .edit-alarm-item {
+	position: relative;
+	padding-right: 30px;
+	margin-bottom: 2px;
+}
+
+#eventedit .edit-alarm-buttons {
+	position: absolute;
+	top: 2px;
+	right: 0;
+}
+
+#eventedit .edit-alarm-buttons a.iconlink {
+	display: none;
+	width: 18px;
+	height: 17px;
+	padding: 1px;
+	text-indent: -5000px;
+	overflow: hidden;
+}
+
+#eventedit .edit-alarm-buttons a.add-alarm {
+	background: url(images/plus.png) 1px 1px no-repeat;
+}
+
+#eventedit .edit-alarm-buttons a.delete-alarm {
+	background: url(images/delete.png) 1px 1px no-repeat;
+}
+
+#eventedit .edit-alarm-buttons a.delete-alarm,
+#eventedit .first .edit-alarm-buttons a.add-alarm {
+	display: inline-block;
+}
+
+#eventedit .first .edit-alarm-buttons a.delete-alarm {
+	display: none;
+}
+
 #eventedit label.weekday,
 #eventedit label.monthday {
 	min-width: 3em;
diff --git a/plugins/calendar/skins/classic/images/delete.png b/plugins/calendar/skins/classic/images/delete.png
new file mode 100644
index 0000000..553ae43
Binary files /dev/null and b/plugins/calendar/skins/classic/images/delete.png differ
diff --git a/plugins/calendar/skins/classic/images/plus.png b/plugins/calendar/skins/classic/images/plus.png
new file mode 100644
index 0000000..1a35013
Binary files /dev/null and b/plugins/calendar/skins/classic/images/plus.png differ
diff --git a/plugins/calendar/skins/classic/templates/eventedit.html b/plugins/calendar/skins/classic/templates/eventedit.html
index 7e7170d..7678d0c 100644
--- a/plugins/calendar/skins/classic/templates/eventedit.html
+++ b/plugins/calendar/skins/classic/templates/eventedit.html
@@ -41,8 +41,14 @@
         <input type="text" name="endtime" size="6"  id="edit-endtime" />
       </div>
       <div class="event-section" id="edit-alarms">
-        <label for="edit-alarm"><roundcube:label name="calendar.alarms" /></label>
-        <roundcube:object name="plugin.alarm_select" />
+        <div class="edit-alarm-item first">
+          <label for="edit-alarm"><roundcube:label name="calendar.alarms" /></label>
+          <roundcube:object name="plugin.alarm_select" />
+          <span class="edit-alarm-buttons">
+            <a href="#add" class="iconlink add add-alarm">+</a>
+            <a href="#delete" class="iconlink delete delete-alarm">-</a>
+          </span>
+        </div>
       </div>
       <div class="event-section" id="calendar-select">
         <label for="edit-calendar"><roundcube:label name="calendar.calendar" /></label>
diff --git a/plugins/calendar/skins/larry/templates/eventedit.html b/plugins/calendar/skins/larry/templates/eventedit.html
index 85b3a77..208579f 100644
--- a/plugins/calendar/skins/larry/templates/eventedit.html
+++ b/plugins/calendar/skins/larry/templates/eventedit.html
@@ -37,8 +37,14 @@
 				<input type="text" name="endtime" size="6" id="edit-endtime" />
 			</div>
 			<div class="event-section" id="edit-alarms">
-				<label for="edit-alarm"><roundcube:label name="calendar.alarms" /></label>
-				<roundcube:object name="plugin.alarm_select" />
+				<div class="edit-alarm-item first">
+					<label><roundcube:label name="calendar.alarms" /></label>
+					<roundcube:object name="plugin.alarm_select" />
+					<span class="edit-alarm-buttons">
+						<a href="#add" class="iconlink add add-alarm">+</a>
+						<a href="#delete" class="iconlink delete delete-alarm">-</a>
+					</span>
+				</div>
 			</div>
 			<div class="event-section" id="calendar-select">
 				<label for="edit-calendar"><roundcube:label name="calendar.calendar" /></label>
diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js
index 3808de1..aa9d227 100644
--- a/plugins/libcalendaring/libcalendaring.js
+++ b/plugins/libcalendaring/libcalendaring.js
@@ -311,9 +311,87 @@ function rcube_libcalendaring(settings)
             $(this).parent().find('.edit-alarm-value').prop('disabled', mode == 'show');
         });
 
-        $(prefix+' .edit-alarm-date').datepicker(datepicker_settings);
+        $(prefix+' .edit-alarm-date').removeClass('hasDatepicker').removeAttr('id').datepicker(datepicker_settings);
+
+        $(prefix).on('click', 'a.delete-alarm', function(e){
+            if ($(this).closest('.edit-alarm-item').siblings().length > 0) {
+                $(this).closest('.edit-alarm-item').remove();
+            }
+            return false;
+        });
+
+        $(prefix).on('click', 'a.add-alarm', function(e){
+            var i = $(this).closest('.edit-alarm-item').siblings().length + 1;
+            var item = $(this).closest('.edit-alarm-item').clone(false)
+              .removeClass('first')
+              .appendTo(prefix);
+
+              me.init_alarms_edit(prefix + ' .edit-alarm-item:eq(' + i + ')');
+              $('select.edit-alarm-type, select.edit-alarm-offset', item).change();
+              return false;
+        });
     }
 
+    this.set_alarms_edit = function(prefix, valarms)
+    {
+        $(prefix + ' .edit-alarm-item:gt(0)').remove();
+
+        var i, alarm, domnode, val, offset;
+        for (i=0; i < valarms.length; i++) {
+          alarm = valarms[i];
+          if (!alarm.action)
+              alarm.action = 'DISPLAY';
+
+          if (i == 0) {
+              domnode = $(prefix + ' .edit-alarm-item').eq(0);
+          }
+          else {
+              domnode = $(prefix + ' .edit-alarm-item').eq(0).clone(false).removeClass('first').appendTo(prefix);
+              this.init_alarms_edit(prefix + ' .edit-alarm-item:eq(' + i + ')');
+          }
+
+          $('select.edit-alarm-type', domnode).val(alarm.action);
+
+          if (String(alarm.trigger).match(/@(\d+)/)) {
+              var ondate = this.fromunixtime(parseInt(RegExp.$1));
+              $('select.edit-alarm-offset', domnode).val('@');
+              $('input.edit-alarm-value', domnode).val('');
+              $('input.edit-alarm-date', domnode).val(this.format_datetime(ondate, 1));
+              $('input.edit-alarm-time', domnode).val(this.format_datetime(ondate, 2));
+          }
+          else if (String(alarm.trigger).match(/([-+])(\d+)([MHDS])/)) {
+              val = RegExp.$2; offset = ''+RegExp.$1+RegExp.$3;
+              $('input.edit-alarm-value', domnode).val(val);
+              $('select.edit-alarm-offset', domnode).val(offset);
+          }
+        }
+
+        // set correct visibility by triggering onchange handlers
+        $(prefix + ' select.edit-alarm-type, ' + prefix + ' select.edit-alarm-offset').change();
+    };
+
+    this.serialize_alarms = function(prefix)
+    {
+        var valarms = [];
+
+        $(prefix + ':visible .edit-alarm-item').each(function(i, elem){
+            var val, offset, alarm = { action: $('select.edit-alarm-type', elem).val() };
+            if (alarm.action) {
+                offset = $('select.edit-alarm-offset', elem).val();
+                if (offset == '@') {
+                    alarm.trigger = '@' + me.date2unixtime(me.parse_datetime($('input.edit-alarm-time', elem).val(), $('input.edit-alarm-date', elem).val()));
+                }
+                else if (!isNaN((val = parseInt($('input.edit-alarm-value', elem).val()))) && val >= 0) {
+                    alarm.trigger = offset[0] + val + offset[1];
+                }
+
+                valarms.push(alarm);
+            }
+        });
+
+        return valarms;
+    };
+
 
     /*****  Alarms handling  *****/
 
diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php
index edc0dde..e71509c 100644
--- a/plugins/libcalendaring/libcalendaring.php
+++ b/plugins/libcalendaring/libcalendaring.php
@@ -345,25 +345,89 @@ class libcalendaring extends rcube_plugin
     public static function parse_alaram_value($val)
     {
         if ($val[0] == '@') {
-            return array(substr($val, 1));
+            return array(new DateTime($val));
         }
-        else if (preg_match('/([+-])P?(T?\d+[HMSDW])+/', $val, $m) && preg_match_all('/T?(\d+)([HMSDW])/', $val, $m2, PREG_SET_ORDER)) {
+        else if (preg_match('/([+-]?)P?(T?\d+[HMSDW])+/', $val, $m) && preg_match_all('/T?(\d+)([HMSDW])/', $val, $m2, PREG_SET_ORDER)) {
+            if ($m[1] == '')
+                $m[1] = '+';
             foreach ($m2 as $seg) {
+                $prefix = $seg[2] == 'D' || $seg[2] == 'W' ? 'P' : 'PT';
                 if ($seg[1] > 0) {  // ignore zero values
-                    return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2]);
+                    return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]);
                 }
             }
+
+            // return zero value nevertheless
+            return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]);
         }
 
         return false;
     }
 
     /**
+     * Convert the alarms list items to be processed on the client
+     */
+    public static function to_client_alarms($valarms)
+    {
+        return array_map(function($alarm){
+            if ($alarm['trigger'] instanceof DateTime) {
+                $alarm['trigger'] = '@' . $alarm['trigger']->format('U');
+            }
+            else if ($trigger = self::parse_alaram_value($alarm['trigger'])) {
+                $alarm['trigger'] = $trigger[2];
+            }
+            return $alarm;
+        }, (array)$valarms);
+    }
+
+    /**
+     * Process the alarms values submitted by the client
+     */
+    public static function from_client_alarms($valarms)
+    {
+        return array_map(function($alarm){
+            if ($alarm['trigger'][0] == '@') {
+                try { $alarm['trigger'] = new DateTime($alarm['trigger']); }
+                catch (Exception $e) { /* handle this ? */ }
+            }
+            else if ($trigger = libcalendaring::parse_alaram_value($alarm['trigger'])) {
+                $alarm['trigger'] = $trigger[3];
+            }
+            return $alarm;
+        }, (array)$valarms);
+    }
+
+    /**
      * Render localized text for alarm settings
      */
-    public static function alarms_text($alarm)
+    public static function alarms_text($alarms)
     {
-        list($trigger, $action) = explode(':', $alarm);
+        if (is_array($alarms) && is_array($alarms[0])) {
+            $texts = array();
+            foreach ($alarms as $alarm) {
+                if ($text = self::alarm_text($alarm))
+                    $texts[] = $text;
+            }
+
+            return join(', ', $texts);
+        }
+        else {
+            return self::alarm_text($alarms);
+        }
+    }
+
+    /**
+     * Render localized text for a single alarm property
+     */
+    public static function alarm_text($alarm)
+    {
+        if (is_string($alarm)) {
+            list($trigger, $action) = explode(':', $alarm);
+        }
+        else {
+            $trigger = $alarm['trigger'];
+            $action = $alarm['action'];
+        }
 
         $text = '';
         $rcube = rcube::get_instance();
@@ -375,19 +439,33 @@ class libcalendaring extends rcube_plugin
         case 'DISPLAY':
             $text = $rcube->gettext('libcalendaring.alarmdisplay');
             break;
+        case 'AUDIO':
+            $text = $rcube->gettext('libcalendaring.alarmaudio');
+            break;
         }
 
-        if (preg_match('/@(\d+)/', $trigger, $m)) {
+        if ($trigger instanceof DateTime) {
+            $text .= ' ' . $rcube->gettext(array(
+                'name' => 'libcalendaring.alarmat',
+                'vars' => array('datetime' => $rcube->format_date($trigger))
+            ));
+        }
+        else if (preg_match('/@(\d+)/', $trigger, $m)) {
             $text .= ' ' . $rcube->gettext(array(
                 'name' => 'libcalendaring.alarmat',
                 'vars' => array('datetime' => $rcube->format_date($m[1]))
             ));
         }
         else if ($val = self::parse_alaram_value($trigger)) {
-            $text .= ' ' . intval($val[0]) . ' ' . $rcube->gettext('libcalendaring.trigger' . $val[1]);
+            // TODO: for all-day events say 'on date of event at XX' ?
+            if ($val[0] == 0)
+                $text .= ' ' . $rcube->gettext('libcalendaring.triggerattime');
+            else
+                $text .= ' ' . intval($val[0]) . ' ' . $rcube->gettext('libcalendaring.trigger' . $val[1]);
         }
-        else
+        else {
             return false;
+        }
 
         return $text;
     }
@@ -400,53 +478,78 @@ class libcalendaring extends rcube_plugin
      */
     public static function get_next_alarm($rec, $type = 'event')
     {
-        if (!$rec['alarms'] || $rec['cancelled'] || $rec['status'] == 'CANCELLED')
+        if (!($rec['valarms'] || $rec['alarms']) || $rec['cancelled'] || $rec['status'] == 'CANCELLED')
             return null;
 
         if ($type == 'task') {
             $timezone = self::get_instance()->timezone;
-            if ($rec['date'])
-                $rec['start'] = new DateTime($rec['date'] . ' ' . ($rec['time'] ?: '12:00'), $timezone);
             if ($rec['startdate'])
-                $rec['end'] = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?: '12:00'), $timezone);
+                $rec['start'] = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?: '12:00'), $timezone);
+            if ($rec['date'])
+                $rec[($rec['start'] ? 'end' : 'start')] = new DateTime($rec['date'] . ' ' . ($rec['time'] ?: '12:00'), $timezone);
         }
 
         if (!$rec['end'])
             $rec['end'] = $rec['start'];
 
+        // support legacy format
+        if (!$rec['valarms']) {
+            list($trigger, $action) = explode(':', $rec['alarms'], 2);
+            if ($alarm = self::parse_alaram_value($trigger)) {
+                $rec['valarms'] = array(array('action' => $action, 'trigger' => $alarm[3] ?: $alarm[0]));
+            }
+        }
+
+        $expires = new DateTime('now - 12 hours');
+        $alarm_id = $rec['id'];  // alarm ID eq. record ID by default to keep backwards compatibility
 
-        // TODO: handle multiple alarms (currently not supported)
-        list($trigger, $action) = explode(':', $rec['alarms'], 2);
-
-        $notify = self::parse_alaram_value($trigger);
-        if (!empty($notify[1])){  // offset
-            $mult = 1;
-            switch ($notify[1]) {
-                case '-S': $mult =     -1; break;
-                case '+S': $mult =      1; break;
-                case '-M': $mult =    -60; break;
-                case '+M': $mult =     60; break;
-                case '-H': $mult =  -3600; break;
-                case '+H': $mult =   3600; break;
-                case '-D': $mult = -86400; break;
-                case '+D': $mult =  86400; break;
-                case '-W': $mult = -604800; break;
-                case '+W': $mult =  604800; break;
+        // handle multiple alarms
+        $notify_at = null;
+        foreach ($rec['valarms'] as $alarm) {
+            $notify_time = null;
+
+            if ($alarm['trigger'] instanceof DateTime) {
+                $notify_time = $alarm['trigger'];
             }
-            $offset = $notify[0] * $mult;
-            $refdate = $mult > 0 ? $rec['end'] : $rec['start'];
+            else if (is_string($alarm['trigger'])) {
+                $refdate = $alarm['trigger'][0] == '+' ? $rec['end'] : $rec['start'];
 
-            // abort of no reference date is available to compute notification time
-            if (!is_a($refdate, 'DateTime'))
-                return null;
+                // abort if no reference date is available to compute notification time
+                if (!is_a($refdate, 'DateTime'))
+                    continue;
 
-            $notify_at = $refdate->format('U') + $offset;
-        }
-        else {  // absolute timestamp
-            $notify_at = $notify[0];
+                // TODO: for all-day events, take start @ 00:00 as reference date ?
+
+                try {
+                    $interval = new DateInterval(trim($alarm['trigger'], '+-'));
+                    $interval->invert = $alarm['trigger'][0] != '+';
+                    $notify_time = clone $refdate;
+                    $notify_time->add($interval);
+                }
+                catch (Exception $e) {
+                    rcube::raise_error($e, true);
+                    continue;
+                }
+            }
+
+            if ($notify_time && (!$notify_at || ($notify_time < $notify_at && $notify_time > $expires))) {
+                $notify_at = $notify_time;
+                $action = $alarm['action'];
+                $alarm_prop = $alarm;
+
+                // generate a unique alarm ID if multiple alarms are set
+                if (count($rec['valarms']) > 1) {
+                    $alarm_id = substr(md5($rec['id']), 0, 16) . '-' . $notify_at->format('Ymd\THis');
+                }
+            }
         }
 
-        return array('time' => $notify_at, 'action' => $action ? strtoupper($action) : 'DISPLAY');
+        return !$notify_at ? null : array(
+            'time'   => $notify_at->format('U'),
+            'action' => $action ? strtoupper($action) : 'DISPLAY',
+            'id'     => $alarm_id,
+            'prop'   => $alarm_prop,
+        );
     }
 
     /**
diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php
index f7d7ccc..1dda548 100644
--- a/plugins/libcalendaring/libvcalendar.php
+++ b/plugins/libcalendaring/libvcalendar.php
@@ -1025,9 +1025,10 @@ class libvcalendar implements Iterator
             $va = VObject\Component::create('VALARM');
             list($trigger, $va->action) = explode(':', $event['alarms']);
             $val = libcalendaring::parse_alaram_value($trigger);
-            $period = $val[1] && preg_match('/[HMS]$/', $val[1]) ? 'PT' : 'P';
-            if ($val[1]) $va->add('TRIGGER', preg_replace('/^([-+])P?T?(.+)/', "\\1$period\\2", $trigger));
-            else         $va->add('TRIGGER', gmdate('Ymd\THis\Z', $val[0]), array('VALUE' => 'DATE-TIME'));
+            if ($val[3])
+                $va->add('TRIGGER', $val[3]);
+            else if ($val[0] instanceof DateTime)
+                $va->add(self::datetime_prop('TRIGGER', $val[0]));
             $ve->add($va);
         }
 
diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc
index 5eecd29..c3159ff 100644
--- a/plugins/libcalendaring/localization/en_US.inc
+++ b/plugins/libcalendaring/localization/en_US.inc
@@ -4,8 +4,10 @@ $labels = array();
 
 $labels['alarmemail'] = 'Send Email';
 $labels['alarmdisplay'] = 'Show message';
+$labels['alarmaudio'] = 'Play sound';
 $labels['alarmdisplayoption'] = 'Message';
 $labels['alarmemailoption'] = 'Email';
+$labels['alarmaudiooption'] = 'Sound';
 $labels['alarmat'] = 'at $datetime';
 $labels['trigger@'] = 'on date';
 $labels['trigger-M'] = 'minutes before';
@@ -14,6 +16,7 @@ $labels['trigger-D'] = 'days before';
 $labels['trigger+M'] = 'minutes after';
 $labels['trigger+H'] = 'hours after';
 $labels['trigger+D'] = 'days after';
+$labels['triggerattime'] = 'at time';
 $labels['addalarm'] = 'add alarm';
 
 $labels['alarmtitle'] = 'Upcoming events';
diff --git a/plugins/libcalendaring/skins/larry/libcal.css b/plugins/libcalendaring/skins/larry/libcal.css
index 62d2947..04fb2f1 100644
--- a/plugins/libcalendaring/skins/larry/libcal.css
+++ b/plugins/libcalendaring/skins/larry/libcal.css
@@ -1,7 +1,7 @@
 /**
  * Roundcube libcalendaring plugin styles for skin "Larry"
  *
- * Copyright (c) 2012, Kolab Systems AG <contact at kolabsys.com>
+ * Copyright (c) 2012-2014, Kolab Systems AG <contact at kolabsys.com>
  *
  * The contents are subject to the Creative Commons Attribution-ShareAlike
  * License. It is allowed to copy, distribute, transmit and to adapt the work
@@ -75,4 +75,39 @@ a.reply-comment-toggle {
 
 .popup textarea.itip-comment {
 	width: 98%;
-}
\ No newline at end of file
+}
+
+.edit-alarm-item {
+	position: relative;
+	padding-right: 30px;
+	margin-bottom: 0.2em;
+}
+
+.edit-alarm-buttons {
+	position: absolute;
+	top: 1px;
+	right: 0;
+}
+
+.edit-alarm-buttons a.iconlink {
+	display: none;
+	width: 18px;
+	height: 17px;
+	padding: 1px;
+	text-indent: -5000px;
+	overflow: hidden;
+}
+
+.edit-alarm-buttons a.delete-alarm {
+	background-position: -7px -377px;
+}
+
+.edit-alarm-buttons a.delete-alarm,
+.edit-alarm-item.first .edit-alarm-buttons a.add-alarm {
+	display: inline-block;
+}
+
+.edit-alarm-item.first .edit-alarm-buttons a.delete-alarm {
+	display: none;
+}
+
diff --git a/plugins/libcalendaring/tests/libvcalendar.php b/plugins/libcalendaring/tests/libvcalendar.php
index a20844e..1ca81bb 100644
--- a/plugins/libcalendaring/tests/libvcalendar.php
+++ b/plugins/libcalendaring/tests/libvcalendar.php
@@ -182,7 +182,7 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase
         $this->assertEquals('-H', $alarm[1], "Alarm unit");
 
         $this->assertEquals('DISPLAY', $event['valarms'][0]['action'],  "Full alarm item (action)");
-        $this->assertEquals('-PT12H',   $event['valarms'][0]['trigger'], "Full alarm item (trigger)");
+        $this->assertEquals('-PT12H',  $event['valarms'][0]['trigger'], "Full alarm item (trigger)");
 
         // alarm trigger with 0 values
         $events = $ical->import_from_file(__DIR__ . '/resources/alarms.ics', 'UTF-8');
@@ -193,6 +193,7 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase
         $this->assertEquals('30', $alarm[0], "Alarm value");
         $this->assertEquals('-M', $alarm[1], "Alarm unit");
         $this->assertEquals('-30M', $alarm[2], "Alarm string");
+        $this->assertEquals('-PT30M', $alarm[3], "Unified alarm string (stripped zero-values)");
 
         $this->assertEquals('DISPLAY', $event['valarms'][0]['action'],  "First alarm action");
         $this->assertEquals('This is the first event reminder', $event['valarms'][0]['description'],  "First alarm text");
diff --git a/plugins/tasklist/drivers/database/tasklist_database_driver.php b/plugins/tasklist/drivers/database/tasklist_database_driver.php
index 7d4c0f1..f40d504 100644
--- a/plugins/tasklist/drivers/database/tasklist_database_driver.php
+++ b/plugins/tasklist/drivers/database/tasklist_database_driver.php
@@ -482,6 +482,12 @@ class tasklist_database_driver extends tasklist_driver
         if (!$rec['parent_id'])
             unset($rec['parent_id']);
 
+        // decode serialized alarms
+        if ($rec['alarms']) {
+            $rec['valarms'] = $this->unserialize_alarms($rec['alarms']);
+            unset($rec['alarms']);
+        }
+
         unset($rec['task_id'], $rec['tasklist_id'], $rec['created']);
         return $rec;
     }
@@ -500,6 +506,10 @@ class tasklist_database_driver extends tasklist_driver
         if (!$this->lists[$list_id] || $this->lists[$list_id]['readonly'])
             return false;
 
+        if (is_array($prop['valarms'])) {
+            $prop['alarms'] = $this->serialize_alarms($prop['valarms']);
+        }
+
         foreach (array('parent_id', 'date', 'time', 'startdate', 'starttime', 'alarms') as $col) {
             if (empty($prop[$col]))
                 $prop[$col] = null;
@@ -542,6 +552,10 @@ class tasklist_database_driver extends tasklist_driver
      */
     public function edit_task($prop)
     {
+        if (is_array($prop['valarms'])) {
+            $prop['alarms'] = $this->serialize_alarms($prop['valarms']);
+        }
+
         $sql_set = array();
         foreach (array('title', 'description', 'flagged', 'complete') as $col) {
             if (isset($prop[$col]))
@@ -655,14 +669,58 @@ class tasklist_database_driver extends tasklist_driver
      */
     private function _get_notification($task)
     {
-        if ($task['alarms'] && $task['complete'] < 1 || strpos($task['alarms'], '@') !== false) {
+        if ($task['valarms'] && $task['complete'] < 1) {
             $alarm = libcalendaring::get_next_alarm($task, 'task');
 
-        if ($alarm['time'] && $alarm['action'] == 'DISPLAY')
+        if ($alarm['time'] && in_array($alarm['action'], $this->alarm_types))
           return date('Y-m-d H:i:s', $alarm['time']);
       }
 
       return null;
     }
 
+    /**
+     * Helper method to serialize the list of alarms into a string
+     */
+    private function serialize_alarms($valarms)
+    {
+        foreach ((array)$valarms as $i => $alarm) {
+            if ($alarm['trigger'] instanceof DateTime) {
+                $valarms[$i]['trigger'] = '@' . $alarm['trigger']->format('c');
+            }
+        }
+
+        return $valarms ? json_encode($valarms) : null;
+    }
+
+    /**
+     * Helper method to decode a serialized list of alarms
+     */
+    private function unserialize_alarms($alarms)
+    {
+        // decode json serialized alarms
+        if ($alarms && $alarms[0] == '[') {
+            $valarms = json_decode($alarms, true);
+            foreach ($valarms as $i => $alarm) {
+                if ($alarm['trigger'][0] == '@') {
+                    try {
+                        $valarms[$i]['trigger'] = new DateTime(substr($alarm['trigger'], 1));
+                    }
+                    catch (Exception $e) {
+                        unset($valarms[$i]);
+                    }
+                }
+            }
+        }
+        // convert legacy alarms data
+        else if (strlen($alarms)) {
+            list($trigger, $action) = explode(':', $alarms, 2);
+            if ($trigger = libcalendaring::parse_alaram_value($trigger)) {
+                $valarms = array(array('action' => $action, 'trigger' => $trigger[3] ?: $trigger[0]));
+            }
+        }
+
+        return $valarms;
+    }
+
 }
diff --git a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php
index b2d3d56..56dc955 100644
--- a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php
+++ b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php
@@ -28,7 +28,7 @@ class tasklist_kolab_driver extends tasklist_driver
     public $alarms = false;
     public $attachments = true;
     public $undelete = false; // task undelete action
-    public $alarm_types = array('DISPLAY');
+    public $alarm_types = array('DISPLAY','AUDIO');
 
     private $rc;
     private $plugin;
@@ -477,7 +477,7 @@ class tasklist_kolab_driver extends tasklist_driver
 
         $time = $slot + $interval;
 
-        $tasks = array();
+        $candidates = array();
         $query = array(array('tags', '=', 'x-has-alarms'), array('tags', '!=', 'x-complete'));
         foreach ($this->lists as $lid => $list) {
             // skip lists with alarms disabled
@@ -486,40 +486,46 @@ class tasklist_kolab_driver extends tasklist_driver
 
             $folder = $this->folders[$lid];
             foreach ($folder->select($query) as $record) {
-                if (!$record['alarms'] || $record['status'] == 'COMPLETED' || $record['complete'] == 100)  // don't trust query :-)
+                if (!($record['valarms'] || $record['alarms']) || $record['status'] == 'COMPLETED' || $record['complete'] == 100)  // don't trust query :-)
                     continue;
 
                 $task = $this->_to_rcube_task($record);
 
                 // add to list if alarm is set
                 $alarm = libcalendaring::get_next_alarm($task, 'task');
-                if ($alarm && $alarm['time'] && $alarm['time'] <= $time && $alarm['action'] == 'DISPLAY') {
-                    $id = $task['id'];
-                    $tasks[$id] = $task;
-                    $tasks[$id]['notifyat'] = $alarm['time'];
+                if ($alarm && $alarm['time'] && $alarm['time'] <= $time && in_array($alarm['action'], $this->alarm_types)) {
+                    $id = $alarm['id'];  // use alarm-id as primary identifier
+                    $candidates[$id] = array(
+                        'id'       => $id,
+                        'title'    => $task['title'],
+                        'date'     => $task['date'],
+                        'time'     => $task['time'],
+                        'notifyat' => $alarm['time'],
+                        'action'   => $alarm['action'],
+                    );
                 }
             }
         }
 
         // get alarm information stored in local database
-        if (!empty($tasks)) {
-            $task_ids = array_map(array($this->rc->db, 'quote'), array_keys($tasks));
+        if (!empty($candidates)) {
+            $alarm_ids = array_map(array($this->rc->db, 'quote'), array_keys($candidates));
             $result = $this->rc->db->query(sprintf(
                 "SELECT * FROM kolab_alarms
-                 WHERE event_id IN (%s) AND user_id=?",
-                 join(',', $task_ids),
+                 WHERE alarm_id IN (%s) AND user_id=?",
+                 join(',', $alarm_ids),
                  $this->rc->db->now()
                 ),
                 $this->rc->user->ID
             );
 
             while ($result && ($rec = $this->rc->db->fetch_assoc($result))) {
-                $dbdata[$rec['event_id']] = $rec;
+                $dbdata[$rec['alarm_id']] = $rec;
             }
         }
 
         $alarms = array();
-        foreach ($tasks as $id => $task) {
+        foreach ($candidates as $id => $task) {
           // skip dismissed
           if ($dbdata[$id]['dismissed'])
               continue;
@@ -545,7 +551,7 @@ class tasklist_kolab_driver extends tasklist_driver
         // delete old alarm entry
         $this->rc->db->query(
             "DELETE FROM kolab_alarms
-             WHERE event_id=? AND user_id=?",
+             WHERE alarm_id=? AND user_id=?",
             $id,
             $this->rc->user->ID
         );
@@ -555,7 +561,7 @@ class tasklist_kolab_driver extends tasklist_driver
 
         $query = $this->rc->db->query(
             "INSERT INTO kolab_alarms
-             (event_id, user_id, dismissed, notifyat)
+             (alarm_id, user_id, dismissed, notifyat)
              VALUES(?, ?, ?, ?)",
             $id,
             $this->rc->user->ID,
@@ -599,7 +605,10 @@ class tasklist_kolab_driver extends tasklist_driver
             $task['changed'] = $record['dtstamp'];
         }
 
-        if ($record['alarms']) {
+        if ($record['valarms']) {
+            $task['valarms'] = $record['valarms'];
+        }
+        else if ($record['alarms']) {
             $task['alarms'] = $record['alarms'];
         }
 
diff --git a/plugins/tasklist/skins/larry/templates/taskedit.html b/plugins/tasklist/skins/larry/templates/taskedit.html
index 1c9aa4e..7dc0b40 100644
--- a/plugins/tasklist/skins/larry/templates/taskedit.html
+++ b/plugins/tasklist/skins/larry/templates/taskedit.html
@@ -32,8 +32,14 @@
 				<a href="#nodate" style="margin-left:1em" class="edit-nodate" rel="#taskedit-date,#taskedit-time"><roundcube:label name="tasklist.nodate" /></a>
 			</div>
 			<div class="form-section" id="taskedit-alarms">
-				<label for="taskedit-alarm"><roundcube:label name="tasklist.alarms" /></label>
-				<roundcube:object name="plugin.alarm_select" />
+				<div class="edit-alarm-item first">
+					<label><roundcube:label name="tasklist.alarms" /></label>
+					<roundcube:object name="plugin.alarm_select" />
+					<span class="edit-alarm-buttons">
+						<a href="#add" class="iconlink add add-alarm">+</a>
+						<a href="#delete" class="iconlink delete delete-alarm">-</a>
+					</span>
+				</div>
 			</div>
 			<div class="form-section">
 				<label for="taskedit-completeness"><roundcube:label name="tasklist.complete" /></label>
diff --git a/plugins/tasklist/tasklist.js b/plugins/tasklist/tasklist.js
index cba9078..6fb3eb3 100644
--- a/plugins/tasklist/tasklist.js
+++ b/plugins/tasklist/tasklist.js
@@ -107,7 +107,6 @@ function rcube_tasklist_ui(settings)
     var parse_datetime = this.parse_datetime;
     var date2unixtime = this.date2unixtime;
     var fromunixtime = this.fromunixtime;
-    var init_alarms_edit = this.init_alarms_edit;
 
     /**
      * initialize the tasks UI
@@ -380,7 +379,7 @@ function rcube_tasklist_ui(settings)
         });
 
         // register events on alarm fields
-        init_alarms_edit('#taskedit');
+        me.init_alarms_edit('#taskedit-alarms');
 
         $('#taskedit-date, #taskedit-startdate').datepicker(datepicker_settings);
 
@@ -1169,7 +1168,7 @@ function rcube_tasklist_ui(settings)
         if (rcmail.busy || !list.editable || (action == 'edit' && (!rec || rec.readonly)))
             return false;
 
-        me.selected_task = $.extend({ alarms:'' }, rec);  // clone task object
+        me.selected_task = $.extend({ valarms:[] }, rec);  // clone task object
         rec =  me.selected_task;
 
         // assign temporary id
@@ -1210,29 +1209,7 @@ function rcube_tasklist_ui(settings)
         });
 
         // set alarm(s)
-        if (rec.alarms || action != 'new') {
-          var valarms = (typeof rec.alarms == 'string' ? rec.alarms.split(';') : rec.alarms) || [''];
-          for (var alarm, i=0; i < valarms.length; i++) {
-              alarm = String(valarms[i]).split(':');
-              if (!alarm[1] && alarm[0]) alarm[1] = 'DISPLAY';
-              $('#taskedit select.edit-alarm-type').val(alarm[1]);
-
-              if (alarm[0].match(/@(\d+)/)) {
-                  var ondate = fromunixtime(parseInt(RegExp.$1));
-                  $('#taskedit select.edit-alarm-offset').val('@');
-                  $('#taskedit input.edit-alarm-date').val(me.format_datetime(ondate, 1));
-                  $('#taskedit input.edit-alarm-time').val(me.format_datetime(ondate, 2));
-              }
-              else if (alarm[0].match(/([-+])(\d+)([MHD])/)) {
-                  $('#taskedit input.edit-alarm-value').val(RegExp.$2);
-                  $('#taskedit select.edit-alarm-offset').val(''+RegExp.$1+RegExp.$3);
-              }
-
-              break; // only one alarm is currently supported
-          }
-        }
-        // set correct visibility by triggering onchange handlers
-        $('#taskedit select.edit-alarm-type, #taskedit select.edit-alarm-offset').change();
+        me.set_alarms_edit('#taskedit-alarms', action != 'new' && rec.valarms ? rec.valarms : []);
 
         // attachments
         rcmail.enable_command('remove-attachment', list.editable);
@@ -1263,6 +1240,7 @@ function rcube_tasklist_ui(settings)
             });
             me.selected_task.tags = [];
             me.selected_task.attachments = [];
+            me.selected_task.valarms = me.serialize_alarms('#taskedit-alarms');
 
             // do some basic input validation
             if (!me.selected_task.title || !me.selected_task.title.length) {
@@ -1289,16 +1267,6 @@ function rcube_tasklist_ui(settings)
                 me.selected_task.tags.push(newtag);
             }
 
-            // serialize alarm settings
-            var alarm = $('#taskedit select.edit-alarm-type').val();
-            if (alarm) {
-                var val, offset = $('#taskedit select.edit-alarm-offset').val();
-                if (offset == '@')
-                    me.selected_task.alarms = '@' + date2unixtime(parse_datetime($('#taskedit input.edit-alarm-time').val(), $('#taskedit input.edit-alarm-date').val())) + ':' + alarm;
-              else if ((val = parseInt($('#taskedit input.edit-alarm-value').val())) && !isNaN(val) && val >= 0)
-                    me.selected_task.alarms = offset[0] + val + offset[1] + ':' + alarm;
-            }
-
             // uploaded attachments list
             for (var i in rcmail.env.attachments) {
                 if (i.match(/^rcmfile(.+)/))
diff --git a/plugins/tasklist/tasklist.php b/plugins/tasklist/tasklist.php
index d53b0a8..aae960a 100644
--- a/plugins/tasklist/tasklist.php
+++ b/plugins/tasklist/tasklist.php
@@ -406,9 +406,16 @@ class tasklist extends rcube_plugin
             $rec['tags'] = array_filter((array)$rec['tags']);
         }
 
-        // alarms cannot work without a date
-        if ($rec['alarms'] && !$rec['date'] && !$rec['startdate'] && strpos($rec['alarms'], '@') === false)
-            $rec['alarms'] = '';
+        // convert the submitted alarm values
+        if ($rec['valarms']) {
+            $valarms = array();
+            foreach (libcalendaring::from_client_alarms($rec['valarms']) as $alarm) {
+                // alarms can only work with a date (either task start, due or absolute alarm date)
+                if (is_a($alarm['trigger'], 'DateTime') || $rec['date'] || $rec['startdate'])
+                    $valarms[] = $alarm;
+            }
+            $rec['valarms'] = $valarms;
+        }
 
         $attachments = array();
         $taskid = $rec['id'];
@@ -663,8 +670,10 @@ class tasklist extends rcube_plugin
             }
         }
 
-        if ($rec['alarms'])
-            $rec['alarms_text'] = libcalendaring::alarms_text($rec['alarms']);
+        if ($rec['valarms']) {
+            $rec['alarms_text'] = libcalendaring::alarms_text($rec['valarms']);
+            $rec['valarms'] = libcalendaring::to_client_alarms($rec['valarms']);
+        }
 
         foreach ((array)$rec['attachments'] as $k => $attachment) {
             $rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']);


commit e648bee7aac2be5f151618c197757dadf2f05d35
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Apr 17 12:36:09 2014 +0200

    Force cache-rebulding for events and tasks with alarms

diff --git a/plugins/libkolab/SQL/mysql/2014040900.sql b/plugins/libkolab/SQL/mysql/2014040900.sql
index 61649c1..cfcaa9d 100644
--- a/plugins/libkolab/SQL/mysql/2014040900.sql
+++ b/plugins/libkolab/SQL/mysql/2014040900.sql
@@ -6,3 +6,11 @@ ALTER TABLE `kolab_cache_note` CHANGE `data` `data` LONGTEXT NOT NULL;
 ALTER TABLE `kolab_cache_file` CHANGE `data` `data` LONGTEXT NOT NULL;
 ALTER TABLE `kolab_cache_configuration` CHANGE `data` `data` LONGTEXT NOT NULL;
 ALTER TABLE `kolab_cache_freebusy` CHANGE `data` `data` LONGTEXT NOT NULL;
+
+-- rebuild cache entries for xcal objects with alarms
+DELETE FROM `kolab_cache_event` WHERE tags LIKE '% x-has-alarms %';
+DELETE FROM `kolab_cache_task` WHERE tags LIKE '% x-has-alarms %';
+
+-- force cache synchronization
+UPDATE `kolab_folders` SET ctag='' WHERE `type` IN ('event','task');
+


commit 2f87e09c3a95e22e9fc669c5bbda9a2c32950a18
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Apr 17 12:10:44 2014 +0200

    Check for new valarms property when writing cache tags

diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php
index b6745e7..c0bcef4 100644
--- a/plugins/libkolab/lib/kolab_format_event.php
+++ b/plugins/libkolab/lib/kolab_format_event.php
@@ -199,7 +199,7 @@ class kolab_format_event extends kolab_format_xcal
             $tags[] = rcube_utils::normalize_string($cat);
         }
 
-        if (!empty($this->data['alarms'])) {
+        if (!empty($this->data['valarms'])) {
             $tags[] = 'x-has-alarms';
         }
 
diff --git a/plugins/libkolab/lib/kolab_format_task.php b/plugins/libkolab/lib/kolab_format_task.php
index b60145a..f1311d2 100644
--- a/plugins/libkolab/lib/kolab_format_task.php
+++ b/plugins/libkolab/lib/kolab_format_task.php
@@ -121,7 +121,7 @@ class kolab_format_task extends kolab_format_xcal
         if ($this->data['priority'] == 1)
             $tags[] = 'x-flagged';
 
-        if (!empty($this->data['alarms']))
+        if (!empty($this->data['valarms']))
             $tags[] = 'x-has-alarms';
 
         if ($this->data['parent_id'])


commit 2b6706c5ffadc1897e25f2b4f2f9c2351cc3ee5a
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Apr 17 12:08:52 2014 +0200

    Make status map property visible to derived classes

diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php
index 8dbff5d..0742f2a 100644
--- a/plugins/libkolab/lib/kolab_format_xcal.php
+++ b/plugins/libkolab/lib/kolab_format_xcal.php
@@ -76,7 +76,7 @@ abstract class kolab_format_xcal extends kolab_format
         'AUDIO' => Alarm::AudioAlarm,
     );
 
-    private $status_map = array(
+    protected $status_map = array(
         'NEEDS-ACTION' => kolabformat::StatusNeedsAction,
         'IN-PROCESS'   => kolabformat::StatusInProcess,
         'COMPLETED'    => kolabformat::StatusCompleted,




More information about the commits mailing list