Add feature to upload and import .ics files
This commit is contained in:
parent
753dbdbbcc
commit
e6ae3b8702
8 changed files with 297 additions and 50 deletions
|
@ -130,6 +130,7 @@ class calendar extends rcube_plugin
|
|||
$this->register_action('calendar', array($this, 'calendar_action'));
|
||||
$this->register_action('load_events', array($this, 'load_events'));
|
||||
$this->register_action('export_events', array($this, 'export_events'));
|
||||
$this->register_action('import_events', array($this, 'import_events'));
|
||||
$this->register_action('upload', array($this, 'attachment_upload'));
|
||||
$this->register_action('get-attachment', array($this, 'attachment_get'));
|
||||
$this->register_action('freebusy-status', array($this, 'freebusy_status'));
|
||||
|
@ -808,7 +809,69 @@ class calendar extends rcube_plugin
|
|||
// NOP
|
||||
$this->rc->output->send();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function import_events()
|
||||
{
|
||||
// Upload progress update
|
||||
if (!empty($_GET['_progress'])) {
|
||||
rcube_upload_progress();
|
||||
}
|
||||
|
||||
$calendar = get_input_value('calendar', RCUBE_INPUT_GPC);
|
||||
$uploadid = get_input_value('_uploadid', RCUBE_INPUT_GPC);
|
||||
|
||||
// process uploaded file if there is no error
|
||||
$err = $_FILES['_data']['error'];
|
||||
|
||||
if (!$err && $_FILES['_data']['tmp_name']) {
|
||||
$calendar = get_input_value('calendar', RCUBE_INPUT_GPC);
|
||||
$events = $this->get_ical()->import_from_file($_FILES['_data']['tmp_name']);
|
||||
|
||||
$count = $errors = 0;
|
||||
$rangestart = $_REQUEST['_range'] ? strtotime("now -" . intval($_REQUEST['_range']) . " months") : 0;
|
||||
foreach ($events as $event) {
|
||||
// TODO: correctly handle recurring events which start before $rangestart
|
||||
if ($event['end'] < $rangestart && (!$event['recurrence'] || ($event['recurrence']['until'] && $event['recurrence']['until'] < $rangestart)))
|
||||
continue;
|
||||
|
||||
$event['calendar'] = $calendar;
|
||||
if ($success = $this->driver->new_event($event)) {
|
||||
$count++;
|
||||
}
|
||||
else
|
||||
$errors++;
|
||||
}
|
||||
|
||||
if ($count) {
|
||||
$this->rc->output->command('display_message', $this->gettext(array('name' => 'importsuccess', 'vars' => array('nr' => $count))), 'confirmation');
|
||||
$this->rc->output->command('plugin.import_success', array('source' => $calendar, 'refetch' => true));
|
||||
}
|
||||
else if (!$errors) {
|
||||
$this->rc->output->command('display_message', $this->gettext('importnone'), 'notice');
|
||||
$this->rc->output->command('plugin.import_success', array('source' => $calendar));
|
||||
}
|
||||
else
|
||||
$this->rc->output->command('display_message', $this->gettext('importerror'), 'error');
|
||||
}
|
||||
else {
|
||||
if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) {
|
||||
$msg = rcube_label(array('name' => 'filesizeerror', 'vars' => array(
|
||||
'size' => show_bytes(parse_bytes(ini_get('upload_max_filesize'))))));
|
||||
}
|
||||
else {
|
||||
$msg = rcube_label('fileuploaderror');
|
||||
}
|
||||
|
||||
$this->rc->output->command('display_message', $msg, 'error');
|
||||
$this->rc->output->command('plugin.unlock_saving', false);
|
||||
}
|
||||
|
||||
$this->rc->output->send('iframe');
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the ics file for exporting events to iCalendar format;
|
||||
*/
|
||||
|
@ -818,11 +881,16 @@ class calendar extends rcube_plugin
|
|||
$end = get_input_value('end', RCUBE_INPUT_GET);
|
||||
if (!$start) $start = mktime(0, 0, 0, 1, date('n'), date('Y')-1);
|
||||
if (!$end) $end = mktime(0, 0, 0, 31, 12, date('Y')+10);
|
||||
$calendar_name = get_input_value('source', RCUBE_INPUT_GET);
|
||||
$events = $this->driver->load_events($start, $end, null, $calendar_name, 0);
|
||||
|
||||
$calid = $calname = get_input_value('source', RCUBE_INPUT_GET);
|
||||
$calendars = $this->driver->list_calendars();
|
||||
|
||||
if ($calendars[$calid]) {
|
||||
$calname = $calendars[$calid]['name'] ? $calendars[$calid]['name'] : $calid;
|
||||
$events = $this->driver->load_events($start, $end, null, $calid, 0);
|
||||
}
|
||||
|
||||
header("Content-Type: text/calendar");
|
||||
header("Content-Disposition: inline; filename=".$calendar_name.'.ics');
|
||||
header("Content-Disposition: inline; filename=".$calname.'.ics');
|
||||
|
||||
$this->get_ical()->export($events, '', true);
|
||||
exit;
|
||||
|
@ -2015,7 +2083,7 @@ class calendar extends rcube_plugin
|
|||
// find writeable calendar to store event
|
||||
$cal_id = $this->rc->config->get('calendar_default_calendar');
|
||||
$calendars = $this->driver->list_calendars();
|
||||
$calendar = $calendars[$cal_id] ? $calendars[$calname] : null;
|
||||
$calendar = $calendars[$cal_id] ? $calendars[$cal_id] : null;
|
||||
if (!$calendar || $calendar['readonly']) {
|
||||
foreach ($calendars as $cal) {
|
||||
if (!$cal['readonly']) {
|
||||
|
|
|
@ -1893,6 +1893,95 @@ function rcube_calendar_ui(settings)
|
|||
}
|
||||
};
|
||||
|
||||
// open a dialog to upload an .ics file with events to be imported
|
||||
this.import_events = function(calendar)
|
||||
{
|
||||
// close show dialog first
|
||||
var $dialog = $("#eventsimport").dialog('close');
|
||||
var form = rcmail.gui_objects.importform;
|
||||
|
||||
$('#event-import-calendar').val(calendar.id);
|
||||
|
||||
var buttons = {};
|
||||
buttons[rcmail.gettext('import', 'calendar')] = function() {
|
||||
if (form && form.elements._data.value) {
|
||||
rcmail.async_upload_form(form, 'import_events', function(e) {
|
||||
rcmail.set_busy(false, null, me.saving_lock);
|
||||
});
|
||||
|
||||
// display upload indicator
|
||||
me.saving_lock = rcmail.set_busy(true, 'uploading');
|
||||
}
|
||||
};
|
||||
|
||||
buttons[rcmail.gettext('cancel', 'calendar')] = function() {
|
||||
$dialog.dialog("close");
|
||||
};
|
||||
|
||||
// open jquery UI dialog
|
||||
$dialog.dialog({
|
||||
modal: true,
|
||||
resizable: false,
|
||||
closeOnEscape: false,
|
||||
title: rcmail.gettext('importevents', 'calendar'),
|
||||
close: function() {
|
||||
$dialog.dialog("destroy").hide();
|
||||
},
|
||||
buttons: buttons,
|
||||
width: 520
|
||||
}).show();
|
||||
|
||||
};
|
||||
|
||||
// callback from server if import succeeded
|
||||
this.import_success = function(p)
|
||||
{
|
||||
$("#eventsimport").dialog('close');
|
||||
rcmail.set_busy(false, null, me.saving_lock);
|
||||
rcmail.gui_objects.importform.reset();
|
||||
|
||||
if (p.refetch)
|
||||
this.refresh(p);
|
||||
};
|
||||
|
||||
// refresh the calendar view after saving event data
|
||||
this.refresh = function(p)
|
||||
{
|
||||
var source = me.calendars[p.source];
|
||||
|
||||
if (source && (p.refetch || (p.update && !source.active))) {
|
||||
// activate event source if new event was added to an invisible calendar
|
||||
if (!source.active) {
|
||||
source.active = true;
|
||||
fc.fullCalendar('addEventSource', source);
|
||||
$('#' + rcmail.get_folder_li(source.id, 'rcmlical').id + ' input').prop('checked', true);
|
||||
}
|
||||
else
|
||||
fc.fullCalendar('refetchEvents', source);
|
||||
}
|
||||
// add/update single event object
|
||||
else if (source && p.update) {
|
||||
var event = p.update;
|
||||
event.temp = false;
|
||||
event.editable = source.editable;
|
||||
var existing = fc.fullCalendar('clientEvents', event.id);
|
||||
if (existing.length) {
|
||||
$.extend(existing[0], event);
|
||||
fc.fullCalendar('updateEvent', existing[0]);
|
||||
}
|
||||
else {
|
||||
event.source = source; // link with source
|
||||
fc.fullCalendar('renderEvent', event);
|
||||
}
|
||||
// refresh fish-eye view
|
||||
if (me.fisheye_date)
|
||||
me.fisheye_view(me.fisheye_date);
|
||||
}
|
||||
|
||||
// remove temp events
|
||||
fc.fullCalendar('removeEvents', function(e){ return e.temp; });
|
||||
};
|
||||
|
||||
|
||||
/*** event searching ***/
|
||||
|
||||
|
@ -2085,7 +2174,7 @@ function rcube_calendar_ui(settings)
|
|||
var id = $(this).data('id');
|
||||
rcmail.select_folder(id, 'rcmlical');
|
||||
rcmail.enable_command('calendar-edit', true);
|
||||
rcmail.enable_command('calendar-remove', !me.calendars[id].readonly);
|
||||
rcmail.enable_command('calendar-remove', 'events-import', !me.calendars[id].readonly);
|
||||
me.selected_calendar = id;
|
||||
})
|
||||
.dblclick(function(){ me.calendar_edit_dialog(me.calendars[me.selected_calendar]); })
|
||||
|
@ -2589,6 +2678,7 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
|
|||
rcmail.register_command('calendar-create', function(){ cal.calendar_edit_dialog(null); }, true);
|
||||
rcmail.register_command('calendar-edit', function(){ cal.calendar_edit_dialog(cal.calendars[cal.selected_calendar]); }, false);
|
||||
rcmail.register_command('calendar-remove', function(){ cal.calendar_remove(cal.calendars[cal.selected_calendar]); }, false);
|
||||
rcmail.register_command('events-import', function(){ cal.import_events(cal.calendars[cal.selected_calendar]); }, false);
|
||||
|
||||
// search and export events
|
||||
rcmail.register_command('export', function(){ rcmail.goto_url('export_events', { source:cal.selected_calendar }); }, true);
|
||||
|
@ -2599,42 +2689,8 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
|
|||
rcmail.addEventListener('plugin.display_alarms', function(alarms){ cal.display_alarms(alarms); });
|
||||
rcmail.addEventListener('plugin.destroy_source', function(p){ cal.calendar_destroy_source(p.id); });
|
||||
rcmail.addEventListener('plugin.unlock_saving', function(p){ rcmail.set_busy(false, null, cal.saving_lock); });
|
||||
rcmail.addEventListener('plugin.refresh_calendar', function(p){
|
||||
var fc = $('#calendar');
|
||||
var source = cal.calendars[p.source];
|
||||
|
||||
if (source && (p.refetch || (p.update && !source.active))) {
|
||||
// activate event source if new event was added to an invisible calendar
|
||||
if (!source.active) {
|
||||
source.active = true;
|
||||
fc.fullCalendar('addEventSource', source);
|
||||
$('#' + rcmail.get_folder_li(source.id, 'rcmlical').id + ' input').prop('checked', true);
|
||||
}
|
||||
else
|
||||
fc.fullCalendar('refetchEvents', source);
|
||||
}
|
||||
// add/update single event object
|
||||
else if (source && p.update) {
|
||||
var event = p.update;
|
||||
event.temp = false;
|
||||
event.editable = source.editable;
|
||||
var existing = fc.fullCalendar('clientEvents', event.id);
|
||||
if (existing.length) {
|
||||
$.extend(existing[0], event);
|
||||
fc.fullCalendar('updateEvent', existing[0]);
|
||||
}
|
||||
else {
|
||||
event.source = source; // link with source
|
||||
fc.fullCalendar('renderEvent', event);
|
||||
}
|
||||
// refresh fish-eye view
|
||||
if (cal.fisheye_date)
|
||||
cal.fisheye_view(cal.fisheye_date);
|
||||
}
|
||||
|
||||
// remove temp events
|
||||
fc.fullCalendar('removeEvents', function(e){ return e.temp; });
|
||||
});
|
||||
rcmail.addEventListener('plugin.refresh_calendar', function(p){ cal.refresh(p); });
|
||||
rcmail.addEventListener('plugin.import_success', function(p){ cal.import_success(p); });
|
||||
|
||||
// let's go
|
||||
var cal = new rcube_calendar_ui(rcmail.env.calendar_settings);
|
||||
|
|
|
@ -66,13 +66,7 @@ class calendar_ical
|
|||
*/
|
||||
public function import($vcal, $charset = RCMAIL_CHARSET)
|
||||
{
|
||||
// use Horde:iCalendar to parse vcalendar file format
|
||||
require_once 'Horde/iCalendar.php';
|
||||
|
||||
// set target charset for parsed events
|
||||
$GLOBALS['_HORDE_STRING_CHARSET'] = RCMAIL_CHARSET;
|
||||
|
||||
$parser = new Horde_iCalendar;
|
||||
$parser = $this->get_parser();
|
||||
$parser->parsevCalendar($vcal, 'VCALENDAR', $charset);
|
||||
$this->method = $parser->getAttributeDefault('METHOD', '');
|
||||
$this->events = array();
|
||||
|
@ -86,6 +80,59 @@ class calendar_ical
|
|||
return $this->events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read iCalendar events from a file
|
||||
*
|
||||
* @param string File path to read from
|
||||
* @return array List of events extracted from the file
|
||||
*/
|
||||
public function import_from_file($filepath)
|
||||
{
|
||||
$this->events = array();
|
||||
$fp = fopen($filepath, 'r');
|
||||
|
||||
// check file content first
|
||||
$begin = fread($fp, 1024);
|
||||
if (!preg_match('/BEGIN:VCALENDAR/i', $begin))
|
||||
return $this->events;
|
||||
|
||||
$parser = $this->get_parser();
|
||||
$buffer = '';
|
||||
|
||||
fseek($fp, 0);
|
||||
while (($line = fgets($fp, 2048)) !== false) {
|
||||
$buffer .= $line;
|
||||
if (preg_match('/END:VEVENT/i', $line)) {
|
||||
$parser->parsevCalendar($buffer, 'VCALENDAR', RCMAIL_CHARSET, false);
|
||||
$buffer = '';
|
||||
}
|
||||
}
|
||||
fclose($fp);
|
||||
|
||||
if ($data = $parser->getComponents()) {
|
||||
foreach ($data as $comp) {
|
||||
if ($comp->getType() == 'vEvent')
|
||||
$this->events[] = $this->_to_rcube_format($comp);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load iCal parser from the Horde lib
|
||||
*/
|
||||
private function get_parser()
|
||||
{
|
||||
// use Horde:iCalendar to parse vcalendar file format
|
||||
require_once 'Horde/iCalendar.php';
|
||||
|
||||
// set target charset for parsed events
|
||||
$GLOBALS['_HORDE_STRING_CHARSET'] = RCMAIL_CHARSET;
|
||||
|
||||
return new Horde_iCalendar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given File_IMC_Parse_Vcalendar_Event object to the internal event format
|
||||
*/
|
||||
|
|
|
@ -88,6 +88,7 @@ class calendar_ui
|
|||
$this->cal->register_handler('plugin.edit_recurring_warning', array($this, 'recurring_event_warning'));
|
||||
$this->cal->register_handler('plugin.event_rsvp_buttons', array($this, 'event_rsvp_buttons'));
|
||||
$this->cal->register_handler('plugin.angenda_options', array($this, 'angenda_options'));
|
||||
$this->cal->register_handler('plugin.events_import_form', array($this, 'events_import_form'));
|
||||
$this->cal->register_handler('plugin.searchform', array($this->rc->output, 'search_form')); // use generic method from rcube_template
|
||||
}
|
||||
|
||||
|
@ -555,6 +556,55 @@ class calendar_ui
|
|||
return $select_prefix->show() . ' ' . $select_wday->show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Form for uploading and importing events
|
||||
*/
|
||||
function events_import_form($attrib = array())
|
||||
{
|
||||
if (!$attrib['id'])
|
||||
$attrib['id'] = 'rcmImportForm';
|
||||
|
||||
// Get max filesize, enable upload progress bar
|
||||
$max_filesize = rcube_upload_init();
|
||||
|
||||
$button = new html_inputfield(array('type' => 'button'));
|
||||
$input = new html_inputfield(array(
|
||||
'type' => 'file', 'name' => '_data', 'size' => $attrib['uploadfieldsize']));
|
||||
|
||||
$select = new html_select(array('name' => '_range', 'id' => 'event-import-range'));
|
||||
$select->add(array(
|
||||
$this->cal->gettext('onemonthback'),
|
||||
$this->cal->gettext(array('name' => 'nmonthsback', 'vars' => array('nr'=>2))),
|
||||
$this->cal->gettext(array('name' => 'nmonthsback', 'vars' => array('nr'=>6))),
|
||||
$this->cal->gettext(array('name' => 'nmonthsback', 'vars' => array('nr'=>12))),
|
||||
$this->cal->gettext('all'),
|
||||
),
|
||||
array('1','2','6','12',0));
|
||||
|
||||
$html .= html::div('form-section',
|
||||
html::div(null, $input->show()) .
|
||||
html::div('hint', rcube_label(array('name' => 'maxuploadsize', 'vars' => array('size' => $max_filesize))))
|
||||
);
|
||||
|
||||
$html .= html::div('form-section',
|
||||
html::label('event-import-calendar', $this->cal->gettext('calendar')) .
|
||||
$this->calendar_select(array('name' => 'calendar', 'id' => 'event-import-calendar'))
|
||||
);
|
||||
|
||||
$html .= html::div('form-section',
|
||||
html::label('event-import-range', $this->cal->gettext('importrange')) .
|
||||
$select->show(1)
|
||||
);
|
||||
|
||||
$this->rc->output->add_gui_object('importform', $attrib['id']);
|
||||
$this->rc->output->add_label('import');
|
||||
|
||||
return html::tag('form', array('action' => $this->rc->url(array('task' => 'calendar', 'action' => 'import_events')),
|
||||
'method' => "post", 'enctype' => 'multipart/form-data', 'id' => $attrib['id']),
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the form for event attachments upload
|
||||
*/
|
||||
|
|
|
@ -68,6 +68,10 @@ $labels['andnmore'] = '$nr weitere...';
|
|||
$labels['showmore'] = 'Mehr anzeigen...';
|
||||
$labels['togglerole'] = 'Klick zum Ändern der Rolle';
|
||||
$labels['createfrommail'] = 'Als Termin speichern';
|
||||
$labels['importevents'] = 'Termine importieren';
|
||||
$labels['importrange'] = 'Termine ab';
|
||||
$labels['onemonthback'] = '1 Monat zurück';
|
||||
$labels['nmonthsback'] = '$nr Monate zurück';
|
||||
|
||||
// agenda view
|
||||
$labels['listrange'] = 'Angezeigter Bereich:';
|
||||
|
@ -196,6 +200,9 @@ $labels['attendeupdateesuccess'] = 'Teilnehmerstatus erfolgreich aktualisiert';
|
|||
$labels['itipresponseerror'] = 'Die Antwort auf diese Einladung konnte nicht versendet werden';
|
||||
$labels['sentresponseto'] = 'Antwort auf diese Einladung erfolgreich an $mailto gesendet';
|
||||
$labels['localchangeswarning'] = 'Die Änderungen an diesem Termin können nur in Ihrem persönlichen Kalender gespeichert werden.';
|
||||
$labels['importsuccess'] = 'Es wurden $nr Termine erfolgreich importiert';
|
||||
$labels['importnone'] = 'Keine Termine zum Importieren gefunden';
|
||||
$labels['importerror'] = 'Fehler beim Importieren';
|
||||
|
||||
// recurrence form
|
||||
$labels['repeat'] = 'Wiederholung';
|
||||
|
|
|
@ -68,6 +68,10 @@ $labels['andnmore'] = '$nr weitere...';
|
|||
$labels['showmore'] = 'Mehr anzeigen...';
|
||||
$labels['togglerole'] = 'Klick zum Ändern der Rolle';
|
||||
$labels['createfrommail'] = 'Als Termin speichern';
|
||||
$labels['importevents'] = 'Termine importieren';
|
||||
$labels['importrange'] = 'Termine ab';
|
||||
$labels['onemonthback'] = '1 Monat zurück';
|
||||
$labels['nmonthsback'] = '$nr Monate zurück';
|
||||
|
||||
// agenda view
|
||||
$labels['listrange'] = 'Angezeigter Bereich:';
|
||||
|
@ -196,6 +200,9 @@ $labels['attendeupdateesuccess'] = 'Teilnehmerstatus erfolgreich aktualisiert';
|
|||
$labels['itipresponseerror'] = 'Die Antwort auf diese Einladung konnte nicht versendet werden';
|
||||
$labels['sentresponseto'] = 'Antwort auf diese Einladung erfolgreich an $mailto gesendet';
|
||||
$labels['localchangeswarning'] = 'Die Änderungen an diesem Termin können nur in Ihrem persönlichen Kalender gespeichert werden.';
|
||||
$labels['importsuccess'] = 'Es wurden $nr Termine erfolgreich importiert';
|
||||
$labels['importnone'] = 'Keine Termine zum Importieren gefunden';
|
||||
$labels['importerror'] = 'Fehler beim Importieren';
|
||||
|
||||
// recurrence form
|
||||
$labels['repeat'] = 'Wiederholung';
|
||||
|
|
|
@ -68,6 +68,10 @@ $labels['andnmore'] = '$nr more...';
|
|||
$labels['showmore'] = 'Show more...';
|
||||
$labels['togglerole'] = 'Click to toggle role';
|
||||
$labels['createfrommail'] = 'Save as event';
|
||||
$labels['importevents'] = 'Import events';
|
||||
$labels['importrange'] = 'Events from';
|
||||
$labels['onemonthback'] = '1 month back';
|
||||
$labels['nmonthsback'] = '$nr months back';
|
||||
|
||||
// agenda view
|
||||
$labels['listrange'] = 'Range to display:';
|
||||
|
@ -197,6 +201,9 @@ $labels['itipresponseerror'] = 'Failed to send the response to this event invita
|
|||
$labels['itipinvalidrequest'] = 'This invitation is no longer valid';
|
||||
$labels['sentresponseto'] = 'Successfully sent invitation response to $mailto';
|
||||
$labels['localchangeswarning'] = 'You are about to make changes that will only be reflected on your personal calendar';
|
||||
$labels['importsuccess'] = 'Successfully imported $nr events';
|
||||
$labels['importnone'] = 'No events found to be imported';
|
||||
$labels['importerror'] = 'An error occured while importing';
|
||||
|
||||
// recurrence form
|
||||
$labels['repeat'] = 'Repeat';
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
<ul>
|
||||
<li><roundcube:button command="calendar-edit" label="calendar.edit" classAct="active" /></li>
|
||||
<li><roundcube:button command="calendar-remove" label="calendar.remove" classAct="active" /></li>
|
||||
<li><roundcube:button command="events-import" label="calendar.importevents" classAct="active" /></li>
|
||||
<roundcube:if condition="env:calendar_driver == 'kolab'" />
|
||||
<li class="separator_above"><roundcube:button command="folders" task="settings" type="link" label="managefolders" classAct="active" /></li>
|
||||
<roundcube:endif />
|
||||
|
@ -137,6 +138,10 @@
|
|||
<roundcube:label name="loading" />
|
||||
</div>
|
||||
|
||||
<div id="eventsimport" class="uidialog">
|
||||
<roundcube:object name="plugin.events_import_form" id="events-import-form" uploadFieldSize="30" />
|
||||
</div>
|
||||
|
||||
<div id="alarm-snooze-dropdown" class="popupmenu">
|
||||
<roundcube:object name="plugin.snooze_select" type="ul" />
|
||||
</div>
|
||||
|
|
Loading…
Add table
Reference in a new issue