diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js index fea85fde..fcf1f4fb 100644 --- a/plugins/libcalendaring/libcalendaring.js +++ b/plugins/libcalendaring/libcalendaring.js @@ -1,1199 +1,1205 @@ /** * Basic Javascript utilities for calendar-related plugins * * @author Thomas Bruederli * * @licstart The following is the entire license notice for the * JavaScript code in this page. * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @licend The above is the entire license notice * for the JavaScript code in this page. */ function rcube_libcalendaring(settings) { // member vars this.settings = settings || {}; this.alarm_ids = []; this.alarm_dialog = null; this.snooze_popup = null; this.dismiss_link = null; this.group2expand = {}; // abort if env isn't set if (!settings || !settings.date_format) return; // private vars var me = this; var gmt_offset = (new Date().getTimezoneOffset() / -60) - (settings.timezone || 0) - (settings.dst || 0); var client_timezone = new Date().getTimezoneOffset(); // general datepicker settings var datepicker_settings = { // translate from fullcalendar format to datepicker format dateFormat: settings.date_format.replace(/M/g, 'm').replace(/mmmmm/, 'MM').replace(/mmm/, 'M').replace(/dddd/, 'DD').replace(/ddd/, 'D').replace(/yy/g, 'y'), firstDay : settings.first_day, dayNamesMin: settings.days_short, monthNames: settings.months, monthNamesShort: settings.months, changeMonth: false, showOtherMonths: true, selectOtherMonths: true }; /** * Quote html entities */ var Q = this.quote_html = function(str) { return String(str).replace(//g, '>').replace(/"/g, '"'); }; /** * Create a nice human-readable string for the date/time range */ this.event_date_text = function(event, voice) { if (!event.start) return ''; if (!event.end) event.end = event.start; var fromto, duration = event.end.getTime() / 1000 - event.start.getTime() / 1000, until = voice ? ' ' + rcmail.gettext('until','libcalendaring') + ' ' : ' — '; if (event.allDay) { fromto = this.format_datetime(event.start, 1, voice) + (duration > 86400 || event.start.getDay() != event.end.getDay() ? until + this.format_datetime(event.end, 1, voice) : ''); } else if (duration < 86400 && event.start.getDay() == event.end.getDay()) { fromto = this.format_datetime(event.start, 0, voice) + (duration > 0 ? until + this.format_datetime(event.end, 2, voice) : ''); } else { fromto = this.format_datetime(event.start, 0, voice) + (duration > 0 ? until + this.format_datetime(event.end, 0, voice) : ''); } return fromto; }; /** * From time and date strings to a real date object */ this.parse_datetime = function(time, date) { // we use the utility function from datepicker to parse dates var date = date ? $.datepicker.parseDate(datepicker_settings.dateFormat, date, datepicker_settings) : new Date(); var time_arr = time.replace(/\s*[ap][.m]*/i, '').replace(/0([0-9])/g, '$1').split(/[:.]/); if (!isNaN(time_arr[0])) { date.setHours(time_arr[0]); if (time.match(/p[.m]*/i) && date.getHours() < 12) date.setHours(parseInt(time_arr[0]) + 12); else if (time.match(/a[.m]*/i) && date.getHours() == 12) date.setHours(0); } if (!isNaN(time_arr[1])) date.setMinutes(time_arr[1]); return date; } /** * Convert an ISO 8601 formatted date string from the server into a Date object. * Timezone information will be ignored, the server already provides dates in user's timezone. */ this.parseISO8601 = function(s) { // force d to be on check's YMD, for daylight savings purposes var fixDate = function(d, check) { if (+d) { // prevent infinite looping on invalid dates while (d.getDate() != check.getDate()) { d.setTime(+d + (d < check ? 1 : -1) * 3600000); } } } // derived from http://delete.me.uk/2005/03/iso8601.html var m = s && s.match(/^([0-9]{4})(-([0-9]{2})(-([0-9]{2})([T ]([0-9]{2}):([0-9]{2})(:([0-9]{2})(\.([0-9]+))?)?(Z|(([-+])([0-9]{2})(:?([0-9]{2}))?))?)?)?)?$/); if (!m) { return null; } var date = new Date(m[1], 0, 2), check = new Date(m[1], 0, 2, 9, 0); if (m[3]) { date.setMonth(m[3] - 1); check.setMonth(m[3] - 1); } if (m[5]) { date.setDate(m[5]); check.setDate(m[5]); } fixDate(date, check); if (m[7]) { date.setHours(m[7]); } if (m[8]) { date.setMinutes(m[8]); } if (m[10]) { date.setSeconds(m[10]); } if (m[12]) { date.setMilliseconds(Number("0." + m[12]) * 1000); } fixDate(date, check); return date; } /** * Turn the given date into an ISO 8601 date string understandable by PHPs strtotime() */ this.date2ISO8601 = function(date) { var zeropad = function(num) { return (num < 10 ? '0' : '') + num; }; return date.getFullYear() + '-' + zeropad(date.getMonth()+1) + '-' + zeropad(date.getDate()) + 'T' + zeropad(date.getHours()) + ':' + zeropad(date.getMinutes()) + ':' + zeropad(date.getSeconds()); }; /** * Format the given date object according to user's prefs */ this.format_datetime = function(date, mode, voice) { var res = ''; if (!mode || mode == 1) { res += $.datepicker.formatDate(voice ? 'MM d yy' : datepicker_settings.dateFormat, date, datepicker_settings); } if (!mode) { res += voice ? ' ' + rcmail.gettext('at','libcalendaring') + ' ' : ' '; } if (!mode || mode == 2) { res += this.format_time(date, voice); } return res; } /** * Clone from fullcalendar.js */ this.format_time = function(date, voice) { var zeroPad = function(n) { return (n < 10 ? '0' : '') + n; } var formatters = { s : function(d) { return d.getSeconds() }, ss : function(d) { return zeroPad(d.getSeconds()) }, m : function(d) { return d.getMinutes() }, mm : function(d) { return zeroPad(d.getMinutes()) }, h : function(d) { return d.getHours() % 12 || 12 }, hh : function(d) { return zeroPad(d.getHours() % 12 || 12) }, H : function(d) { return d.getHours() }, HH : function(d) { return zeroPad(d.getHours()) }, t : function(d) { return d.getHours() < 12 ? 'a' : 'p' }, tt : function(d) { return d.getHours() < 12 ? 'am' : 'pm' }, T : function(d) { return d.getHours() < 12 ? 'A' : 'P' }, TT : function(d) { return d.getHours() < 12 ? 'AM' : 'PM' } }; var i, i2, c, formatter, res = '', format = voice ? settings['time_format'].replace(':',' ').replace('HH','H').replace('hh','h').replace('mm','m').replace('ss','s') : settings['time_format']; for (i=0; i < format.length; i++) { c = format.charAt(i); for (i2=Math.min(i+2, format.length); i2 > i; i2--) { if (formatter = formatters[format.substring(i, i2)]) { res += formatter(date); i = i2 - 1; break; } } if (i2 == i) { res += c; } } return res; } /** * Convert the given Date object into a unix timestamp respecting browser's and user's timezone settings */ this.date2unixtime = function(date) { var dst_offset = (client_timezone - date.getTimezoneOffset()) * 60; // adjust DST offset return Math.round(date.getTime()/1000 + gmt_offset * 3600 + dst_offset); } /** * Turn a unix timestamp value into a Date object */ this.fromunixtime = function(ts) { ts -= gmt_offset * 3600; var date = new Date(ts * 1000), dst_offset = (client_timezone - date.getTimezoneOffset()) * 60; if (dst_offset) // adjust DST offset date.setTime((ts + 3600) * 1000); return date; } /** * Simple plaintext to HTML converter, makig URLs clickable */ this.text2html = function(str, maxlen, maxlines) { var html = Q(String(str)); // limit visible text length if (maxlen) { var morelink = '... '+rcmail.gettext('showmore','libcalendaring')+'', lines = html.split(/\r?\n/), words, out = '', len = 0; for (var i=0; i < lines.length; i++) { len += lines[i].length; if (maxlines && i == maxlines - 1) { out += lines[i] + '\n' + morelink; maxlen = html.length * 2; } else if (len > maxlen) { len = out.length; words = lines[i].split(' '); for (var j=0; j < words.length; j++) { len += words[j].length + 1; out += words[j] + ' '; if (len > maxlen) { out += morelink; maxlen = html.length * 2; maxlines = 0; } } out += '\n'; } else out += lines[i] + '\n'; } if (maxlen > str.length) out += ''; html = out; } // simple link parser (similar to rcube_string_replacer class in PHP) var utf_domain = '[^?&@"\'/\\(\\)\\s\\r\\t\\n]+\\.([^\x00-\x2f\x3b-\x40\x5b-\x60\x7b-\x7f]{2,}|xn--[a-z0-9]{2,})'; var url1 = '.:;,', url2 = 'a-z0-9%=#@+?&/_~\\[\\]-'; var link_pattern = new RegExp('([hf]t+ps?://)('+utf_domain+'(['+url1+']?['+url2+']+)*)', 'ig'); var mailto_pattern = new RegExp('([^\\s\\n\\(\\);]+@'+utf_domain+')', 'ig'); var link_replace = function(matches, p1, p2) { var title = '', text = p2; if (p2 && p2.length > 55) { text = p2.substr(0, 45) + '...' + p2.substr(-8); title = p1 + p2; } return ''+p1+text+'' }; return html .replace(link_pattern, link_replace) .replace(mailto_pattern, '$1') .replace(/(mailto:)([^"]+)"/g, '$1$2" onclick="rcmail.command(\'compose\', \'$2\');return false"') .replace(/\n/g, "
"); }; this.init_alarms_edit = function(prefix, index) { var edit_type = $(prefix+' select.edit-alarm-type'), dom_id = edit_type.attr('id'); // register events on alarm fields edit_type.change(function(){ $(this).parent().find('span.edit-alarm-values')[(this.selectedIndex>0?'show':'hide')](); }); $(prefix+' select.edit-alarm-offset').change(function(){ - var val = $(this).val(); - $(this).parent().find('.edit-alarm-date, .edit-alarm-time')[val == '@' ? 'show' : 'hide'](); - $(this).parent().find('.edit-alarm-value').prop('disabled', val === '@' || val === '0'); + var val = $(this).val(), parent = $(this).parent(); + parent.find('.edit-alarm-date, .edit-alarm-time')[val == '@' ? 'show' : 'hide'](); + parent.find('.edit-alarm-value').prop('disabled', val === '@' || val === '0'); + parent.find('.edit-alarm-related')[val == '@' ? 'hide' : 'show'](); }); $(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; }); // set a unique id attribute and set label reference accordingly if ((index || 0) > 0 && dom_id) { dom_id += ':' + (new Date().getTime()); edit_type.attr('id', dom_id); $(prefix+' label:first').attr('for', dom_id); } $(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 + ')', 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 + ')', i); } $('select.edit-alarm-type', domnode).val(alarm.action); + $('select.edit-alarm-related', domnode).val(/END/i.test(alarm.related) ? 'end' : 'start'); 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(/^[-+]*0[MHDS]$/)) { $('input.edit-alarm-value', domnode).val('0'); $('select.edit-alarm-offset', domnode).val('0'); } 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 + ' .edit-alarm-item').each(function(i, elem) { - var val, offset, alarm = { action: $('select.edit-alarm-type', elem).val() }; + var val, offset, alarm = { + action: $('select.edit-alarm-type', elem).val(), + related: $('select.edit-alarm-related', 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 (offset === '0') { alarm.trigger = '0S'; } 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 *****/ /** * Display a notification for the given pending alarms */ this.display_alarms = function(alarms) { // clear old alert first if (this.alarm_dialog) this.alarm_dialog.dialog('destroy').remove(); this.alarm_dialog = $('
').attr('id', 'alarm-display'); var i, actions, adismiss, asnooze, alarm, html, event_ids = [], buttons = {}; for (i=0; i < alarms.length; i++) { alarm = alarms[i]; alarm.start = this.parseISO8601(alarm.start); alarm.end = this.parseISO8601(alarm.end); event_ids.push(alarm.id); html = '

' + Q(alarm.title) + '

'; html += '
' + Q(alarm.location || '') + '
'; html += '
' + Q(this.event_date_text(alarm)) + '
'; adismiss = $('').html(rcmail.gettext('dismiss','libcalendaring')).click(function(){ me.dismiss_link = $(this); me.dismiss_alarm(me.dismiss_link.data('id'), 0); }); asnooze = $('').html(rcmail.gettext('snooze','libcalendaring')).click(function(e){ me.snooze_dropdown($(this), e); e.stopPropagation(); return false; }); actions = $('
').addClass('alarm-actions').append(adismiss.data('id', alarm.id)).append(asnooze.data('id', alarm.id)); $('
').addClass('alarm-item').html(html).append(actions).appendTo(this.alarm_dialog); } buttons[rcmail.gettext('close')] = function() { $(this).dialog('close'); }; buttons[rcmail.gettext('dismissall','libcalendaring')] = function() { // submit dismissed event_ids to server me.dismiss_alarm(me.alarm_ids.join(','), 0); $(this).dialog('close'); }; this.alarm_dialog.appendTo(document.body).dialog({ modal: false, resizable: true, closeOnEscape: false, dialogClass: 'alarms', title: rcmail.gettext('alarmtitle','libcalendaring'), buttons: buttons, open: function() { setTimeout(function() { me.alarm_dialog.parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().focus(); }, 5); }, close: function() { $('#alarm-snooze-dropdown').hide(); $(this).dialog('destroy').remove(); me.alarm_dialog = null; me.alarm_ids = null; }, drag: function(event, ui) { $('#alarm-snooze-dropdown').hide(); } }); this.alarm_dialog.closest('div[role=dialog]').attr('role', 'alertdialog'); this.alarm_ids = event_ids; }; /** * Show a drop-down menu with a selection of snooze times */ this.snooze_dropdown = function(link, event) { if (!this.snooze_popup) { this.snooze_popup = $('#alarm-snooze-dropdown'); // create popup if not found if (!this.snooze_popup.length) { this.snooze_popup = $('
').attr('id', 'alarm-snooze-dropdown').addClass('popupmenu').appendTo(document.body); this.snooze_popup.html(rcmail.env.snooze_select) } $('#alarm-snooze-dropdown a').click(function(e){ var time = String(this.href).replace(/.+#/, ''); me.dismiss_alarm($('#alarm-snooze-dropdown').data('id'), time); return false; }); } // hide visible popup if (this.snooze_popup.is(':visible') && this.snooze_popup.data('id') == link.data('id')) { rcmail.command('menu-close', 'alarm-snooze-dropdown'); this.dismiss_link = null; } else { // open popup below the clicked link rcmail.command('menu-open', 'alarm-snooze-dropdown', link.get(0), event); this.snooze_popup.data('id', link.data('id')); this.dismiss_link = link; } }; /** * Dismiss or snooze alarms for the given event */ this.dismiss_alarm = function(id, snooze) { rcmail.command('menu-close', 'alarm-snooze-dropdown'); rcmail.http_post('utils/plugin.alarms', { action:'dismiss', data:{ id:id, snooze:snooze } }); // remove dismissed alarm from list if (this.dismiss_link) { this.dismiss_link.closest('div.alarm-item').hide(); var new_ids = jQuery.grep(this.alarm_ids, function(v){ return v != id; }); if (new_ids.length) this.alarm_ids = new_ids; else this.alarm_dialog.dialog('close'); } this.dismiss_link = null; }; /***** Recurrence form handling *****/ /** * Install event handlers on recurrence form elements */ this.init_recurrence_edit = function(prefix) { // toggle recurrence frequency forms $('#edit-recurrence-frequency').change(function(e){ var freq = $(this).val().toLowerCase(); $('.recurrence-form').hide(); if (freq) { $('#recurrence-form-'+freq).show(); if (freq != 'rdate') $('#recurrence-form-until').show(); } }); $('#recurrence-form-rdate input.button.add').click(function(e){ var dt, dv = $('#edit-recurrence-rdate-input').val(); if (dv && (dt = me.parse_datetime('12:00', dv))) { me.add_rdate(dt); me.sort_rdates(); $('#edit-recurrence-rdate-input').val('') } else { $('#edit-recurrence-rdate-input').select(); } }); $('#edit-recurrence-rdates').on('click', 'a.delete', function(e){ $(this).closest('li').remove(); return false; }); $('#edit-recurrence-enddate').datepicker(datepicker_settings).click(function(){ $("#edit-recurrence-repeat-until").prop('checked', true) }); $('#edit-recurrence-repeat-times').change(function(e){ $('#edit-recurrence-repeat-count').prop('checked', true); }); $('#edit-recurrence-rdate-input').datepicker(datepicker_settings); }; /** * Set recurrence form according to the given event/task record */ this.set_recurrence_edit = function(rec) { var recurrence = $('#edit-recurrence-frequency').val(rec.recurrence ? rec.recurrence.FREQ || (rec.recurrence.RDATE ? 'RDATE' : '') : '').change(), interval = $('.recurrence-form select.edit-recurrence-interval').val(rec.recurrence ? rec.recurrence.INTERVAL || 1 : 1), rrtimes = $('#edit-recurrence-repeat-times').val(rec.recurrence ? rec.recurrence.COUNT || 1 : 1), rrenddate = $('#edit-recurrence-enddate').val(rec.recurrence && rec.recurrence.UNTIL ? this.format_datetime(this.parseISO8601(rec.recurrence.UNTIL), 1) : ''); $('.recurrence-form input.edit-recurrence-until:checked').prop('checked', false); $('#edit-recurrence-rdates').html(''); var weekdays = ['SU','MO','TU','WE','TH','FR','SA'], rrepeat_id = '#edit-recurrence-repeat-forever'; if (rec.recurrence && rec.recurrence.COUNT) rrepeat_id = '#edit-recurrence-repeat-count'; else if (rec.recurrence && rec.recurrence.UNTIL) rrepeat_id = '#edit-recurrence-repeat-until'; $(rrepeat_id).prop('checked', true); if (rec.recurrence && rec.recurrence.BYDAY && rec.recurrence.FREQ == 'WEEKLY') { var wdays = rec.recurrence.BYDAY.split(','); $('input.edit-recurrence-weekly-byday').val(wdays); } if (rec.recurrence && rec.recurrence.BYMONTHDAY) { $('input.edit-recurrence-monthly-bymonthday').val(String(rec.recurrence.BYMONTHDAY).split(',')); $('input.edit-recurrence-monthly-mode').val(['BYMONTHDAY']); } if (rec.recurrence && rec.recurrence.BYDAY && (rec.recurrence.FREQ == 'MONTHLY' || rec.recurrence.FREQ == 'YEARLY')) { var byday, section = rec.recurrence.FREQ.toLowerCase(); if ((byday = String(rec.recurrence.BYDAY).match(/(-?[1-4])([A-Z]+)/))) { $('#edit-recurrence-'+section+'-prefix').val(byday[1]); $('#edit-recurrence-'+section+'-byday').val(byday[2]); } $('input.edit-recurrence-'+section+'-mode').val(['BYDAY']); } else if (rec.start) { $('#edit-recurrence-monthly-byday').val(weekdays[rec.start.getDay()]); } if (rec.recurrence && rec.recurrence.BYMONTH) { $('input.edit-recurrence-yearly-bymonth').val(String(rec.recurrence.BYMONTH).split(',')); } else if (rec.start) { $('input.edit-recurrence-yearly-bymonth').val([String(rec.start.getMonth()+1)]); } if (rec.recurrence && rec.recurrence.RDATE) { $.each(rec.recurrence.RDATE, function(i,rdate){ me.add_rdate(me.parseISO8601(rdate)); }); } }; /** * Gather recurrence settings from form */ this.serialize_recurrence = function(timestr) { var recurrence = '', freq = $('#edit-recurrence-frequency').val(); if (freq != '') { recurrence = { FREQ: freq, INTERVAL: $('#edit-recurrence-interval-'+freq.toLowerCase()).val() }; var until = $('input.edit-recurrence-until:checked').val(); if (until == 'count') recurrence.COUNT = $('#edit-recurrence-repeat-times').val(); else if (until == 'until') recurrence.UNTIL = me.date2ISO8601(me.parse_datetime(timestr || '00:00', $('#edit-recurrence-enddate').val())); if (freq == 'WEEKLY') { var byday = []; $('input.edit-recurrence-weekly-byday:checked').each(function(){ byday.push(this.value); }); if (byday.length) recurrence.BYDAY = byday.join(','); } else if (freq == 'MONTHLY') { var mode = $('input.edit-recurrence-monthly-mode:checked').val(), bymonday = []; if (mode == 'BYMONTHDAY') { $('input.edit-recurrence-monthly-bymonthday:checked').each(function(){ bymonday.push(this.value); }); if (bymonday.length) recurrence.BYMONTHDAY = bymonday.join(','); } else recurrence.BYDAY = $('#edit-recurrence-monthly-prefix').val() + $('#edit-recurrence-monthly-byday').val(); } else if (freq == 'YEARLY') { var byday, bymonth = []; $('input.edit-recurrence-yearly-bymonth:checked').each(function(){ bymonth.push(this.value); }); if (bymonth.length) recurrence.BYMONTH = bymonth.join(','); if ((byday = $('#edit-recurrence-yearly-byday').val())) recurrence.BYDAY = $('#edit-recurrence-yearly-prefix').val() + byday; } else if (freq == 'RDATE') { recurrence = { RDATE:[] }; // take selected but not yet added date into account if ($('#edit-recurrence-rdate-input').val() != '') { $('#recurrence-form-rdate input.button.add').click(); } $('#edit-recurrence-rdates li').each(function(i, li){ recurrence.RDATE.push($(li).attr('data-value')); }); } } return recurrence; }; // add the given date to the RDATE list this.add_rdate = function(date) { var li = $('
  • ') .attr('data-value', this.date2ISO8601(date)) .html('' + Q(this.format_datetime(date, 1)) + '') .appendTo('#edit-recurrence-rdates'); $('').attr('href', '#del') .addClass('iconbutton delete') .html(rcmail.get_label('delete', 'libcalendaring')) .attr('title', rcmail.get_label('delete', 'libcalendaring')) .appendTo(li); }; // re-sort the list items by their 'data-value' attribute this.sort_rdates = function() { var mylist = $('#edit-recurrence-rdates'), listitems = mylist.children('li').get(); listitems.sort(function(a, b) { var compA = $(a).attr('data-value'); var compB = $(b).attr('data-value'); return (compA < compB) ? -1 : (compA > compB) ? 1 : 0; }) $.each(listitems, function(idx, item) { mylist.append(item); }); }; /***** Attendee form handling *****/ // expand the given contact group into individual event/task attendees this.expand_attendee_group = function(e, add, remove) { var id = (e.data ? e.data.email : null) || $(e.target).attr('data-email'), role_select = $(e.target).closest('tr').find('select.edit-attendee-role option:selected'); this.group2expand[id] = { link: e.target, data: $.extend({}, e.data || {}), adder: add, remover: remove } // copy group role from the according form element if (role_select.length) { this.group2expand[id].data.role = role_select.val(); } // register callback handler if (!this._expand_attendee_listener) { this._expand_attendee_listener = this.expand_attendee_callback; rcmail.addEventListener('plugin.expand_attendee_callback', function(result) { me._expand_attendee_listener(result); }); } rcmail.http_post('libcal/plugin.expand_attendee_group', { id: id, data: e.data || {} }, rcmail.set_busy(true, 'loading')); }; // callback from server to expand an attendee group this.expand_attendee_callback = function(result) { var attendee, id = result.id, data = this.group2expand[id], row = $(data.link).closest('tr'); // replace group entry with all members returned by the server if (data && data.adder && result.members && result.members.length) { for (var i=0; i < result.members.length; i++) { attendee = result.members[i]; attendee.role = data.data.role; attendee.cutype = 'INDIVIDUAL'; attendee.status = 'NEEDS-ACTION'; data.adder(attendee, null, row); } if (data.remover) { data.remover(data.link, id) } else { row.remove(); } delete this.group2expand[id]; } else { rcmail.display_message(result.error || rcmail.gettext('expandattendeegroupnodata','libcalendaring'), 'error'); } }; // Render message reference links to the given container this.render_message_links = function(links, container, edit, plugin) { var ul = $('
      ').addClass('attachmentslist'); $.each(links, function(i, link) { if (!link.mailurl) return true; // continue var li = $('
    • ').addClass('link') .addClass('message eml') .append($('') .attr('href', link.mailurl) .addClass('messagelink') .text(link.subject || link.uri) ) .appendTo(ul); // add icon to remove the link if (edit) { $('') .attr('href', '#delete') .attr('title', rcmail.gettext('removelink', plugin)) .attr('data-uri', link.uri) .addClass('delete') .text(rcmail.gettext('delete')) .appendTo(li); } }); container.empty().append(ul); } } ////// static methods /** * */ rcube_libcalendaring.add_from_itip_mail = function(mime_id, task, status, dom_id) { // ask user to delete the declined event from the local calendar (#1670) var del = false; if (rcmail.env.rsvp_saved && status == 'declined') { del = confirm(rcmail.gettext('itip.declinedeleteconfirm')); } // open dialog for iTip delegation if (status == 'delegated') { rcube_libcalendaring.itip_delegate_dialog(function(data) { rcmail.http_post(task + '/itip-delegate', { _uid: rcmail.env.uid, _mbox: rcmail.env.mailbox, _part: mime_id, _to: data.to, _rsvp: data.rsvp ? 1 : 0, _comment: data.comment, _folder: data.target }, rcmail.set_busy(true, 'itip.savingdata')); }, $('#rsvp-'+dom_id+' .folder-select')); return false; } var noreply = 0, comment = ''; if (dom_id) { noreply = $('#noreply-'+dom_id+':checked').length ? 1 : 0; if (!noreply) comment = $('#reply-comment-'+dom_id).val(); } rcmail.http_post(task + '/mailimportitip', { _uid: rcmail.env.uid, _mbox: rcmail.env.mailbox, _part: mime_id, _folder: $('#itip-saveto').val(), _status: status, _del: del?1:0, _noreply: noreply, _comment: comment }, rcmail.set_busy(true, 'itip.savingdata')); return false; }; /** * Helper function to render the iTip delegation dialog * and trigger a callback function when submitted. */ rcube_libcalendaring.itip_delegate_dialog = function(callback, selector) { // show dialog for entering the delegatee address and comment var html = '
      ' + '
      ' + '
      ' + '' + '
      ' + '
      ' + '' + '
      ' + '
      ' + '' + '
      ' + '
      ' + (selector && selector.length ? selector.html() : '') + '
      ' + '
      '; var dialog, buttons = []; buttons.push({ text: rcmail.gettext('itipdelegated', 'itip'), click: function() { var doc = window.parent.document, delegatee = String($('#itip-delegate-to', doc).val()).replace(/(^\s+)|(\s+$)/, ''); if (delegatee != '' && rcube_check_email(delegatee, true)) { callback({ to: delegatee, rsvp: $('#itip-delegate-rsvp', doc).prop('checked'), comment: $('#itip-delegate-comment', doc).val(), target: $('#itip-saveto', doc).val() }); setTimeout(function() { dialog.dialog("close"); }, 500); } else { alert(rcmail.gettext('itip.delegateinvalidaddress')); $('#itip-delegate-to', doc).focus(); } } }); buttons.push({ text: rcmail.gettext('cancel', 'itip'), click: function() { dialog.dialog('close'); } }); dialog = rcmail.show_popup_dialog(html, rcmail.gettext('delegateinvitation', 'itip'), buttons, { width: 460, open: function(event, ui) { $(this).parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().addClass('mainaction'); $(this).find('#itip-saveto').val(''); // initialize autocompletion var ac_props, rcm = rcmail.is_framed() ? parent.rcmail : rcmail; if (rcmail.env.autocomplete_threads > 0) { ac_props = { threads: rcmail.env.autocomplete_threads, sources: rcmail.env.autocomplete_sources }; } rcm.init_address_input_events($(this).find('#itip-delegate-to').focus(), ac_props); rcm.env.recipients_delimiter = ''; }, close: function(event, ui) { rcm = rcmail.is_framed() ? parent.rcmail : rcmail; rcm.ksearch_blur(); $(this).remove(); } }); return dialog; }; /** * Show a menu for selecting the RSVP reply mode */ rcube_libcalendaring.itip_rsvp_recurring = function(btn, callback) { var mnu = $('
        ').addClass('popupmenu libcal-rsvp-replymode'); $.each(['all','current'/*,'future'*/], function(i, mode) { $('
      • ' + rcmail.get_label('rsvpmode'+mode, 'libcalendaring') + '') .addClass('ui-menu-item') .attr('rel', mode) .appendTo(mnu); }); var action = btn.attr('rel'); // open the mennu mnu.menu({ select: function(event, ui) { callback(action, ui.item.attr('rel')); } }) .appendTo(document.body) .position({ my: 'left top', at: 'left bottom+2', of: btn }) .data('action', action); setTimeout(function() { $(document).one('click', function() { mnu.menu('destroy'); mnu.remove(); }); }, 100); }; /** * */ rcube_libcalendaring.remove_from_itip = function(event, task, title) { if (confirm(rcmail.gettext('itip.deleteobjectconfirm').replace('$title', title))) { rcmail.http_post(task + '/itip-remove', event, rcmail.set_busy(true, 'itip.savingdata') ); } }; /** * */ rcube_libcalendaring.decline_attendee_reply = function(mime_id, task) { // show dialog for entering a comment and send to server var html = '
        ' + rcmail.gettext('itip.declineattendeeconfirm') + '
        ' + ''; var dialog, buttons = []; buttons.push({ text: rcmail.gettext('declineattendee', 'itip'), click: function() { rcmail.http_post(task + '/itip-decline-reply', { _uid: rcmail.env.uid, _mbox: rcmail.env.mailbox, _part: mime_id, _comment: $('#itip-decline-comment', window.parent.document).val() }, rcmail.set_busy(true, 'itip.savingdata')); dialog.dialog("close"); } }); buttons.push({ text: rcmail.gettext('cancel', 'itip'), click: function() { dialog.dialog('close'); } }); dialog = rcmail.show_popup_dialog(html, rcmail.gettext('declineattendee', 'itip'), buttons, { width: 460, open: function() { $(this).parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().addClass('mainaction'); $('#itip-decline-comment').focus(); } }); return false; }; /** * */ rcube_libcalendaring.fetch_itip_object_status = function(p) { rcmail.http_post(p.task + '/itip-status', { data: p }); }; /** * */ rcube_libcalendaring.update_itip_object_status = function(p) { rcmail.env.rsvp_saved = p.saved; rcmail.env.itip_existing = p.existing; // hide all elements first $('#itip-buttons-'+p.id+' > div').hide(); $('#rsvp-'+p.id+' .folder-select').remove(); if (p.html) { // append/replace rsvp status display $('#loading-'+p.id).next('.rsvp-status').remove(); $('#loading-'+p.id).hide().after(p.html); } // enable/disable rsvp buttons if (p.action == 'rsvp') { $('#rsvp-'+p.id+' input.button').prop('disabled', false) .filter('.'+String(p.status||'unknown').toLowerCase()).prop('disabled', p.latest); } // show rsvp/import buttons (with calendar selector) $('#'+p.action+'-'+p.id).show().find('input.button').last().after(p.select); // show itip box appendix after replacing the given placeholders if (p.append && p.append.selector) { var elem = $(p.append.selector); if (p.append.replacements) { $.each(p.append.replacements, function(k, html) { elem.html(elem.html().replace(k, html)); }); } else if (p.append.html) { elem.html(p.append.html) } elem.show(); } }; /** * Callback from server after an iTip message has been processed */ rcube_libcalendaring.itip_message_processed = function(metadata) { if (metadata.after_action) { setTimeout(function(){ rcube_libcalendaring.itip_after_action(metadata.after_action); }, 1200); } else { rcube_libcalendaring.fetch_itip_object_status(metadata); } }; /** * After-action on iTip request message. Action types: * 0 - no action * 1 - move to Trash * 2 - delete the message * 3 - flag as deleted * folder_name - move the message to the specified folder */ rcube_libcalendaring.itip_after_action = function(action) { if (!action) { return; } var rc = rcmail.is_framed() ? parent.rcmail : rcmail; if (action === 2) { rc.permanently_remove_messages(); } else if (action === 3) { rc.mark_message('delete'); } else { rc.move_messages(action === 1 ? rc.env.trash_mailbox : action); } }; /** * Open the calendar preview for the current iTip event */ rcube_libcalendaring.open_itip_preview = function(url, msgref) { if (!rcmail.env.itip_existing) url += '&itip=' + escape(msgref); var win = rcmail.open_window(url); }; // extend jQuery (function($){ $.fn.serializeJSON = function(){ var json = {}; jQuery.map($(this).serializeArray(), function(n, i) { json[n['name']] = n['value']; }); return json; }; })(jQuery); /* libcalendaring plugin initialization */ window.rcmail && rcmail.addEventListener('init', function(evt) { if (rcmail.env.libcal_settings) { var libcal = new rcube_libcalendaring(rcmail.env.libcal_settings); rcmail.addEventListener('plugin.display_alarms', function(alarms){ libcal.display_alarms(alarms); }); } rcmail.addEventListener('plugin.update_itip_object_status', rcube_libcalendaring.update_itip_object_status) .addEventListener('plugin.fetch_itip_object_status', rcube_libcalendaring.fetch_itip_object_status) .addEventListener('plugin.itip_message_processed', rcube_libcalendaring.itip_message_processed); $('.rsvp-buttons').on('click', 'a.reply-comment-toggle', function(e){ $(this).hide().parent().find('textarea').show().focus(); return false; }); if (rcmail.env.action == 'get-attachment' && rcmail.gui_objects['attachmentframe']) { rcmail.register_command('print-attachment', function() { var frame = rcmail.get_frame_window(rcmail.gui_objects['attachmentframe'].id); if (frame) frame.print(); }, true); } if (rcmail.env.action == 'get-attachment' && rcmail.env.attachment_download_url) { rcmail.register_command('download-attachment', function() { rcmail.location_href(rcmail.env.attachment_download_url, window); }, true); } }); diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php index 4246fa7f..ddbebe43 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -1,1640 +1,1650 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libcalendaring extends rcube_plugin { public $rc; public $timezone; public $gmt_offset; public $dst_active; public $timezone_offset; public $ical_parts = array(); public $ical_message; public $defaults = array( 'calendar_date_format' => "yyyy-MM-dd", 'calendar_date_short' => "M-d", 'calendar_date_long' => "MMM d yyyy", 'calendar_date_agenda' => "ddd MM-dd", 'calendar_time_format' => "HH:mm", 'calendar_first_day' => 1, 'calendar_first_hour' => 6, 'calendar_date_format_sets' => array( 'yyyy-MM-dd' => array('MMM d yyyy', 'M-d', 'ddd MM-dd'), 'dd-MM-yyyy' => array('d MMM yyyy', 'd-M', 'ddd dd-MM'), 'yyyy/MM/dd' => array('MMM d yyyy', 'M/d', 'ddd MM/dd'), 'MM/dd/yyyy' => array('MMM d yyyy', 'M/d', 'ddd MM/dd'), 'dd/MM/yyyy' => array('d MMM yyyy', 'd/M', 'ddd dd/MM'), 'dd.MM.yyyy' => array('dd. MMM yyyy', 'd.M', 'ddd dd.MM.'), 'd.M.yyyy' => array('d. MMM yyyy', 'd.M', 'ddd d.MM.'), ), ); private static $instance; private static $email_regex = '/([a-z0-9][a-z0-9\-\.\+\_]*@[^&@"\'.][^@&"\']*\\.([^\\x00-\\x40\\x5b-\\x60\\x7b-\\x7f]{2,}|xn--[a-z0-9]{2,}))/'; private $mail_ical_parser; /** * Singleton getter to allow direct access from other plugins */ public static function get_instance() { return self::$instance; } /** * Required plugin startup method */ public function init() { self::$instance = $this; $this->rc = rcube::get_instance(); // set user's timezone try { $this->timezone = new DateTimeZone($this->rc->config->get('timezone', 'GMT')); } catch (Exception $e) { $this->timezone = new DateTimeZone('GMT'); } $now = new DateTime('now', $this->timezone); $this->gmt_offset = $now->getOffset(); $this->dst_active = $now->format('I'); $this->timezone_offset = $this->gmt_offset / 3600 - $this->dst_active; $this->add_texts('localization/', false); // include client scripts and styles if ($this->rc->output) { // add hook to display alarms $this->add_hook('refresh', array($this, 'refresh')); $this->register_action('plugin.alarms', array($this, 'alarms_action')); $this->register_action('plugin.expand_attendee_group', array($this, 'expand_attendee_group')); } // proceed initialization in startup hook $this->add_hook('startup', array($this, 'startup')); } /** * Startup hook */ public function startup($args) { if ($this->rc->output && $this->rc->output->type == 'html') { $this->rc->output->set_env('libcal_settings', $this->load_settings()); $this->include_script('libcalendaring.js'); $this->include_stylesheet($this->local_skin_path() . '/libcal.css'); } if ($args['task'] == 'mail') { if ($args['action'] == 'show' || $args['action'] == 'preview') { $this->add_hook('message_load', array($this, 'mail_message_load')); } } } /** * Load iCalendar functions */ public static function get_ical() { $self = self::get_instance(); require_once($self->home . '/libvcalendar.php'); return new libvcalendar(); } /** * Load iTip functions */ public static function get_itip($domain = 'libcalendaring') { $self = self::get_instance(); require_once($self->home . '/lib/libcalendaring_itip.php'); return new libcalendaring_itip($self, $domain); } /** * Load recurrence computation engine */ public static function get_recurrence() { $self = self::get_instance(); require_once($self->home . '/lib/libcalendaring_recurrence.php'); return new libcalendaring_recurrence($self); } /** * Shift dates into user's current timezone * * @param mixed Any kind of a date representation (DateTime object, string or unix timestamp) * @return object DateTime object in user's timezone */ public function adjust_timezone($dt, $dateonly = false) { if (is_numeric($dt)) $dt = new DateTime('@'.$dt); else if (is_string($dt)) $dt = rcube_utils::anytodatetime($dt); if ($dt instanceof DateTime && !($dt->_dateonly || $dateonly)) { $dt->setTimezone($this->timezone); } return $dt; } /** * */ public function load_settings() { $this->date_format_defaults(); $settings = array(); // configuration $settings['date_format'] = (string)$this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']); $settings['time_format'] = (string)$this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format']); $settings['date_short'] = (string)$this->rc->config->get('calendar_date_short', $this->defaults['calendar_date_short']); $settings['date_long'] = (string)$this->rc->config->get('calendar_date_long', $this->defaults['calendar_date_long']); $settings['dates_long'] = str_replace(' yyyy', '[ yyyy]', $settings['date_long']) . "{ '—' " . $settings['date_long'] . '}'; $settings['first_day'] = (int)$this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']); $settings['timezone'] = $this->timezone_offset; $settings['dst'] = $this->dst_active; // localization $settings['days'] = array( $this->rc->gettext('sunday'), $this->rc->gettext('monday'), $this->rc->gettext('tuesday'), $this->rc->gettext('wednesday'), $this->rc->gettext('thursday'), $this->rc->gettext('friday'), $this->rc->gettext('saturday') ); $settings['days_short'] = array( $this->rc->gettext('sun'), $this->rc->gettext('mon'), $this->rc->gettext('tue'), $this->rc->gettext('wed'), $this->rc->gettext('thu'), $this->rc->gettext('fri'), $this->rc->gettext('sat') ); $settings['months'] = array( $this->rc->gettext('longjan'), $this->rc->gettext('longfeb'), $this->rc->gettext('longmar'), $this->rc->gettext('longapr'), $this->rc->gettext('longmay'), $this->rc->gettext('longjun'), $this->rc->gettext('longjul'), $this->rc->gettext('longaug'), $this->rc->gettext('longsep'), $this->rc->gettext('longoct'), $this->rc->gettext('longnov'), $this->rc->gettext('longdec') ); $settings['months_short'] = array( $this->rc->gettext('jan'), $this->rc->gettext('feb'), $this->rc->gettext('mar'), $this->rc->gettext('apr'), $this->rc->gettext('may'), $this->rc->gettext('jun'), $this->rc->gettext('jul'), $this->rc->gettext('aug'), $this->rc->gettext('sep'), $this->rc->gettext('oct'), $this->rc->gettext('nov'), $this->rc->gettext('dec') ); $settings['today'] = $this->rc->gettext('today'); // define list of file types which can be displayed inline // same as in program/steps/mail/show.inc $settings['mimetypes'] = (array)$this->rc->config->get('client_mimetypes'); return $settings; } /** * Helper function to set date/time format according to config and user preferences */ private function date_format_defaults() { static $defaults = array(); // nothing to be done if (isset($defaults['date_format'])) return; $defaults['date_format'] = $this->rc->config->get('calendar_date_format', self::from_php_date_format($this->rc->config->get('date_format'))); $defaults['time_format'] = $this->rc->config->get('calendar_time_format', self::from_php_date_format($this->rc->config->get('time_format'))); // override defaults if ($defaults['date_format']) $this->defaults['calendar_date_format'] = $defaults['date_format']; if ($defaults['time_format']) $this->defaults['calendar_time_format'] = $defaults['time_format']; // derive format variants from basic date format $format_sets = $this->rc->config->get('calendar_date_format_sets', $this->defaults['calendar_date_format_sets']); if ($format_set = $format_sets[$this->defaults['calendar_date_format']]) { $this->defaults['calendar_date_long'] = $format_set[0]; $this->defaults['calendar_date_short'] = $format_set[1]; $this->defaults['calendar_date_agenda'] = $format_set[2]; } } /** * Compose a date string for the given event */ public function event_date_text($event, $tzinfo = false) { $fromto = '--'; // handle task objects if ($event['_type'] == 'task' && is_object($event['due'])) { $date_format = $event['due']->_dateonly ? self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])) : null; $fromto = $this->rc->format_date($event['due'], $date_format, false); // add timezone information if ($fromto && $tzinfo && ($tzname = $this->timezone->getName())) { $fromto .= ' (' . strtr($tzname, '_', ' ') . ')'; } return $fromto; } // abort if no valid event dates are given if (!is_object($event['start']) || !is_a($event['start'], 'DateTime') || !is_object($event['end']) || !is_a($event['end'], 'DateTime')) { return $fromto; } $duration = $event['start']->diff($event['end'])->format('s'); $this->date_format_defaults(); $date_format = self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])); $time_format = self::to_php_date_format($this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format'])); if ($event['allday']) { $fromto = format_date($event['start'], $date_format); if (($todate = format_date($event['end'], $date_format)) != $fromto) $fromto .= ' - ' . $todate; } else if ($duration < 86400 && $event['start']->format('d') == $event['end']->format('d')) { $fromto = format_date($event['start'], $date_format) . ' ' . format_date($event['start'], $time_format) . ' - ' . format_date($event['end'], $time_format); } else { $fromto = format_date($event['start'], $date_format) . ' ' . format_date($event['start'], $time_format) . ' - ' . format_date($event['end'], $date_format) . ' ' . format_date($event['end'], $time_format); } // add timezone information if ($tzinfo && ($tzname = $this->timezone->getName())) { $fromto .= ' (' . strtr($tzname, '_', ' ') . ')'; } return $fromto; } /** * Render HTML form for alarm configuration */ public function alarm_select($attrib, $alarm_types, $absolute_time = true) { unset($attrib['name']); $input_value = new html_inputfield(array('name' => 'alarmvalue[]', 'class' => 'edit-alarm-value', 'size' => 3)); $input_date = new html_inputfield(array('name' => 'alarmdate[]', 'class' => 'edit-alarm-date', 'size' => 10)); $input_time = new html_inputfield(array('name' => 'alarmtime[]', 'class' => 'edit-alarm-time', 'size' => 6)); - $select_type = new html_select(array('name' => 'alarmtype[]', 'class' => 'edit-alarm-type', 'id' => $attrib['id'])); - $select_offset = new html_select(array('name' => 'alarmoffset[]', 'class' => 'edit-alarm-offset')); + $select_type = new html_select(array('name' => 'alarmtype[]', 'class' => 'edit-alarm-type', 'id' => $attrib['id'])); + $select_offset = new html_select(array('name' => 'alarmoffset[]', 'class' => 'edit-alarm-offset')); + $select_related = new html_select(array('name' => 'alarmrelated[]', 'class' => 'edit-alarm-related')); + $object_type = $attrib['_type'] ?: 'event'; $select_type->add($this->gettext('none'), ''); foreach ($alarm_types as $type) $select_type->add($this->gettext(strtolower("alarm{$type}option")), $type); foreach (array('-M','-H','-D','+M','+H','+D') as $trigger) $select_offset->add($this->gettext('trigger' . $trigger), $trigger); $select_offset->add($this->gettext('trigger0'), '0'); if ($absolute_time) $select_offset->add($this->gettext('trigger@'), '@'); + $select_related->add($this->gettext('relatedstart'), 'start'); + $select_related->add($this->gettext('relatedend' . $object_type), 'end'); + // pre-set with default values from user settings $preset = self::parse_alarm_value($this->rc->config->get('calendar_default_alarm_offset', '-15M')); $hidden = array('style' => 'display:none'); $html = html::span('edit-alarm-set', $select_type->show($this->rc->config->get('calendar_default_alarm_type', '')) . ' ' . html::span(array('class' => 'edit-alarm-values', 'style' => 'display:none'), $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1]) . ' ' . + $select_related->show() . ' ' . $input_date->show('', $hidden) . ' ' . $input_time->show('', $hidden) ) ); // TODO: support adding more alarms #$html .= html::a(array('href' => '#', 'id' => 'edit-alam-add', 'title' => $this->gettext('addalarm')), # $attrib['addicon'] ? html::img(array('src' => $attrib['addicon'], 'alt' => 'add')) : '(+)'); return $html; } /** * Get a list of email addresses of the given user (from login and identities) * * @param string User Email (default to current user) * @return array Email addresses related to the user */ public function get_user_emails($user = null) { static $_emails = array(); if (empty($user)) { $user = $this->rc->user->get_username(); } // return cached result if (is_array($_emails[$user])) { return $_emails[$user]; } $emails = array($user); $plugin = $this->rc->plugins->exec_hook('calendar_user_emails', array('emails' => $emails)); $emails = array_map('strtolower', $plugin['emails']); // add all emails from the current user's identities if (!$plugin['abort'] && ($user == $this->rc->user->get_username())) { foreach ($this->rc->user->list_emails() as $identity) { $emails[] = strtolower($identity['email']); } } $_emails[$user] = array_unique($emails); return $_emails[$user]; } /** * Set the given participant status to the attendee matching the current user's identities * * @param array Hash array with event struct * @param string The PARTSTAT value to set * @return mixed Email address of the updated attendee or False if none matching found */ public function set_partstat(&$event, $status, $recursive = true) { $success = false; $emails = $this->get_user_emails(); foreach ((array)$event['attendees'] as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $event['attendees'][$i]['status'] = strtoupper($status); $success = $attendee['email']; } } // apply partstat update to each existing exception if ($event['recurrence'] && is_array($event['recurrence']['EXCEPTIONS'])) { foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { $this->set_partstat($event['recurrence']['EXCEPTIONS'][$i], $status, false); } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } return $success; } /********* Alarms handling *********/ /** * Helper function to convert alarm trigger strings * into two-field values (e.g. "-45M" => 45, "-M") */ public static function parse_alarm_value($val) { if ($val[0] == '@') { 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)) { 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 // convert seconds to minutes if ($seg[2] == 'S') { $seg[2] = 'M'; $seg[1] = max(1, round($seg[1]/60)); } 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 = libcalendaring::parse_alarm_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']); $alarm['trigger']->setTimezone(new DateTimeZone('UTC')); } catch (Exception $e) { /* handle this ? */ } } else if ($trigger = libcalendaring::parse_alarm_value($alarm['trigger'])) { $alarm['trigger'] = $trigger[3]; } return $alarm; }, (array)$valarms); } /** * Render localized text for alarm settings */ public static function alarms_text($alarms) { 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']; + $action = $alarm['action']; + $related = $alarm['related']; } - $text = ''; + $text = ''; $rcube = rcube::get_instance(); switch ($action) { case 'EMAIL': $text = $rcube->gettext('libcalendaring.alarmemail'); break; case 'DISPLAY': $text = $rcube->gettext('libcalendaring.alarmdisplay'); break; case 'AUDIO': $text = $rcube->gettext('libcalendaring.alarmaudio'); break; } 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_alarm_value($trigger)) { + $r = strtoupper($related ?: 'start') == 'END' ? 'end' : ''; // 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]); + if ($val[0] == 0) { + $text .= ' ' . $rcube->gettext('libcalendaring.triggerattime' . $r); + } + else { + $label = 'libcalendaring.trigger' . $r . $val[1]; + $text .= ' ' . intval($val[0]) . ' ' . $rcube->gettext($label); + } } else { return false; } return $text; } /** * Get the next alarm (time & action) for the given event * * @param array Record data * @return array Hash array with alarm time/type or null if no alarms are configured */ public static function get_next_alarm($rec, $type = 'event') { if (!($rec['valarms'] || $rec['alarms']) || $rec['cancelled'] || $rec['status'] == 'CANCELLED') return null; if ($type == 'task') { $timezone = self::get_instance()->timezone; if ($rec['startdate']) $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_alarm_value($trigger)) { $rec['valarms'] = array(array('action' => $action, 'trigger' => $alarm[3] ?: $alarm[0])); } } - $expires = new DateTime('now - 12 hours'); + $expires = new DateTime('now - 12 hours'); $alarm_id = $rec['id']; // alarm ID eq. record ID by default to keep backwards compatibility // handle multiple alarms $notify_at = null; foreach ($rec['valarms'] as $alarm) { $notify_time = null; if ($alarm['trigger'] instanceof DateTime) { $notify_time = $alarm['trigger']; } else if (is_string($alarm['trigger'])) { -// $refdate = $alarm['trigger'][0] == '+' ? $rec['end'] : $rec['start']; - $refdate = $rec['start']; + $refdate = $alarm['related'] == 'END' ? $rec['end'] : $rec['start']; // abort if no reference date is available to compute notification time if (!is_a($refdate, 'DateTime')) continue; // TODO: for all-day events, take start @ 00:00 as reference date ? try { $interval = new DateInterval(trim($alarm['trigger'], '+-')); - $interval->invert = $alarm['trigger'][0] != '+'; + $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 !$notify_at ? null : array( 'time' => $notify_at->format('U'), 'action' => $action ? strtoupper($action) : 'DISPLAY', 'id' => $alarm_id, 'prop' => $alarm_prop, ); } /** * Handler for keep-alive requests * This will check for pending notifications and pass them to the client */ public function refresh($attr) { // collect pending alarms from all providers (e.g. calendar, tasks) $plugin = $this->rc->plugins->exec_hook('pending_alarms', array( 'time' => time(), 'alarms' => array(), )); if (!$plugin['abort'] && !empty($plugin['alarms'])) { // make sure texts and env vars are available on client $this->add_texts('localization/', true); $this->rc->output->add_label('close'); $this->rc->output->set_env('snooze_select', $this->snooze_select()); $this->rc->output->command('plugin.display_alarms', $this->_alarms_output($plugin['alarms'])); } } /** * Handler for alarm dismiss/snooze requests */ public function alarms_action() { // $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); $data['ids'] = explode(',', $data['id']); $plugin = $this->rc->plugins->exec_hook('dismiss_alarms', $data); if ($plugin['success']) $this->rc->output->show_message('successfullysaved', 'confirmation'); else $this->rc->output->show_message('calendar.errorsaving', 'error'); } /** * Generate reduced and streamlined output for pending alarms */ private function _alarms_output($alarms) { $out = array(); foreach ($alarms as $alarm) { $out[] = array( 'id' => $alarm['id'], 'start' => $alarm['start'] ? $this->adjust_timezone($alarm['start'])->format('c') : '', 'end' => $alarm['end'] ? $this->adjust_timezone($alarm['end'])->format('c') : '', 'allDay' => ($alarm['allday'] == 1)?true:false, 'title' => $alarm['title'], 'location' => $alarm['location'], 'calendar' => $alarm['calendar'], ); } return $out; } /** * Render a dropdown menu to choose snooze time */ private function snooze_select($attrib = array()) { $steps = array( 5 => 'repeatinmin', 10 => 'repeatinmin', 15 => 'repeatinmin', 20 => 'repeatinmin', 30 => 'repeatinmin', 60 => 'repeatinhr', 120 => 'repeatinhrs', 1440 => 'repeattomorrow', 10080 => 'repeatinweek', ); $items = array(); foreach ($steps as $n => $label) { $items[] = html::tag('li', null, html::a(array('href' => "#" . ($n * 60), 'class' => 'active'), $this->gettext(array('name' => $label, 'vars' => array('min' => $n % 60, 'hrs' => intval($n / 60)))))); } return html::tag('ul', $attrib + array('class' => 'toolbarmenu'), join("\n", $items), html::$common_attrib); } /********* Recurrence rules handling ********/ /** * Render localized text describing the recurrence rule of an event */ public function recurrence_text($rrule) { // derive missing FREQ and INTERVAL from RDATE list if (empty($rrule['FREQ']) && !empty($rrule['RDATE'])) { $first = $rrule['RDATE'][0]; $second = $rrule['RDATE'][1]; $third = $rrule['RDATE'][2]; if (is_a($first, 'DateTime') && is_a($second, 'DateTime')) { $diff = $first->diff($second); foreach (array('y' => 'YEARLY', 'm' => 'MONTHLY', 'd' => 'DAILY') as $k => $freq) { if ($diff->$k != 0) { $rrule['FREQ'] = $freq; $rrule['INTERVAL'] = $diff->$k; // verify interval with next item if (is_a($third, 'DateTime')) { $diff2 = $second->diff($third); if ($diff2->$k != $diff->$k) { unset($rrule['INTERVAL']); } } break; } } } if (!$rrule['INTERVAL']) { $rrule['FREQ'] = 'RDATE'; } $rrule['UNTIL'] = end($rrule['RDATE']); } $freq = sprintf('%s %d ', $this->gettext('every'), $rrule['INTERVAL']); $details = ''; switch ($rrule['FREQ']) { case 'DAILY': $freq .= $this->gettext('days'); break; case 'WEEKLY': $freq .= $this->gettext('weeks'); break; case 'MONTHLY': $freq .= $this->gettext('months'); break; case 'YEARLY': $freq .= $this->gettext('years'); break; } if ($rrule['INTERVAL'] <= 1) { $freq = $this->gettext(strtolower($rrule['FREQ'])); } if ($rrule['COUNT']) { $until = $this->gettext(array('name' => 'forntimes', 'vars' => array('nr' => $rrule['COUNT']))); } else if ($rrule['UNTIL']) { $until = $this->gettext('recurrencend') . ' ' . format_date($rrule['UNTIL'], self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']))); } else { $until = $this->gettext('forever'); } $except = ''; if (is_array($rrule['EXDATE']) && !empty($rrule['EXDATE'])) { $format = self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])); $exdates = array_map( function($dt) use ($format) { return format_date($dt, $format); }, array_slice($rrule['EXDATE'], 0, 10) ); $except = '; ' . $this->gettext('except') . ' ' . join(', ', $exdates); } return rtrim($freq . $details . ', ' . $until . $except); } /** * Generate the form for recurrence settings */ public function recurrence_form($attrib = array()) { switch ($attrib['part']) { // frequency selector case 'frequency': $select = new html_select(array('name' => 'frequency', 'id' => 'edit-recurrence-frequency')); $select->add($this->gettext('never'), ''); $select->add($this->gettext('daily'), 'DAILY'); $select->add($this->gettext('weekly'), 'WEEKLY'); $select->add($this->gettext('monthly'), 'MONTHLY'); $select->add($this->gettext('yearly'), 'YEARLY'); $select->add($this->gettext('rdate'), 'RDATE'); $html = html::label('edit-recurrence-frequency', $this->gettext('frequency')) . $select->show(''); break; // daily recurrence case 'daily': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-daily')); $html = html::div($attrib, html::label('edit-recurrence-interval-daily', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('days'))); break; // weekly recurrence form case 'weekly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-weekly')); $html = html::div($attrib, html::label('edit-recurrence-interval-weekly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('weeks'))); // weekday selection $daymap = array('sun','mon','tue','wed','thu','fri','sat'); $checkbox = new html_checkbox(array('name' => 'byday', 'class' => 'edit-recurrence-weekly-byday')); $first = $this->rc->config->get('calendar_first_day', 1); for ($weekdays = '', $j = $first; $j <= $first+6; $j++) { $d = $j % 7; $weekdays .= html::label(array('class' => 'weekday'), $checkbox->show('', array('value' => strtoupper(substr($daymap[$d], 0, 2)))) . $this->gettext($daymap[$d]) ) . ' '; } $html .= html::div($attrib, html::label(null, $this->gettext('bydays')) . $weekdays); break; // monthly recurrence form case 'monthly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-monthly')); $html = html::div($attrib, html::label('edit-recurrence-interval-monthly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('months'))); $checkbox = new html_checkbox(array('name' => 'bymonthday', 'class' => 'edit-recurrence-monthly-bymonthday')); for ($monthdays = '', $d = 1; $d <= 31; $d++) { $monthdays .= html::label(array('class' => 'monthday'), $checkbox->show('', array('value' => $d)) . $d); $monthdays .= $d % 7 ? ' ' : html::br(); } // rule selectors $radio = new html_radiobutton(array('name' => 'repeatmode', 'class' => 'edit-recurrence-monthly-mode')); $table = new html_table(array('cols' => 2, 'border' => 0, 'cellpadding' => 0, 'class' => 'formtable')); $table->add('label', html::label(null, $radio->show('BYMONTHDAY', array('value' => 'BYMONTHDAY')) . ' ' . $this->gettext('each'))); $table->add(null, $monthdays); $table->add('label', html::label(null, $radio->show('', array('value' => 'BYDAY')) . ' ' . $this->gettext('onevery'))); $table->add(null, $this->rrule_selectors($attrib['part'])); $html .= html::div($attrib, $table->show()); break; // annually recurrence form case 'yearly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-yearly')); $html = html::div($attrib, html::label('edit-recurrence-interval-yearly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('years'))); // month selector $monthmap = array('','jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'); $checkbox = new html_checkbox(array('name' => 'bymonth', 'class' => 'edit-recurrence-yearly-bymonth')); for ($months = '', $m = 1; $m <= 12; $m++) { $months .= html::label(array('class' => 'month'), $checkbox->show(null, array('value' => $m)) . $this->gettext($monthmap[$m])); $months .= $m % 4 ? ' ' : html::br(); } $html .= html::div($attrib + array('id' => 'edit-recurrence-yearly-bymonthblock'), $months); // day rule selection $html .= html::div($attrib, html::label(null, $this->gettext('onevery')) . $this->rrule_selectors($attrib['part'], '---')); break; // end of recurrence form case 'until': $radio = new html_radiobutton(array('name' => 'repeat', 'class' => 'edit-recurrence-until')); $select = $this->interval_selector(array('name' => 'times', 'id' => 'edit-recurrence-repeat-times')); $input = new html_inputfield(array('name' => 'untildate', 'id' => 'edit-recurrence-enddate', 'size' => "10")); $html = html::div('line first', html::label(null, $radio->show('', array('value' => '', 'id' => 'edit-recurrence-repeat-forever')) . ' ' . $this->gettext('forever')) ); $forntimes = $this->gettext(array( 'name' => 'forntimes', 'vars' => array('nr' => '%s')) ); $html .= html::div('line', $radio->show('', array('value' => 'count', 'id' => 'edit-recurrence-repeat-count', 'aria-label' => sprintf($forntimes, 'N'))) . ' ' . sprintf($forntimes, $select->show(1)) ); $html .= html::div('line', $radio->show('', array('value' => 'until', 'id' => 'edit-recurrence-repeat-until', 'aria-label' => $this->gettext('untilenddate'))) . ' ' . $this->gettext('untildate') . ' ' . $input->show('', array('aria-label' => $this->gettext('untilenddate'))) ); $html = html::div($attrib, html::label(null, ucfirst($this->gettext('recurrencend'))) . $html); break; case 'rdate': $ul = html::tag('ul', array('id' => 'edit-recurrence-rdates'), ''); $input = new html_inputfield(array('name' => 'rdate', 'id' => 'edit-recurrence-rdate-input', 'size' => "10")); $button = new html_inputfield(array('type' => 'button', 'class' => 'button add', 'value' => $this->gettext('addrdate'))); $html .= html::div($attrib, $ul . html::div('inputform', $input->show() . $button->show())); break; } return $html; } /** * Input field for interval selection */ private function interval_selector($attrib) { $select = new html_select($attrib); $select->add(range(1,30), range(1,30)); return $select; } /** * Drop-down menus for recurrence rules like "each last sunday of" */ private function rrule_selectors($part, $noselect = null) { // rule selectors $select_prefix = new html_select(array('name' => 'bydayprefix', 'id' => "edit-recurrence-$part-prefix")); if ($noselect) $select_prefix->add($noselect, ''); $select_prefix->add(array( $this->gettext('first'), $this->gettext('second'), $this->gettext('third'), $this->gettext('fourth'), $this->gettext('last') ), array(1, 2, 3, 4, -1)); $select_wday = new html_select(array('name' => 'byday', 'id' => "edit-recurrence-$part-byday")); if ($noselect) $select_wday->add($noselect, ''); $daymap = array('sunday','monday','tuesday','wednesday','thursday','friday','saturday'); $first = $this->rc->config->get('calendar_first_day', 1); for ($j = $first; $j <= $first+6; $j++) { $d = $j % 7; $select_wday->add($this->gettext($daymap[$d]), strtoupper(substr($daymap[$d], 0, 2))); } return $select_prefix->show() . ' ' . $select_wday->show(); } /** * Convert the recurrence settings to be processed on the client */ public function to_client_recurrence($recurrence, $allday = false) { if ($recurrence['UNTIL']) $recurrence['UNTIL'] = $this->adjust_timezone($recurrence['UNTIL'], $allday)->format('c'); // format RDATE values if (is_array($recurrence['RDATE'])) { $libcal = $this; $recurrence['RDATE'] = array_map(function($rdate) use ($libcal) { return $libcal->adjust_timezone($rdate, true)->format('c'); }, $recurrence['RDATE']); } unset($recurrence['EXCEPTIONS']); return $recurrence; } /** * Process the alarms values submitted by the client */ public function from_client_recurrence($recurrence, $start = null) { if (is_array($recurrence) && !empty($recurrence['UNTIL'])) { $recurrence['UNTIL'] = new DateTime($recurrence['UNTIL'], $this->timezone); } if (is_array($recurrence) && is_array($recurrence['RDATE'])) { $tz = $this->timezone; $recurrence['RDATE'] = array_map(function($rdate) use ($tz, $start) { try { $dt = new DateTime($rdate, $tz); if (is_a($start, 'DateTime')) $dt->setTime($start->format('G'), $start->format('i')); return $dt; } catch (Exception $e) { return null; } }, $recurrence['RDATE']); } return $recurrence; } /********* Attachments handling *********/ /** * Handler for attachment uploads */ public function attachment_upload($session_key, $id_prefix = '') { // Upload progress update if (!empty($_GET['_progress'])) { $this->rc->upload_progress(); } $recid = $id_prefix . rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $uploadid = rcube_utils::get_input_value('_uploadid', rcube_utils::INPUT_GPC); if (!is_array($_SESSION[$session_key]) || $_SESSION[$session_key]['id'] != $recid) { $_SESSION[$session_key] = array(); $_SESSION[$session_key]['id'] = $recid; $_SESSION[$session_key]['attachments'] = array(); } // clear all stored output properties (like scripts and env vars) $this->rc->output->reset(); if (is_array($_FILES['_attachments']['tmp_name'])) { foreach ($_FILES['_attachments']['tmp_name'] as $i => $filepath) { // Process uploaded attachment if there is no error $err = $_FILES['_attachments']['error'][$i]; if (!$err) { $attachment = array( 'path' => $filepath, 'size' => $_FILES['_attachments']['size'][$i], 'name' => $_FILES['_attachments']['name'][$i], 'mimetype' => rcube_mime::file_content_type($filepath, $_FILES['_attachments']['name'][$i], $_FILES['_attachments']['type'][$i]), 'group' => $recid, ); $attachment = $this->rc->plugins->exec_hook('attachment_upload', $attachment); } if (!$err && $attachment['status'] && !$attachment['abort']) { $id = $attachment['id']; // store new attachment in session unset($attachment['status'], $attachment['abort']); $_SESSION[$session_key]['attachments'][$id] = $attachment; if (($icon = $_SESSION[$session_key . '_deleteicon']) && is_file($icon)) { $button = html::img(array( 'src' => $icon, 'alt' => $this->rc->gettext('delete') )); } else { $button = Q($this->rc->gettext('delete')); } $content = html::a(array( 'href' => "#delete", 'class' => 'delete', 'onclick' => sprintf("return %s.remove_from_attachment_list('rcmfile%s')", JS_OBJECT_NAME, $id), 'title' => $this->rc->gettext('delete'), 'aria-label' => $this->rc->gettext('delete') . ' ' . $attachment['name'], ), $button); $content .= Q($attachment['name']); $this->rc->output->command('add2attachment_list', "rcmfile$id", array( 'html' => $content, 'name' => $attachment['name'], 'mimetype' => $attachment['mimetype'], 'classname' => rcube_utils::file2class($attachment['mimetype'], $attachment['name']), 'complete' => true), $uploadid); } else { // upload failed if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) { $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( 'size' => show_bytes(parse_bytes(ini_get('upload_max_filesize')))))); } else if ($attachment['error']) { $msg = $attachment['error']; } else { $msg = $this->rc->gettext('fileuploaderror'); } $this->rc->output->command('display_message', $msg, 'error'); $this->rc->output->command('remove_from_attachment_list', $uploadid); } } } else if ($_SERVER['REQUEST_METHOD'] == 'POST') { // if filesize exceeds post_max_size then $_FILES array is empty, // show filesizeerror instead of fileuploaderror if ($maxsize = ini_get('post_max_size')) $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( 'size' => show_bytes(parse_bytes($maxsize))))); else $msg = $this->rc->gettext('fileuploaderror'); $this->rc->output->command('display_message', $msg, 'error'); $this->rc->output->command('remove_from_attachment_list', $uploadid); } $this->rc->output->send('iframe'); } /** * Deliver an event/task attachment to the client * (similar as in Roundcube core program/steps/mail/get.inc) */ public function attachment_get($attachment) { ob_end_clean(); if ($attachment && $attachment['body']) { // allow post-processing of the attachment body $part = new rcube_message_part; $part->filename = $attachment['name']; $part->size = $attachment['size']; $part->mimetype = $attachment['mimetype']; $plugin = $this->rc->plugins->exec_hook('message_part_get', array( 'body' => $attachment['body'], 'mimetype' => strtolower($attachment['mimetype']), 'download' => !empty($_GET['_download']), 'part' => $part, )); if ($plugin['abort']) exit; $mimetype = $plugin['mimetype']; list($ctype_primary, $ctype_secondary) = explode('/', $mimetype); $browser = $this->rc->output->browser; // send download headers if ($plugin['download']) { header("Content-Type: application/octet-stream"); if ($browser->ie) header("Content-Type: application/force-download"); } else if ($ctype_primary == 'text') { header("Content-Type: text/$ctype_secondary"); } else { header("Content-Type: $mimetype"); header("Content-Transfer-Encoding: binary"); } // display page, @TODO: support text/plain (and maybe some other text formats) if ($mimetype == 'text/html' && empty($_GET['_download'])) { $OUTPUT = new rcube_html_page(); // @TODO: use washtml on $body $OUTPUT->write($plugin['body']); } else { // don't kill the connection if download takes more than 30 sec. @set_time_limit(0); $filename = $attachment['name']; $filename = preg_replace('[\r\n]', '', $filename); if ($browser->ie && $browser->ver < 7) $filename = rawurlencode(abbreviate_string($filename, 55)); else if ($browser->ie) $filename = rawurlencode($filename); else $filename = addcslashes($filename, '"'); $disposition = !empty($_GET['_download']) ? 'attachment' : 'inline'; header("Content-Disposition: $disposition; filename=\"$filename\""); echo $plugin['body']; } exit; } // if we arrive here, the requested part was not found header('HTTP/1.1 404 Not Found'); exit; } /** * Show "loading..." page in attachment iframe */ public function attachment_loading_page() { $url = str_replace('&_preload=1', '', $_SERVER['REQUEST_URI']); $message = $this->rc->gettext('loadingdata'); header('Content-Type: text/html; charset=' . RCUBE_CHARSET); print "\n\n" . '' . "\n" . '' . "\n" . "\n\n$message\n\n"; exit; } /** * Template object for attachment display frame */ public function attachment_frame($attrib = array()) { $mimetype = strtolower($this->attachment['mimetype']); list($ctype_primary, $ctype_secondary) = explode('/', $mimetype); $attrib['src'] = './?' . str_replace('_frame=', ($ctype_primary == 'text' ? '_show=' : '_preload='), $_SERVER['QUERY_STRING']); $this->rc->output->add_gui_object('attachmentframe', $attrib['id']); return html::iframe($attrib); } /** * */ public function attachment_header($attrib = array()) { $rcmail = rcmail::get_instance(); $dl_link = strtolower($attrib['downloadlink']) == 'true'; $dl_url = $this->rc->url(array('_frame' => null, '_download' => 1) + $_GET); $table = new html_table(array('cols' => $dl_link ? 3 : 2)); if (!empty($this->attachment['name'])) { $table->add('title', Q($this->rc->gettext('filename'))); $table->add('header', Q($this->attachment['name'])); if ($dl_link) { $table->add('download-link', html::a($dl_url, Q($this->rc->gettext('download')))); } } if (!empty($this->attachment['mimetype'])) { $table->add('title', Q($this->rc->gettext('type'))); $table->add('header', Q($this->attachment['mimetype'])); } if (!empty($this->attachment['size'])) { $table->add('title', Q($this->rc->gettext('filesize'))); $table->add('header', Q(show_bytes($this->attachment['size']))); } $this->rc->output->set_env('attachment_download_url', $dl_url); return $table->show($attrib); } /********* iTip message detection *********/ /** * Check mail message structure of there are .ics files attached */ public function mail_message_load($p) { $this->ical_message = $p['object']; $itip_part = null; // check all message parts for .ics files foreach ((array)$this->ical_message->mime_parts as $part) { if (self::part_is_vcalendar($part)) { if ($part->ctype_parameters['method']) $itip_part = $part->mime_id; else $this->ical_parts[] = $part->mime_id; } } // priorize part with method parameter if ($itip_part) { $this->ical_parts = array($itip_part); } } /** * Getter for the parsed iCal objects attached to the current email message * * @return object libvcalendar parser instance with the parsed objects */ public function get_mail_ical_objects() { // create parser and load ical objects if (!$this->mail_ical_parser) { $this->mail_ical_parser = $this->get_ical(); foreach ($this->ical_parts as $mime_id) { $part = $this->ical_message->mime_parts[$mime_id]; $charset = $part->ctype_parameters['charset'] ?: RCMAIL_CHARSET; $this->mail_ical_parser->import($this->ical_message->get_part_body($mime_id, true), $charset); // check if the parsed object is an instance of a recurring event/task array_walk($this->mail_ical_parser->objects, 'libcalendaring::identify_recurrence_instance'); // stop on the part that has an iTip method specified if (count($this->mail_ical_parser->objects) && $this->mail_ical_parser->method) { $this->mail_ical_parser->message_date = $this->ical_message->headers->date; $this->mail_ical_parser->mime_id = $mime_id; // store the message's sender address for comparisons $this->mail_ical_parser->sender = preg_match(self::$email_regex, $this->ical_message->headers->from, $m) ? $m[1] : ''; if (!empty($this->mail_ical_parser->sender)) { foreach ($this->mail_ical_parser->objects as $i => $object) { $this->mail_ical_parser->objects[$i]['_sender'] = $this->mail_ical_parser->sender; $this->mail_ical_parser->objects[$i]['_sender_utf'] = rcube_utils::idn_to_utf8($this->mail_ical_parser->sender); } } break; } } } return $this->mail_ical_parser; } /** * Read the given mime message from IMAP and parse ical data * * @param string Mailbox name * @param string Message UID * @param string Message part ID and object index (e.g. '1.2:0') * @param string Object type filter (optional) * * @return array Hash array with the parsed iCal */ public function mail_get_itip_object($mbox, $uid, $mime_id, $type = null) { $charset = RCMAIL_CHARSET; // establish imap connection $imap = $this->rc->get_storage(); $imap->set_mailbox($mbox); if ($uid && $mime_id) { list($mime_id, $index) = explode(':', $mime_id); $part = $imap->get_message_part($uid, $mime_id); $headers = $imap->get_message_headers($uid); $parser = $this->get_ical(); if ($part->ctype_parameters['charset']) { $charset = $part->ctype_parameters['charset']; } if ($part) { $objects = $parser->import($part, $charset); } } // successfully parsed events/tasks? if (!empty($objects) && ($object = $objects[$index]) && (!$type || $object['_type'] == $type)) { if ($parser->method) $object['_method'] = $parser->method; // store the message's sender address for comparisons $object['_sender'] = preg_match(self::$email_regex, $headers->from, $m) ? $m[1] : ''; $object['_sender_utf'] = rcube_utils::idn_to_utf8($object['_sender']); // check if this is an instance of a recurring event/task self::identify_recurrence_instance($object); return $object; } return null; } /** * Checks if specified message part is a vcalendar data * * @param rcube_message_part Part object * @return boolean True if part is of type vcard */ public static function part_is_vcalendar($part) { return ( in_array($part->mimetype, array('text/calendar', 'text/x-vcalendar', 'application/ics')) || // Apple sends files as application/x-any (!?) ($part->mimetype == 'application/x-any' && $part->filename && preg_match('/\.ics$/i', $part->filename)) ); } /** * Single occourrences of recurring events are identified by their RECURRENCE-ID property * in iCal which is represented as 'recurrence_date' in our internal data structure. * * Check if such a property exists and derive the '_instance' identifier and '_savemode' * attributes which are used in the storage backend to identify the nested exception item. */ public static function identify_recurrence_instance(&$object) { // for savemode=all, remove recurrence instance identifiers if (!empty($object['_savemode']) && $object['_savemode'] == 'all' && $object['recurrence']) { unset($object['_instance'], $object['recurrence_date']); } // set instance and 'savemode' according to recurrence-id else if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) { $object['_instance'] = self::recurrence_instance_identifier($object); $object['_savemode'] = $object['thisandfuture'] ? 'future' : 'current'; } else if (!empty($object['recurrence_id']) && !empty($object['_instance'])) { if (strlen($object['_instance']) > 4) { $object['recurrence_date'] = rcube_utils::anytodatetime($object['_instance'], $object['start']->getTimezone()); } else { $object['recurrence_date'] = clone $object['start']; } } } /** * Return a date() format string to render identifiers for recurrence instances * * @param array Hash array with event properties * @return string Format string */ public static function recurrence_id_format($event) { return $event['allday'] ? 'Ymd' : 'Ymd\THis'; } /** * Return the identifer for the given instance of a recurring event * * @param array Hash array with event properties * @return mixed Format string or null if identifier cannot be generated */ public static function recurrence_instance_identifier($event) { $instance_date = $event['recurrence_date'] ?: $event['start']; if ($instance_date && is_a($instance_date, 'DateTime')) { $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis'; return $instance_date->format($recurrence_id_format); } return null; } /********* Attendee handling functions *********/ /** * Handler for attendee group expansion requests */ public function expand_attendee_group() { $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); $result = array('id' => $id, 'members' => array()); $maxnum = 500; // iterate over all autocomplete address books (we don't know the source of the group) foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $abook_id) { if (($abook = $this->rc->get_address_book($abook_id)) && $abook->groups) { foreach ($abook->list_groups($data['name'], 1) as $group) { // this is the matching group to expand if (in_array($data['email'], (array)$group['email'])) { $abook->set_pagesize($maxnum); $abook->set_group($group['ID']); // get all members $res = $abook->list_records($this->rc->config->get('contactlist_fields')); // handle errors (e.g. sizelimit, timelimit) if ($abook->get_error()) { $result['error'] = $this->rc->gettext('expandattendeegrouperror', 'libcalendaring'); $res = false; } // check for maximum number of members (we don't wanna bloat the UI too much) else if ($res->count > $maxnum) { $result['error'] = $this->rc->gettext('expandattendeegroupsizelimit', 'libcalendaring'); $res = false; } while ($res && ($member = $res->iterate())) { $emails = (array)$abook->get_col_values('email', $member, true); if (!empty($emails) && ($email = array_shift($emails))) { $result['members'][] = array( 'email' => $email, 'name' => rcube_addressbook::compose_list_name($member), ); } } break 2; } } } } $this->rc->output->command('plugin.expand_attendee_callback', $result); } /********* Static utility functions *********/ /** * Convert the internal structured data into a vcalendar rrule 2.0 string */ public static function to_rrule($recurrence, $allday = false) { if (is_string($recurrence)) return $recurrence; $rrule = ''; foreach ((array)$recurrence as $k => $val) { $k = strtoupper($k); switch ($k) { case 'UNTIL': // convert to UTC according to RFC 5545 if (is_a($val, 'DateTime')) { if (!$allday && !$val->_dateonly) { $until = clone $val; $until->setTimezone(new DateTimeZone('UTC')); $val = $until->format('Ymd\THis\Z'); } else { $val = $val->format('Ymd'); } } break; case 'RDATE': case 'EXDATE': foreach ((array)$val as $i => $ex) { if (is_a($ex, 'DateTime')) $val[$i] = $ex->format('Ymd\THis'); } $val = join(',', (array)$val); break; case 'EXCEPTIONS': continue 2; } if (strlen($val)) $rrule .= $k . '=' . $val . ';'; } return rtrim($rrule, ';'); } /** * Convert from fullcalendar date format to PHP date() format string */ public static function to_php_date_format($from) { // "dd.MM.yyyy HH:mm:ss" => "d.m.Y H:i:s" return strtr(strtr($from, array( 'yyyy' => 'Y', 'yy' => 'y', 'MMMM' => 'F', 'MMM' => 'M', 'MM' => 'm', 'M' => 'n', 'dddd' => 'l', 'ddd' => 'D', 'dd' => 'd', 'd' => 'j', 'HH' => '**', 'hh' => '%%', 'H' => 'G', 'h' => 'g', 'mm' => 'i', 'ss' => 's', 'TT' => 'A', 'tt' => 'a', 'T' => 'A', 't' => 'a', 'u' => 'c', )), array( '**' => 'H', '%%' => 'h', )); } /** * Convert from PHP date() format to fullcalendar format string */ public static function from_php_date_format($from) { // "d.m.Y H:i:s" => "dd.MM.yyyy HH:mm:ss" return strtr($from, array( 'y' => 'yy', 'Y' => 'yyyy', 'M' => 'MMM', 'F' => 'MMMM', 'm' => 'MM', 'n' => 'M', 'j' => 'd', 'd' => 'dd', 'D' => 'ddd', 'l' => 'dddd', 'H' => 'HH', 'h' => 'hh', 'G' => 'H', 'g' => 'h', 'i' => 'mm', 's' => 'ss', 'A' => 'TT', 'a' => 'tt', 'c' => 'u', )); } } diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index 8df7bd9b..7575b537 100644 --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -1,1371 +1,1378 @@ * * Copyright (C) 2013-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ use \Sabre\VObject; // load Sabre\VObject classes if (!class_exists('\Sabre\VObject\Reader')) { require_once __DIR__ . '/lib/Sabre/VObject/includes.php'; } /** * Class to parse and build vCalendar (iCalendar) files * * Uses the SabreTooth VObject library, version 2.1. * * Download from https://github.com/fruux/sabre-vobject/archive/2.1.0.zip * and place the lib files in this plugin's lib directory * */ class libvcalendar implements Iterator { private $timezone; private $attach_uri = null; private $prodid = '-//Roundcube libcalendaring//Sabre//Sabre VObject//EN'; private $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO'); private $attendee_keymap = array('name' => 'CN', 'status' => 'PARTSTAT', 'role' => 'ROLE', 'cutype' => 'CUTYPE', 'rsvp' => 'RSVP', 'delegated-from' => 'DELEGATED-FROM', 'delegated-to' => 'DELEGATED-TO'); private $iteratorkey = 0; private $charset; private $forward_exceptions; private $vhead; private $fp; private $vtimezones = array(); public $method; public $agent = ''; public $objects = array(); public $freebusy = array(); /** * Default constructor */ function __construct($tz = null) { $this->timezone = $tz; $this->prodid = '-//Roundcube libcalendaring ' . RCUBE_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; } /** * Setter for timezone information */ public function set_timezone($tz) { $this->timezone = $tz; } /** * Setter for URI template for attachment links */ public function set_attach_uri($uri) { $this->attach_uri = $uri; } /** * Setter for a custom PRODID attribute */ public function set_prodid($prodid) { $this->prodid = $prodid; } /** * Setter for a user-agent string to tweak input/output accordingly */ public function set_agent($agent) { $this->agent = $agent; } /** * Free resources by clearing member vars */ public function reset() { $this->vhead = ''; $this->method = ''; $this->objects = array(); $this->freebusy = array(); $this->vtimezones = array(); $this->iteratorkey = 0; if ($this->fp) { fclose($this->fp); $this->fp = null; } } /** * Import events from iCalendar format * * @param string vCalendar input * @param string Input charset (from envelope) * @param boolean True if parsing exceptions should be forwarded to the caller * @return array List of events extracted from the input */ public function import($vcal, $charset = 'UTF-8', $forward_exceptions = false, $memcheck = true) { // TODO: convert charset to UTF-8 if other try { // estimate the memory usage and try to avoid fatal errors when allowed memory gets exhausted if ($memcheck) { $count = substr_count($vcal, 'BEGIN:VEVENT') + substr_count($vcal, 'BEGIN:VTODO'); $expected_memory = $count * 70*1024; // assume ~ 70K per event (empirically determined) if (!rcube_utils::mem_check($expected_memory)) { throw new Exception("iCal file too big"); } } $vobject = VObject\Reader::read($vcal, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); if ($vobject) return $this->import_from_vobject($vobject); } catch (Exception $e) { if ($forward_exceptions) { throw $e; } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "iCal data parse error: " . $e->getMessage()), true, false); } } return array(); } /** * Read iCalendar events from a file * * @param string File path to read from * @param string Input charset (from envelope) * @param boolean True if parsing exceptions should be forwarded to the caller * @return array List of events extracted from the file */ public function import_from_file($filepath, $charset = 'UTF-8', $forward_exceptions = false) { if ($this->fopen($filepath, $charset, $forward_exceptions)) { while ($this->_parse_next(false)) { // nop } fclose($this->fp); $this->fp = null; } return $this->objects; } /** * Open a file to read iCalendar events sequentially * * @param string File path to read from * @param string Input charset (from envelope) * @param boolean True if parsing exceptions should be forwarded to the caller * @return boolean True if file contents are considered valid */ public function fopen($filepath, $charset = 'UTF-8', $forward_exceptions = false) { $this->reset(); // just to be sure... @ini_set('auto_detect_line_endings', true); $this->charset = $charset; $this->forward_exceptions = $forward_exceptions; $this->fp = fopen($filepath, 'r'); // check file content first $begin = fread($this->fp, 1024); if (!preg_match('/BEGIN:VCALENDAR/i', $begin)) { return false; } fseek($this->fp, 0); return $this->_parse_next(); } /** * Parse the next event/todo/freebusy object from the input file */ private function _parse_next($reset = true) { if ($reset) { $this->iteratorkey = 0; $this->objects = array(); $this->freebusy = array(); } $next = $this->_next_component(); $buffer = $next; // load the next component(s) too, as they could contain recurrence exceptions while (preg_match('/(RRULE|RECURRENCE-ID)[:;]/i', $next)) { $next = $this->_next_component(); $buffer .= $next; } // parse the vevent block surrounded with the vcalendar heading if (strlen($buffer) && preg_match('/BEGIN:(VEVENT|VTODO|VFREEBUSY)/i', $buffer)) { try { $this->import($this->vhead . $buffer . "END:VCALENDAR", $this->charset, true, false); } catch (Exception $e) { if ($this->forward_exceptions) { throw new VObject\ParseException($e->getMessage() . " in\n" . $buffer); } else { // write the failing section to error log rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => $e->getMessage() . " in\n" . $buffer), true, false); } // advance to next return $this->_parse_next($reset); } return count($this->objects) > 0; } return false; } /** * Helper method to read the next calendar component from the file */ private function _next_component() { $buffer = ''; $vcalendar_head = false; while (($line = fgets($this->fp, 1024)) !== false) { // ignore END:VCALENDAR lines if (preg_match('/END:VCALENDAR/i', $line)) { continue; } // read vcalendar header (with timezone defintion) if (preg_match('/BEGIN:VCALENDAR/i', $line)) { $this->vhead = ''; $vcalendar_head = true; } // end of VCALENDAR header part if ($vcalendar_head && preg_match('/BEGIN:(VEVENT|VTODO|VFREEBUSY)/i', $line)) { $vcalendar_head = false; } if ($vcalendar_head) { $this->vhead .= $line; } else { $buffer .= $line; if (preg_match('/END:(VEVENT|VTODO|VFREEBUSY)/i', $line)) { break; } } } return $buffer; } /** * Import objects from an already parsed Sabre\VObject\Component object * * @param object Sabre\VObject\Component to read from * @return array List of events extracted from the file */ public function import_from_vobject($vobject) { $seen = array(); $exceptions = array(); if ($vobject->name == 'VCALENDAR') { $this->method = strval($vobject->METHOD); $this->agent = strval($vobject->PRODID); foreach ($vobject->getBaseComponents() ?: $vobject->getComponents() as $ve) { if ($ve->name == 'VEVENT' || $ve->name == 'VTODO') { // convert to hash array representation $object = $this->_to_array($ve); // temporarily store this as exception if ($object['recurrence_date']) { $exceptions[] = $object; } else if (!$seen[$object['uid']]++) { $this->objects[] = $object; } } else if ($ve->name == 'VFREEBUSY') { $this->objects[] = $this->_parse_freebusy($ve); } } // add exceptions to the according master events foreach ($exceptions as $exception) { $uid = $exception['uid']; // make this exception the master if (!$seen[$uid]++) { $this->objects[] = $exception; } else { foreach ($this->objects as $i => $object) { // add as exception to existing entry with a matching UID if ($object['uid'] == $uid) { $this->objects[$i]['exceptions'][] = $exception; if (!empty($object['recurrence'])) { $this->objects[$i]['recurrence']['EXCEPTIONS'] = &$this->objects[$i]['exceptions']; } break; } } } } } return $this->objects; } /** * Getter for free-busy periods */ public function get_busy_periods() { $out = array(); foreach ((array)$this->freebusy['periods'] as $period) { if ($period[2] != 'FREE') { $out[] = $period; } } return $out; } /** * Helper method to determine whether the connected client is an Apple device */ private function is_apple() { return stripos($this->agent, 'Apple') !== false || stripos($this->agent, 'Mac OS X') !== false || stripos($this->agent, 'iOS/') !== false; } /** * Convert the given VEvent object to a libkolab compatible array representation * * @param object Vevent object to convert * @return array Hash array with object properties */ private function _to_array($ve) { $event = array( 'uid' => self::convert_string($ve->UID), 'title' => self::convert_string($ve->SUMMARY), '_type' => $ve->name == 'VTODO' ? 'task' : 'event', // set defaults 'priority' => 0, 'attendees' => array(), 'x-custom' => array(), ); // Catch possible exceptions when date is invalid (Bug #2144) // We can skip these fields, they aren't critical foreach (array('CREATED' => 'created', 'LAST-MODIFIED' => 'changed', 'DTSTAMP' => 'changed') as $attr => $field) { try { if (!$event[$field] && $ve->{$attr}) { $event[$field] = $ve->{$attr}->getDateTime(); } } catch (Exception $e) {} } // map other attributes to internal fields foreach ($ve->children as $prop) { if (!($prop instanceof VObject\Property)) continue; switch ($prop->name) { case 'DTSTART': case 'DTEND': case 'DUE': $propmap = array('DTSTART' => 'start', 'DTEND' => 'end', 'DUE' => 'due'); $event[$propmap[$prop->name]] = self::convert_datetime($prop); break; case 'TRANSP': $event['free_busy'] = $prop->value == 'TRANSPARENT' ? 'free' : 'busy'; break; case 'STATUS': if ($prop->value == 'TENTATIVE') $event['free_busy'] = 'tentative'; else if ($prop->value == 'CANCELLED') $event['cancelled'] = true; else if ($prop->value == 'COMPLETED') $event['complete'] = 100; $event['status'] = strval($prop->value); break; case 'COMPLETED': if (self::convert_datetime($prop)) { $event['status'] = 'COMPLETED'; $event['complete'] = 100; } break; case 'PRIORITY': if (is_numeric($prop->value)) $event['priority'] = $prop->value; break; case 'RRULE': $params = is_array($event['recurrence']) ? $event['recurrence'] : array(); // parse recurrence rule attributes foreach (explode(';', $prop->value) as $par) { list($k, $v) = explode('=', $par); $params[$k] = $v; } if ($params['UNTIL']) $params['UNTIL'] = date_create($params['UNTIL']); if (!$params['INTERVAL']) $params['INTERVAL'] = 1; $event['recurrence'] = array_filter($params); break; case 'EXDATE': if (!empty($prop->value)) $event['recurrence']['EXDATE'] = array_merge((array)$event['recurrence']['EXDATE'], self::convert_datetime($prop, true)); break; case 'RDATE': if (!empty($prop->value)) $event['recurrence']['RDATE'] = array_merge((array)$event['recurrence']['RDATE'], self::convert_datetime($prop, true)); break; case 'RECURRENCE-ID': $event['recurrence_date'] = self::convert_datetime($prop); if ($prop->offsetGet('RANGE') == 'THISANDFUTURE' || $prop->offsetGet('THISANDFUTURE') !== null) { $event['thisandfuture'] = true; } break; case 'RELATED-TO': $reltype = $prop->offsetGet('RELTYPE'); if ($reltype == 'PARENT' || $reltype === null) { $event['parent_id'] = $prop->value; } break; case 'SEQUENCE': $event['sequence'] = intval($prop->value); break; case 'PERCENT-COMPLETE': $event['complete'] = intval($prop->value); break; case 'LOCATION': case 'DESCRIPTION': case 'URL': case 'COMMENT': $event[strtolower($prop->name)] = self::convert_string($prop); break; case 'CATEGORY': case 'CATEGORIES': $event['categories'] = array_merge((array)$event['categories'], $prop->getParts()); break; case 'CLASS': case 'X-CALENDARSERVER-ACCESS': $event['sensitivity'] = strtolower($prop->value); break; case 'X-MICROSOFT-CDO-BUSYSTATUS': if ($prop->value == 'OOF') $event['free_busy'] = 'outofoffice'; else if (in_array($prop->value, array('FREE', 'BUSY', 'TENTATIVE'))) $event['free_busy'] = strtolower($prop->value); break; case 'ATTENDEE': case 'ORGANIZER': $params = array('rsvp' => false); foreach ($prop->parameters as $param) { switch ($param->name) { case 'RSVP': $params[$param->name] = strtolower($param->value) == 'true'; break; default: $params[$param->name] = $param->value; break; } } $attendee = self::map_keys($params, array_flip($this->attendee_keymap)); $attendee['email'] = preg_replace('/^mailto:/i', '', $prop->value); if ($prop->name == 'ORGANIZER') { $attendee['role'] = 'ORGANIZER'; $attendee['status'] = 'ACCEPTED'; $event['organizer'] = $attendee; } else if ($attendee['email'] != $event['organizer']['email']) { $event['attendees'][] = $attendee; } break; case 'ATTACH': $params = self::parameters_array($prop); if (substr($prop->value, 0, 4) == 'http' && !strpos($prop->value, ':attachment:')) { $event['links'][] = $prop->value; } else if (strlen($prop->value) && strtoupper($params['VALUE']) == 'BINARY') { $attachment = self::map_keys($params, array('FMTTYPE' => 'mimetype', 'X-LABEL' => 'name')); $attachment['data'] = base64_decode($prop->value); $attachment['size'] = strlen($attachment['data']); $event['attachments'][] = $attachment; } break; default: if (substr($prop->name, 0, 2) == 'X-') $event['x-custom'][] = array($prop->name, strval($prop->value)); break; } } // check DURATION property if no end date is set if (empty($event['end']) && $ve->DURATION) { try { $duration = new DateInterval(strval($ve->DURATION)); $end = clone $event['start']; $end->add($duration); $event['end'] = $end; } catch (\Exception $e) { trigger_error(strval($e), E_USER_WARNING); } } // validate event dates if ($event['_type'] == 'event') { // check for all-day dates if ($event['start']->_dateonly) { $event['allday'] = true; } // all-day events may lack the DTEND property if ($event['allday'] && empty($event['end'])) { $event['end'] = clone $event['start']; } // shift end-date by one day (except Thunderbird) else if ($event['allday'] && is_object($event['end'])) { $event['end']->sub(new \DateInterval('PT23H')); } // sanity-check and fix end date if (!empty($event['end']) && $event['end'] < $event['start']) { $event['end'] = clone $event['start']; } } // make organizer part of the attendees list for compatibility reasons if (!empty($event['organizer']) && is_array($event['attendees']) && $event['_type'] == 'event') { array_unshift($event['attendees'], $event['organizer']); } // find alarms foreach ($ve->select('VALARM') as $valarm) { - $action = 'DISPLAY'; + $action = 'DISPLAY'; $trigger = null; - $alarm = array(); + $alarm = array(); foreach ($valarm->children as $prop) { switch ($prop->name) { case 'TRIGGER': foreach ($prop->parameters as $param) { if ($param->name == 'VALUE' && $param->value == 'DATE-TIME') { $trigger = '@' . $prop->getDateTime()->format('U'); $alarm['trigger'] = $prop->getDateTime(); } + else if ($param->name == 'RELATED') { + $alarm['related'] = $param->value; + } } if (!$trigger && ($values = libcalendaring::parse_alarm_value($prop->value))) { $trigger = $values[2]; } if (!$alarm['trigger']) { $alarm['trigger'] = rtrim(preg_replace('/([A-Z])0[WDHMS]/', '\\1', $prop->value), 'T'); // if all 0-values have been stripped, assume 'at time' if ($alarm['trigger'] == 'P') $alarm['trigger'] = 'PT0S'; } break; case 'ACTION': $action = $alarm['action'] = strtoupper($prop->value); break; case 'SUMMARY': case 'DESCRIPTION': case 'DURATION': $alarm[strtolower($prop->name)] = self::convert_string($prop); break; case 'REPEAT': $alarm['repeat'] = intval($prop->value); break; case 'ATTENDEE': $alarm['attendees'][] = preg_replace('/^mailto:/i', '', $prop->value); break; case 'ATTACH': $params = self::parameters_array($prop); if (strlen($prop->value) && (preg_match('/^[a-z]+:/', $prop->value) || strtoupper($params['VALUE']) == 'URI')) { // we only support URI-type of attachments here $alarm['uri'] = $prop->value; } break; } } if ($action != 'NONE') { if ($trigger && !$event['alarms']) // store first alarm in legacy property $event['alarms'] = $trigger . ':' . $action; if ($alarm['trigger']) $event['valarms'][] = $alarm; } } // assign current timezone to event start/end if ($event['start'] instanceof DateTime) { if ($this->timezone) $event['start']->setTimezone($this->timezone); } else { unset($event['start']); } if ($event['end'] instanceof DateTime) { if ($this->timezone) $event['end']->setTimezone($this->timezone); } else { unset($event['end']); } // minimal validation if (empty($event['uid']) || ($event['_type'] == 'event' && empty($event['start']) != empty($event['end']))) { throw new VObject\ParseException('Object validation failed: missing mandatory object properties'); } return $event; } /** * Parse the given vfreebusy component into an array representation */ private function _parse_freebusy($ve) { $this->freebusy = array('_type' => 'freebusy', 'periods' => array()); $seen = array(); foreach ($ve->children as $prop) { if (!($prop instanceof VObject\Property)) continue; switch ($prop->name) { case 'CREATED': case 'LAST-MODIFIED': case 'DTSTAMP': case 'DTSTART': case 'DTEND': $propmap = array('DTSTART' => 'start', 'DTEND' => 'end', 'CREATED' => 'created', 'LAST-MODIFIED' => 'changed', 'DTSTAMP' => 'changed'); $this->freebusy[$propmap[$prop->name]] = self::convert_datetime($prop); break; case 'ORGANIZER': $this->freebusy['organizer'] = preg_replace('/^mailto:/i', '', $prop->value); break; case 'FREEBUSY': // The freebusy component can hold more than 1 value, separated by commas. $periods = explode(',', $prop->value); $fbtype = strval($prop['FBTYPE']) ?: 'BUSY'; // skip dupes if ($seen[$prop->value.':'.$fbtype]++) continue; foreach ($periods as $period) { // Every period is formatted as [start]/[end]. The start is an // absolute UTC time, the end may be an absolute UTC time, or // duration (relative) value. list($busyStart, $busyEnd) = explode('/', $period); $busyStart = VObject\DateTimeParser::parse($busyStart); $busyEnd = VObject\DateTimeParser::parse($busyEnd); if ($busyEnd instanceof \DateInterval) { $tmp = clone $busyStart; $tmp->add($busyEnd); $busyEnd = $tmp; } if ($busyEnd && $busyEnd > $busyStart) $this->freebusy['periods'][] = array($busyStart, $busyEnd, $fbtype); } break; case 'COMMENT': $this->freebusy['comment'] = $prop->value; } } return $this->freebusy; } /** * */ public static function convert_string($prop) { return str_replace('\,', ',', strval($prop->value)); } /** * Helper method to correctly interpret an all-day date value */ public static function convert_datetime($prop, $as_array = false) { if (empty($prop)) { return $as_array ? array() : null; } else if ($prop instanceof VObject\Property\MultiDateTime) { $dt = array(); $dateonly = ($prop->getDateType() & VObject\Property\DateTime::DATE); foreach ($prop->getDateTimes() as $item) { $item->_dateonly = $dateonly; $dt[] = $item; } } else if ($prop instanceof VObject\Property\DateTime) { $dt = $prop->getDateTime(); if ($prop->getDateType() & VObject\Property\DateTime::DATE) { $dt->_dateonly = true; } } else if ($prop instanceof VObject\Property && ($prop['VALUE'] == 'DATE' || $prop['VALUE'] == 'DATE-TIME')) { try { list($type, $dt) = VObject\Property\DateTime::parseData($prop->value, $prop); $dt->_dateonly = ($type & VObject\Property\DateTime::DATE); } catch (Exception $e) { // ignore date parse errors } } else if ($prop instanceof VObject\Property && $prop['VALUE'] == 'PERIOD') { $dt = array(); foreach(explode(',', $prop->value) as $val) { try { list($start, $end) = explode('/', $val); list($type, $item) = VObject\Property\DateTime::parseData($start, $prop); $item->_dateonly = ($type & VObject\Property\DateTime::DATE); $dt[] = $item; } catch (Exception $e) { // ignore single date parse errors } } } else if ($prop instanceof DateTime) { $dt = $prop; } // force return value to array if requested if ($as_array && !is_array($dt)) { $dt = empty($dt) ? array() : array($dt); } return $dt; } /** * Create a Sabre\VObject\Property instance from a PHP DateTime object * * @param string Property name * @param object DateTime */ public function datetime_prop($name, $dt, $utc = false, $dateonly = null) { $is_utc = $utc || (($tz = $dt->getTimezone()) && in_array($tz->getName(), array('UTC','GMT','Z'))); $is_dateonly = $dateonly === null ? (bool)$dt->_dateonly : (bool)$dateonly; $vdt = new VObject\Property\DateTime($name); $vdt->setDateTime($dt, $is_dateonly ? VObject\Property\DateTime::DATE : ($is_utc ? VObject\Property\DateTime::UTC : VObject\Property\DateTime::LOCALTZ)); // register timezone for VTIMEZONE block if (!$is_utc && !$dateonly && $tz && ($tzname = $tz->getName())) { $ts = $dt->format('U'); if (is_array($this->vtimezones[$tzname])) { $this->vtimezones[$tzname][0] = min($this->vtimezones[$tzname][0], $ts); $this->vtimezones[$tzname][1] = max($this->vtimezones[$tzname][1], $ts); } else { $this->vtimezones[$tzname] = array($ts, $ts); } } return $vdt; } /** * Copy values from one hash array to another using a key-map */ public static function map_keys($values, $map) { $out = array(); foreach ($map as $from => $to) { if (isset($values[$from])) $out[$to] = is_array($values[$from]) ? join(',', $values[$from]) : $values[$from]; } return $out; } /** * */ private static function parameters_array($prop) { $params = array(); foreach ($prop->parameters as $param) { $params[strtoupper($param->name)] = $param->value; } return $params; } /** * Export events to iCalendar format * * @param array Events as array * @param string VCalendar method to advertise * @param boolean Directly send data to stdout instead of returning * @param callable Callback function to fetch attachment contents, false if no attachment export * @param boolean Add VTIMEZONE block with timezone definitions for the included events * @return string Events in iCalendar format (http://tools.ietf.org/html/rfc5545) */ public function export($objects, $method = null, $write = false, $get_attachment = false, $with_timezones = true) { $this->method = $method; // encapsulate in VCALENDAR container $vcal = VObject\Component::create('VCALENDAR'); $vcal->version = '2.0'; $vcal->prodid = $this->prodid; $vcal->calscale = 'GREGORIAN'; if (!empty($method)) { $vcal->METHOD = $method; } // write vcalendar header if ($write) { echo preg_replace('/END:VCALENDAR[\r\n]*$/m', '', $vcal->serialize()); } foreach ($objects as $object) { $this->_to_ical($object, !$write?$vcal:false, $get_attachment); } // include timezone information if ($with_timezones || !empty($method)) { foreach ($this->vtimezones as $tzid => $range) { $vt = self::get_vtimezone($tzid, $range[0], $range[1]); if (empty($vt)) { continue; // no timezone information found } if ($write) { echo $vt->serialize(); } else { $vcal->add($vt); } } } if ($write) { echo "END:VCALENDAR\r\n"; return true; } else { return $vcal->serialize(); } } /** * Build a valid iCal format block from the given event * * @param array Hash array with event/task properties from libkolab * @param object VCalendar object to append event to or false for directly sending data to stdout * @param callable Callback function to fetch attachment contents, false if no attachment export * @param object RECURRENCE-ID property when serializing a recurrence exception */ private function _to_ical($event, $vcal, $get_attachment, $recurrence_id = null) { $type = $event['_type'] ?: 'event'; $ve = VObject\Component::create($this->type_component_map[$type]); $ve->add('UID', $event['uid']); // set DTSTAMP according to RFC 5545, 3.8.7.2. $dtstamp = !empty($event['changed']) && !empty($this->method) ? $event['changed'] : new DateTime(); $ve->add($this->datetime_prop('DTSTAMP', $dtstamp, true)); // all-day events end the next day if ($event['allday'] && !empty($event['end'])) { $event['end'] = clone $event['end']; $event['end']->add(new \DateInterval('P1D')); $event['end']->_dateonly = true; } if (!empty($event['created'])) $ve->add($this->datetime_prop('CREATED', $event['created'], true)); if (!empty($event['changed'])) $ve->add($this->datetime_prop('LAST-MODIFIED', $event['changed'], true)); if (!empty($event['start'])) $ve->add($this->datetime_prop('DTSTART', $event['start'], false, (bool)$event['allday'])); if (!empty($event['end'])) $ve->add($this->datetime_prop('DTEND', $event['end'], false, (bool)$event['allday'])); if (!empty($event['due'])) $ve->add($this->datetime_prop('DUE', $event['due'], false)); // we're exporting a recurrence instance only if (!$recurrence_id && $event['recurrence_date'] && $event['recurrence_date'] instanceof DateTime) { $recurrence_id = $this->datetime_prop('RECURRENCE-ID', $event['recurrence_date'], false, (bool)$event['allday']); if ($event['thisandfuture']) $recurrence_id->add('RANGE', 'THISANDFUTURE'); } if ($recurrence_id) $ve->add($recurrence_id); $ve->add('SUMMARY', $event['title']); if ($event['location']) $ve->add($this->is_apple() ? new vobject_location_property('LOCATION', $event['location']) : new VObject\Property('LOCATION', $event['location'])); if ($event['description']) $ve->add('DESCRIPTION', strtr($event['description'], array("\r\n" => "\n", "\r" => "\n"))); // normalize line endings if (isset($event['sequence'])) $ve->add('SEQUENCE', $event['sequence']); if ($event['recurrence'] && !$recurrence_id) { $exdates = $rdates = null; if (isset($event['recurrence']['EXDATE'])) { $exdates = $event['recurrence']['EXDATE']; unset($event['recurrence']['EXDATE']); // don't serialize EXDATEs into RRULE value } if (isset($event['recurrence']['RDATE'])) { $rdates = $event['recurrence']['RDATE']; unset($event['recurrence']['RDATE']); // don't serialize RDATEs into RRULE value } if ($event['recurrence']['FREQ']) { $ve->add('RRULE', libcalendaring::to_rrule($event['recurrence'], (bool)$event['allday'])); } // add EXDATEs each one per line (for Thunderbird Lightning) if (is_array($exdates)) { foreach ($exdates as $ex) { if ($ex instanceof \DateTime) { $exd = clone $event['start']; $exd->setDate($ex->format('Y'), $ex->format('n'), $ex->format('j')); $exd->setTimeZone(new \DateTimeZone('UTC')); $ve->add(new VObject\Property('EXDATE', $exd->format('Ymd\\THis\\Z'))); } } } // add RDATEs if (is_array($rdates) && !empty($rdates)) { $sample = $this->datetime_prop('RDATE', $rdates[0]); $rdprop = new VObject\Property\MultiDateTime('RDATE', null); $rdprop->setDateTimes($rdates, $sample->getDateType()); $ve->add($rdprop); } } if ($event['categories']) { $cat = VObject\Property::create('CATEGORIES'); $cat->setParts((array)$event['categories']); $ve->add($cat); } if (!empty($event['free_busy'])) { $ve->add('TRANSP', $event['free_busy'] == 'free' ? 'TRANSPARENT' : 'OPAQUE'); // for Outlook clients we provide the X-MICROSOFT-CDO-BUSYSTATUS property if (stripos($this->agent, 'outlook') !== false) { $ve->add('X-MICROSOFT-CDO-BUSYSTATUS', $event['free_busy'] == 'outofoffice' ? 'OOF' : strtoupper($event['free_busy'])); } } if ($event['priority']) $ve->add('PRIORITY', $event['priority']); if ($event['cancelled']) $ve->add('STATUS', 'CANCELLED'); else if ($event['free_busy'] == 'tentative') $ve->add('STATUS', 'TENTATIVE'); else if ($event['complete'] == 100) $ve->add('STATUS', 'COMPLETED'); else if (!empty($event['status'])) $ve->add('STATUS', $event['status']); if (!empty($event['sensitivity'])) $ve->add('CLASS', strtoupper($event['sensitivity'])); if (!empty($event['complete'])) { $ve->add('PERCENT-COMPLETE', intval($event['complete'])); } // Apple iCal and BusyCal required the COMPLETED date to be set in order to consider a task complete if ($event['status'] == 'COMPLETED' || $event['complete'] == 100) { $ve->add($this->datetime_prop('COMPLETED', $event['changed'] ?: new DateTime('now - 1 hour'), true)); } if ($event['valarms']) { foreach ($event['valarms'] as $alarm) { $va = VObject\Component::create('VALARM'); $va->action = $alarm['action']; if ($alarm['trigger'] instanceof DateTime) { $va->add($this->datetime_prop('TRIGGER', $alarm['trigger'], true)); } else { - $va->add('TRIGGER', $alarm['trigger']); + $alarm_props = array(); + if (strtoupper($alarm['related']) == 'END') { + $alarm_props['RELATED'] = 'END'; + } + $va->add('TRIGGER', $alarm['trigger'], $alarm_props); } if ($alarm['action'] == 'EMAIL') { foreach ((array)$alarm['attendees'] as $attendee) { $va->add('ATTENDEE', 'mailto:' . $attendee); } } if ($alarm['description']) { $va->add('DESCRIPTION', $alarm['description'] ?: $event['title']); } if ($alarm['summary']) { $va->add('SUMMARY', $alarm['summary']); } if ($alarm['duration']) { $va->add('DURATION', $alarm['duration']); $va->add('REPEAT', intval($alarm['repeat'])); } if ($alarm['uri']) { $va->add('ATTACH', $alarm['uri'], array('VALUE' => 'URI')); } $ve->add($va); } } // legacy support else if ($event['alarms']) { $va = VObject\Component::create('VALARM'); list($trigger, $va->action) = explode(':', $event['alarms']); $val = libcalendaring::parse_alarm_value($trigger); if ($val[3]) $va->add('TRIGGER', $val[3]); else if ($val[0] instanceof DateTime) $va->add($this->datetime_prop('TRIGGER', $val[0])); $ve->add($va); } foreach ((array)$event['attendees'] as $attendee) { if ($attendee['role'] == 'ORGANIZER') { if (empty($event['organizer'])) $event['organizer'] = $attendee; } else if (!empty($attendee['email'])) { if (isset($attendee['rsvp'])) $attendee['rsvp'] = $attendee['rsvp'] ? 'TRUE' : null; $ve->add('ATTENDEE', 'mailto:' . $attendee['email'], array_filter(self::map_keys($attendee, $this->attendee_keymap))); } } if ($event['organizer']) { $ve->add('ORGANIZER', 'mailto:' . $event['organizer']['email'], self::map_keys($event['organizer'], array('name' => 'CN'))); } foreach ((array)$event['url'] as $url) { if (!empty($url)) { $ve->add('URL', $url); } } if (!empty($event['parent_id'])) { $ve->add('RELATED-TO', $event['parent_id'], array('RELTYPE' => 'PARENT')); } if ($event['comment']) $ve->add('COMMENT', $event['comment']); $memory_limit = parse_bytes(ini_get('memory_limit')); // export attachments if (!empty($event['attachments'])) { foreach ((array)$event['attachments'] as $attach) { // check available memory and skip attachment export if we can't buffer it // @todo: use rcube_utils::mem_check() if (is_callable($get_attachment) && $memory_limit > 0 && ($memory_used = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024) && $attach['size'] && $memory_used + $attach['size'] * 3 > $memory_limit) { continue; } // embed attachments using the given callback function if (is_callable($get_attachment) && ($data = call_user_func($get_attachment, $attach['id'], $event))) { // embed attachments for iCal $ve->add('ATTACH', base64_encode($data), array_filter(array('VALUE' => 'BINARY', 'ENCODING' => 'BASE64', 'FMTTYPE' => $attach['mimetype'], 'X-LABEL' => $attach['name']))); unset($data); // attempt to free memory } // list attachments as absolute URIs else if (!empty($this->attach_uri)) { $ve->add('ATTACH', strtr($this->attach_uri, array( '{{id}}' => urlencode($attach['id']), '{{name}}' => urlencode($attach['name']), '{{mimetype}}' => urlencode($attach['mimetype']), )), array('FMTTYPE' => $attach['mimetype'], 'VALUE' => 'URI')); } } } foreach ((array)$event['links'] as $uri) { $ve->add('ATTACH', $uri); } // add custom properties foreach ((array)$event['x-custom'] as $prop) { $ve->add($prop[0], $prop[1]); } // append to vcalendar container if ($vcal) { $vcal->add($ve); } else { // serialize and send to stdout echo $ve->serialize(); } // append recurrence exceptions if (is_array($event['recurrence']) && $event['recurrence']['EXCEPTIONS']) { foreach ($event['recurrence']['EXCEPTIONS'] as $ex) { $exdate = $ex['recurrence_date'] ?: $ex['start']; $recurrence_id = $this->datetime_prop('RECURRENCE-ID', $exdate, false, (bool)$event['allday']); if ($ex['thisandfuture']) $recurrence_id->add('RANGE', 'THISANDFUTURE'); $this->_to_ical($ex, $vcal, $get_attachment, $recurrence_id); } } } /** * Returns a VTIMEZONE component for a Olson timezone identifier * with daylight transitions covering the given date range. * * @param string Timezone ID as used in PHP's Date functions * @param integer Unix timestamp with first date/time in this timezone * @param integer Unix timestap with last date/time in this timezone * * @return mixed A Sabre\VObject\Component object representing a VTIMEZONE definition * or false if no timezone information is available */ public static function get_vtimezone($tzid, $from = 0, $to = 0) { if (!$from) $from = time(); if (!$to) $to = $from; if (is_string($tzid)) { try { $tz = new \DateTimeZone($tzid); } catch (\Exception $e) { return false; } } else if (is_a($tzid, '\\DateTimeZone')) { $tz = $tzid; } if (!is_a($tz, '\\DateTimeZone')) { return false; } $year = 86400 * 360; $transitions = $tz->getTransitions($from - $year, $to + $year); $vt = new VObject\Component('VTIMEZONE'); $vt->TZID = $tz->getName(); $std = null; $dst = null; foreach ($transitions as $i => $trans) { $cmp = null; if ($i == 0) { $tzfrom = $trans['offset'] / 3600; continue; } if ($trans['isdst']) { $t_dst = $trans['ts']; $dst = new VObject\Component('DAYLIGHT'); $cmp = $dst; } else { $t_std = $trans['ts']; $std = new VObject\Component('STANDARD'); $cmp = $std; } if ($cmp) { $dt = new DateTime($trans['time']); $offset = $trans['offset'] / 3600; $cmp->DTSTART = $dt->format('Ymd\THis'); $cmp->TZOFFSETFROM = sprintf('%s%02d%02d', $tzfrom >= 0 ? '+' : '', floor($tzfrom), ($tzfrom - floor($tzfrom)) * 60); $cmp->TZOFFSETTO = sprintf('%s%02d%02d', $offset >= 0 ? '+' : '', floor($offset), ($offset - floor($offset)) * 60); if (!empty($trans['abbr'])) { $cmp->TZNAME = $trans['abbr']; } $tzfrom = $offset; $vt->add($cmp); } // we covered the entire date range if ($std && $dst && min($t_std, $t_dst) < $from && max($t_std, $t_dst) > $to) { break; } } // add X-MICROSOFT-CDO-TZID if available $microsoftExchangeMap = array_flip(VObject\TimeZoneUtil::$microsoftExchangeMap); if (array_key_exists($tz->getName(), $microsoftExchangeMap)) { $vt->add('X-MICROSOFT-CDO-TZID', $microsoftExchangeMap[$tz->getName()]); } return $vt; } /*** Implement PHP 5 Iterator interface to make foreach work ***/ function current() { return $this->objects[$this->iteratorkey]; } function key() { return $this->iteratorkey; } function next() { $this->iteratorkey++; // read next chunk if we're reading from a file if (!$this->objects[$this->iteratorkey] && $this->fp) { $this->_parse_next(true); } return $this->valid(); } function rewind() { $this->iteratorkey = 0; } function valid() { return !empty($this->objects[$this->iteratorkey]); } } /** * Override Sabre\VObject\Property that quotes commas in the location property * because Apple clients treat that property as list. */ class vobject_location_property extends VObject\Property { /** * Turns the object back into a serialized blob. * * @return string */ public function serialize() { $str = $this->name; foreach ($this->parameters as $param) { $str.=';' . $param->serialize(); } $src = array( '\\', "\n", ',', ); $out = array( '\\\\', '\n', '\,', ); $str.=':' . str_replace($src, $out, $this->value); $out = ''; while (strlen($str) > 0) { if (strlen($str) > 75) { $out.= mb_strcut($str, 0, 75, 'utf-8') . "\r\n"; $str = ' ' . mb_strcut($str, 75, strlen($str), 'utf-8'); } else { $out.= $str . "\r\n"; $str = ''; break; } } return $out; } } diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc index 00a01f5b..b0a3585e 100644 --- a/plugins/libcalendaring/localization/en_US.inc +++ b/plugins/libcalendaring/localization/en_US.inc @@ -1,166 +1,176 @@ single occurrence out of a series of events'; $labels['itipfutureoccurrence'] = 'Refers to this and all future occurrences of a series of events'; $labels['itipmessagesingleoccurrence'] = 'The message only refers to this single occurrence'; $labels['itipmessagefutureoccurrence'] = 'The message refers to this and all future occurrences'; $labels['youhaveaccepted'] = 'You have accepted this invitation'; $labels['youhavetentative'] = 'You have tentatively accepted this invitation'; $labels['youhavedeclined'] = 'You have declined this invitation'; $labels['youhavedelegated'] = 'You have delegated this invitation'; $labels['youhavein-process'] = 'You are working on this assignment'; $labels['youhavecompleted'] = 'You have completed this assignment'; $labels['youhaveneeds-action'] = 'Your response to this invitation is still pending'; $labels['youhavepreviouslyaccepted'] = 'You have previously accepted this invitation'; $labels['youhavepreviouslytentative'] = 'You have previously accepted this invitation tentatively'; $labels['youhavepreviouslydeclined'] = 'You have previously declined this invitation'; $labels['youhavepreviouslydelegated'] = 'You have previously delegated this invitation'; $labels['youhavepreviouslyin-process'] = 'You have previously reported to work on this assignment'; $labels['youhavepreviouslycompleted'] = 'You have previously completed this assignment'; $labels['youhavepreviouslyneeds-action'] = 'Your response to this invitation is still pending'; $labels['attendeeaccepted'] = 'Participant has accepted'; $labels['attendeetentative'] = 'Participant has tentatively accepted'; $labels['attendeedeclined'] = 'Participant has declined'; $labels['attendeedelegated'] = 'Participant has delegated to $delegatedto'; $labels['attendeein-process'] = 'Participant is in-process'; $labels['attendeecompleted'] = 'Participant has completed'; $labels['notanattendee'] = 'You\'re not listed as an attendee of this object'; $labels['outdatedinvitation'] = 'This invitation has been replaced by a newer version'; $labels['importtocalendar'] = 'Save to my calendar'; $labels['removefromcalendar'] = 'Remove from my calendar'; $labels['updatemycopy'] = 'Update my copy'; $labels['openpreview'] = 'Open Preview'; $labels['deleteobjectconfirm'] = 'Do you really want to delete this object?'; $labels['declinedeleteconfirm'] = 'Do you also want to delete this declined object from your account?'; $labels['delegateinvitation'] = 'Delegate Invitation'; $labels['delegateto'] = 'Delegate to'; $labels['delegatersvpme'] = 'Keep me informed about updates of this incidence'; $labels['delegateinvalidaddress'] = 'Please enter a valid email address for the delegate'; $labels['savingdata'] = 'Saving data...'; // attendees labels $labels['expandattendeegroup'] = 'Substitute with group members'; $labels['expandattendeegroupnodata'] = 'Unable to substitute this group. No members found.'; $labels['expandattendeegrouperror'] = 'Unable to substitute this group. It might contain too many members.'; $labels['expandattendeegroupsizelimit'] = 'This group contains too many members for substituting.'; diff --git a/plugins/libcalendaring/tests/libcalendaring.php b/plugins/libcalendaring/tests/libcalendaring.php index b4373ad4..311d25e2 100644 --- a/plugins/libcalendaring/tests/libcalendaring.php +++ b/plugins/libcalendaring/tests/libcalendaring.php @@ -1,164 +1,184 @@ * * Copyright (C) 2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libcalendaring_test extends PHPUnit_Framework_TestCase { function setUp() { require_once __DIR__ . '/../libcalendaring.php'; } /** * libcalendaring::parse_alarm_value() */ function test_parse_alarm_value() { $alarm = libcalendaring::parse_alarm_value('-15M'); $this->assertEquals('15', $alarm[0]); $this->assertEquals('-M', $alarm[1]); $this->assertEquals('-PT15M', $alarm[3]); $alarm = libcalendaring::parse_alarm_value('-PT5H'); $this->assertEquals('5', $alarm[0]); $this->assertEquals('-H', $alarm[1]); $alarm = libcalendaring::parse_alarm_value('P0DT1H0M0S'); $this->assertEquals('1', $alarm[0]); $this->assertEquals('+H', $alarm[1]); // FIXME: this should return something like (1140 + 120 + 30)M $alarm = libcalendaring::parse_alarm_value('-P1DT2H30M'); // $this->assertEquals('1590', $alarm[0]); // $this->assertEquals('-M', $alarm[1]); $alarm = libcalendaring::parse_alarm_value('@1420722000'); $this->assertInstanceOf('DateTime', $alarm[0]); } /** * libcalendaring::get_next_alarm() */ function test_get_next_alarm() { // alarm 10 minutes before event $date = date('Ymd', strtotime('today + 2 days')); $event = array( 'start' => new DateTime($date . 'T160000Z'), - 'end' => new DateTime($date . 'T180000Z'), + 'end' => new DateTime($date . 'T200000Z'), 'valarms' => array( array( 'trigger' => '-PT10M', 'action' => 'DISPLAY', ), ), ); $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals($event['valarms'][0]['action'], $alarm['action']); $this->assertEquals(strtotime($date . 'T155000Z'), $alarm['time']); - // alarm 1 hour after before event + // alarm 1 hour after event start $event['valarms'] = array( array( 'trigger' => '+PT1H', ), ); $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals('DISPLAY', $alarm['action']); + $this->assertEquals(strtotime($date . 'T170000Z'), $alarm['time']); + + // alarm 1 hour before event end + $event['valarms'] = array( + array( + 'trigger' => '-PT1H', + 'related' => 'END', + ), + ); + $alarm = libcalendaring::get_next_alarm($event); + $this->assertEquals('DISPLAY', $alarm['action']); $this->assertEquals(strtotime($date . 'T190000Z'), $alarm['time']); + // alarm 1 hour after event end + $event['valarms'] = array( + array( + 'trigger' => 'PT1H', + 'related' => 'END', + ), + ); + $alarm = libcalendaring::get_next_alarm($event); + $this->assertEquals('DISPLAY', $alarm['action']); + $this->assertEquals(strtotime($date . 'T210000Z'), $alarm['time']); + // ignore past alarms $event['start'] = new DateTime('today 22:00:00'); $event['end'] = new DateTime('today 23:00:00'); $event['valarms'] = array( array( 'trigger' => '-P2D', 'action' => 'EMAIL', ), array( 'trigger' => '-PT30M', 'action' => 'DISPLAY', ), ); $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals('DISPLAY', $alarm['action']); $this->assertEquals(strtotime('today 21:30:00'), $alarm['time']); // absolute alarm date/time $event['valarms'] = array( array('trigger' => new DateTime('today 20:00:00')) ); $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals($event['valarms'][0]['trigger']->format('U'), $alarm['time']); // no alarms for cancelled events $event['status'] = 'CANCELLED'; $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals(null, $alarm); } /** * libcalendaring::part_is_vcalendar() */ function test_part_is_vcalendar() { $part = new StdClass; $part->mimetype = 'text/plain'; $part->filename = 'event.ics'; $this->assertFalse(libcalendaring::part_is_vcalendar($part)); $part->mimetype = 'text/calendar'; $this->assertTrue(libcalendaring::part_is_vcalendar($part)); $part->mimetype = 'text/x-vcalendar'; $this->assertTrue(libcalendaring::part_is_vcalendar($part)); $part->mimetype = 'application/ics'; $this->assertTrue(libcalendaring::part_is_vcalendar($part)); $part->mimetype = 'application/x-any'; $this->assertTrue(libcalendaring::part_is_vcalendar($part)); } /** * libcalendaring::to_rrule() */ function test_to_rrule() { $rrule = array( 'FREQ' => 'MONTHLY', 'BYDAY' => '2WE', 'INTERVAL' => 2, 'UNTIL' => new DateTime('2025-05-01 18:00:00 CEST'), ); $s = libcalendaring::to_rrule($rrule); $this->assertRegExp('/FREQ='.$rrule['FREQ'].'/', $s, "Recurrence Frequence"); $this->assertRegExp('/INTERVAL='.$rrule['INTERVAL'].'/', $s, "Recurrence Interval"); $this->assertRegExp('/BYDAY='.$rrule['BYDAY'].'/', $s, "Recurrence BYDAY"); $this->assertRegExp('/UNTIL=20250501T160000Z/', $s, "Recurrence End date (in UTC)"); } - } - diff --git a/plugins/libcalendaring/tests/libvcalendar.php b/plugins/libcalendaring/tests/libvcalendar.php index ee9adb05..3d42a7a5 100644 --- a/plugins/libcalendaring/tests/libvcalendar.php +++ b/plugins/libcalendaring/tests/libvcalendar.php @@ -1,569 +1,571 @@ * * Copyright (C) 2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libvcalendar_test extends PHPUnit_Framework_TestCase { function setUp() { require_once __DIR__ . '/../libvcalendar.php'; require_once __DIR__ . '/../libcalendaring.php'; } /** * Simple iCal parsing test */ function test_import() { $ical = new libvcalendar(); $ics = file_get_contents(__DIR__ . '/resources/snd.ics'); $events = $ical->import($ics, 'UTF-8'); $this->assertEquals(1, count($events)); $event = $events[0]; $this->assertInstanceOf('DateTime', $event['created'], "'created' property is DateTime object"); $this->assertInstanceOf('DateTime', $event['changed'], "'changed' property is DateTime object"); $this->assertEquals('UTC', $event['created']->getTimezone()->getName(), "'created' date is in UTC"); $this->assertInstanceOf('DateTime', $event['start'], "'start' property is DateTime object"); $this->assertInstanceOf('DateTime', $event['end'], "'end' property is DateTime object"); $this->assertEquals('08-01', $event['start']->format('m-d'), "Start date is August 1st"); $this->assertTrue($event['allday'], "All-day event flag"); $this->assertEquals('B968B885-08FB-40E5-B89E-6DA05F26AA79', $event['uid'], "Event UID"); $this->assertEquals('Swiss National Day', $event['title'], "Event title"); $this->assertEquals('http://en.wikipedia.org/wiki/Swiss_National_Day', $event['url'], "URL property"); $this->assertEquals(2, $event['sequence'], "Sequence number"); $desclines = explode("\n", $event['description']); $this->assertEquals(4, count($desclines), "Multiline description"); $this->assertEquals("French: Fête nationale Suisse", rtrim($desclines[1]), "UTF-8 encoding"); } /** * Test parsing from files */ function test_import_from_file() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple.ics', 'UTF-8'); $this->assertEquals(2, count($events)); $events = $ical->import_from_file(__DIR__ . '/resources/invalid.txt', 'UTF-8'); $this->assertEmpty($events); } /** * Test parsing from files with multiple VCALENDAR blocks (#2884) */ function test_import_from_file_multiple() { $ical = new libvcalendar(); $ical->fopen(__DIR__ . '/resources/multiple-rdate.ics', 'UTF-8'); $events = array(); foreach ($ical as $event) { $events[] = $event; } $this->assertEquals(2, count($events)); $this->assertEquals("AAAA6A8C3CCE4EE2C1257B5C00FFFFFF-Lotus_Notes_Generated", $events[0]['uid']); $this->assertEquals("AAAA1C572093EC3FC125799C004AFFFF-Lotus_Notes_Generated", $events[1]['uid']); } function test_invalid_dates() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/invalid-dates.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals(1, count($events), "Import event data"); $this->assertFalse(array_key_exists('created', $event), "No created date field"); $this->assertFalse(array_key_exists('changed', $event), "No changed date field"); } function test_invalid_vevent() { $this->setExpectedException('\Sabre\VObject\ParseException'); $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/invalid-event.ics', 'UTF-8', true); } /** * Test some extended ical properties such as attendees, recurrence rules, alarms and attachments */ function test_extended() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/itip.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals('REQUEST', $ical->method, "iTip method"); // attendees $this->assertEquals(2, count($event['attendees']), "Attendees list (including organizer)"); $organizer = $event['attendees'][0]; $this->assertEquals('ORGANIZER', $organizer['role'], 'Organizer ROLE'); $this->assertEquals('Rolf Test', $organizer['name'], 'Organizer name'); $attendee = $event['attendees'][1]; $this->assertEquals('REQ-PARTICIPANT', $attendee['role'], 'Attendee ROLE'); $this->assertEquals('NEEDS-ACTION', $attendee['status'], 'Attendee STATUS'); $this->assertEquals('rolf2@mykolab.com', $attendee['email'], 'Attendee mailto:'); $this->assertTrue($attendee['rsvp'], 'Attendee RSVP'); // attachments $this->assertEquals(1, count($event['attachments']), "Embedded attachments"); $attachment = $event['attachments'][0]; $this->assertEquals('text/html', $attachment['mimetype'], "Attachment mimetype attribute"); $this->assertEquals('calendar.html', $attachment['name'], "Attachment filename (X-LABEL) attribute"); $this->assertContains('Kalender', $attachment['data'], "Attachment content (decoded)"); // recurrence rules $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); $event = $events[0]; $this->assertTrue(is_array($event['recurrence']), 'Recurrences rule as hash array'); $rrule = $event['recurrence']; $this->assertEquals('MONTHLY', $rrule['FREQ'], "Recurrence frequency"); $this->assertEquals('1', $rrule['INTERVAL'], "Recurrence interval"); $this->assertEquals('3WE', $rrule['BYDAY'], "Recurrence frequency"); $this->assertInstanceOf('DateTime', $rrule['UNTIL'], "Recurrence end date"); $this->assertEquals(2, count($rrule['EXDATE']), "Recurrence EXDATEs"); $this->assertInstanceOf('DateTime', $rrule['EXDATE'][0], "Recurrence EXDATE as DateTime"); // categories, class $this->assertEquals('libcalendaring tests', join(',', (array)$event['categories']), "Event categories"); $this->assertEquals('confidential', $event['sensitivity'], "Class/sensitivity = confidential"); // parse a recurrence chain instance $events = $ical->import_from_file(__DIR__ . '/resources/recurrence-id.ics', 'UTF-8'); $this->assertEquals(1, count($events), "Fall back to Component::getComponents() when getBaseComponents() is empty"); $this->assertInstanceOf('DateTime', $events[0]['recurrence_date'], "Recurrence-ID as date"); $this->assertTrue($events[0]['thisandfuture'], "Range=THISANDFUTURE"); $this->assertEquals(count($events[0]['exceptions']), 1, "Second VEVENT as exception"); $this->assertEquals($events[0]['exceptions'][0]['uid'], $events[0]['uid'], "Exception UID match"); $this->assertEquals($events[0]['exceptions'][0]['sequence'], '2', "Exception sequence"); } /** * */ function test_alarms() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals('-12H:DISPLAY', $event['alarms'], "Serialized alarms string"); $alarm = libcalendaring::parse_alarm_value($event['alarms']); $this->assertEquals('12', $alarm[0], "Alarm value"); $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('END', $event['valarms'][0]['related'], "Full alarm item (related)"); // alarm trigger with 0 values $events = $ical->import_from_file(__DIR__ . '/resources/alarms.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals('-30M:DISPLAY', $event['alarms'], "Stripped alarm string"); $alarm = libcalendaring::parse_alarm_value($event['alarms']); $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('', $event['valarms'][0]['related'], "First alarm related property"); $this->assertEquals('This is the first event reminder', $event['valarms'][0]['description'], "First alarm text"); $this->assertEquals(2, count($event['valarms']), "List all VALARM blocks"); $valarm = $event['valarms'][1]; $this->assertEquals(1, count($valarm['attendees']), "Email alarm attendees"); $this->assertEquals('EMAIL', $valarm['action'], "Second alarm item (action)"); $this->assertEquals('-P1D', $valarm['trigger'], "Second alarm item (trigger)"); $this->assertEquals('This is the reminder message', $valarm['summary'], "Email alarm text"); // test alarms export $ics = $ical->export(array($event)); $this->assertContains('ACTION:DISPLAY', $ics, "Display alarm block"); $this->assertContains('ACTION:EMAIL', $ics, "Email alarm block"); $this->assertContains('DESCRIPTION:This is the first event reminder', $ics, "Alarm description"); $this->assertContains('SUMMARY:This is the reminder message', $ics, "Email alarm summary"); $this->assertContains('ATTENDEE:mailto:reminder-recipient@example.org', $ics, "Email alarm recipient"); } /** * @depends test_import_from_file */ function test_attachment() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/attachment.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals(2, count($events)); $this->assertEquals(1, count($event['attachments'])); $this->assertEquals('image/png', $event['attachments'][0]['mimetype']); $this->assertEquals('500px-Opensource.svg.png', $event['attachments'][0]['name']); } /** * @depends test_import */ function test_apple_alarms() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/apple-alarms.ics', 'UTF-8'); $event = $events[0]; // alarms $this->assertEquals('-45M:AUDIO', $event['alarms'], "Relative alarm string"); $alarm = libcalendaring::parse_alarm_value($event['alarms']); $this->assertEquals('45', $alarm[0], "Alarm value"); $this->assertEquals('-M', $alarm[1], "Alarm unit"); $this->assertEquals(1, count($event['valarms']), "Ignore invalid alarm blocks"); $this->assertEquals('AUDIO', $event['valarms'][0]['action'], "Full alarm item (action)"); $this->assertEquals('-PT45M', $event['valarms'][0]['trigger'], "Full alarm item (trigger)"); $this->assertEquals('Basso', $event['valarms'][0]['uri'], "Full alarm item (attachment)"); } /** * */ function test_escaped_values() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/escaped.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals("House, Street, Zip Place", $event['location'], "Decode escaped commas in location value"); $this->assertEquals("Me, meets Them\nThem, meet Me", $event['description'], "Decode description value"); $this->assertEquals("Kolab, Thomas", $event['attendees'][3]['name'], "Unescaped"); $ics = $ical->export($events); $this->assertContains('ATTENDEE;CN="Kolab, Thomas";PARTSTAT=', $ics, "Quoted attendee parameters"); } /** * Parse RDATE properties (#2885) */ function test_rdate() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple-rdate.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals(9, count($event['recurrence']['RDATE'])); $this->assertInstanceOf('DateTime', $event['recurrence']['RDATE'][0]); } /** * @depends test_import */ function test_freebusy() { $ical = new libvcalendar(); $ical->import_from_file(__DIR__ . '/resources/freebusy.ifb', 'UTF-8'); $freebusy = $ical->freebusy; $this->assertInstanceOf('DateTime', $freebusy['start'], "'start' property is DateTime object"); $this->assertInstanceOf('DateTime', $freebusy['end'], "'end' property is DateTime object"); $this->assertEquals(11, count($freebusy['periods']), "Number of freebusy periods defined"); $this->assertEquals(9, count($ical->get_busy_periods()), "Number of busy periods found"); } /** * @depends test_import */ function test_freebusy_dummy() { $ical = new libvcalendar(); $ical->import_from_file(__DIR__ . '/resources/dummy.ifb', 'UTF-8'); $freebusy = $ical->freebusy; $this->assertEquals(0, count($freebusy['periods']), "Ignore 0-length freebudy periods"); $this->assertContains('dummy', $freebusy['comment'], "Parse comment"); } function test_vtodo() { $ical = new libvcalendar(); $tasks = $ical->import_from_file(__DIR__ . '/resources/vtodo.ics', 'UTF-8', true); $task = $tasks[0]; $this->assertInstanceOf('DateTime', $task['start'], "'start' property is DateTime object"); $this->assertInstanceOf('DateTime', $task['due'], "'due' property is DateTime object"); $this->assertEquals('-1D:DISPLAY', $task['alarms'], "Taks alarm value"); $this->assertEquals('IN-PROCESS', $task['status'], "Task status property"); $this->assertEquals(1, count($task['x-custom']), "Custom properties"); $this->assertEquals(4, count($task['categories'])); $this->assertEquals('1234567890-12345678-PARENT', $task['parent_id'], "Parent Relation"); $completed = $tasks[1]; $this->assertEquals('COMPLETED', $completed['status'], "Task status=completed when COMPLETED property is present"); $this->assertEquals(100, $completed['complete'], "Task percent complete value"); $ics = $ical->export(array($completed)); - $this->assertRegExp('/COMPLETED:[0-9TZ]+/', $ics, "Export COMPLETED property"); + $this->assertRegExp('/COMPLETED(;VALUE=DATE-TIME)?:[0-9TZ]+/', $ics, "Export COMPLETED property"); } /** * Test for iCal export from internal hash array representation * * */ function test_export() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/itip.ics', 'UTF-8'); $event = $events[0]; $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); $event += $events[0]; $this->attachment_data = $event['attachments'][0]['data']; unset($event['attachments'][0]['data']); $event['attachments'][0]['id'] = '1'; $event['description'] = '*Exported by libvcalendar*'; $event['start']->setTimezone(new DateTimezone('Europe/Berlin')); $event['end']->setTimezone(new DateTimezone('Europe/Berlin')); $ics = $ical->export(array($event), 'REQUEST', false, array($this, 'get_attachment_data'), true); $this->assertContains('BEGIN:VCALENDAR', $ics, "VCALENDAR encapsulation BEGIN"); $this->assertContains('BEGIN:VTIMEZONE', $ics, "VTIMEZONE encapsulation BEGIN"); $this->assertContains('TZID:Europe/Berlin', $ics, "Timezone ID"); $this->assertContains('TZOFFSETFROM:+0100', $ics, "Timzone transition FROM"); $this->assertContains('TZOFFSETTO:+0200', $ics, "Timzone transition TO"); $this->assertContains('END:VTIMEZONE', $ics, "VTIMEZONE encapsulation END"); $this->assertContains('BEGIN:VEVENT', $ics, "VEVENT encapsulation BEGIN"); $this->assertContains('UID:ac6b0aee-2519-4e5c-9a25-48c57064c9f0', $ics, "Event UID"); $this->assertContains('SEQUENCE:' . $event['sequence'], $ics, "Export Sequence number"); $this->assertContains('CLASS:CONFIDENTIAL', $ics, "Sensitivity => Class"); $this->assertContains('DESCRIPTION:*Exported by', $ics, "Export Description"); $this->assertContains('ORGANIZER;CN=Rolf Test:mailto:rolf@', $ics, "Export organizer"); $this->assertRegExp('/ATTENDEE.*;ROLE=REQ-PARTICIPANT/', $ics, "Export Attendee ROLE"); $this->assertRegExp('/ATTENDEE.*;PARTSTAT=NEEDS-ACTION/', $ics, "Export Attendee Status"); $this->assertRegExp('/ATTENDEE.*;RSVP=TRUE/', $ics, "Export Attendee RSVP"); $this->assertRegExp('/ATTENDEE.*:mailto:rolf2@/', $ics, "Export Attendee mailto:"); $rrule = $event['recurrence']; $this->assertRegExp('/RRULE:.*FREQ='.$rrule['FREQ'].'/', $ics, "Export Recurrence Frequence"); $this->assertRegExp('/RRULE:.*INTERVAL='.$rrule['INTERVAL'].'/', $ics, "Export Recurrence Interval"); $this->assertRegExp('/RRULE:.*UNTIL=20140718T215959Z/', $ics, "Export Recurrence End date"); $this->assertRegExp('/RRULE:.*BYDAY='.$rrule['BYDAY'].'/', $ics, "Export Recurrence BYDAY"); $this->assertRegExp('/EXDATE.*:20131218/', $ics, "Export Recurrence EXDATE"); $this->assertContains('BEGIN:VALARM', $ics, "Export VALARM"); - $this->assertContains('TRIGGER:-PT12H', $ics, "Export Alarm trigger"); + $this->assertContains('TRIGGER;RELATED=END:-PT12H', $ics, "Export Alarm trigger"); $this->assertRegExp('/ATTACH.*;VALUE=BINARY/', $ics, "Embed attachment"); $this->assertRegExp('/ATTACH.*;ENCODING=BASE64/', $ics, "Attachment B64 encoding"); $this->assertRegExp('!ATTACH.*;FMTTYPE=text/html!', $ics, "Attachment mimetype"); $this->assertRegExp('!ATTACH.*;X-LABEL=calendar.html!', $ics, "Attachment filename with X-LABEL"); $this->assertContains('END:VEVENT', $ics, "VEVENT encapsulation END"); $this->assertContains('END:VCALENDAR', $ics, "VCALENDAR encapsulation END"); } /** * @depends test_extended * @depends test_export */ function test_export_multiple() { $ical = new libvcalendar(); $events = array_merge( $ical->import_from_file(__DIR__ . '/resources/snd.ics', 'UTF-8'), $ical->import_from_file(__DIR__ . '/resources/multiple.ics', 'UTF-8') ); $num = count($events); $ics = $ical->export($events, null, false); $this->assertContains('BEGIN:VCALENDAR', $ics, "VCALENDAR encapsulation BEGIN"); $this->assertContains('END:VCALENDAR', $ics, "VCALENDAR encapsulation END"); $this->assertEquals($num, substr_count($ics, 'BEGIN:VEVENT'), "VEVENT encapsulation BEGIN"); $this->assertEquals($num, substr_count($ics, 'END:VEVENT'), "VEVENT encapsulation END"); } /** * @depends test_export */ function test_export_recurrence_exceptions() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); // add exceptions $event = $events[0]; $exception1 = $event; $exception1['start'] = clone $event['start']; $exception1['start']->setDate(2013, 8, 14); $exception1['end'] = clone $event['end']; $exception1['end']->setDate(2013, 8, 14); $exception2 = $event; $exception2['start'] = clone $event['start']; $exception2['start']->setDate(2013, 11, 13); $exception2['end'] = clone $event['end']; $exception2['end']->setDate(2013, 11, 13); $exception2['title'] = 'Recurring Exception'; $events[0]['recurrence']['EXCEPTIONS'] = array($exception1, $exception2); $ics = $ical->export($events, null, false); $num = count($events[0]['recurrence']['EXCEPTIONS']) + 1; $this->assertEquals($num, substr_count($ics, 'BEGIN:VEVENT'), "VEVENT encapsulation BEGIN"); $this->assertEquals($num, substr_count($ics, 'UID:'.$event['uid']), "Recurrence Exceptions with same UID"); $this->assertEquals($num, substr_count($ics, 'END:VEVENT'), "VEVENT encapsulation END"); $this->assertContains('RECURRENCE-ID;VALUE=DATE-TIME;TZID=Europe/Zurich:20130814', $ics, "Recurrence-ID (1) being the exception date"); $this->assertContains('RECURRENCE-ID;VALUE=DATE-TIME;TZID=Europe/Zurich:20131113', $ics, "Recurrence-ID (2) being the exception date"); $this->assertContains('SUMMARY:'.$exception2['title'], $ics, "Exception title"); } function test_export_valid_rrules() { $event = array( 'uid' => '1234567890', 'start' => new DateTime('now'), 'end' => new DateTime('now + 30min'), 'title' => 'test_export_valid_rrules', 'recurrence' => array( 'FREQ' => 'DAILY', 'COUNT' => 5, 'EXDATE' => array(), 'RDATE' => array(), ), ); $ical = new libvcalendar(); $ics = $ical->export(array($event), null, false, null, false); $this->assertNotContains('EXDATE=', $ics); $this->assertNotContains('RDATE=', $ics); } /** * */ function test_export_rdate() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple-rdate.ics', 'UTF-8'); $ics = $ical->export($events, null, false); $this->assertContains('RDATE;VALUE=DATE-TIME:20140520T020000Z', $ics, "VALUE=PERIOD is translated into single DATE-TIME values"); } /** * @depends test_export */ function test_export_direct() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple.ics', 'UTF-8'); $num = count($events); ob_start(); $return = $ical->export($events, null, true); $output = ob_get_contents(); ob_end_clean(); $this->assertTrue($return, "Return true on successful writing"); $this->assertContains('BEGIN:VCALENDAR', $output, "VCALENDAR encapsulation BEGIN"); $this->assertContains('END:VCALENDAR', $output, "VCALENDAR encapsulation END"); $this->assertEquals($num, substr_count($output, 'BEGIN:VEVENT'), "VEVENT encapsulation BEGIN"); $this->assertEquals($num, substr_count($output, 'END:VEVENT'), "VEVENT encapsulation END"); } function test_datetime() { $ical = new libvcalendar(); $localtime = $ical->datetime_prop('DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('Europe/Berlin'))); $localdate = $ical->datetime_prop('DTSTART', new DateTime('2013-09-01', new DateTimeZone('Europe/Berlin')), false, true); $utctime = $ical->datetime_prop('DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('UTC'))); $asutctime = $ical->datetime_prop('DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('Europe/Berlin')), true); $this->assertContains('TZID=Europe/Berlin', $localtime->serialize()); $this->assertContains('VALUE=DATE', $localdate->serialize()); $this->assertContains('20130901T120000Z', $utctime->serialize()); $this->assertContains('20130901T100000Z', $asutctime->serialize()); } function test_get_vtimezone() { $vtz = libvcalendar::get_vtimezone('Europe/Berlin', strtotime('2014-08-22T15:00:00+02:00')); $this->assertInstanceOf('\Sabre\VObject\Component', $vtz, "VTIMEZONE is a Component object"); $this->assertEquals('Europe/Berlin', $vtz->TZID); $this->assertEquals('4', $vtz->{'X-MICROSOFT-CDO-TZID'}); // check for transition to daylight saving time which is BEFORE the given date $dst = reset($vtz->select('DAYLIGHT')); $this->assertEquals('DAYLIGHT', $dst->name); $this->assertEquals('20140330T010000', $dst->DTSTART); $this->assertEquals('+0100', $dst->TZOFFSETFROM); $this->assertEquals('+0200', $dst->TZOFFSETTO); $this->assertEquals('CEST', $dst->TZNAME); // check (last) transition to standard time which is AFTER the given date $std = end($vtz->select('STANDARD')); $this->assertEquals('STANDARD', $std->name); $this->assertEquals('20141026T010000', $std->DTSTART); $this->assertEquals('+0200', $std->TZOFFSETFROM); $this->assertEquals('+0100', $std->TZOFFSETTO); $this->assertEquals('CET', $std->TZNAME); // unknown timezone $vtz = libvcalendar::get_vtimezone('America/Foo Bar'); $this->assertEquals(false, $vtz); // invalid input data $vtz = libvcalendar::get_vtimezone(new DateTime()); $this->assertEquals(false, $vtz); // DateTimezone as input data $vtz = libvcalendar::get_vtimezone(new DateTimezone('Pacific/Chatham')); $this->assertInstanceOf('\Sabre\VObject\Component', $vtz); $this->assertContains('TZOFFSETFROM:+1245', $vtz->serialize()); $this->assertContains('TZOFFSETTO:+1345', $vtz->serialize()); } function get_attachment_data($id, $event) { return $this->attachment_data; } } diff --git a/plugins/libcalendaring/tests/resources/recurring.ics b/plugins/libcalendaring/tests/resources/recurring.ics index 92db9e28..8fed3878 100644 --- a/plugins/libcalendaring/tests/resources/recurring.ics +++ b/plugins/libcalendaring/tests/resources/recurring.ics @@ -1,43 +1,43 @@ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Apple Inc.//iCal 5.0.3//EN CALSCALE:GREGORIAN BEGIN:VTIMEZONE TZID:Europe/Zurich BEGIN:DAYLIGHT TZOFFSETFROM:+0100 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU DTSTART:19810329T020000 TZNAME:CEST TZOFFSETTO:+0200 END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU DTSTART:19961027T030000 TZNAME:CET TZOFFSETTO:+0100 END:STANDARD END:VTIMEZONE BEGIN:VEVENT PRIORITY:3 DTEND;TZID=Europe/Zurich:20130717T130000 TRANSP:TRANSPARENT UID:7e93e8e8eef16f28aa33b78cd73613eb DTSTAMP:20130718T082032Z SEQUENCE:6 CLASS:CONFIDENTIAL CATEGORIES:libcalendaring tests SUMMARY:Recurring Test LAST-MODIFIED:20120621 DTSTART;TZID=Europe/Zurich:20130717T080000 CREATED:20081223T232600Z RRULE:FREQ=MONTHLY;INTERVAL=1;UNTIL=20140718T215959Z;BYDAY=3WE EXDATE;TZID=Europe/Zurich:20131218T080000 EXDATE;TZID=Europe/Zurich:20140415T080000 BEGIN:VALARM -TRIGGER:-PT12H +TRIGGER;RELATED=END:-PT12H ACTION:DISPLAY END:VALARM END:VEVENT END:VCALENDAR diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php index e7d1122a..a9dd70c8 100644 --- a/plugins/libkolab/lib/kolab_format_xcal.php +++ b/plugins/libkolab/lib/kolab_format_xcal.php @@ -1,706 +1,714 @@ * * Copyright (C) 2012, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ abstract class kolab_format_xcal extends kolab_format { public $CTYPE = 'application/calendar+xml'; public static $fulltext_cols = array('title', 'description', 'location', 'attendees:name', 'attendees:email', 'categories'); public static $scheduling_properties = array('start', 'end', 'location'); protected $_scheduling_properties = null; protected $sensitivity_map = array( 'public' => kolabformat::ClassPublic, 'private' => kolabformat::ClassPrivate, 'confidential' => kolabformat::ClassConfidential, ); protected $role_map = array( 'REQ-PARTICIPANT' => kolabformat::Required, 'OPT-PARTICIPANT' => kolabformat::Optional, 'NON-PARTICIPANT' => kolabformat::NonParticipant, 'CHAIR' => kolabformat::Chair, ); protected $cutype_map = array( 'INDIVIDUAL' => kolabformat::CutypeIndividual, 'GROUP' => kolabformat::CutypeGroup, 'ROOM' => kolabformat::CutypeRoom, 'RESOURCE' => kolabformat::CutypeResource, 'UNKNOWN' => kolabformat::CutypeUnknown, ); protected $rrule_type_map = array( 'MINUTELY' => RecurrenceRule::Minutely, 'HOURLY' => RecurrenceRule::Hourly, 'DAILY' => RecurrenceRule::Daily, 'WEEKLY' => RecurrenceRule::Weekly, 'MONTHLY' => RecurrenceRule::Monthly, 'YEARLY' => RecurrenceRule::Yearly, ); protected $weekday_map = array( 'MO' => kolabformat::Monday, 'TU' => kolabformat::Tuesday, 'WE' => kolabformat::Wednesday, 'TH' => kolabformat::Thursday, 'FR' => kolabformat::Friday, 'SA' => kolabformat::Saturday, 'SU' => kolabformat::Sunday, ); protected $alarm_type_map = array( 'DISPLAY' => Alarm::DisplayAlarm, 'EMAIL' => Alarm::EMailAlarm, 'AUDIO' => Alarm::AudioAlarm, ); protected $status_map = array( 'NEEDS-ACTION' => kolabformat::StatusNeedsAction, 'IN-PROCESS' => kolabformat::StatusInProcess, 'COMPLETED' => kolabformat::StatusCompleted, 'CANCELLED' => kolabformat::StatusCancelled, 'TENTATIVE' => kolabformat::StatusTentative, 'CONFIRMED' => kolabformat::StatusConfirmed, 'DRAFT' => kolabformat::StatusDraft, 'FINAL' => kolabformat::StatusFinal, ); protected $part_status_map = array( 'UNKNOWN' => kolabformat::PartNeedsAction, 'NEEDS-ACTION' => kolabformat::PartNeedsAction, 'TENTATIVE' => kolabformat::PartTentative, 'ACCEPTED' => kolabformat::PartAccepted, 'DECLINED' => kolabformat::PartDeclined, 'DELEGATED' => kolabformat::PartDelegated, ); /** * Convert common xcard properties into a hash array data structure * * @param array Additional data for merge * * @return array Object data as hash array */ public function to_array($data = array()) { // read common object props $object = parent::to_array($data); $status_map = array_flip($this->status_map); $sensitivity_map = array_flip($this->sensitivity_map); $object += array( 'sequence' => intval($this->obj->sequence()), 'title' => $this->obj->summary(), 'location' => $this->obj->location(), 'description' => $this->obj->description(), 'url' => $this->obj->url(), 'status' => $status_map[$this->obj->status()], 'sensitivity' => $sensitivity_map[$this->obj->classification()], 'priority' => $this->obj->priority(), 'categories' => self::vector2array($this->obj->categories()), 'start' => self::php_datetime($this->obj->start()), ); if (method_exists($this->obj, 'comment')) { $object['comment'] = $this->obj->comment(); } // read organizer and attendees if (($organizer = $this->obj->organizer()) && ($organizer->email() || $organizer->name())) { $object['organizer'] = array( 'email' => $organizer->email(), 'name' => $organizer->name(), ); } $role_map = array_flip($this->role_map); $cutype_map = array_flip($this->cutype_map); $part_status_map = array_flip($this->part_status_map); $attvec = $this->obj->attendees(); for ($i=0; $i < $attvec->size(); $i++) { $attendee = $attvec->get($i); $cr = $attendee->contact(); if ($cr->email() != $object['organizer']['email']) { $delegators = $delegatees = array(); $vdelegators = $attendee->delegatedFrom(); for ($j=0; $j < $vdelegators->size(); $j++) { $delegators[] = $vdelegators->get($j)->email(); } $vdelegatees = $attendee->delegatedTo(); for ($j=0; $j < $vdelegatees->size(); $j++) { $delegatees[] = $vdelegatees->get($j)->email(); } $object['attendees'][] = array( 'role' => $role_map[$attendee->role()], 'cutype' => $cutype_map[$attendee->cutype()], 'status' => $part_status_map[$attendee->partStat()], 'rsvp' => $attendee->rsvp(), 'email' => $cr->email(), 'name' => $cr->name(), 'delegated-from' => $delegators, 'delegated-to' => $delegatees, ); } } // read recurrence rule if (($rr = $this->obj->recurrenceRule()) && $rr->isValid()) { $rrule_type_map = array_flip($this->rrule_type_map); $object['recurrence'] = array('FREQ' => $rrule_type_map[$rr->frequency()]); if ($intvl = $rr->interval()) $object['recurrence']['INTERVAL'] = $intvl; if (($count = $rr->count()) && $count > 0) { $object['recurrence']['COUNT'] = $count; } else if ($until = self::php_datetime($rr->end())) { $refdate = $this->get_reference_date(); if ($refdate && $refdate instanceof DateTime && !$refdate->_dateonly) { $until->setTime($refdate->format('G'), $refdate->format('i'), 0); } $object['recurrence']['UNTIL'] = $until; } if (($byday = $rr->byday()) && $byday->size()) { $weekday_map = array_flip($this->weekday_map); $weekdays = array(); for ($i=0; $i < $byday->size(); $i++) { $daypos = $byday->get($i); $prefix = $daypos->occurence(); $weekdays[] = ($prefix ? $prefix : '') . $weekday_map[$daypos->weekday()]; } $object['recurrence']['BYDAY'] = join(',', $weekdays); } if (($bymday = $rr->bymonthday()) && $bymday->size()) { $object['recurrence']['BYMONTHDAY'] = join(',', self::vector2array($bymday)); } if (($bymonth = $rr->bymonth()) && $bymonth->size()) { $object['recurrence']['BYMONTH'] = join(',', self::vector2array($bymonth)); } if ($exdates = $this->obj->exceptionDates()) { for ($i=0; $i < $exdates->size(); $i++) { if ($exdate = self::php_datetime($exdates->get($i))) $object['recurrence']['EXDATE'][] = $exdate; } } } if ($rdates = $this->obj->recurrenceDates()) { for ($i=0; $i < $rdates->size(); $i++) { if ($rdate = self::php_datetime($rdates->get($i))) $object['recurrence']['RDATE'][] = $rdate; } } // read alarm $valarms = $this->obj->alarms(); $alarm_types = array_flip($this->alarm_type_map); $object['valarms'] = array(); for ($i=0; $i < $valarms->size(); $i++) { $alarm = $valarms->get($i); - $type = $alarm_types[$alarm->type()]; + $type = $alarm_types[$alarm->type()]; if ($type == 'DISPLAY' || $type == 'EMAIL' || $type == 'AUDIO') { // only some alarms are supported $valarm = array( - 'action' => $type, - 'summary' => $alarm->summary(), + 'action' => $type, + 'summary' => $alarm->summary(), 'description' => $alarm->description(), ); if ($type == 'EMAIL') { $valarm['attendees'] = array(); $attvec = $alarm->attendees(); for ($j=0; $j < $attvec->size(); $j++) { $cr = $attvec->get($j); $valarm['attendees'][] = $cr->email(); } } else if ($type == 'AUDIO') { $attach = $alarm->audioFile(); $valarm['uri'] = $attach->uri(); } if ($start = self::php_datetime($alarm->start())) { - $object['alarms'] = '@' . $start->format('U'); + $object['alarms'] = '@' . $start->format('U'); $valarm['trigger'] = $start; } else if ($offset = $alarm->relativeStart()) { - $prefix = $alarm->relativeTo() == kolabformat::End ? '+' : '-'; - $value = $time = ''; + $prefix = $offset->isNegative() ? '-' : '+'; + $value = ''; + $time = ''; + if ($w = $offset->weeks()) $value .= $w . 'W'; else if ($d = $offset->days()) $value .= $d . 'D'; else if ($h = $offset->hours()) $time .= $h . 'H'; else if ($m = $offset->minutes()) $time .= $m . 'M'; else if ($s = $offset->seconds()) $time .= $s . 'S'; // assume 'at event time' if (empty($value) && empty($time)) { $prefix = ''; - $time = '0S'; + $time = '0S'; } - $object['alarms'] = $prefix . $value . $time; + $object['alarms'] = $prefix . $value . $time; $valarm['trigger'] = $prefix . 'P' . $value . ($time ? 'T' . $time : ''); + + if ($alarm->relativeTo() == kolabformat::End) { + $valarm['related'] == 'END'; + } } // read alarm duration and repeat properties if (($duration = $alarm->duration()) && $duration->isValid()) { $value = $time = ''; + if ($w = $duration->weeks()) $value .= $w . 'W'; else if ($d = $duration->days()) $value .= $d . 'D'; else if ($h = $duration->hours()) $time .= $h . 'H'; else if ($m = $duration->minutes()) $time .= $m . 'M'; else if ($s = $duration->seconds()) $time .= $s . 'S'; + $valarm['duration'] = 'P' . $value . ($time ? 'T' . $time : ''); - $valarm['repeat'] = $alarm->numrepeat(); + $valarm['repeat'] = $alarm->numrepeat(); } $object['alarms'] .= ':' . $type; // legacy property $object['valarms'][] = array_filter($valarm); } } $this->get_attachments($object); return $object; } /** * Set common xcal properties to the kolabformat object * * @param array Event data as hash array */ public function set(&$object) { $this->init(); $is_new = !$this->obj->uid(); $old_sequence = $this->obj->sequence(); $reschedule = $is_new; // set common object properties parent::set($object); // set sequence value if (!isset($object['sequence'])) { if ($is_new) { $object['sequence'] = 0; } else { $object['sequence'] = $old_sequence; // increment sequence when updating properties relevant for scheduling. // RFC 5545: "It is incremented [...] each time the Organizer makes a significant revision to the calendar component." if ($this->check_rescheduling($object)) { $object['sequence']++; } } } $this->obj->setSequence(intval($object['sequence'])); if ($object['sequence'] > $old_sequence) { $reschedule = true; } $this->obj->setSummary($object['title']); $this->obj->setLocation($object['location']); $this->obj->setDescription($object['description']); $this->obj->setPriority($object['priority']); $this->obj->setClassification($this->sensitivity_map[$object['sensitivity']]); $this->obj->setCategories(self::array2vector($object['categories'])); $this->obj->setUrl(strval($object['url'])); if (method_exists($this->obj, 'setComment')) { $this->obj->setComment($object['comment']); } // process event attendees $attendees = new vectorattendee; foreach ((array)$object['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') { $object['organizer'] = $attendee; } else if ($attendee['email'] != $object['organizer']['email']) { $cr = new ContactReference(ContactReference::EmailReference, $attendee['email']); $cr->setName($attendee['name']); // set attendee RSVP if missing if (!isset($attendee['rsvp'])) { $object['attendees'][$i]['rsvp'] = $attendee['rsvp'] = $reschedule; } $att = new Attendee; $att->setContact($cr); $att->setPartStat($this->part_status_map[$attendee['status']]); $att->setRole($this->role_map[$attendee['role']] ? $this->role_map[$attendee['role']] : kolabformat::Required); $att->setCutype($this->cutype_map[$attendee['cutype']] ? $this->cutype_map[$attendee['cutype']] : kolabformat::CutypeIndividual); $att->setRSVP((bool)$attendee['rsvp']); if (!empty($attendee['delegated-from'])) { $vdelegators = new vectorcontactref; foreach ((array)$attendee['delegated-from'] as $delegator) { $vdelegators->push(new ContactReference(ContactReference::EmailReference, $delegator)); } $att->setDelegatedFrom($vdelegators); } if (!empty($attendee['delegated-to'])) { $vdelegatees = new vectorcontactref; foreach ((array)$attendee['delegated-to'] as $delegatee) { $vdelegatees->push(new ContactReference(ContactReference::EmailReference, $delegatee)); } $att->setDelegatedTo($vdelegatees); } if ($att->isValid()) { $attendees->push($att); } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Invalid event attendee: " . json_encode($attendee), ), true); } } } $this->obj->setAttendees($attendees); if ($object['organizer']) { $organizer = new ContactReference(ContactReference::EmailReference, $object['organizer']['email']); $organizer->setName($object['organizer']['name']); $this->obj->setOrganizer($organizer); } // save recurrence rule $rr = new RecurrenceRule; $rr->setFrequency(RecurrenceRule::FreqNone); if ($object['recurrence'] && !empty($object['recurrence']['FREQ'])) { $rr->setFrequency($this->rrule_type_map[$object['recurrence']['FREQ']]); if ($object['recurrence']['INTERVAL']) $rr->setInterval(intval($object['recurrence']['INTERVAL'])); if ($object['recurrence']['BYDAY']) { $byday = new vectordaypos; foreach (explode(',', $object['recurrence']['BYDAY']) as $day) { $occurrence = 0; if (preg_match('/^([\d-]+)([A-Z]+)$/', $day, $m)) { $occurrence = intval($m[1]); $day = $m[2]; } if (isset($this->weekday_map[$day])) $byday->push(new DayPos($occurrence, $this->weekday_map[$day])); } $rr->setByday($byday); } if ($object['recurrence']['BYMONTHDAY']) { $bymday = new vectori; foreach (explode(',', $object['recurrence']['BYMONTHDAY']) as $day) $bymday->push(intval($day)); $rr->setBymonthday($bymday); } if ($object['recurrence']['BYMONTH']) { $bymonth = new vectori; foreach (explode(',', $object['recurrence']['BYMONTH']) as $month) $bymonth->push(intval($month)); $rr->setBymonth($bymonth); } if ($object['recurrence']['COUNT']) $rr->setCount(intval($object['recurrence']['COUNT'])); else if ($object['recurrence']['UNTIL']) $rr->setEnd(self::get_datetime($object['recurrence']['UNTIL'], null, true)); if ($rr->isValid()) { // add exception dates (only if recurrence rule is valid) $exdates = new vectordatetime; foreach ((array)$object['recurrence']['EXDATE'] as $exdate) $exdates->push(self::get_datetime($exdate, null, true)); $this->obj->setExceptionDates($exdates); } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Invalid event recurrence rule: " . json_encode($object['recurrence']), ), true); } } $this->obj->setRecurrenceRule($rr); // save recurrence dates (aka RDATE) if (!empty($object['recurrence']['RDATE'])) { $rdates = new vectordatetime; foreach ((array)$object['recurrence']['RDATE'] as $rdate) $rdates->push(self::get_datetime($rdate, null, true)); $this->obj->setRecurrenceDates($rdates); } // save alarm $valarms = new vectoralarm; if ($object['valarms']) { foreach ($object['valarms'] as $valarm) { if (!array_key_exists($valarm['action'], $this->alarm_type_map)) { continue; // skip unknown alarm types } if ($valarm['action'] == 'EMAIL') { $recipients = new vectorcontactref; foreach (($valarm['attendees'] ?: array($object['_owner'])) as $email) { $recipients->push(new ContactReference(ContactReference::EmailReference, $email)); } $alarm = new Alarm( strval($valarm['summary'] ?: $object['title']), strval($valarm['description'] ?: $object['description']), $recipients ); } else if ($valarm['action'] == 'AUDIO') { $attach = new Attachment; $attach->setUri($valarm['uri'] ?: 'null', 'unknown'); $alarm = new Alarm($attach); } else { // action == DISPLAY $alarm = new Alarm(strval($valarm['summary'] ?: $object['title'])); } if (is_object($valarm['trigger']) && $valarm['trigger'] instanceof DateTime) { $alarm->setStart(self::get_datetime($valarm['trigger'], new DateTimeZone('UTC'))); } else { try { - $prefix = $valarm['trigger'][0]; - $period = new DateInterval(preg_replace('/[^0-9PTWDHMS]/', '', $valarm['trigger'])); - $duration = new Duration($period->d, $period->h, $period->i, $period->s, $prefix == '-'); + $period = new DateInterval(preg_replace('/[^0-9PTWDHMS]/', '', $valarm['trigger'])); + $duration = new Duration($period->d, $period->h, $period->i, $period->s, $valarm['trigger'][0] == '-'); } catch (Exception $e) { // skip alarm with invalid trigger values rcube::raise_error($e, true); continue; } - $alarm->setRelativeStart($duration, $prefix == '-' ? kolabformat::Start : kolabformat::End); + $related = strtoupper($valarm['related']) == 'END' ? kolabformat::End : kolabformat::Start; + $alarm->setRelativeStart($duration, $related); } if ($valarm['duration']) { try { $d = new DateInterval($valarm['duration']); $duration = new Duration($d->d, $d->h, $d->i, $d->s); $alarm->setDuration($duration, intval($valarm['repeat'])); } catch (Exception $e) { // ignore } } $valarms->push($alarm); } } // legacy support else if ($object['alarms']) { list($offset, $type) = explode(":", $object['alarms']); if ($type == 'EMAIL' && !empty($object['_owner'])) { // email alarms implicitly go to event owner $recipients = new vectorcontactref; $recipients->push(new ContactReference(ContactReference::EmailReference, $object['_owner'])); $alarm = new Alarm($object['title'], strval($object['description']), $recipients); } else { // default: display alarm $alarm = new Alarm($object['title']); } if (preg_match('/^@(\d+)/', $offset, $d)) { $alarm->setStart(self::get_datetime($d[1], new DateTimeZone('UTC'))); } else if (preg_match('/^([-+]?)P?T?(\d+)([SMHDW])/', $offset, $d)) { $days = $hours = $minutes = $seconds = 0; switch ($d[3]) { case 'W': $days = 7*intval($d[2]); break; case 'D': $days = intval($d[2]); break; case 'H': $hours = intval($d[2]); break; case 'M': $minutes = intval($d[2]); break; case 'S': $seconds = intval($d[2]); break; } $alarm->setRelativeStart(new Duration($days, $hours, $minutes, $seconds, $d[1] == '-'), $d[1] == '-' ? kolabformat::Start : kolabformat::End); } $valarms->push($alarm); } $this->obj->setAlarms($valarms); $this->set_attachments($object); } /** * Return the reference date for recurrence and alarms * * @return mixed DateTime instance of null if no refdate is available */ public function get_reference_date() { if ($this->data['start'] && $this->data['start'] instanceof DateTime) { return $this->data['start']; } return self::php_datetime($this->obj->start()); } /** * Callback for kolab_storage_cache to get words to index for fulltext search * * @return array List of words to save in cache */ public function get_words($obj = null) { $data = ''; $object = $obj ?: $this->data; foreach (self::$fulltext_cols as $colname) { list($col, $field) = explode(':', $colname); if ($field) { $a = array(); foreach ((array)$object[$col] as $attr) $a[] = $attr[$field]; $val = join(' ', $a); } else { $val = is_array($object[$col]) ? join(' ', $object[$col]) : $object[$col]; } if (strlen($val)) $data .= $val . ' '; } $words = rcube_utils::normalize_string($data, true); // collect words from recurrence exceptions if (is_array($object['exceptions'])) { foreach ($object['exceptions'] as $exception) { $words = array_merge($words, $this->get_words($exception)); } } return array_unique($words); } /** * Callback for kolab_storage_cache to get object specific tags to cache * * @return array List of tags to save in cache */ public function get_tags($obj = null) { $tags = array(); $object = $obj ?: $this->data; if (!empty($object['valarms'])) { $tags[] = 'x-has-alarms'; } // create tags reflecting participant status if (is_array($object['attendees'])) { foreach ($object['attendees'] as $attendee) { if (!empty($attendee['email']) && !empty($attendee['status'])) $tags[] = 'x-partstat:' . $attendee['email'] . ':' . strtolower($attendee['status']); } } // collect tags from recurrence exceptions if (is_array($object['exceptions'])) { foreach ($object['exceptions'] as $exception) { $tags = array_merge($tags, $this->get_tags($exception)); } } if (!empty($object['status'])) { $tags[] = 'x-status:' . strtolower($object['status']); } return array_unique($tags); } /** * Identify changes considered relevant for scheduling * * @param array Hash array with NEW object properties * @param array Hash array with OLD object properties * * @return boolean True if changes affect scheduling, False otherwise */ public function check_rescheduling($object, $old = null) { $reschedule = false; if (!is_array($old)) { $old = $this->data['uid'] ? $this->data : $this->to_array(); } foreach ($this->_scheduling_properties ?: self::$scheduling_properties as $prop) { $a = $old[$prop]; $b = $object[$prop]; if ($object['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) { $a = $a->format('Y-m-d'); $b = $b->format('Y-m-d'); } if ($prop == 'recurrence' && is_array($a) && is_array($b)) { unset($a['EXCEPTIONS'], $b['EXCEPTIONS']); $a = array_filter($a); $b = array_filter($b); // advanced rrule comparison: no rescheduling if series was shortened if ($a['COUNT'] && $b['COUNT'] && $b['COUNT'] < $a['COUNT']) { unset($a['COUNT'], $b['COUNT']); } else if ($a['UNTIL'] && $b['UNTIL'] && $b['UNTIL'] < $a['UNTIL']) { unset($a['UNTIL'], $b['UNTIL']); } } if ($a != $b) { $reschedule = true; break; } } return $reschedule; } } \ No newline at end of file diff --git a/plugins/tasklist/tasklist_ui.php b/plugins/tasklist/tasklist_ui.php index 97b16334..0315d8f8 100644 --- a/plugins/tasklist/tasklist_ui.php +++ b/plugins/tasklist/tasklist_ui.php @@ -1,540 +1,541 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class tasklist_ui { private $rc; private $plugin; private $ready = false; private $gui_objects = array(); function __construct($plugin) { $this->plugin = $plugin; $this->rc = $plugin->rc; } /** * Calendar UI initialization and requests handlers */ public function init() { if ($this->ready) { return; } // add taskbar button $this->plugin->add_button(array( 'command' => 'tasks', 'class' => 'button-tasklist', 'classsel' => 'button-tasklist button-selected', 'innerclass' => 'button-inner', 'label' => 'tasklist.navtitle', ), 'taskbar'); $this->plugin->include_stylesheet($this->plugin->local_skin_path() . '/tasklist.css'); if ($this->rc->task == 'mail' || $this->rc->task == 'tasks') { jqueryui::tagedit(); $this->plugin->include_script('tasklist_base.js'); // copy config to client $this->rc->output->set_env('tasklist_settings', $this->load_settings()); // initialize attendees autocompletion $this->rc->autocomplete_init(); } $this->ready = true; } /** * */ function load_settings() { $settings = array(); $settings['invite_shared'] = (int)$this->rc->config->get('calendar_allow_invite_shared', 0); $settings['itip_notify'] = (int)$this->rc->config->get('calendar_itip_send_option', 3); $settings['sort_col'] = $this->rc->config->get('tasklist_sort_col', ''); $settings['sort_order'] = $this->rc->config->get('tasklist_sort_order', 'asc'); // get user identity to create default attendee foreach ($this->rc->user->list_emails() as $rec) { if (!$identity) $identity = $rec; $identity['emails'][] = $rec['email']; $settings['identities'][$rec['identity_id']] = $rec['email']; } $identity['emails'][] = $this->rc->user->get_username(); $settings['identity'] = array( 'name' => $identity['name'], 'email' => strtolower($identity['email']), 'emails' => ';' . strtolower(join(';', $identity['emails'])) ); if ($list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC)) { $settings['selected_list'] = $list; } if ($list && ($id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC))) { $settings['selected_id'] = $id; // check if the referenced task is completed $task = $this->plugin->driver->get_task(array('id' => $id, 'list' => $list)); if ($task && $this->plugin->driver->is_complete($task)) { $settings['selected_filter'] = 'complete'; } } else if ($filter = rcube_utils::get_input_value('_filter', rcube_utils::INPUT_GPC)) { $settings['selected_filter'] = $filter; } return $settings; } /** * Render a HTML select box for user identity selection */ function identity_select($attrib = array()) { $attrib['name'] = 'identity'; $select = new html_select($attrib); $identities = $this->rc->user->list_emails(); foreach ($identities as $ident) { $select->add(format_email_recipient($ident['email'], $ident['name']), $ident['identity_id']); } return $select->show(null); } /** * Register handler methods for the template engine */ public function init_templates() { $this->plugin->register_handler('plugin.tasklists', array($this, 'tasklists')); $this->plugin->register_handler('plugin.tasklist_select', array($this, 'tasklist_select')); $this->plugin->register_handler('plugin.status_select', array($this, 'status_select')); $this->plugin->register_handler('plugin.searchform', array($this->rc->output, 'search_form')); $this->plugin->register_handler('plugin.quickaddform', array($this, 'quickadd_form')); $this->plugin->register_handler('plugin.tasks', array($this, 'tasks_resultview')); $this->plugin->register_handler('plugin.tagslist', array($this, 'tagslist')); $this->plugin->register_handler('plugin.tags_editline', array($this, 'tags_editline')); $this->plugin->register_handler('plugin.alarm_select', array($this, 'alarm_select')); $this->plugin->register_handler('plugin.recurrence_form', array($this->plugin->lib, 'recurrence_form')); $this->plugin->register_handler('plugin.attachments_form', array($this, 'attachments_form')); $this->plugin->register_handler('plugin.attachments_list', array($this, 'attachments_list')); $this->plugin->register_handler('plugin.filedroparea', array($this, 'file_drop_area')); $this->plugin->register_handler('plugin.attendees_list', array($this, 'attendees_list')); $this->plugin->register_handler('plugin.attendees_form', array($this, 'attendees_form')); $this->plugin->register_handler('plugin.identity_select', array($this, 'identity_select')); $this->plugin->register_handler('plugin.edit_attendees_notify', array($this, 'edit_attendees_notify')); $this->plugin->register_handler('plugin.task_rsvp_buttons', array($this->plugin->itip, 'itip_rsvp_buttons')); $this->plugin->register_handler('plugin.object_changelog_table', array('libkolab', 'object_changelog_table')); jqueryui::tagedit(); $this->plugin->include_script('tasklist.js'); $this->rc->output->include_script('treelist.js'); // include kolab folderlist widget if available if (in_array('libkolab', $this->plugin->api->loaded_plugins())) { $this->plugin->api->include_script('libkolab/js/folderlist.js'); $this->plugin->api->include_script('libkolab/js/audittrail.js'); } } /** * */ public function tasklists($attrib = array()) { $tree = true; $jsenv = array(); $lists = $this->plugin->driver->get_lists($tree); // walk folder tree if (is_object($tree)) { $html = $this->list_tree_html($tree, $lists, $jsenv, $attrib); } else { // fall-back to flat folder listing $attrib['class'] .= ' flat'; $html = ''; foreach ((array)$lists as $id => $prop) { if ($attrib['activeonly'] && !$prop['active']) continue; $html .= html::tag('li', array( 'id' => 'rcmlitasklist' . rcube_utils::html_identifier($id), 'class' => $prop['group'], ), $this->tasklist_list_item($id, $prop, $jsenv, $attrib['activeonly']) ); } } $this->rc->output->set_env('tasklists', $jsenv); $this->register_gui_object('tasklistslist', $attrib['id']); return html::tag('ul', $attrib, $html, html::$common_attrib); } /** * Return html for a structured list
          for the folder tree */ public function list_tree_html($node, $data, &$jsenv, $attrib) { $out = ''; foreach ($node->children as $folder) { $id = $folder->id; $prop = $data[$id]; $is_collapsed = false; // TODO: determine this somehow? $content = $this->tasklist_list_item($id, $prop, $jsenv, $attrib['activeonly']); if (!empty($folder->children)) { $content .= html::tag('ul', array('style' => ($is_collapsed ? "display:none;" : null)), $this->list_tree_html($folder, $data, $jsenv, $attrib)); } if (strlen($content)) { $out .= html::tag('li', array( 'id' => 'rcmlitasklist' . rcube_utils::html_identifier($id), 'class' => $prop['group'] . ($prop['virtual'] ? ' virtual' : ''), ), $content); } } return $out; } /** * Helper method to build a tasklist item (HTML content and js data) */ public function tasklist_list_item($id, $prop, &$jsenv, $activeonly = false) { // enrich list properties with settings from the driver if (!$prop['virtual']) { unset($prop['user_id']); $prop['alarms'] = $this->plugin->driver->alarms; $prop['undelete'] = $this->plugin->driver->undelete; $prop['sortable'] = $this->plugin->driver->sortable; $prop['attachments'] = $this->plugin->driver->attachments; $prop['attendees'] = $this->plugin->driver->attendees; $prop['caldavurl'] = $this->plugin->driver->tasklist_caldav_url($prop); $jsenv[$id] = $prop; } $classes = array('tasklist'); $title = $prop['title'] ?: ($prop['name'] != $prop['listname'] || strlen($prop['name']) > 25 ? html_entity_decode($prop['name'], ENT_COMPAT, RCMAIL_CHARSET) : ''); if ($prop['virtual']) $classes[] = 'virtual'; else if (!$prop['editable']) $classes[] = 'readonly'; if ($prop['subscribed']) $classes[] = 'subscribed'; if ($prop['class']) $classes[] = $prop['class']; if (!$activeonly || $prop['active']) { $label_id = 'tl:' . $id; return html::div(join(' ', $classes), html::span(array('class' => 'listname', 'title' => $title, 'id' => $label_id), $prop['listname'] ?: $prop['name']) . ($prop['virtual'] ? '' : html::tag('input', array('type' => 'checkbox', 'name' => '_list[]', 'value' => $id, 'checked' => $prop['active'], 'aria-labelledby' => $label_id)) . html::span('actions', ($prop['removable'] ? html::a(array('href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')), ' ') : '') . html::a(array('href' => '#', 'class' => 'quickview', 'title' => $this->plugin->gettext('focusview'), 'role' => 'checkbox', 'aria-checked' => 'false'), ' ') . (isset($prop['subscribed']) ? html::a(array('href' => '#', 'class' => 'subscribed', 'title' => $this->plugin->gettext('tasklistsubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'), ' ') : '') ) ) ); } return ''; } /** * Render HTML form for task status selector */ function status_select($attrib = array()) { $attrib['name'] = 'status'; $select = new html_select($attrib); $select->add('---', ''); $select->add($this->plugin->gettext('status-needs-action'), 'NEEDS-ACTION'); $select->add($this->plugin->gettext('status-in-process'), 'IN-PROCESS'); $select->add($this->plugin->gettext('status-completed'), 'COMPLETED'); $select->add($this->plugin->gettext('status-cancelled'), 'CANCELLED'); return $select->show(null); } /** * Render a HTML select box for list selection */ function tasklist_select($attrib = array()) { $attrib['name'] = 'list'; $attrib['is_escaped'] = true; $select = new html_select($attrib); $default = null; foreach ((array)$this->plugin->driver->get_lists() as $id => $prop) { if ($prop['editable'] || strpos($prop['rights'], 'i') !== false) { $select->add($prop['name'], $id); if (!$default || $prop['default']) $default = $id; } } return $select->show($default); } function tasklist_editform($action, $list = array()) { $fields = array( 'name' => array( 'id' => 'taskedit-tasklistame', 'label' => $this->plugin->gettext('listname'), 'value' => html::tag('input', array('id' => 'taskedit-tasklistame', 'name' => 'name', 'type' => 'text', 'class' => 'text', 'size' => 40)), ), /* 'color' => array( 'id' => 'taskedit-color', 'label' => $this->plugin->gettext('color'), 'value' => html::tag('input', array('id' => 'taskedit-color', 'name' => 'color', 'type' => 'text', 'class' => 'text colorpicker', 'size' => 6)), ), */ 'showalarms' => array( 'id' => 'taskedit-showalarms', 'label' => $this->plugin->gettext('showalarms'), 'value' => html::tag('input', array('id' => 'taskedit-showalarms', 'name' => 'color', 'type' => 'checkbox')), ), ); return html::tag('form', array('action' => "#", 'method' => "post", 'id' => 'tasklisteditform'), $this->plugin->driver->tasklist_edit_form($action, $list, $fields) ); } /** * Render HTML form for alarm configuration */ function alarm_select($attrib = array()) { + $attrib['_type'] = 'task'; return $this->plugin->lib->alarm_select($attrib, $this->plugin->driver->alarm_types, $this->plugin->driver->alarm_absolute); } /** * */ function quickadd_form($attrib) { $attrib += array('action' => $this->rc->url('add'), 'method' => 'post', 'id' => 'quickaddform'); $label = html::label(array('for' => 'quickaddinput', 'class' => 'voice'), $this->plugin->gettext('quickaddinput')); $input = new html_inputfield(array('name' => 'text', 'id' => 'quickaddinput')); $button = html::tag('input', array('type' => 'submit', 'value' => '+', 'title' => $this->plugin->gettext('createtask'), 'class' => 'button mainaction')); $this->register_gui_object('quickaddform', $attrib['id']); return html::tag('form', $attrib, $label . $input->show() . $button); } /** * The result view */ function tasks_resultview($attrib) { $attrib += array('id' => 'rcmtaskslist'); $this->register_gui_object('resultlist', $attrib['id']); unset($attrib['name']); return html::tag('ul', $attrib, ''); } /** * Container for a tags cloud */ function tagslist($attrib) { $attrib += array('id' => 'rcmtasktagslist'); unset($attrib['name']); $this->register_gui_object('tagslist', $attrib['id']); return html::tag('ul', $attrib, ''); } /** * Interactive UI element to add/remove tags */ function tags_editline($attrib) { $attrib += array('id' => 'rcmtasktagsedit'); $this->register_gui_object('edittagline', $attrib['id']); $input = new html_inputfield(array('name' => 'tags[]', 'class' => 'tag', 'size' => $attrib['size'], 'tabindex' => $attrib['tabindex'])); unset($attrib['tabindex']); return html::div($attrib, $input->show('')); } /** * Generate HTML element for attachments list */ function attachments_list($attrib = array()) { if (!$attrib['id']) $attrib['id'] = 'rcmtaskattachmentlist'; $this->register_gui_object('attachmentlist', $attrib['id']); return html::tag('ul', $attrib, '', html::$common_attrib); } /** * Generate the form for event attachments upload */ function attachments_form($attrib = array()) { // add ID if not given if (!$attrib['id']) $attrib['id'] = 'rcmtaskuploadform'; // 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' => '_attachments[]', 'multiple' => 'multiple', 'size' => $attrib['attachmentfieldsize'], )); return html::div($attrib, html::div(null, $input->show()) . html::div('formbuttons', $button->show(rcube_label('upload'), array('class' => 'button mainaction', 'onclick' => JS_OBJECT_NAME . ".upload_file(this.form)"))) . html::div('hint', rcube_label(array('name' => 'maxuploadsize', 'vars' => array('size' => $max_filesize)))) ); } /** * Register UI object for HTML5 drag & drop file upload */ function file_drop_area($attrib = array()) { if ($attrib['id']) { $this->register_gui_object('filedrop', $attrib['id']); $this->rc->output->set_env('filedrop', array('action' => 'upload', 'fieldname' => '_attachments')); } } /** * */ function attendees_list($attrib = array()) { // add "noreply" checkbox to attendees table only $invitations = strpos($attrib['id'], 'attend') !== false; $invite = new html_checkbox(array('value' => 1, 'id' => 'edit-attendees-invite')); $table = new html_table(array('cols' => 4 + intval($invitations), 'border' => 0, 'cellpadding' => 0, 'class' => 'rectable')); // $table->add_header('role', $this->plugin->gettext('role')); $table->add_header('name', $this->plugin->gettext($attrib['coltitle'] ?: 'attendee')); $table->add_header('confirmstate', $this->plugin->gettext('confirmstate')); if ($invitations) { $table->add_header(array('class' => 'invite', 'title' => $this->plugin->gettext('sendinvitations')), $invite->show(1) . html::label('edit-attendees-invite', $this->plugin->gettext('sendinvitations'))); } $table->add_header('options', ''); // hide invite column if disabled by config $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', 3); if ($invitations && !($itip_notify & 2)) { $css = sprintf('#%s td.invite, #%s th.invite { display:none !important }', $attrib['id'], $attrib['id']); $this->rc->output->add_footer(html::tag('style', array('type' => 'text/css'), $css)); } return $table->show($attrib); } /** * */ function attendees_form($attrib = array()) { $input = new html_inputfield(array('name' => 'participant', 'id' => 'edit-attendee-name', 'size' => 30)); $textarea = new html_textarea(array('name' => 'comment', 'id' => 'edit-attendees-comment', 'rows' => 4, 'cols' => 55, 'title' => $this->plugin->gettext('itipcommenttitle'))); return html::div($attrib, html::div(null, $input->show() . " " . html::tag('input', array('type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-add', 'value' => $this->plugin->gettext('addattendee'))) // . " " . html::tag('input', array('type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-schedule', 'value' => $this->plugin->gettext('scheduletime').'...')) ) . html::p('attendees-commentbox', html::label(null, $this->plugin->gettext('itipcomment') . $textarea->show())) ); } /** * */ function edit_attendees_notify($attrib = array()) { $checkbox = new html_checkbox(array('name' => '_notify', 'id' => 'edit-attendees-donotify', 'value' => 1)); return html::div($attrib, html::label(null, $checkbox->show(1) . ' ' . $this->plugin->gettext('sendnotifications'))); } /** * Wrapper for rcube_output_html::add_gui_object() */ function register_gui_object($name, $id) { $this->gui_objects[$name] = $id; $this->rc->output->add_gui_object($name, $id); } /** * Getter for registered gui objects. * (for manual registration when loading the inline UI) */ function get_gui_objects() { return $this->gui_objects; } }