diff --git a/plugins/libcalendaring/alarm.mp3 b/plugins/libcalendaring/alarm.mp3 new file mode 100644 index 00000000..c47c7eca Binary files /dev/null and b/plugins/libcalendaring/alarm.mp3 differ diff --git a/plugins/libcalendaring/alarm.wav b/plugins/libcalendaring/alarm.wav new file mode 100644 index 00000000..ea2978f2 Binary files /dev/null and b/plugins/libcalendaring/alarm.wav differ diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js index f2fe467b..72c451fd 100644 --- a/plugins/libcalendaring/libcalendaring.js +++ b/plugins/libcalendaring/libcalendaring.js @@ -1,1324 +1,1370 @@ /** * 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) { // already a Date object? if (s && s.getMonth) { return 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(), 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(), 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; }; // format time string var time_autocomplete_format = function(hour, minutes, start) { var time, diff, unit, duration = '', d = new Date(); d.setHours(hour); d.setMinutes(minutes); time = me.format_time(d); if (start) { diff = Math.floor((d.getTime() - start.getTime()) / 60000); if (diff > 0) { unit = 'm'; if (diff >= 60) { unit = 'h'; diff = Math.round(diff / 3) / 20; } duration = ' (' + diff + unit + ')'; } } return [time, duration]; }; var time_autocomplete_list = function(p, callback) { // Time completions var st, h, step = 15, result = [], now = new Date(), id = String(this.element.attr('id')), m = id.match(/^(.*)-(starttime|endtime)$/), start = (m && m[2] == 'endtime' && (st = $('#' + m[1] + '-starttime').val()) && $('#' + m[1] + '-startdate').val() == $('#' + m[1] + '-enddate').val()) ? me.parse_datetime(st, '') : null, full = p.term - 1 > 0 || p.term.length > 1, hours = start ? start.getHours() : (full ? me.parse_datetime(p.term, '') : now).getHours(), minutes = hours * 60 + (full ? 0 : now.getMinutes()), min = Math.ceil(minutes / step) * step % 60, hour = Math.floor(Math.ceil(minutes / step) * step / 60); // list hours from 0:00 till now for (h = start ? start.getHours() : 0; h < hours; h++) result.push(time_autocomplete_format(h, 0, start)); // list 15min steps for the next two hours for (; h < hour + 2 && h < 24; h++) { while (min < 60) { result.push(time_autocomplete_format(h, min, start)); min += step; } min = 0; } // list the remaining hours till 23:00 while (h < 24) result.push(time_autocomplete_format((h++), 0, start)); return callback(result); }; var time_autocomplete_open = function(event, ui) { // scroll to current time var $this = $(this), widget = $this.autocomplete('widget') menu = $this.data('ui-autocomplete').menu, amregex = /^(.+)(a[.m]*)/i, pmregex = /^(.+)(a[.m]*)/i, val = $(this).val().replace(amregex, '0:$1').replace(pmregex, '1:$1'); widget.css('width', '10em'); if (val === '') menu._scrollIntoView(widget.children('li:first')); else widget.children().each(function() { var li = $(this), html = li.children().first().html() .replace(/\s+\(.+\)$/, '') .replace(amregex, '0:$1') .replace(pmregex, '1:$1'); if (html.indexOf(val) == 0) menu._scrollIntoView(li); }); }; /** * Initializes time autocompletion */ this.init_time_autocomplete = function(elem, props) { var default_props = { delay: 100, minLength: 1, appendTo: props.container, source: time_autocomplete_list, open: time_autocomplete_open, // change: time_autocomplete_change, select: function(event, ui) { $(this).val(ui.item[0]).change(); return false; } }; $(elem).attr('autocomplete', "off") .autocomplete($.extend(default_props, props)) .click(function() { // show drop-down upon clicks $(this).autocomplete('search', $(this).val() ? $(this).val().replace(/\D.*/, "") : " "); }); $(elem).data('ui-autocomplete')._renderItem = function(ul, item) { return $('
  • ') .data('ui-autocomplete-item', item) .append('' + item[0] + item[1] + '') .appendTo(ul); }; }; /***** 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, + audio_alarms = [], records = [], event_ids = [], buttons = {}; - 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); + + if (alarm.action == 'AUDIO') { + audio_alarms.push(alarm); + continue; + } + 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(e){ me.dismiss_link = $(this); me.dismiss_alarm(me.dismiss_link.data('id'), 0, e); }); 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); + records.push($('
    ').addClass('alarm-item').html(html).append(actions)); } + if (audio_alarms.length) + this.audio_alarms(audio_alarms); + + if (!records.length) + return; + + this.alarm_dialog = $('
    ').attr('id', 'alarm-display').append(records); + buttons[rcmail.gettext('close')] = function() { $(this).dialog('close'); }; buttons[rcmail.gettext('dismissall','libcalendaring')] = function(e) { // submit dismissed event_ids to server me.dismiss_alarm(me.alarm_ids.join(','), 0, e); $(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; }; + /** + * Display a notification and play a sound for a set of alarms + */ + this.audio_alarms = function(alarms) + { + var elem, txt = [], + src = rcmail.assets_path('plugins/libcalendaring/alarm'), + plugin = navigator.mimeTypes ? navigator.mimeTypes['audio/mp3'] : {}; + + // first generate and display notification text + $.each(alarms, function() { txt.push(this.title); }); + + rcmail.display_message(rcmail.gettext('alarmtitle','libcalendaring') + ': ' + Q(txt.join(', ')), 'notice', 10000); + + // Internet Explorer does not support wav files, + // support in other browsers depends on enabled plugins, + // so we use wav as a fallback + src += bw.ie || (plugin && plugin.enabledPlugin) ? '.mp3' : '.wav'; + + // HTML5 + try { + elem = $('
  • ') .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 44528650..e8e134ea 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -1,1709 +1,1710 @@ * * 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() { if (!self::$instance) { self::$instance = new libcalendaring(rcube::get_instance()->plugins); self::$instance->init_instance(); } return self::$instance; } /** * Initializes class properties */ public function init_instance() { $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); } /** * Required plugin startup method */ public function init() { self::$instance = $this; $this->rc = rcube::get_instance(); $this->init_instance(); // 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 __DIR__ . '/libvcalendar.php'; return new libvcalendar(); } /** * Load iTip functions */ public static function get_itip($domain = 'libcalendaring') { $self = self::get_instance(); require_once __DIR__ . '/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 __DIR__ . '/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 = $this->rc->format_date($event['start'], $date_format); if (($todate = $this->rc->format_date($event['end'], $date_format)) != $fromto) $fromto .= ' - ' . $todate; } else if ($duration < 86400 && $event['start']->format('d') == $event['end']->format('d')) { $fromto = $this->rc->format_date($event['start'], $date_format) . ' ' . $this->rc->format_date($event['start'], $time_format) . ' - ' . $this->rc->format_date($event['end'], $time_format); } else { $fromto = $this->rc->format_date($event['start'], $date_format) . ' ' . $this->rc->format_date($event['start'], $time_format) . ' - ' . $this->rc->format_date($event['end'], $date_format) . ' ' . $this->rc->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_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']; $related = $alarm['related']; } $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' . $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'); $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['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] == '-'; $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, + 'allDay' => $alarm['allday'] == 1, + 'action' => $alarm['action'], '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) { $limit = 10; $exdates = array(); $format = $this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']); $format = self::to_php_date_format($format); $format_fn = function($dt) use ($format) { return rcmail::get_instance()->format_date($dt, $format); }; if (is_array($rrule['EXDATE']) && !empty($rrule['EXDATE'])) { $exdates = array_map($format_fn, $rrule['EXDATE']); } if (empty($rrule['FREQ']) && !empty($rrule['RDATE'])) { $rdates = array_map($format_fn, $rrule['RDATE']); if (!empty($exdates)) { $rdates = array_diff($rdates, $exdates); } if (count($rdates) > $limit) { $rdates = array_slice($rdates, 0, $limit); $more = true; } return $this->gettext('ondate') . ' ' . join(', ', $rdates) . ($more ? '...' : ''); } $output = sprintf('%s %d ', $this->gettext('every'), $rrule['INTERVAL'] ?: 1); switch ($rrule['FREQ']) { case 'DAILY': $output .= $this->gettext('days'); break; case 'WEEKLY': $output .= $this->gettext('weeks'); break; case 'MONTHLY': $output .= $this->gettext('months'); break; case 'YEARLY': $output .= $this->gettext('years'); break; } if ($rrule['COUNT']) { $until = $this->gettext(array('name' => 'forntimes', 'vars' => array('nr' => $rrule['COUNT']))); } else if ($rrule['UNTIL']) { $until = $this->gettext('recurrencend') . ' ' . $this->rc->format_date($rrule['UNTIL'], $format); } else { $until = $this->gettext('forever'); } $output .= ', ' . $until; if (!empty($exdates)) { if (count($exdates) > $limit) { $exdates = array_slice($exdates, 0, $limit); $more = true; } $output = '; ' . $this->gettext('except') . ' ' . join(', ', $exdates) . ($more ? '...' : ''); } return $output; } /** * 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 = rcube::Q($this->rc->gettext('delete')); } $content = html::a(array( 'href' => "#delete", 'class' => 'delete', 'onclick' => sprintf("return %s.remove_from_attachment_list('rcmfile%s')", rcmail_output::JS_OBJECT_NAME, $id), 'title' => $this->rc->gettext('delete'), 'aria-label' => $this->rc->gettext('delete') . ' ' . $attachment['name'], ), $button); $content .= rcube::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' => $this->rc->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' => $this->rc->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 rcmail_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', rcube::Q($this->rc->gettext('filename'))); $table->add('header', rcube::Q($this->attachment['name'])); if ($dl_link) { $table->add('download-link', html::a($dl_url, rcube::Q($this->rc->gettext('download')))); } } if (!empty($this->attachment['mimetype'])) { $table->add('title', rcube::Q($this->rc->gettext('type'))); $table->add('header', rcube::Q($this->attachment['mimetype'])); } if (!empty($this->attachment['size'])) { $table->add('title', rcube::Q($this->rc->gettext('filesize'))); $table->add('header', rcube::Q($this->rc->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'] ?: RCUBE_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 = RCUBE_CHARSET; // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($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); } /** * Merge attendees of the old and new event version * with keeping current user and his delegatees status * * @param array &$new New object data * @param array $old Old object data */ public function merge_attendees(&$new, $old) { $emails = $this->get_user_emails(); $delegates = array(); $attendees = array(); // keep attendee status of the current user foreach ((array) $new['attendees'] as $i => $attendee) { if (empty($attendee['email'])) { continue; } $attendees[] = $email = strtolower($attendee['email']); if (in_array($email, $emails)) { foreach ($old['attendees'] as $_attendee) { if ($attendee['email'] == $_attendee['email']) { $new['attendees'][$i] = $_attendee; if ($_attendee['status'] == 'DELEGATED' && ($email = $_attendee['delegated-to'])) { $delegates[] = strtolower($email); } break; } } } } // make sure delegated attendee is not lost foreach ($delegates as $delegatee) { if (!in_array($delegatee, $attendees)) { foreach ((array) $old['attendees'] as $attendee) { if ($attendee['email'] && ($email = strtolower($attendee['email'])) && $email == $delegatee) { $new['attendees'][] = $attendee; break; } } } } } /********* 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', )); } }