From be8a0e0a7a2fe2dc6f16bdb23b7282db9114324e Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Fri, 20 May 2011 19:04:25 +0200 Subject: [PATCH] Added (modified) copy of Roundcube calendar from https://github.com/rc-calendar/calendar --- plugins/calendar/.gitignore | 7 + plugins/calendar/TODO | 37 + plugins/calendar/calendar.js | 768 +++ plugins/calendar/calendar.php | 450 ++ plugins/calendar/config.inc.php.dist | 54 + .../calendar/drivers/caldav/caldav-client.php | 550 ++ .../calendar/drivers/caldav/caldav_driver.php | 158 + plugins/calendar/drivers/calendar_driver.php | 171 + .../drivers/database/database_driver.php | 388 ++ .../calendar/drivers/database/sql/mysql.sql | 70 + .../drivers/database/sql/postgresql.sql | 40 + .../calendar/drivers/database/sql/sqlite.sql | 58 + .../calendar/drivers/kolab/kolab_calendar.php | 176 + .../calendar/drivers/kolab/kolab_driver.php | 272 + plugins/calendar/lib/calendar_ical.php | 82 + plugins/calendar/lib/calendar_ui.php | 394 ++ plugins/calendar/lib/js/fullcalendar.js | 5208 +++++++++++++++++ plugins/calendar/localization/bg_BG.inc | 38 + plugins/calendar/localization/cs_CZ.inc | 38 + plugins/calendar/localization/de_DE.inc | 51 + plugins/calendar/localization/en_US.inc | 99 + plugins/calendar/localization/es_ES.inc | 39 + plugins/calendar/localization/fr_FR.inc | 38 + plugins/calendar/localization/hu_HU.inc | 38 + plugins/calendar/localization/it_IT.inc | 38 + plugins/calendar/localization/nl_NL.inc | 39 + plugins/calendar/localization/pl_PL.inc | 39 + plugins/calendar/localization/pt_BR.inc | 38 + plugins/calendar/localization/ru_RU.inc | 32 + plugins/calendar/skins/default/README.txt | 5 + plugins/calendar/skins/default/calendar.css | 392 ++ .../calendar/skins/default/fullcalendar.css | 618 ++ .../skins/default/fullcalendar.print.css | 61 + .../skins/default/images/calendar-blue.png | Bin 0 -> 896 bytes .../skins/default/images/calendar.png | Bin 0 -> 888 bytes .../calendar/skins/default/images/export.png | Bin 0 -> 746 bytes .../calendar/skins/default/images/preview.png | Bin 0 -> 1131 bytes .../calendar/skins/default/images/print.png | Bin 0 -> 869 bytes .../calendar/skins/default/images/spacer.gif | Bin 0 -> 807 bytes .../calendar/skins/default/images/toggle.gif | Bin 0 -> 110 bytes .../calendar/skins/default/images/toolbar.png | Bin 0 -> 14308 bytes .../skins/default/templates/calendar.html | 171 + 42 files changed, 10657 insertions(+) create mode 100644 plugins/calendar/.gitignore create mode 100644 plugins/calendar/TODO create mode 100644 plugins/calendar/calendar.js create mode 100644 plugins/calendar/calendar.php create mode 100644 plugins/calendar/config.inc.php.dist create mode 100644 plugins/calendar/drivers/caldav/caldav-client.php create mode 100644 plugins/calendar/drivers/caldav/caldav_driver.php create mode 100644 plugins/calendar/drivers/calendar_driver.php create mode 100644 plugins/calendar/drivers/database/database_driver.php create mode 100644 plugins/calendar/drivers/database/sql/mysql.sql create mode 100644 plugins/calendar/drivers/database/sql/postgresql.sql create mode 100644 plugins/calendar/drivers/database/sql/sqlite.sql create mode 100644 plugins/calendar/drivers/kolab/kolab_calendar.php create mode 100644 plugins/calendar/drivers/kolab/kolab_driver.php create mode 100644 plugins/calendar/lib/calendar_ical.php create mode 100644 plugins/calendar/lib/calendar_ui.php create mode 100644 plugins/calendar/lib/js/fullcalendar.js create mode 100644 plugins/calendar/localization/bg_BG.inc create mode 100644 plugins/calendar/localization/cs_CZ.inc create mode 100644 plugins/calendar/localization/de_DE.inc create mode 100644 plugins/calendar/localization/en_US.inc create mode 100644 plugins/calendar/localization/es_ES.inc create mode 100644 plugins/calendar/localization/fr_FR.inc create mode 100644 plugins/calendar/localization/hu_HU.inc create mode 100644 plugins/calendar/localization/it_IT.inc create mode 100644 plugins/calendar/localization/nl_NL.inc create mode 100644 plugins/calendar/localization/pl_PL.inc create mode 100644 plugins/calendar/localization/pt_BR.inc create mode 100644 plugins/calendar/localization/ru_RU.inc create mode 100644 plugins/calendar/skins/default/README.txt create mode 100644 plugins/calendar/skins/default/calendar.css create mode 100644 plugins/calendar/skins/default/fullcalendar.css create mode 100644 plugins/calendar/skins/default/fullcalendar.print.css create mode 100644 plugins/calendar/skins/default/images/calendar-blue.png create mode 100644 plugins/calendar/skins/default/images/calendar.png create mode 100644 plugins/calendar/skins/default/images/export.png create mode 100644 plugins/calendar/skins/default/images/preview.png create mode 100644 plugins/calendar/skins/default/images/print.png create mode 100644 plugins/calendar/skins/default/images/spacer.gif create mode 100644 plugins/calendar/skins/default/images/toggle.gif create mode 100644 plugins/calendar/skins/default/images/toolbar.png create mode 100644 plugins/calendar/skins/default/templates/calendar.html diff --git a/plugins/calendar/.gitignore b/plugins/calendar/.gitignore new file mode 100644 index 00000000..93262bcd --- /dev/null +++ b/plugins/calendar/.gitignore @@ -0,0 +1,7 @@ +*.swp +*.bak +*.old +*~ +config.inc.php +skins/* +!skins/default \ No newline at end of file diff --git a/plugins/calendar/TODO b/plugins/calendar/TODO new file mode 100644 index 00000000..d01a8fdf --- /dev/null +++ b/plugins/calendar/TODO @@ -0,0 +1,37 @@ ++ Edit: 3.12: Subject ++ Edit: 3.13: Location ++ Edit: 3.14: Start / End / All Day ++ Edit: 3.15: Show time as: Busy, Free, Out of office +- Edit: 3.16: Reminder set ++ Edit: 3.17: Priority: High/Low +- Edit: 3.19: Attachment Upload +- Edit: 3.20: Print +- Recurring events +- Add/Manage Attendees + - Edit: 3.21: Required / Optional / Resource specification + - Edit: 3.22: Conflict Handling (Free/Busy Check for attendees) +- Edit: 3.23: Specify folder for new event (prefs) +- View: 3.1: Folder list +- View: 3.3: Display modes (agenda / day / week / month) + + Day / Week / Month + - List (Agenda) view + - Individual days selection +- View: 3.4: Fish-Eye View For Busy Days +- View: 3.5: Search ++ Show list of calendars in a (hideable) drawer + + View: 3.6: Combined calendar view (Turn calendars on/off) + + View: 3.7: Small month overview calendar +- View: 3.8: Color according to calendar and category (similar to Kontact) ++ View: 3.9: Alter event with drag/drop +- Option: 4.12: Set default reminder time ++ Support for multiple calendars (replace categories) +- Remember last visited view +- Allow user to create/edit/delete calendars +- Colors for calendars should be user-configurable +- ICS parser/generator ((http://code.google.com/p/qcal/) +- Importing ICS files +- Create/manage invdividual views + +- Support for tasks/todos with task list view (ordered by date/time) +? Show and edit two different views +? Use ICS format for storage in DB diff --git a/plugins/calendar/calendar.js b/plugins/calendar/calendar.js new file mode 100644 index 00000000..ecbb97a6 --- /dev/null +++ b/plugins/calendar/calendar.js @@ -0,0 +1,768 @@ +/* + +-------------------------------------------------------------------------+ + | Javascript for the Calendar Plugin | + | Version 0.3 beta | + | | + | This program is free software; you can redistribute it and/or modify | + | it under the terms of the GNU General Public License version 2 | + | as published by the Free Software Foundation. | + | | + | 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 General Public License for more details. | + | | + | You should have received a copy of the GNU General Public License along | + | with this program; if not, write to the Free Software Foundation, Inc., | + | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. | + | | + +-------------------------------------------------------------------------+ + | Author: Lazlo Westerhof | + | Thomas Bruederli | + +-------------------------------------------------------------------------+ +*/ + +/* calendar initialization */ +window.rcmail && rcmail.addEventListener('init', function(evt) { + + // quote html entities + function Q(str) + { + return String(str).replace(//g, '>').replace(/"/g, '"'); + } + // php equivalent + function nl2br(str) + { + return String(str).replace(/\n/g, "
"); + } + + // Roundcube calendar client class + function rcube_calendar(settings) + { + this.settings = settings; + var me = this; + + // private vars + var day_clicked = 0; + var ignore_click = false; + + // data loader + var load_events = function(calendar, start, end, callback) { + $.ajax({ + url: "./?_task=calendar&_action=plugin.load_events", + dataType: 'json', + data: { + source: calendar, + start: Math.round(start.getTime() / 1000), + end: Math.round(end.getTime() / 1000) + }, + success: callback + }); + }; + + // event details dialog (show only) + var event_show_dialog = function(event) { + var $dialog = $("#eventshow"); + var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { editable:false }; + + $dialog.find('div.event-section, div.event-line').hide(); + $('#event-title').html(Q(event.title)).show(); + + if (event.location) + $('#event-location').html('@ ' + Q(event.location)).show(); + if (event.description) + $('#event-description').show().children('.event-text').html(nl2br(Q(event.description))); // TODO: format HTML with clickable links and stuff + + // TODO: create a nice human-readable string for the date/time range + var fromto, duration = event.end.getTime() / 1000 - event.start.getTime() / 1000; + if (event.allDay) + fromto = $.fullCalendar.formatDate(event.start, settings['date_format']) + ' — ' + $.fullCalendar.formatDate(event.end, settings['date_format']); + else if (duration < 86400 && event.start.getDay() == event.end.getDay()) + fromto = $.fullCalendar.formatDate(event.start, settings['date_format']) + ' ' + $.fullCalendar.formatDate(event.start, settings['time_format']) + ' — ' + + $.fullCalendar.formatDate(event.end, settings['time_format']); + else + fromto = $.fullCalendar.formatDate(event.start, settings['date_format']) + ' ' + $.fullCalendar.formatDate(event.start, settings['time_format']) + ' — ' + + $.fullCalendar.formatDate(event.end, settings['date_format']) + ' ' + $.fullCalendar.formatDate(event.end, settings['time_format']); + $('#event-date').html(Q(fromto)).show(); + + if (event.recurrence && event.recurrence_text) + $('#event-repeat').show().children('.event-text').html(Q(event.recurrence_text)); + + if (event.reminders && event.reminders_text) + $('#event-alarm').show().children('.event-text').html(Q(event.reminders_text)); + + if (calendar.name) + $('#event-calendar').show().children('.event-text').html(Q(calendar.name)).removeClass().addClass('event-text').addClass('cal-'+calendar.id); + if (event.categories) + $('#event-category').show().children('.event-text').html(Q(event.categories)).removeClass().addClass('event-text '+event.className); + if (event.free_busy) + $('#event-free-busy').show().children('.event-text').html(Q(rcmail.gettext(event.free_busy, 'calendar'))); + if (event.priority != 1) { + var priolabels = { 0:rcmail.gettext('low'), 1:rcmail.gettext('normal'), 2:rcmail.gettext('high') }; + $('#event-priority').show().children('.event-text').html(Q(priolabels[event.priority])); + } + + var buttons = {}; + if (calendar.editable) { + buttons[rcmail.gettext('edit', 'calendar')] = function() { + event_edit_dialog('edit', event); + }; + buttons[rcmail.gettext('remove', 'calendar')] = function() { + me.delete_event(event); + $dialog.dialog('close'); + }; + } + else { + buttons[rcmail.gettext('close', 'calendar')] = function(){ + $dialog.dialog('close'); + }; + } + + // open jquery UI dialog + $dialog.dialog({ + modal: false, + resizable: true, + title: null, + close: function() { + $dialog.dialog('destroy'); + $dialog.hide(); + }, + buttons: buttons, + minWidth: 320, + width: 420 + }).show(); + + $('') + .attr('href', '#') + .html('More Options') + .addClass('dropdown-link') + .click(function(){ return false; }) + .insertBefore($dialog.parent().find('.ui-dialog-buttonset').children().first()); + + }; + + // bring up the event dialog (jquery-ui popup) + var event_edit_dialog = function(action, event) { + // close show dialog first + $("#eventshow").dialog('close'); + + var $dialog = $("#eventedit"); + var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { editable:action=='new' }; + + // reset dialog first, enable/disable fields according to editable state + $('#eventtabs').get(0).reset(); + $('#calendar-select')[(action == 'new' ? 'show' : 'hide')](); + + // event details + var title = $('#edit-title').val(event.title); + var location = $('#edit-location').val(event.location); + var description = $('#edit-description').val(event.description); + var categories = $('#edit-categories').val(event.categories); + var calendars = $('#edit-calendar').val(event.calendar); + var freebusy = $('#edit-free-busy').val(event.free_busy); + var priority = $('#edit-priority').val(event.priority); + + var duration = Math.round((event.end.getTime() - event.start.getTime()) / 1000); + var startdate = $('#edit-startdate').val($.fullCalendar.formatDate(event.start, settings['date_format'])).data('duration', duration); + var starttime = $('#edit-starttime').val($.fullCalendar.formatDate(event.start, settings['time_format'])).show(); + var enddate = $('#edit-enddate').val($.fullCalendar.formatDate(event.end, settings['date_format'])); + var endtime = $('#edit-endtime').val($.fullCalendar.formatDate(event.end, settings['time_format'])).show(); + var allday = $('#edit-allday').get(0); + + if (event.allDay) { + starttime.val("00:00").hide(); + endtime.val("23:59").hide(); + allday.checked = true; + } + else { + allday.checked = false; + } + + // set alarm(s) + // TODO: support multiple alarm entries + if (event.alarms) { + if (typeof event.alarms == 'string') + event.alarms = event.alarms.split(';'); + + for (var alarm, i=0; i < event.alarms.length; i++) { + alarm = String(event.alarms[i]).split(':'); + $('select.edit-alarm-type').val(alarm[0]); + + if (alarm[1].match(/@(\d+)/)) { + var ondate = new Date(parseInt(RegExp.$1)); + $('select.edit-alarm-offset').val('@'); + $('input.edit-alarm-date').val($.fullCalendar.formatDate(ondate, settings['date_format'])); + $('input.edit-alarmtime').val($.fullCalendar.formatDate(ondate, settings['time_format'])); + } + else if (alarm[1].match(/([-+])(\d+)([mhd])/)) { + $('input.edit-alarm-value').val(RegExp.$2); + $('select.edit-alarm-offset').val(''+RegExp.$1+RegExp.$3); + } + } + // set correct visibility by triggering onchange handlers + $('select.edit-alarm-type, select.edit-alarm-offset').change(); + } + + // set recurrence form + var recurrence = $('#edit-recurrence-frequency').val(event.recurrence ? event.recurrence.FREQ : '').change(); + var interval = $('select.edit-recurrence-interval').val(event.recurrence ? event.recurrence.INTERVAL : 1); + var rrtimes = $('#edit-recurrence-repeat-times').val(event.recurrence ? event.recurrence.COUNT : 1); + var rrenddate = $('#edit-recurrence-enddate').val(event.recurrence && event.recurrence.UNTIL ? $.fullCalendar.formatDate(new Date(event.recurrence.UNTIL*1000), settings['date_format']) : ''); + $('input.edit-recurrence-until:checked').prop('checked', false); + + var weekdays = ['SU','MO','TU','WE','TH','FR','SA']; + var rrepeat_id = '#edit-recurrence-repeat-forever'; + if (event.recurrence && event.recurrence.COUNT) rrepeat_id = '#edit-recurrence-repeat-count'; + else if (event.recurrence && event.recurrence.UNTIL) rrepeat_id = '#edit-recurrence-repeat-until'; + $(rrepeat_id).prop('checked', true); + + if (event.recurrence && event.recurrence.BYDAY && event.recurrence.FREQ == 'WEEKLY') { + var wdays = event.recurrence.BYDAY.split(','); + $('input.edit-recurrence-weekly-byday').val(wdays); + } + else if (event.start) { + $('input.edit-recurrence-weekly-byday').val([weekdays[event.start.getDay()]]); + } + if (event.recurrence && event.recurrence.BYMONTHDAY) { + $('input.edit-recurrence-monthly-bymonthday').val(String(event.recurrence.BYMONTHDAY).split(',')); + $('input.edit-recurrence-monthly-mode').val(['BYMONTHDAY']); + } + else if (event.start) { + $('input.edit-recurrence-monthly-bymonthday').val([event.start.getDate()]); + } + if (event.recurrence && event.recurrence.BYDAY && (event.recurrence.FREQ == 'MONTHLY' || event.recurrence.FREQ == 'YEARLY')) { + var byday, section = event.recurrence.FREQ.toLowerCase(); + if ((byday = String(event.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 (event.start) { + $('#edit-recurrence-monthly-byday').val(weekdays[event.start.getDay()]); + } + if (event.recurrence && event.recurrence.BYMONTH) { + $('input.edit-recurrence-yearly-bymonth').val(String(event.recurrence.BYMONTH).split(',')); + } + else if (event.start) { + $('input.edit-recurrence-yearly-bymonth').val([String(event.start.getMonth()+1)]); + } + + // buttons + var buttons = {}; + + buttons[rcmail.gettext('save', 'calendar')] = function() { + var start = me.parse_datetime(starttime.val(), startdate.val()); + var end = me.parse_datetime(endtime.val(), enddate.val()); + + // post data to server + var data = { + action: action, + start: start.getTime()/1000, + end: end.getTime()/1000, + allday: allday.checked?1:0, + title: title.val(), + description: description.val(), + location: location.val(), + categories: categories.val(), + free_busy: freebusy.val(), + priority: priority.val(), + recurrence: '', + alarms:'', + }; + + // serialize alarm settings + // TODO: support multiple alarm entries + var alarm = $('select.edit-alarm-type').val(); + if (alarm) { + var val, offset = $('select.edit-alarm-offset').val(); + if (offset == '@') + data.alarms = alarm + ':@' + (me.parse_datetime($('input.edit-alarm-time').val(), $('input.edit-alarm-date').val()).getTime()/1000); + else if ((val = parseInt($('input.edit-alarm-value').val())) && !isNaN(val) && val >= 0) + data.alarms = alarm + ':' + offset[0] + val + offset[1]; + } + + // gather recurrence settings + var freq; + if ((freq = recurrence.val()) != '') { + data.recurrence = { + FREQ: freq, + INTERVAL: $('#edit-recurrence-interval-'+freq.toLowerCase()).val() + }; + + var until = $('input.edit-recurrence-until:checked').val(); + if (until == 'count') + data.recurrence.COUNT = rrtimes.val(); + else if (until == 'until') + data.recurrence.UNTIL = me.parse_datetime(endtime.val(), rrenddate.val()).getTime()/1000; + + if (freq == 'WEEKLY') { + var byday = []; + $('input.edit-recurrence-weekly-byday:checked').each(function(){ byday.push(this.value); }); + data.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); }); + data.recurrence.BYMONTHDAY = bymonday.join(','); + } + else + data.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); }); + data.recurrence.BYMONTH = bymonth.join(','); + if ((byday = $('#edit-recurrence-yearly-byday').val())) + data.recurrence.BYDAY = $('#edit-recurrence-yearly-prefix').val() + byday; + } + } + + if (event.id) + data.id = event.id; + else + data.calendar = calendars.val(); + + rcmail.http_post('plugin.event', { e:data }); + $dialog.dialog("close"); + }; + + if (event.id) { + buttons[rcmail.gettext('remove', 'calendar')] = function() { + me.delete_event(event); + $dialog.dialog('close'); + }; + } + + buttons[rcmail.gettext('cancel', 'calendar')] = function() { + $dialog.dialog("close"); + }; + + // show/hide tabs according to calendar's feature support + $('#edit-tab-attendees')[(calendar.attendees?'show':'hide')](); + $('#edit-tab-attachments')[(calendar.attachments?'show':'hide')](); + + // activate the first tab + $('#eventtabs').tabs('select', 0); + + // open jquery UI dialog + $dialog.dialog({ + modal: true, + resizable: true, + title: rcmail.gettext((action == 'edit' ? 'edit_event' : 'new_event'), 'calendar'), + close: function() { + $dialog.dialog("destroy"); + $dialog.hide(); + }, + buttons: buttons, + minWidth: 440, + width: 480 + }).show(); + + title.select(); + }; + + // mouse-click handler to check if the show dialog is still open and prevent default action + var dialog_check = function(e) { + var showd = $("#eventshow"); + if (showd.is(':visible') && !$(e.target).closest('.ui-dialog').length) { + showd.dialog('close'); + e.stopImmediatePropagation(); + ignore_click = true; + return false; + } + else if (ignore_click) { + window.setTimeout(function(){ ignore_click = false; }, 20); + return false; + } + return true; + }; + + // general datepicker settings + this.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, + }; + + + // 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 = $.datepicker.parseDate(me.datepicker_settings.dateFormat, date, me.datepicker_settings); + var time_arr = time.split(/[:.]/); + if (!isNaN(time_arr[0])) date.setHours(time_arr[0]); + if (!isNaN(time_arr[1])) date.setMinutes(time_arr[1]); + return date; + }; + + + // public method to bring up the new event dialog + this.add_event = function() { + if (this.selected_calendar) { + var now = new Date(); + var date = $('#calendar').fullCalendar('getDate') || now; + date.setHours(now.getHours()+1); + date.setMinutes(0); + var end = new Date(date.getTime()); + end.setHours(date.getHours()+1); + event_edit_dialog('new', { start:date, end:end, allDay:false, calendar:this.selected_calendar }); + } + }; + + // delete the given event after showing a confirmation dialog + this.delete_event = function(event) { + // send remove request to plugin + if (confirm(rcmail.gettext('deleteventconfirm', 'calendar'))) { + rcmail.http_post('plugin.event', { e:{ action:'remove', id:event.id } }); + return true; + } + + return false; + }; + + + // create list of event sources AKA calendars + this.calendars = {}; + var li, cal, event_sources = []; + for (var id in rcmail.env.calendars) { + cal = rcmail.env.calendars[id]; + this.calendars[id] = $.extend({ + events: function(start, end, callback) { load_events(id, start, end, callback); }, + editable: !cal.readonly, + className: 'fc-event-cal-'+id, + id: id + }, cal); + event_sources.push(this.calendars[id]); + + // init event handler on calendar list checkbox + if ((li = rcmail.get_folder_li(id, 'rcmlical'))) { + $('#'+li.id+' input').click(function(e){ + var id = $(this).data('id'); + if (me.calendars[id]) { // add or remove event source on click + var action = this.checked ? 'addEventSource' : 'removeEventSource'; + $('#calendar').fullCalendar(action, me.calendars[id]); + } + }).data('id', id); + $(li).click(function(e){ + rcmail.select_folder(id, me.selected_calendar, 'rcmlical'); + me.selected_calendar = $(this).data('id'); + }).data('id', id); + } + + if (!cal.readonly) { + this.selected_calendar = id; + rcmail.enable_command('plugin.addevent', true); + } + } + + // initalize the fullCalendar plugin + $('#calendar').fullCalendar({ + header: { + left: 'prev,next today', + center: 'title', + right: 'agendaDay,agendaWeek,month' + }, + aspectRatio: 1, + height: $(window).height() - 95, + eventSources: event_sources, + monthNames : settings['months'], + monthNamesShort : settings['months_short'], + dayNames : settings['days'], + dayNamesShort : settings['days_short'], + firstDay : settings['first_day'], + firstHour : settings['first_hour'], + slotMinutes : 60/settings['timeslots'], + timeFormat: settings['time_format'], + axisFormat : settings['time_format'], + columnFormat: { + month: 'ddd', // Mon + week: 'ddd ' + settings['date_short'], // Mon 9/7 + day: 'dddd ' + settings['date_short'] // Monday 9/7 + }, + defaultView: settings['default_view'], + allDayText: rcmail.gettext('all-day', 'calendar'), + buttonText: { + today: settings['today'], + day: rcmail.gettext('day', 'calendar'), + week: rcmail.gettext('week', 'calendar'), + month: rcmail.gettext('month', 'calendar') + }, + selectable: true, + selectHelper: true, + loading : function(isLoading) { + this._rc_loading = rcmail.set_busy(isLoading, 'loading', this._rc_loading); + }, + // event rendering + eventRender: function(event, element, view) { + if(view.name != "month") { + if (event.categories) { + if(!event.allDay) + element.find('span.fc-event-title').after('' + event.categories + ''); + } + if (event.location) { + element.find('span.fc-event-title').after('@' + event.location + ''); + } + if (event.description) { + if (!event.allDay){ + element.find('span.fc-event-title').after('' + event.description + ''); + } + } + } + }, + // callback for date range selection + select: function(start, end, allDay, e, view) { + var range_select = (!allDay || start.getDate() != end.getDate()) + if (dialog_check(e) && range_select) + event_edit_dialog('new', { start:start, end:end, allDay:allDay, calendar:me.selected_calendar }); + if (range_select || ignore_click) + view.calendar.unselect(); + }, + // callback for clicks in all-day box + dayClick: function(date, allDay, e, view) { + var now = new Date().getTime(); + if (now - day_clicked < 400) // emulate double-click on day + event_edit_dialog('new', { start:date, end:date, allDay:allDay, calendar:me.selected_calendar }); + day_clicked = now; + if (!ignore_click) { + view.calendar.gotoDate(date); + fullcalendar_update(); + } + }, + // callback when a specific event is clicked + eventClick : function(event) { + event_show_dialog(event); + }, + // callback when an event was dragged and finally dropped + eventDrop: function(event, dayDelta, minuteDelta, allDay, revertFunc) { + if (event.end == null) { + event.end = event.start; + } + // send move request to server + var data = { + action: 'move', + id: event.id, + start: event.start.getTime()/1000, + end: event.end.getTime()/1000, + allday: allDay?1:0 + }; + rcmail.http_post('plugin.event', { e:data }); + }, + // callback for event resizing + eventResize : function(event, delta) { + // send resize request to server + var data = { + action: 'resize', + id: event.id, + start: event.start.getTime()/1000, + end: event.end.getTime()/1000, + }; + rcmail.http_post('plugin.event', { e:data }); + } + }); + + + // event handler for clicks on calendar week cell of the datepicker widget + var init_week_events = function(){ + $('#datepicker table.ui-datepicker-calendar td.ui-datepicker-week-col').click(function(e){ + var base_date = $("#datepicker").datepicker('getDate'); + var day_off = base_date.getDay() - 1; + if (day_off < 0) day_off = 6; + var base_kw = $.datepicker.iso8601Week(base_date); + var kw = parseInt($(this).html()); + var diff = (kw - base_kw) * 7 * 86400000; + // select monday of the chosen calendar week + var date = new Date(base_date.getTime() - day_off * 86400000 + diff); + $('#calendar').fullCalendar('gotoDate', date).fullCalendar('setDate', date).fullCalendar('changeView', 'agendaWeek'); + $("#datepicker").datepicker('setDate', date); + window.setTimeout(init_week_events, 10); + }).css('cursor', 'pointer'); + }; + + // initialize small calendar widget using jQuery UI datepicker + $('#datepicker').datepicker($.extend(this.datepicker_settings, { + inline: true, + showWeek: true, + changeMonth: false, // maybe enable? + changeYear: false, // maybe enable? + onSelect: function(dateText, inst) { + ignore_click = true; + var d = $("#datepicker").datepicker('getDate'); //parse_datetime('0:0', dateText); + $('#calendar').fullCalendar('gotoDate', d).fullCalendar('select', d, d, true); + window.setTimeout(init_week_events, 10); + }, + onChangeMonthYear: function(year, month, inst) { + window.setTimeout(init_week_events, 10); + var d = $("#datepicker").datepicker('getDate'); + d.setYear(year); + d.setMonth(month - 1); + $("#datepicker").data('year', year).data('month', month); + //$('#calendar').fullCalendar('gotoDate', d).fullCalendar('setDate', d); + }, + })); + window.setTimeout(init_week_events, 10); + + // react on fullcalendar buttons + var fullcalendar_update = function() { + var d = $('#calendar').fullCalendar('getDate'); + $("#datepicker").datepicker('setDate', d); + window.setTimeout(init_week_events, 10); + }; + $("#calendar .fc-button-prev").click(fullcalendar_update); + $("#calendar .fc-button-next").click(fullcalendar_update); + $("#calendar .fc-button-today").click(fullcalendar_update); + + // hide event dialog when clicking somewhere into document + $(document).bind('mousedown', dialog_check); + + } // end rcube_calendar class + + + // configure toobar buttons + rcmail.register_command('plugin.addevent', function(){ cal.add_event(); }, true); + + // export events + rcmail.register_command('plugin.export', function(){ rcmail.goto_url('plugin.export_events', { source:cal.selected_calendar }); }, true); + rcmail.enable_command('plugin.export', true); + + // reload calendar + rcmail.addEventListener('plugin.reload_calendar', reload_calendar); + function reload_calendar() { + $('#calendar').fullCalendar('refetchEvents'); + } + + + var formattime = function(hour, minutes) { + return ((hour < 10) ? "0" : "") + hour + ((minutes < 10) ? ":0" : ":") + minutes; + }; + + // if start date is changed, shift end date according to initial duration + var shift_enddate = function(dateText) { + var newstart = cal.parse_datetime('0', dateText); + var newend = new Date(newstart.getTime() + $('#edit-startdate').data('duration') * 1000); + $('#edit-enddate').val($.fullCalendar.formatDate(newend, cal.settings['date_format'])); + }; + + + // let's go + var cal = new rcube_calendar(rcmail.env.calendar_settings); + + $(window).resize(function() { + $('#calendar').fullCalendar('option', 'height', $(window).height() - 95); + }).resize(); + + // show toolbar + $('#toolbar').show(); + + // init event dialog + $('#eventtabs').tabs(); + $('#edit-enddate, input.edit-alarm-date').datepicker(cal.datepicker_settings); + $('#edit-startdate').datepicker(cal.datepicker_settings).datepicker('option', 'onSelect', shift_enddate).change(function(){ shift_enddate(this.value); }); + $('#edit-allday').click(function(){ $('#edit-starttime, #edit-endtime')[(this.checked?'hide':'show')](); }); + + // configure drop-down menu on time input fields based on jquery UI autocomplete + $('#edit-starttime, #edit-endtime, input.edit-alarm-time') + .attr('autocomplete', "off") + .autocomplete({ + delay: 100, + minLength: 1, + source: function(p, callback) { + /* Time completions */ + var result = []; + var now = new Date(); + var full = p.term - 1 > 0 || p.term.length > 1; + var hours = full? p.term - 0 : now.getHours(); + var step = 15; + var minutes = hours * 60 + (full ? 0 : now.getMinutes()); + var min = Math.ceil(minutes / step) * step % 60; + var hour = Math.floor(Math.ceil(minutes / step) * step / 60); + // list hours from 0:00 till now + for (var h = 0; h < hours; h++) + result.push(formattime(h, 0)); + // list 15min steps for the next two hours + for (; h < hour + 2; h++) { + while (min < 60) { + result.push(formattime(h, min)); + min += step; + } + min = 0; + } + // list the remaining hours till 23:00 + while (h < 24) + result.push(formattime((h++), 0)); + return callback(result); + }, + open: function(event, ui) { + // scroll to current time + var widget = $(this).autocomplete('widget'); + var menu = $(this).data('autocomplete').menu; + var val = $(this).val(); + var li, html, offset = 0; + widget.children().each(function(){ + li = $(this); + html = li.children().first().html(); + if (html < val) + offset += li.height(); + if (html == val) + menu.activate($.Event({ type: 'mouseenter' }), li); + }); + widget.scrollTop(offset - 1); + } + }) + .click(function() { // show drop-down upon clicks + $(this).autocomplete('search', $(this).val() ? $(this).val().replace(/\D.*/, "") : " "); + }); + + // register events on alarm fields + $('select.edit-alarm-type').change(function(){ + $(this).parent().find('span.edit-alarm-values')[(this.selectedIndex>0?'show':'hide')](); + }); + $('select.edit-alarm-offset').change(function(){ + var mode = $(this).val() == '@' ? 'show' : 'hide'; + $(this).parent().find('.edit-alarm-date, .edit-alarm-time')[mode](); + $(this).parent().find('.edit-alarm-value').prop('disabled', mode == 'show'); + }); + + // toggle recurrence frequency forms + $('#edit-recurrence-frequency').change(function(e){ + var freq = $(this).val().toLowerCase(); + $('.recurrence-form').hide(); + if (freq) + $('#recurrence-form-'+freq+', #recurrence-form-until').show(); + }); + $('#edit-recurrence-enddate').datepicker(cal.datepicker_settings).click(function(){ $("#edit-recurrence-repeat-until").prop('checked', true) }); + + // avoid unselecting all weekdays, monthdays and months + $('input.edit-recurrence-weekly-byday, input.edit-recurrence-monthly-bymonthday, input.edit-recurrence-yearly-bymonth').click(function(){ + if (!$('input.'+this.className+':checked').length) + this.checked = true; + }); + + // initialize sidebar toggle + $('#sidebartoggle').click(function() { + var width = $(this).data('sidebarwidth'); + var offset = $(this).data('offset'); + var $sidebar = $('#sidebar'), time = 250; + + if ($sidebar.is(':visible')) { + $sidebar.animate({ left:'-'+(width+10)+'px' }, time, function(){ $('#sidebar').hide(); }); + $(this).animate({ left:'6px'}, time, function(){ $('#sidebartoggle').addClass('sidebarclosed') }); + $('#calendar').animate({ left:'20px'}, time, function(){ $(this).fullCalendar('render'); }); + } + else { + $sidebar.show().animate({ left:'10px' }, time); + $(this).animate({ left:offset+'px'}, time, function(){ $('#sidebartoggle').removeClass('sidebarclosed'); }); + $('#calendar').animate({ left:(width+20)+'px'}, time, function(){ $(this).fullCalendar('render'); }); + } + }) + .data('offset', $('#sidebartoggle').position().left) + .data('sidebarwidth', $('#sidebar').width() + $('#sidebar').position().left); + +}); diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php new file mode 100644 index 00000000..f963dbc3 --- /dev/null +++ b/plugins/calendar/calendar.php @@ -0,0 +1,450 @@ + | + | Thomas Bruederli | + +-------------------------------------------------------------------------+ +*/ + +class calendar extends rcube_plugin +{ + public $task = '?(?!login|logout).*'; + public $rc; + public $driver; + + public $ical; + public $ui; + + /** + * Plugin initialization. + */ + function init() + { + $this->rc = rcmail::get_instance(); + + $this->register_task('calendar', 'calendar'); + + // load calendar configuration + if(file_exists($this->home . "/config.inc.php")) { + $this->load_config('config.inc.php'); + } else { + $this->load_config('config.inc.php.dist'); + } + + // load localizations + $this->add_texts('localization/', true); + + // load Calendar user interface which includes jquery-ui + $this->require_plugin('jqueryui'); + + require('lib/calendar_ui.php'); + $this->ui = new calendar_ui($this); + $this->ui->init(); + + $skin = $this->rc->config->get('skin'); + $this->include_stylesheet('skins/' . $skin . '/calendar.css'); + + if ($this->rc->task == 'calendar') { + $this->load_driver(); + + // load iCalendar functions + require('lib/calendar_ical.php'); + $this->ical = new calendar_ical($this->rc, $this->driver); + + // register calendar actions + $this->register_action('index', array($this, 'calendar_view')); + $this->register_action('plugin.calendar', array($this, 'calendar_view')); + $this->register_action('plugin.load_events', array($this, 'load_events')); + $this->register_action('plugin.event', array($this, 'event')); + $this->register_action('plugin.export_events', array($this, 'export_events')); + + // set user's timezone + if ($this->rc->config->get('timezone') === 'auto') + $this->timezone = isset($_SESSION['timezone']) ? $_SESSION['timezone'] : date('Z'); + else + $this->timezone = ($this->rc->config->get('timezone') + intval($this->rc->config->get('dst_active'))); + + $this->gmt_offset = $this->timezone * 3600; + } + else if ($this->rc->task == 'settings') { + $this->load_driver(); + + // add hooks for Calendar settings + $this->add_hook('preferences_sections_list', array($this, 'preferences_sections_list')); + $this->add_hook('preferences_list', array($this, 'preferences_list')); + $this->add_hook('preferences_save', array($this, 'preferences_save')); + } + } + + private function load_driver() + { + $driver_name = $this->rc->config->get('calendar_driver', 'database'); + $driver_class = $driver_name . '_driver'; + + require_once('drivers/calendar_driver.php'); + require_once('drivers/' . $driver_name . '/' . $driver_class . '.php'); + + switch ($driver_name) { + case "kolab": + $this->require_plugin('kolab_core'); + default: + $this->driver = new $driver_class($this); + break; + } + } + + function calendar_view() + { + $this->rc->output->set_pagetitle($this->gettext('calendar')); + + // Add CSS stylesheets to the page header + $this->ui->addCSS(); + + // Add JS files to the page header + $this->ui->addJS(); + + $this->register_handler('plugin.calendar_css', array($this->ui, 'calendar_css')); + $this->register_handler('plugin.calendar_list', array($this->ui, 'calendar_list')); + $this->register_handler('plugin.calendar_select', array($this->ui, 'calendar_select')); + $this->register_handler('plugin.category_select', array($this->ui, 'category_select')); + $this->register_handler('plugin.freebusy_select', array($this->ui, 'freebusy_select')); + $this->register_handler('plugin.priority_select', array($this->ui, 'priority_select')); + $this->register_handler('plugin.alarm_select', array($this->ui, 'alarm_select')); + $this->register_handler('plugin.recurrence_form', array($this->ui, 'recurrence_form')); + + $this->rc->output->set_env('calendar_settings', $this->load_settings()); + $this->rc->output->add_label('low','normal','high'); + + $this->rc->output->send("calendar.calendar"); + } + + /** + * Handler for preferences_sections_list hook. + * Adds Calendar settings sections into preferences sections list. + * + * @param array Original parameters + * @return array Modified parameters + */ + function preferences_sections_list($p) + { + $p['list']['calendar'] = array( + 'id' => 'calendar', 'section' => $this->gettext('calendar'), + ); + + return $p; + } + + /** + * Handler for preferences_list hook. + * Adds options blocks into Calendar settings sections in Preferences. + * + * @param array Original parameters + * @return array Modified parameters + */ + function preferences_list($p) + { + if ($p['section'] == 'calendar') { + $p['blocks']['view']['name'] = $this->gettext('mainoptions'); + + $field_id = 'rcmfd_default_view'; + $select = new html_select(array('name' => '_default_view', 'id' => $field_id)); + $select->add($this->gettext('day'), "agendaDay"); + $select->add($this->gettext('week'), "agendaWeek"); + $select->add($this->gettext('month'), "month"); + $p['blocks']['view']['options']['default_view'] = array( + 'title' => html::label($field_id, Q($this->gettext('default_view'))), + 'content' => $select->show($this->rc->config->get('calendar_default_view', "agendaWeek")), + ); + + $field_id = 'rcmfd_time_format'; + $choices = array('HH:mm', 'H:mm', 'h:mmt'); + $select = new html_select(array('name' => '_time_format', 'id' => $field_id)); + $select->add($choices); + $p['blocks']['view']['options']['time_format'] = array( + 'title' => html::label($field_id, Q($this->gettext('time_format'))), + 'content' => $select->show($this->rc->config->get('calendar_time_format', "HH:mm")), + ); + + $field_id = 'rcmfd_timeslot'; + $choices = array('1', '2', '3', '4', '6'); + $select = new html_select(array('name' => '_timeslots', 'id' => $field_id)); + $select->add($choices); + $p['blocks']['view']['options']['timeslots'] = array( + 'title' => html::label($field_id, Q($this->gettext('timeslots'))), + 'content' => $select->show($this->rc->config->get('calendar_timeslots', 2)), + ); + + $field_id = 'rcmfd_timeslot'; + $select = new html_select(array('name' => '_first_day', 'id' => $field_id)); + $select->add(rcube_label('sunday'), '0'); + $select->add(rcube_label('monday'), '1'); + $select->add(rcube_label('tuesday'), '2'); + $select->add(rcube_label('wednesday'), '3'); + $select->add(rcube_label('thursday'), '4'); + $select->add(rcube_label('friday'), '5'); + $select->add(rcube_label('saturday'), '6'); + $p['blocks']['view']['options']['first_day'] = array( + 'title' => html::label($field_id, Q($this->gettext('first_day'))), + 'content' => $select->show($this->rc->config->get('calendar_first_day', 1)), + ); + + + // category definitions + $p['blocks']['categories']['name'] = $this->gettext('categories'); + + $categories = $this->rc->config->get('calendar_categories', array()); + $categories_list = ''; + foreach ($categories as $name => $color){ + $key = md5($name); + $field_class = 'rcmfd_category_' . str_replace(' ', '_', $name); + $category_remove = new html_inputfield(array('type' => 'button', 'value' => 'X', 'class' => 'button', 'onclick' => '$(this).parent().remove()', 'title' => $this->gettext('remove_category'))); + $category_name = new html_inputfield(array('name' => "_categories[$key]", 'class' => $field_class, 'size' => 30)); + $category_color = new html_inputfield(array('name' => "_colors[$key]", 'class' => $field_class, 'size' => 6)); + $categories_list .= html::div(null, $category_name->show($name) . ' ' . $category_color->show($color) . ' ' . $category_remove->show()); + } + + $p['blocks']['categories']['options']['category_' . $name] = array( + 'content' => html::div(array('id' => 'calendarcategories'), $categories_list), + ); + + $field_id = 'rcmfd_new_category'; + $new_category = new html_inputfield(array('name' => '_new_category', 'id' => $field_id, 'size' => 30)); + $add_category = new html_inputfield(array('type' => 'button', 'class' => 'button', 'value' => $this->gettext('add_category'), 'onclick' => "rcube_calendar_add_category()")); + $p['blocks']['categories']['options']['categories'] = array( + 'content' => $new_category->show('') . ' ' . $add_category->show(), + ); + + $this->rc->output->add_script('function rcube_calendar_add_category(){ + var name = $("#rcmfd_new_category").val(); + if (name.length) { + var input = $("").attr("type", "text").attr("name", "_categories[]").attr("size", 30).val(name); + var color = $("").attr("type", "text").attr("name", "_colors[]").attr("size", 6).val("000000"); + var button = $("").attr("type", "button").attr("value", "X").addClass("button").click(function(){ $(this).parent().remove() }); + $("
").append(input).append(" ").append(color).append(" ").append(button).appendTo("#calendarcategories"); + } + }'); + } + + return $p; + } + + /** + * Handler for preferences_save hook. + * Executed on Calendar settings form submit. + * + * @param array Original parameters + * @return array Modified parameters + */ + function preferences_save($p) + { + if ($p['section'] == 'calendar') { + // categories + $old_categories = $new_categories = array(); + foreach ($this->driver->list_categories() as $name => $color) { + $old_categories[md5($name)] = $name; + } + $categories = get_input_value('_categories', RCUBE_INPUT_POST); + $colors = get_input_value('_colors', RCUBE_INPUT_POST); + foreach ($categories as $key => $name) { + $color = preg_replace('/^#/', '', strval($colors[$key])); + + // rename categories in existing events -> driver's job + if ($oldname = $old_categories[$key]) { + $this->driver->replace_category($oldname, $name, $color); + unset($old_categories[$key]); + } + else + $this->driver->add_category($name, $color); + + $new_categories[$name] = $color; + } + + // these old categories have been removed, alter events accordingly -> driver's job + foreach ((array)$old_categories[$key] as $key => $name) { + $this->driver->remove_category($name); + } + + $p['prefs'] = array( + 'calendar_default_view' => get_input_value('_default_view', RCUBE_INPUT_POST), + 'calendar_time_format' => get_input_value('_time_format', RCUBE_INPUT_POST), + 'calendar_timeslots' => get_input_value('_timeslots', RCUBE_INPUT_POST), + 'calendar_first_day' => get_input_value('_first_day', RCUBE_INPUT_POST), + 'calendar_categories' => $new_categories, + ); + } + + return $p; + } + + function event() + { + $event = get_input_value('e', RCUBE_INPUT_POST); + $success = false; + + switch ($event['action']) { + case "new": + // create UID for new event + $events['uid'] = strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($this->rc->user->get_username()), 0, 16)); + $success = $this->driver->new_event($event); + break; + case "edit": + $success = $this->driver->edit_event($event); + break; + case "resize": + $success = $this->driver->resize_event($event); + break; + case "move": + $success = $this->driver->move_event($event); + break; + case "remove": + $success = $this->driver->remove_event($event); + break; + } + + if ($success) { + $this->rc->output->command('plugin.reload_calendar', array()); + } + else { + $this->rc->output->show_message('calendar.errorsaving', 'error'); + } + } + + function load_events() + { + $events = $this->driver->load_events(get_input_value('start', RCUBE_INPUT_GET), get_input_value('end', RCUBE_INPUT_GET), get_input_value('source', RCUBE_INPUT_GET)); + echo $this->encode($events); + exit; + } + + function export_events() + { + $start = get_input_value('start', RCUBE_INPUT_GET); + $end = get_input_value('end', RCUBE_INPUT_GET); + if (!$start) $start = mktime(0, 0, 0, 1, date('n'), date('Y')-1); + if (!$end) $end = mktime(0, 0, 0, 31, 12, date('Y')+10); + $events = $this->driver->load_events($start, $end, get_input_value('source', RCUBE_INPUT_GET)); + + header("Content-Type: text/calendar"); + header("Content-Disposition: inline; filename=calendar.ics"); + + echo $this->ical->export($events); + exit; + } + + function load_settings() + { + $settings = array(); + + // configuration + $settings['default_view'] = (string)$this->rc->config->get('calendar_default_view', "agendaWeek"); + $settings['date_format'] = (string)$this->rc->config->get('calendar_date_format', "yyyy/MM/dd"); + $settings['date_short'] = (string)$this->rc->config->get('calendar_date_short', "M/d"); + $settings['time_format'] = (string)$this->rc->config->get('calendar_time_format', "HH:mm"); + $settings['timeslots'] = (int)$this->rc->config->get('calendar_timeslots', 2); + $settings['first_day'] = (int)$this->rc->config->get('calendar_first_day', 1); + $settings['first_hour'] = (int)$this->rc->config->get('calendar_first_hour', 6); + $settings['timezone'] = $this->timezone; + + // localization + $settings['days'] = array( + rcube_label('sunday'), rcube_label('monday'), + rcube_label('tuesday'), rcube_label('wednesday'), + rcube_label('thursday'), rcube_label('friday'), + rcube_label('saturday') + ); + $settings['days_short'] = array( + rcube_label('sun'), rcube_label('mon'), + rcube_label('tue'), rcube_label('wed'), + rcube_label('thu'), rcube_label('fri'), + rcube_label('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'] = rcube_label('today'); + + return $settings; + } + + /** + * Convert the given time stamp to a GMT date string + */ + function toGMT($time, $user_tz = true) + { + $tz = $user_tz ? $this->gmt_offset : date('Z'); + return date('Y-m-d H:i:s', $time - $tz); + } + + /** + * Shift the given time stamo to a GMT time zone + */ + function toGMTTS($time, $user_tz = true) + { + $tz = $user_tz ? $this->gmt_offset : date('Z'); + return $time - $tz; + } + + /** + * Convert the given date string into a GMT-based time stamp + */ + function fromGMT($datetime, $user_tz = true) + { + $tz = $user_tz ? $this->gmt_offset : date('Z'); + return strtotime($datetime) + $tz; + } + + /** + * Encode events as JSON + * + * @param array Events as array + * @return string JSON encoded events + */ + function encode($events) + { + $json = array(); + foreach ($events as $event) { + // TODO: compose a human readable string for recurrence_text + $json[] = array( + 'start' => date('c', $event['start']), // ISO 8601 date (added in PHP 5) + 'end' => date('c', $event['end']), // ISO 8601 date (added in PHP 5) + 'description' => $event['description'], + 'location' => $event['location'], + 'className' => 'cat-' . asciiwords($event['categories'], true), + 'allDay' => ($event['all_day'] == 1)?true:false, + ) + $event; + } + return json_encode($json); + } + + +} diff --git a/plugins/calendar/config.inc.php.dist b/plugins/calendar/config.inc.php.dist new file mode 100644 index 00000000..4b8cb0b1 --- /dev/null +++ b/plugins/calendar/config.inc.php.dist @@ -0,0 +1,54 @@ + | + +-------------------------------------------------------------------------+ +*/ + +// backend type (database, google, kolab) +$rcmail_config['calendar_driver'] = "database"; + +// default calendar view (agendaDay, agendaWeek, month) +$rcmail_config['calendar_default_view'] = "agendaWeek"; + +// general date format +$rcmail_config['calendar_date_format'] = "yyyy-MM-dd"; + +// time format (HH:mm, H:mm, h:mmt) +$rcmail_config['calendar_time_format'] = "HH:mm"; + +// short date format (used for column titles) +$rcmail_config['calendar_date_short'] = 'M-d'; + +// timeslots per hour (1, 2, 3, 4, 6) +$rcmail_config['calendar_timeslots'] = 2; + +// first day of the week (0-6) +$rcmail_config['calendar_first_day'] = 1; + +// first hour of the calendar (0-23) +$rcmail_config['calendar_first_hour'] = 6; + +// event categories +$rcmail_config['calendar_categories'] = array('Personal' => 'c0c0c0', + 'Work' => 'ff0000', + 'Family' => '00ff00', + 'Holiday' => 'ff6600'); +?> \ No newline at end of file diff --git a/plugins/calendar/drivers/caldav/caldav-client.php b/plugins/calendar/drivers/caldav/caldav-client.php new file mode 100644 index 00000000..4b6e9076 --- /dev/null +++ b/plugins/calendar/drivers/caldav/caldav-client.php @@ -0,0 +1,550 @@ + +* @copyright Andrew McMillan +* @license http://gnu.org/copyleft/gpl.html GNU GPL v2 +*/ + + +/** +* A class for accessing DAViCal via CalDAV, as a client +* +* @package awl +*/ +class CalDAVClient { + /** + * Server, username, password, calendar + * + * @var string + */ + var $base_url, $user, $pass, $calendar, $entry, $protocol, $server, $port; + + /** + * The useragent which is send to the caldav server + * + * @var string + */ + var $user_agent = 'DAViCalClient'; + + var $headers = array(); + var $body = ""; + var $requestMethod = "GET"; + var $httpRequest = ""; // for debugging http headers sent + var $xmlRequest = ""; // for debugging xml sent + var $httpResponse = ""; // for debugging http headers received + var $xmlResponse = ""; // for debugging xml received + + /** + * Constructor, initialises the class + * + * @param string $base_url The URL for the calendar server + * @param string $user The name of the user logging in + * @param string $pass The password for that user + * @param string $calendar The name of the calendar (not currently used) + */ + function CalDAVClient( $base_url, $user, $pass, $calendar = '' ) { + $this->user = $user; + $this->pass = $pass; + $this->calendar = $calendar; + $this->headers = array(); + + if ( preg_match( '#^(https?)://([a-z0-9.-]+)(:([0-9]+))?(/.*)$#', $base_url, $matches ) ) { + $this->server = $matches[2]; + $this->base_url = $matches[5]; + if ( $matches[1] == 'https' ) { + $this->protocol = 'ssl'; + $this->port = 443; + } + else { + $this->protocol = 'tcp'; + $this->port = 80; + } + if ( $matches[4] != '' ) { + $this->port = intval($matches[4]); + } + } + else { + trigger_error("Invalid URL: '".$base_url."'", E_USER_ERROR); + } + } + + /** + * Adds an If-Match or If-None-Match header + * + * @param bool $match to Match or Not to Match, that is the question! + * @param string $etag The etag to match / not match against. + */ + function SetMatch( $match, $etag = '*' ) { + $this->headers[] = sprintf( "%s-Match: %s", ($match ? "If" : "If-None"), $etag); + } + +/** + * Add a Depth: header. Valid values are 0, 1 or infinity + * + * @param int $depth The depth, default to infinity + */ + function SetDepth( $depth = '0' ) { + $this->headers[] = 'Depth: '. ($depth == '1' ? "1" : ($depth == 'infinity' ? $depth : "0") ); + } + + /** + * Add a Depth: header. Valid values are 1 or infinity + * + * @param int $depth The depth, default to infinity + */ + function SetUserAgent( $user_agent = null ) { + if ( !isset($user_agent) ) $user_agent = $this->user_agent; + $this->user_agent = $user_agent; + } + + /** + * Add a Content-type: header. + * + * @param int $type The content type + */ + function SetContentType( $type ) { + $this->headers[] = "Content-type: $type"; + } + + /** + * Split response into httpResponse and xmlResponse + * + * @param string Response from server + */ + function ParseResponse( $response ) { + $pos = strpos($response, 'httpResponse = trim($response); + } + else { + $this->httpResponse = trim(substr($response, 0, $pos)); + $this->xmlResponse = trim(substr($response, $pos)); + } + } + + /** + * Output http request headers + * + * @return HTTP headers + */ + function GetHttpRequest() { + return $this->httpRequest; + } + /** + * Output http response headers + * + * @return HTTP headers + */ + function GetHttpResponse() { + return $this->httpResponse; + } + /** + * Output xml request + * + * @return raw xml + */ + function GetXmlRequest() { + return $this->xmlRequest; + } + /** + * Output xml response + * + * @return raw xml + */ + function GetXmlResponse() { + return $this->xmlResponse; + } + + /** + * Send a request to the server + * + * @param string $relative_url The URL to make the request to, relative to $base_url + * + * @return string The content of the response from the server + */ + function DoRequest( $relative_url = "" ) { + if(!defined("_FSOCK_TIMEOUT")){ define("_FSOCK_TIMEOUT", 10); } + $headers = array(); + + $headers[] = $this->requestMethod." ". $this->base_url . $relative_url . " HTTP/1.1"; + $headers[] = "Authorization: Basic ".base64_encode($this->user .":". $this->pass ); + $headers[] = "Host: ".$this->server .":".$this->port; + + foreach( $this->headers as $ii => $head ) { + $headers[] = $head; + } + $headers[] = "Content-Length: " . strlen($this->body); + $headers[] = "User-Agent: " . $this->user_agent; + $headers[] = 'Connection: close'; + $this->httpRequest = join("\r\n",$headers); + $this->xmlRequest = $this->body; + + $fip = fsockopen( $this->protocol . '://' . $this->server, $this->port, $errno, $errstr, _FSOCK_TIMEOUT); //error handling? + if ( !(get_resource_type($fip) == 'stream') ) return false; + if ( !fwrite($fip, $this->httpRequest."\r\n\r\n".$this->body) ) { fclose($fip); return false; } + $rsp = ""; + while( !feof($fip) ) { $rsp .= fgets($fip,8192); } + fclose($fip); + + $this->headers = array(); // reset the headers array for our next request + $this->ParseResponse($rsp); + return $rsp; + } + + + /** + * Send an OPTIONS request to the server + * + * @param string $relative_url The URL to make the request to, relative to $base_url + * + * @return array The allowed options + */ + function DoOptionsRequest( $relative_url = "" ) { + $this->requestMethod = "OPTIONS"; + $this->body = ""; + $headers = $this->DoRequest($relative_url); + $options_header = preg_replace( '/^.*Allow: ([a-z, ]+)\r?\n.*/is', '$1', $headers ); + $options = array_flip( preg_split( '/[, ]+/', $options_header )); + return $options; + } + + + + /** + * Send an XML request to the server (e.g. PROPFIND, REPORT, MKCALENDAR) + * + * @param string $method The method (PROPFIND, REPORT, etc) to use with the request + * @param string $xml The XML to send along with the request + * @param string $relative_url The URL to make the request to, relative to $base_url + * + * @return array An array of the allowed methods + */ + function DoXMLRequest( $request_method, $xml, $relative_url = '' ) { + $this->body = $xml; + $this->requestMethod = $request_method; + $this->SetContentType("text/xml"); + return $this->DoRequest($relative_url); + } + + + + /** + * Get a single item from the server. + * + * @param string $relative_url The part of the URL after the calendar + */ + function DoGETRequest( $relative_url ) { + $this->body = ""; + $this->requestMethod = "GET"; + return $this->DoRequest( $relative_url ); + } + + + /** + * PUT a text/icalendar resource, returning the etag + * + * @param string $relative_url The URL to make the request to, relative to $base_url + * @param string $icalendar The iCalendar resource to send to the server + * @param string $etag The etag of an existing resource to be overwritten, or '*' for a new resource. + * + * @return string The content of the response from the server + */ + function DoPUTRequest( $relative_url, $icalendar, $etag = null ) { + $this->body = $icalendar; + + $this->requestMethod = "PUT"; + if ( $etag != null ) { + $this->SetMatch( ($etag != '*'), $etag ); + } + $this->SetContentType("text/icalendar"); + $headers = $this->DoRequest($relative_url); + + /** + * RSCDS will always return the real etag on PUT. Other CalDAV servers may need + * more work, but we are assuming we are running against RSCDS in this case. + */ + $etag = preg_replace( '/^.*Etag: "?([^"\r\n]+)"?\r?\n.*/is', '$1', $headers ); + return $etag; + } + + + /** + * DELETE a text/icalendar resource + * + * @param string $relative_url The URL to make the request to, relative to $base_url + * @param string $etag The etag of an existing resource to be deleted, or '*' for any resource at that URL. + * + * @return int The HTTP Result Code for the DELETE + */ + function DoDELETERequest( $relative_url, $etag = null ) { + $this->body = ""; + + $this->requestMethod = "DELETE"; + if ( $etag != null ) { + $this->SetMatch( true, $etag ); + } + $this->DoRequest($relative_url); + return $this->resultcode; + } + + + /** + * Given XML for a calendar query, return an array of the events (/todos) in the + * response. Each event in the array will have a 'href', 'etag' and '$response_type' + * part, where the 'href' is relative to the calendar and the '$response_type' contains the + * definition of the calendar data in iCalendar format. + * + * @param string $filter XML fragment which is the element of a calendar-query + * @param string $relative_url The URL relative to the base_url specified when the calendar was opened. Default ''. + * @param string $report_type Used as a name for the array element containing the calendar data. @deprecated + * + * @return array An array of the relative URLs, etags, and events from the server. Each element of the array will + * be an array with 'href', 'etag' and 'data' elements, corresponding to the URL, the server-supplied + * etag (which only varies when the data changes) and the calendar data in iCalendar format. + */ + function DoCalendarQuery( $filter, $relative_url = '' ) { + + $xml = << + + + + + $filter + +EOXML; + + $this->DoXMLRequest( 'REPORT', $xml, $relative_url ); + $xml_parser = xml_parser_create_ns('UTF-8'); + $this->xml_tags = array(); + xml_parser_set_option ( $xml_parser, XML_OPTION_SKIP_WHITE, 1 ); + xml_parse_into_struct( $xml_parser, $this->xmlResponse, $this->xml_tags ); + xml_parser_free($xml_parser); + + $report = array(); + foreach( $this->xml_tags as $k => $v ) { + switch( $v['tag'] ) { + case 'DAV::RESPONSE': + if ( $v['type'] == 'open' ) { + $response = array(); + } + elseif ( $v['type'] == 'close' ) { + $report[] = $response; + } + break; + case 'DAV::HREF': + $response['href'] = basename( $v['value'] ); + break; + case 'DAV::GETETAG': + $response['etag'] = preg_replace('/^"?([^"]+)"?/', '$1', $v['value']); + break; + case 'URN:IETF:PARAMS:XML:NS:CALDAV:CALENDAR-DATA': + $response['data'] = $v['value']; + break; + } + } + return $report; + } + + + /** + * Get the events in a range from $start to $finish. The dates should be in the + * format yyyymmddThhmmssZ and should be in GMT. The events are returned as an + * array of event arrays. Each event array will have a 'href', 'etag' and 'event' + * part, where the 'href' is relative to the calendar and the event contains the + * definition of the event in iCalendar format. + * + * @param timestamp $start The start time for the period + * @param timestamp $finish The finish time for the period + * @param string $relative_url The URL relative to the base_url specified when the calendar was opened. Default ''. + * + * @return array An array of the relative URLs, etags, and events, returned from DoCalendarQuery() @see DoCalendarQuery() + */ + function GetEvents( $start = null, $finish = null, $relative_url = '' ) { + $filter = ""; + if ( isset($start) && isset($finish) ) + $range = ""; + else + $range = ''; + + $filter = << + + + $range + + + +EOFILTER; + + return $this->DoCalendarQuery($filter, $relative_url); + } + + + /** + * Get the todo's in a range from $start to $finish. The dates should be in the + * format yyyymmddThhmmssZ and should be in GMT. The events are returned as an + * array of event arrays. Each event array will have a 'href', 'etag' and 'event' + * part, where the 'href' is relative to the calendar and the event contains the + * definition of the event in iCalendar format. + * + * @param timestamp $start The start time for the period + * @param timestamp $finish The finish time for the period + * @param boolean $completed Whether to include completed tasks + * @param boolean $cancelled Whether to include cancelled tasks + * @param string $relative_url The URL relative to the base_url specified when the calendar was opened. Default ''. + * + * @return array An array of the relative URLs, etags, and events, returned from DoCalendarQuery() @see DoCalendarQuery() + */ + function GetTodos( $start, $finish, $completed = false, $cancelled = false, $relative_url = "" ) { + + if ( $start && $finish ) { +$time_range = << +EOTIME; + } + + // Warning! May contain traces of double negatives... + $neg_cancelled = ( $cancelled === true ? "no" : "yes" ); + $neg_completed = ( $cancelled === true ? "no" : "yes" ); + + $filter = << + + + + COMPLETED + + + CANCELLED + $time_range + + + +EOFILTER; + + return $this->DoCalendarQuery($filter, $relative_url); + } + + + /** + * Get the calendar entry by UID + * + * @param uid + * @param string $relative_url The URL relative to the base_url specified when the calendar was opened. Default ''. + * + * @return array An array of the relative URL, etag, and calendar data returned from DoCalendarQuery() @see DoCalendarQuery() + */ + function GetEntryByUid( $uid, $relative_url = '' ) { + $filter = ""; + if ( $uid ) { + $filter = << + + + + $uid + + + + +EOFILTER; + } + + return $this->DoCalendarQuery($filter, $relative_url); + } + + + /** + * Get the calendar entry by HREF + * + * @param string $href The href from a call to GetEvents or GetTodos etc. + * @param string $relative_url The URL relative to the base_url specified when the calendar was opened. Default ''. + * + * @return string The iCalendar of the calendar entry + */ + function GetEntryByHref( $href, $relative_url = '' ) { + return $this->DoGETRequest( $relative_url . $href ); + } + +} + +/** +* Usage example +* +* $cal = new CalDAVClient( "http://calendar.example.com/caldav.php/username/calendar/", "username", "password", "calendar" ); +* $options = $cal->DoOptionsRequest(); +* if ( isset($options["PROPFIND"]) ) { +* // Fetch some information about the events in that calendar +* $cal->SetDepth(1); +* $folder_xml = $cal->DoXMLRequest("PROPFIND", '' ); +* } +* // Fetch all events for February +* $events = $cal->GetEvents("20070101T000000Z","20070201T000000Z"); +* foreach ( $events AS $k => $event ) { +* do_something_with_event_data( $event['data'] ); +* } +* $acc = array(); +* $acc["google"] = array( +* "user"=>"kunsttherapie@gmail.com", +* "pass"=>"xxxxx", +* "server"=>"ssl://www.google.com", +* "port"=>"443", +* "uri"=>"https://www.google.com/calendar/dav/kunsttherapie@gmail.com/events/", +* ); +* +* $acc["davical"] = array( +* "user"=>"some_user", +* "pass"=>"big secret", +* "server"=>"calendar.foo.bar", +* "port"=>"80", +* "uri"=>"http://calendar.foo.bar/caldav.php/some_user/home/", +* ); +* //******************************* +* +* $account = $acc["davical"]; +* +* //******************************* +* $cal = new CalDAVClient( $account["uri"], $account["user"], $account["pass"], "", $account["server"], $account["port"] ); +* $options = $cal->DoOptionsRequest(); +* print_r($options); +* +* //******************************* +* //******************************* +* +* $xmlC = << +* +* +* +* +* +* +* +* +* PROPP; +* //if ( isset($options["PROPFIND"]) ) { +* // Fetch some information about the events in that calendar +* // $cal->SetDepth(1); +* // $folder_xml = $cal->DoXMLRequest("PROPFIND", $xmlC); +* // print_r( $folder_xml); +* //} +* +* // Fetch all events for February +* $events = $cal->GetEvents("20090201T000000Z","20090301T000000Z"); +* foreach ( $events as $k => $event ) { +* print_r($event['data']); +* print "\n---------------------------------------------\n"; +* } +* +* //******************************* +* //******************************* +*/ +?> \ No newline at end of file diff --git a/plugins/calendar/drivers/caldav/caldav_driver.php b/plugins/calendar/drivers/caldav/caldav_driver.php new file mode 100644 index 00000000..e26f94cd --- /dev/null +++ b/plugins/calendar/drivers/caldav/caldav_driver.php @@ -0,0 +1,158 @@ +rcmail = $rcmail; + $this->calendar = '/' . $calendar; + + $this->cal = new CalDAVClient($server. "/" . $user, $user, $pass, $calendar /* is ignored currently */); + $this->cal->setUserAgent('RoundCube'); + } + + public function new_event($event) { + // FIXME Implement + } + + public function edit_event($event) { + // FIXME Implement + } + + public function move_event($event) { + // FIXME Implement. Can be done via editEvent + } + + public function resize_event($event) { + // FIXME Implement. Can be done via editEvent + } + + public function remove_event($event) { + // FIXME Implement. + } + + public function load_events($start, $end, $calendars = null) { + if (!empty($this->rcmail->user->ID)) { + // Fetch events. + $result = $this->cal->GetEvents($this->GMT_to_iCalendar($start), $this->GMT_to_iCalendar($end), $this->calendar); + + $events = array(); + foreach ($result as $k => $event) { + $lines = explode("\n", $event['data']); + + $n = count($lines); + $eventid = null; + + $flag = true; + for ($i = 0; $i < $n; $i++) { + if ($flag) { + if (strpos($lines[$i], "BEGIN:VEVENT") === 0) + $flag = false; + + continue; + } + + if (strpos($lines[$i], "END:VEVENT") === 0) + break; + + if (empty($lines[$i])) + continue; // FIXME + + $tmp = explode(":", $lines[$i]); + + if (count($tmp) !== 2) + continue; // FIXME + + list($id, $value) = $tmp; + + if (!isset($id) || !isset($value)) + continue; // FIXME + + if (is_null($eventid) && strpos($id, "UID") === 0) + $eventid = $value; + elseif (!isset($event['start']) && strpos($id, "DTSTART") === 0) { + $event['start'] = $this->iCalendar_to_Unix($value); + + // Check for all-day event. + $event['all_day'] = (strlen($value) === 8 ? 0 : 1); + } elseif (!isset($event['end']) && strpos($id, "DTEND") === 0) + $event['end'] = $this->iCalendar_to_Unix($value); + elseif (!isset($event['title']) && strpos($id, "SUMMARY") === 0) + $event['title'] = $value; + elseif (!isset($event['description']) && strpos($id, "DESCRIPTION") === 0) { + $event['description'] = $value; + + // FIXME Problem with multiple lines! +// if ($i+1 < $n && $lines[$i+1] does not contain keyword...) { +// Add line to description +// $i++; +// } + } elseif (!isset($event['location']) && strpos($id, "LOCATION") === 0) + $event['location'] = $value; + elseif (!isset($event['categories']) && strpos($id, "CATEGORIES") === 0) + $event['categories'] = $value; + } + + $events[]=array( + 'event_id' => $eventid, + 'start' => $event['start'], + 'end' => $event['end'], + 'title' => strval($event['title']), + 'description' => strval($event['description']), + 'location' => strval($event['location']), + 'categories' => $event['categories'], + 'allDay' => $event['all_day'], + ); + } + + return $events; + } + } + + /** + * Convert a GMT time stamp ('Y-m-d H:i:s') to the iCalendar format as defined in + * RFC 5545, Section 3.2.19, http://tools.ietf.org/html/rfc5545#section-3.2.19. + * + * @param timestamp A GMT time stamp ('Y-m-d H:i:s') + * @return An iCalendar time stamp, e.g. yyyymmddThhmmssZ + */ + private function GMT_to_iCalendar($timestamp) { + $unix_timestamp = strtotime($timestamp); + return date("Ymd", $unix_timestamp) . "T" . date("His", $unix_timestamp) . "Z"; + } + + /** + * Convert a time stamp in iCalendar format as defined in + * RFC 5545, Section 3.2.19, http://tools.ietf.org/html/rfc5545#section-3.2.19 + * to a Unix time stamp. Further conversion is done in jsonEvents. + * + * @param timestamp An iCalendar time stamp, e.g. yyyymmddThhmmssZ + * @return A Unix time stamp + */ + private function iCalendar_to_Unix($timestamp) { + return strtotime($timestamp); + } +} +?> \ No newline at end of file diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php new file mode 100644 index 00000000..a2abe898 --- /dev/null +++ b/plugins/calendar/drivers/calendar_driver.php @@ -0,0 +1,171 @@ + | + | Thomas Bruederli | + +-------------------------------------------------------------------------+ +*/ +abstract class calendar_driver +{ + // backend features + public $attendees = false; + public $attachments = false; + + /** + * Get a list of available calendars from this source + */ + abstract function list_calendars(); + + /** + * Create a new calendar assigned to the current user + * + * @param array Hash array with calendar properties + * name: Calendar name + * color: The color of the calendar + * @return mixed ID of the calendar on success, False on error + */ + abstract function create_calendar($prop); + + /** + * Add a single event to the database + * + * @param array Hash array with vent properties: + * calendar: Calendar identifier to add event to (optional) + * uid: Unique identifier of this event + * start: Event start date/time as unix timestamp + * end: Event end date/time as unix timestamp + * allday: Boolean flag if this is an all-day event + * title: Event title/summary + * location: Location string + * description: Event description + * recurrence: Recurrence definition according to iCalendar specification + * categories: Event categories (comma-separated list) + * free_busy: Show time as free/busy/outofoffice + * priority: Event priority + * alarms: Reminder settings (TBD.) + * @return mixed New event ID on success, False on error + */ + abstract function new_event($event); + + /** + * Update an event entry with the given data + * + * @see Driver:new_event() + * @return boolean True on success, False on error + */ + abstract function edit_event($event); + + /** + * Move a single event + * + * @param array Hash array with event properties: + * id: Event identifier + * start: Event start date/time as unix timestamp + * end: Event end date/time as unix timestamp + * allday: Boolean flag if this is an all-day event + * @return boolean True on success, False on error + */ + abstract function move_event($event); + + /** + * Resize a single event + * + * @param array Hash array with event properties: + * id: Event identifier + * start: Event start date/time as unix timestamp in user timezone + * end: Event end date/time as unix timestamp in user timezone + * @return boolean True on success, False on error + */ + abstract function resize_event($event); + + /** + * Remove a single event from the database + * + * @param array Hash array with event properties: + * id: Event identifier + * @return boolean True on success, False on error + */ + abstract function remove_event($event); + + /** + * Get events from source. + * + * @param integer Event's new start (unix timestamp) + * @param integer Event's new end (unix timestamp) + * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) + * @return array A list of event records + */ + abstract function load_events($start, $end, $calendars = null); + + /** + * Search events using the given query + * + * @param integer Event's new start (unix timestamp) + * @param integer Event's new end (unix timestamp) + * @param string Search query + * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) + * @return array A list of event records + */ + abstract function search_events($start, $end, $query, $calendars = null); + + /** + * Save an attachment related to the given event + */ + public function add_attachment($attachment, $event_id) { } + + /** + * Remove a specific attachment from the given event + */ + public function remove_attachment($attachment, $event_id) { } + + /** + * List availabale categories + * The default implementation reads them from config/user prefs + */ + public function list_categories() + { + $rcmail = rcmail::get_instance(); + return $rcmail->config->get('calendar_categories', array()); + } + + /** + * Create a new category + */ + public function add_category($name, $color) { } + + /** + * Remove the given category + */ + public function remove_category($name) { } + + /** + * Update/replace a category + */ + public function replace_category($oldname, $name, $color) { } + + /** + * Fetch free/busy information from a person within the given range + */ + public function get_freebusy_list($email, $start, $end) + { + return array(); + } + +} \ No newline at end of file diff --git a/plugins/calendar/drivers/database/database_driver.php b/plugins/calendar/drivers/database/database_driver.php new file mode 100644 index 00000000..7ab32522 --- /dev/null +++ b/plugins/calendar/drivers/database/database_driver.php @@ -0,0 +1,388 @@ + | + | Thomas Bruederli | + +-------------------------------------------------------------------------+ +*/ + +class database_driver extends calendar_driver +{ + // features this backend supports + public $attendees = true; + public $attachments = true; + + private $rc; + private $cal; + private $calendars = array(); + private $calendar_ids = ''; + private $free_busy_map = array('free' => 0, 'busy' => 1, 'out-of-office' => 2, 'outofoffice' => 2); + + /** + * Default constructor + */ + public function __construct($cal) + { + $this->cal = $cal; + $this->rc = $cal->rc; + $this->_read_calendars(); + } + + /** + * Read available calendars for the current user and store them internally + */ + private function _read_calendars() + { + if (!empty($this->rc->user->ID)) { + $calendar_ids = array(); + $result = $this->rc->db->query( + "SELECT * FROM calendars + WHERE user_id=?", + $this->rc->user->ID + ); + while ($result && ($arr = $this->rc->db->fetch_assoc($result))) { + $this->calendars[$arr['calendar_id']] = $arr; + $calendar_ids[] = $this->rc->db->quote($arr['calendar_id']); + } + $this->calendar_ids = join(',', $calendar_ids); + } + } + + /** + * Get a list of available calendars from this source + */ + public function list_calendars() + { + // attempt to create a default calendar for this user + if (empty($this->calendars)) { + if ($this->create_calendar(array('name' => 'Default', 'color' => 'cc0000'))) + $this->_read_calendars(); + } + + return $this->calendars; + } + + /** + * Create a new calendar assigned to the current user + * + * @param array Hash array with calendar properties + * name: Calendar name + * color: The color of the calendar + * @return mixed ID of the calendar on success, False on error + */ + public function create_calendar($prop) + { + $result = $this->rc->db->query( + "INSERT INTO calendars + (user_id, name, color) + VALUES (?, ?, ?)", + $this->rc->user->ID, + $prop['name'], + $prop['color'] + ); + + if ($result) + return $this->rc->db->insert_id('calendars'); + + return false; + } + + /** + * Add a single event to the database + * + * @param array Hash array with event properties + * @see Driver:new_event() + */ + public function new_event($event) + { + if (!empty($this->calendars)) { + if ($event['calendar'] && !$this->calendars[$event['calendar']]) + return false; + if (!$event['calendar']) + $event['calendar'] = reset(array_keys($this->calendars)); + + $event = $this->_save_preprocess($event); + $query = $this->rc->db->query(sprintf( + "INSERT INTO events + (calendar_id, created, changed, uid, start, end, all_day, recurrence, title, description, location, categories, free_busy, priority, alarms) + VALUES (?, %s, %s, ?, %s, %s, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + $this->rc->db->now(), + $this->rc->db->now(), + $this->rc->db->fromunixtime($event['start']), + $this->rc->db->fromunixtime($event['end']) + ), + $event['calendar'], + strval($event['uid']), + intval($event['allday']), + $event['recurrence'], + strval($event['title']), + strval($event['description']), + strval($event['location']), + strval($event['categories']), + intval($event['free_busy']), + intval($event['priority']), + $event['alarms'] + ); + return $this->rc->db->insert_id('events'); + } + + return false; + } + + /** + * Update an event entry with the given data + * + * @param array Hash array with event properties + * @see Driver:new_event() + */ + public function edit_event($event) + { + if (!empty($this->calendars)) { + $event = $this->_save_preprocess($event); + $query = $this->rc->db->query(sprintf( + "UPDATE events + SET changed=%s, start=%s, end=%s, all_day=?, recurrence=?, title=?, description=?, location=?, categories=?, free_busy=?, priority=?, alarms=? + WHERE event_id=? + AND calendar_id IN (" . $this->calendar_ids . ")", + $this->rc->db->now(), + $this->rc->db->fromunixtime($event['start']), + $this->rc->db->fromunixtime($event['end']) + ), + intval($event['allday']), + $event['recurrence'], + strval($event['title']), + strval($event['description']), + strval($event['location']), + strval($event['categories']), + intval($event['free_busy']), + intval($event['priority']), + $event['alarms'], + $event['id'] + ); + return $this->rc->db->affected_rows($query); + } + + return false; + } + + /** + * Convert save data to be used in SQL statements + */ + private function _save_preprocess($event) + { + // compose vcalendar-style recurrencue rule from structured data + $rrule = ''; + if (is_array($event['recurrence'])) { + foreach ($event['recurrence'] as $k => $val) { + $k = strtoupper($k); + switch ($k) { + case 'UNTIL': + $val = gmdate('Ymd\THis', $val); + break; + } + $rrule .= $k . '=' . $val . ';'; + } + } + else if (is_string($event['recurrence'])) + $rrule = $event['recurrence']; + + $event['recurrence'] = rtrim($rrule, ';'); + $event['free_busy'] = intval($this->free_busy_map[strtolower($event['free_busy'])]); + $event['allday'] = $event['allday'] ? 1 : 0; + + return $event; + } + + /** + * Move a single event + * + * @param array Hash array with event properties + * @see Driver:move_event() + */ + public function move_event($event) + { + if (!empty($this->calendars)) { + $query = $this->rc->db->query(sprintf( + "UPDATE events + SET changed=%s, start=%s, end=%s, all_day=? + WHERE event_id=? + AND calendar_id IN (" . $this->calendar_ids . ")", + $this->rc->db->now(), + $this->rc->db->fromunixtime($event['start']), + $this->rc->db->fromunixtime($event['end']) + ), + $event['allday'] ? 1 : 0, + $event['id'] + ); + return $this->rc->db->affected_rows($query); + } + + return false; + } + + /** + * Resize a single event + * + * @param array Hash array with event properties + * @see Driver:resize_event() + */ + public function resize_event($event) + { + if (!empty($this->calendars)) { + $query = $this->rc->db->query(sprintf( + "UPDATE events + SET changed=%s, start=%s, end=%s + WHERE event_id=? + AND calendar_id IN (" . $this->calendar_ids . ")", + $this->rc->db->now(), + $this->rc->db->fromunixtime($event['start']), + $this->rc->db->fromunixtime($event['end']) + ), + $event['id'] + ); + return $this->rc->db->affected_rows($query); + } + + return false; + } + + /** + * Remove a single event from the database + * + * @param array Hash array with event properties + * @see Driver:remove_event() + */ + public function remove_event($event) + { + if (!empty($this->calendars)) { + $query = $this->rc->db->query( + "DELETE FROM events + WHERE event_id=? + AND calendar_id IN (" . $this->calendar_ids . ")", + $event['id'] + ); + return $this->rc->db->affected_rows($query); + } + + return false; + } + + /** + * Get event data + * + * @see Driver:load_events() + */ + public function load_events($start, $end, $calendars = null) + { + if (empty($calendars)) + $calendars = array_keys($this->calendars); + else if (is_string($calendars)) + $calendars = explode(',', $calendars); + + // only allow to select from calendars of this use + $calendars = array_intersect($calendars, array_keys($this->calendars)); + + $events = array(); + $free_busy_map = array_flip($this->free_busy_map); + + if (!empty($calendars)) { + $result = $this->rc->db->query(sprintf( + "SELECT * FROM events + WHERE calendar_id IN (%s) + AND start >= %s AND end <= %s", + $this->calendar_ids, + $this->rc->db->fromunixtime($start), + $this->rc->db->fromunixtime($end) + )); + + while ($result && ($event = $this->rc->db->fetch_assoc($result))) { + $event['id'] = $event['event_id']; + $event['start'] = strtotime($event['start']); + $event['end'] = strtotime($event['end']); + $event['free_busy'] = $free_busy_map[$event['free_busy']]; + $event['calendar'] = $event['calendar_id']; + + // parse recurrence rule + if ($event['recurrence'] && preg_match_all('/([A-Z]+)=([^;]+);?/', $event['recurrence'], $m, PREG_SET_ORDER)) { + $event['recurrence'] = array(); + foreach ($m as $rr) { + if (is_numeric($rr[2])) + $rr[2] = intval($rr[2]); + else if ($rr[1] == 'UNTIL') + $rr[2] = strtotime($rr[2]); + $event['recurrence'][$rr[1]] = $rr[2]; + } + } + + unset($event['event_id'], $event['calendar_id']); + $events[] = $event; + } + } + + return $events; + } + + /** + * Search events + * + * @see Driver:search_events() + */ + public function search_events($start, $end, $query, $calendars = null) + { + + } + + /** + * Save an attachment related to the given event + */ + function add_attachment($attachment, $event_id) + { + // TBD. + return false; + } + + /** + * Remove a specific attachment from the given event + */ + function remove_attachment($attachment, $event_id) + { + // TBD. + return false; + } + + /** + * Remove the given category + */ + public function remove_category($name) + { + // TBD. alter events accordingly + return false; + } + + /** + * Update/replace a category + */ + public function replace_category($oldname, $name, $color) + { + // TBD. alter events accordingly + return false; + } + +} diff --git a/plugins/calendar/drivers/database/sql/mysql.sql b/plugins/calendar/drivers/database/sql/mysql.sql new file mode 100644 index 00000000..f0b0cea9 --- /dev/null +++ b/plugins/calendar/drivers/database/sql/mysql.sql @@ -0,0 +1,70 @@ +/** + * Roundcube Calendar + * + * Plugin to add a calendar to Roundcube. + * + * @version 0.3 beta + * @author Lazlo Westerhof + * @author Thomas Bruederli + * @url http://rc-calendar.lazlo.me + * @licence GNU GPL + * @copyright (c) 2010 Lazlo Westerhof - Netherlands + * + **/ + +CREATE TABLE `calendars` ( + `calendar_id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` int(10) UNSIGNED NOT NULL DEFAULT '0', + `name` varchar(255) NOT NULL, + `color` varchar(8) NOT NULL, + PRIMARY KEY(`calendar_id`), + CONSTRAINT `fk_calendars_user_id` FOREIGN KEY (`user_id`) + REFERENCES `users`(`user_id`) + /*!40008 + ON DELETE CASCADE + ON UPDATE CASCADE */ +) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; + +CREATE TABLE `events` ( + `event_id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `calendar_id` int(11) UNSIGNED NOT NULL DEFAULT '0', + `recurrence_id` int(11) UNSIGNED NOT NULL DEFAULT '0', + `uid` varchar(255) NOT NULL DEFAULT '', + `created` datetime NOT NULL DEFAULT '1000-01-01 00:00:00', + `changed` datetime NOT NULL DEFAULT '1000-01-01 00:00:00', + `start` datetime NOT NULL DEFAULT '1000-01-01 00:00:00', + `end` datetime NOT NULL DEFAULT '1000-01-01 00:00:00', + `recurrence` varchar(255) DEFAULT NULL, + `title` varchar(255) NOT NULL, + `description` text NOT NULL, + `location` varchar(255) NOT NULL DEFAULT '', + `categories` varchar(255) NOT NULL DEFAULT '', + `all_day` tinyint(1) NOT NULL DEFAULT '0', + `free_busy` tinyint(1) NOT NULL DEFAULT '0', + `priority` tinyint(1) NOT NULL DEFAULT '1', + `alarms` varchar(255) DEFAULT NULL, + `attendees` text DEFAULT NULL, + PRIMARY KEY(`event_id`), + CONSTRAINT `fk_events_calendar_id` FOREIGN KEY (`calendar_id`) + REFERENCES `calendars`(`calendar_id`) + /*!40008 + ON DELETE CASCADE + ON UPDATE CASCADE */ +) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; + +CREATE TABLE `attachments` ( + `attachment_id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `event_id` int(11) UNSIGNED NOT NULL DEFAULT '0', + `filename` varchar(255) NOT NULL DEFAULT '', + `mimetype` varchar(255) NOT NULL DEFAULT '', + `size` int(11) NOT NULL DEFAULT '0', + `data` longtext NOT NULL DEFAULT '', + PRIMARY KEY(`attachment_id`), + CONSTRAINT `fk_attachments_event_id` FOREIGN KEY (`event_id`) + REFERENCES `events`(`event_id`) + /*!40008 + ON DELETE CASCADE + ON UPDATE CASCADE */ +) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; + + diff --git a/plugins/calendar/drivers/database/sql/postgresql.sql b/plugins/calendar/drivers/database/sql/postgresql.sql new file mode 100644 index 00000000..cdcc1c78 --- /dev/null +++ b/plugins/calendar/drivers/database/sql/postgresql.sql @@ -0,0 +1,40 @@ +/** + * RoundCube Calendar + * + * Plugin to add a calendar to RoundCube. + * + * @version 0.2 BETA 2 + * @author Lazlo Westerhof + * @author Albert Lee + * @url http://rc-calendar.lazlo.me + * @licence GNU GPL + * @copyright (c) 2010 Lazlo Westerhof - Netherlands + * + **/ + +CREATE SEQUENCE event_ids + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + +CREATE TABLE events ( + event_id integer DEFAULT nextval('event_ids'::regclass) NOT NULL, + user_id integer NOT NULL, + "start" timestamp without time zone DEFAULT now() NOT NULL, + "end" timestamp without time zone DEFAULT now() NOT NULL, + "title" character varying(255) NOT NULL, + "description" text NOT NULL, + "location" character varying(255) NOT NULL, + "categories" character varying(255) NOT NULL, + "all_day" smallint NOT NULL DEFAULT 0 +); + +CREATE INDEX events_event_id_idx ON events USING btree (event_id); + + +-- +-- Constraints Table `events` +-- +ALTER TABLE ONLY events + ADD CONSTRAINT events_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(user_id) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/plugins/calendar/drivers/database/sql/sqlite.sql b/plugins/calendar/drivers/database/sql/sqlite.sql new file mode 100644 index 00000000..22ef23d2 --- /dev/null +++ b/plugins/calendar/drivers/database/sql/sqlite.sql @@ -0,0 +1,58 @@ +/** + * Roundcube Calendar + * + * Plugin to add a calendar to Roundcube. + * + * @version 0.3 beta + * @author Lazlo Westerhof + * @author Thomas Bruederli + * @author Albert Lee + * @url http://rc-calendar.lazlo.me + * @licence GNU GPL + * @copyright (c) 2010 Lazlo Westerhof - Netherlands + * + **/ + +CREATE TABLE calendars ( + calendar_id integer NOT NULL PRIMARY KEY, + user_id integer NOT NULL default '0', + name varchar(255) NOT NULL default '', + color varchar(255) NOT NULL default '', + CONSTRAINT fk_calendars_user_id FOREIGN KEY (user_id) + REFERENCES users(user_id) +); + +CREATE TABLE events ( + event_id integer NOT NULL PRIMARY KEY, + calendar_id integer NOT NULL default '0', + recurrence_id integer NOT NULL default '0', + uid varchar(255) NOT NULL default '', + created datetime NOT NULL default '1000-01-01 00:00:00', + changed datetime NOT NULL default '1000-01-01 00:00:00', + start datetime NOT NULL default '1000-01-01 00:00:00', + end datetime NOT NULL default '1000-01-01 00:00:00', + recurrence varchar(255) default NULL, + title varchar(255) NOT NULL, + description text NOT NULL, + location varchar(255) NOT NULL default '', + categories varchar(255) NOT NULL default '', + all_day tinyint(1) NOT NULL default '0', + free_busy tinyint(1) NOT NULL default '0', + priority tinyint(1) NOT NULL default '1', + alarms varchar(255) default NULL, + attendees text default NULL, + CONSTRAINT fk_events_calendar_id FOREIGN KEY (calendar_id) + REFERENCES calendars(calendar_id) +); + +CREATE TABLE attachments ( + attachment_id integer NOT NULL PRIMARY KEY, + event_id integer NOT NULL default '0', + filename varchar(255) NOT NULL default '', + mimetype varchar(255) NOT NULL default '', + size integer NOT NULL default '0', + data text NOT NULL default '', + CONSTRAINT fk_attachment_event_id FOREIGN KEY (event_id) + REFERENCES events(event_id) +); + diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php new file mode 100644 index 00000000..4eb1a9bc --- /dev/null +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -0,0 +1,176 @@ + | + +-------------------------------------------------------------------------+ +*/ + +class Kolab_calendar +{ + public $id; + public $ready = false; + public $readonly = true; + + private $storage; + private $events; + private $id2uid; + private $imap_folder = 'INBOX/Calendar'; + + /** + * Default constructor + */ + public function __construct($imap_folder = null) + { + if ($imap_folder) + $this->imap_folder = $imap_folder; + + // ID is derrived from folder name + $this->id = strtolower(asciiwords(strtr($this->imap_folder, '/.', '--'))); + + // fetch objects from the given IMAP folder + $this->storage = rcube_kolab::get_storage($this->imap_folder); + + $this->ready = !PEAR::isError($this->storage); + } + + + /** + * Getter for a nice and human readable name for this calendar + * See http://wiki.kolab.org/UI-Concepts/Folder-Listing for reference + * + * @return string Name of this calendar + */ + public function get_name() + { + $dispname = preg_replace(array('!INBOX/Calendar/!', '!^INBOX/!', '!^shared/!', '!^user/([^/]+)/!'), array('','','','(\\1) '), $this->imap_folder); + return strlen($dispname) ? $dispname : $this->imap_folder; + } + + /** + * Return color to display this calendar + */ + public function get_color() + { + // TODO: read color from backend (not yet supported) + return '0000cc'; + } + + + /** + * Getter for a single event object + */ + public function get_event($id) + { + $this->_fetch_events(); + return $this->events[$id]; + } + + + /** + * @param integer Event's new start (unix timestamp) + * @param integer Event's new end (unix timestamp) + * @return array A list of event records + */ + public function list_events($start, $end) + { + $this->_fetch_events(); + + $events = array(); + foreach ($this->events as $id => $event) { + // TODO: also list recurring events + if ($event['start'] >= $start && $event['end'] <= $end) { + $events[] = $event; + } + } + + return $events; + } + + + /** + * Create a new event record + * + * @see Driver:new_event() + * @return mixed The created record ID on success, False on error + */ + public function insert_event($event) + { + return false; + } + + /** + * Update a specific event record + * + * @see Driver:new_event() + * @return boolean True on success, False on error + */ + + public function update_event($event) + { + + return false; + } + + + /** + * Simply fetch all records and store them in private member vars + * We thereby rely on cahcing done by the Horde classes + */ + private function _fetch_events() + { + if (!isset($this->events)) { + $this->events = array(); + foreach ((array)$this->storage->getObjects() as $record) { + $event = $this->_to_rcube_event($record); + $this->events[$event['id']] = $event; + } + } + } + + /** + * Convert from Kolab_Format to internal representation + */ + private function _to_rcube_event($rec) + { + $start_time = date('H:i:s', $rec['start-date']); + $allday = $start_time == '00:00:00' && $start_time == date('H:i:s', $rec['end-date']); + + return array( + 'id' => $rec['uid'], + 'uid' => $rec['uid'], + 'title' => $rec['summary'], + 'location' => $rec['location'], + 'description' => $rec['body'], + 'start' => $rec['start-date'], + 'end' => $rec['end-date'], + 'all_day' => $allday, + 'categories' => $rec['categories'], + 'free_busy' => $rec['show-time-as'], + 'priority' => 1, // normal + 'calendar' => $this->id, + ); + } + + /** + * Convert the given event record into a data structure that can be passed to Kolab_Storage backend for saving + * (opposite of self::_to_rcube_event()) + */ + private function _from_rcube_eventt($event) + { + $object = array(); + + + return $object; + } + +} diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php new file mode 100644 index 00000000..4d968ce9 --- /dev/null +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -0,0 +1,272 @@ + | + +-------------------------------------------------------------------------+ +*/ + +require_once(dirname(__FILE__) . '/kolab_calendar.php'); + +class kolab_driver extends calendar_driver +{ + // features this backend supports + public $attendees = false; + public $attachments = false; + + private $rc; + private $cal; + private $calendars; + private $folders; + + /** + * Default constructor + */ + public function __construct($cal) + { + $this->cal = $cal; + $this->rc = $cal->rc; + $this->_read_calendars(); + } + + + /** + * Read available calendars from server + */ + private function _read_calendars() + { + // already read sources + if (isset($this->calendars)) + return $this->calendars; + + // get all folders that have "event" type + $folders = rcube_kolab::get_folders('event'); + $this->folders = $this->calendars = array(); + + if (PEAR::isError($folders)) { + raise_error(array( + 'code' => 600, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Failed to list calendar folders from Kolab server:" . $folders->getMessage()), + true, false); + } + else { + foreach ($folders as $c_folder) { + $calendar = new kolab_calendar($c_folder->name); + $this->folders[$calendar->id] = $calendar; + if ($calendar->ready) { + $this->calendars[$calendar->id] = array( + 'id' => $calendar->id, + 'name' => $calendar->get_name(), + 'color' => $calendar->get_color(), + 'readonly' => $c_folder->_owner != $_SESSION['username'], + ); + } + } + } + + return $this->calendars; + } + + + private function _get_storage($cid, $readonly = false) + { + if ($readonly) + return $this->folders[$cid]; + else if (!$this->calendars[$cid]['readonly']) + return $this->folders[$cid]; + return false; + } + + + /** + * Get a list of available calendars from this source + */ + public function list_calendars() + { + // attempt to create a default calendar for this user + if (empty($this->calendars)) { + if ($this->create_calendar(array('name' => 'Default', 'color' => 'cc0000'))) + $this->_read_calendars(); + } + + return $this->calendars; + } + + + /** + * Create a new calendar assigned to the current user + * + * @param array Hash array with calendar properties + * name: Calendar name + * color: The color of the calendar + * @return mixed ID of the calendar on success, False on error + */ + public function create_calendar($prop) + { + + return false; + } + + /** + * Add a single event to the database + * + * @see Driver:new_event() + */ + public function new_event($event) + { + $cid = $event['calendar'] ? $event['calendar'] : reset(array_keys($this->calendars)); + if ($storage = $this->_get_storage($cid)) + return $storage->insert_event($event); + + return false; + } + + /** + * Update an event entry with the given data + * + * @see Driver:new_event() + * @return boolean True on success, False on error + */ + public function edit_event($event) + { + if ($storage = $this->_get_storage($event['calendar'])) + return $storage->update_event($event); + + return false; + } + + /** + * Move a single event + * + * @see Driver:move_event() + * @return boolean True on success, False on error + */ + public function move_event($event) + { + if (($storage = $this->_get_storage($event['calendar'])) && ($ev = $storage->get_event($event['id']))) + return $storage->update_event($event + $ev); + + return false; + } + + /** + * Resize a single event + * + * @see Driver:resize_event() + * @return boolean True on success, False on error + */ + public function resize_event($event) + { + if (($storage = $this->_get_storage($event['calendar'])) && ($ev = $storage->get_event($event['id']))) + return $storage->update_event($event + $ev); + + return false; + } + + /** + * Remove a single event from the database + * + * @param array Hash array with event properties: + * id: Event identifier + * @return boolean True on success, False on error + */ + public function remove_event($event) + { + return false; + } + + /** + * Get events from source. + * + * @param integer Event's new start (unix timestamp) + * @param integer Event's new end (unix timestamp) + * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) + * @return array A list of event records + */ + public function load_events($start, $end, $calendars = null) + { + if ($calendars && is_string($calendars)) + $calendars = explode(',', $calendars); + + $events = array(); + foreach ($this->calendars as $cid => $calendar) { + if ($calendars && !in_array($cid, $calendars)) + continue; + + $events = array_merge($this->folders[$cid]->list_events($start, $end)); + } + + return $events; + } + + /** + * Search events using the given query + * + * @see Driver::search_events() + * @return array A list of event records + */ + public function search_events($start, $end, $query, $calendars = null) + { + return array(); + } + + /** + * Save an attachment related to the given event + */ + public function add_attachment($attachment, $event_id) + { + + } + + /** + * Remove a specific attachment from the given event + */ + public function remove_attachment($attachment, $event_id) + { + + } + + /** + * Create a new category + */ + public function add_category($name, $color) + { + + } + + /** + * Remove the given category + */ + public function remove_category($name) + { + + } + + /** + * Update/replace a category + */ + public function replace_category($oldname, $name, $color) + { + + } + + /** + * Fetch free/busy information from a person within the given range + */ + public function get_freebusy_list($email, $start, $end) + { + return array(); + } + +} diff --git a/plugins/calendar/lib/calendar_ical.php b/plugins/calendar/lib/calendar_ical.php new file mode 100644 index 00000000..a253b203 --- /dev/null +++ b/plugins/calendar/lib/calendar_ical.php @@ -0,0 +1,82 @@ + | + +-------------------------------------------------------------------------+ +*/ + +class calendar_ical +{ + private $rc; + private $driver; + + function __construct($rc, $driver) { + $this->rc = $rc; + $this->driver = $driver; + } + + /** + * Import events from iCalendar format + * + * @param array Associative events array + * @access public + */ + public function import($events) { + //TODO + // for ($events as $event) + // $this->backend->newEvent(...); + } + + /** + * Export events to iCalendar format + * + * @param array Events as array + * @return string Events in iCalendar format (http://tools.ietf.org/html/rfc5545) + * @access public + */ + public function export($events) { + if (!empty($this->rc->user->ID)) { + $ical = "BEGIN:VCALENDAR\n"; + $ical .= "VERSION:2.0\n"; + $ical .= "PRODID:-//Roundcube Webmail//NONSGML Calendar//EN\n"; + foreach ($events as $event) { + $ical .= "BEGIN:VEVENT\n"; + $ical .= "DTSTART:" . date('Ymd\THis\Z', $event['start'] - date('Z')) . "\n"; + $ical .= "DTEND:" . date('Ymd\THis\Z', $event['end'] - date('Z')) . "\n"; + $ical .= "SUMMARY:" . $event['title'] . "\n"; + $ical .= "DESCRIPTION:" . $event['description'] . "\n"; + if (!empty($event['location'])) { + $ical .= "LOCATION:" . $event['location'] . "\n"; + } + if(!empty($event['categories'])) { + $ical .= "CATEGORIES:" . strtoupper($event['categories']) . "\n"; + } + if ($event['private']) { + $ical .= 'X-CALENDARSERVER-ACCESS:CONFIDENTIAL'; + } + $ical .= 'TRANSP:' . ($event['free_busy'] == 'free' ? 'TRANSPARENT' : 'OPAQUE'); + $ical .= "END:VEVENT\n"; + } + $ical .= "END:VCALENDAR"; + + return $ical; + } + } +} \ No newline at end of file diff --git a/plugins/calendar/lib/calendar_ui.php b/plugins/calendar/lib/calendar_ui.php new file mode 100644 index 00000000..f7dfb4e1 --- /dev/null +++ b/plugins/calendar/lib/calendar_ui.php @@ -0,0 +1,394 @@ + | + +-------------------------------------------------------------------------+ +*/ + +class calendar_ui +{ + private $rc; + private $calendar; + + function __construct($calendar) + { + $this->calendar = $calendar; + $this->rc = $calendar->rc; + } + + /** + * Calendar UI initialization and requests handlers + */ + public function init() + { + // add taskbar button + $this->calendar->add_button(array( + 'name' => 'calendar', + 'class' => 'button-calendar', + 'label' => 'calendar.calendar', + 'href' => './?_task=calendar', + ), 'taskbar'); + } + + /** + * Adds CSS stylesheets to the page header + */ + public function addCSS() + { + $skin = $this->rc->config->get('skin'); + $this->calendar->include_stylesheet('skins/' . $skin . '/fullcalendar.css'); + } + + /** + * Adds JS files to the page header + */ + public function addJS() + { + $this->calendar->include_script('lib/js/fullcalendar.js'); + $this->calendar->include_script('calendar.js'); + } + + /** + * Creates the Calendar toolbar + */ + public function toolbar() + { + $skin = $this->rc->config->get('skin'); + $this->calendar->add_button(array( + 'command' => 'plugin.export_events', + 'href' => './?_task=calendar&_action=plugin.export_events', + 'title' => 'calendar.export', + 'imagepas' => 'skins/' . $skin . '/images/export.png', + 'imageact' => 'skins/' . $skin . '/images/export.png'), + 'toolbar' + ); + } + + /** + * + */ + function calendar_css() + { + $categories = $this->rc->config->get('calendar_categories', array()); + + $css = "\n"; + + foreach ((array)$categories as $class => $color) { + $class = 'cat-' . asciiwords($class, true); + $css .= "." . $class . ",\n"; + $css .= ".fc-event-" . $class . ",\n"; + $css .= "." . $class . " a {\n"; + $css .= "color: #" . $color . ";\n"; + $css .= "border-color: #" . $color . ";\n"; + $css .= "}\n"; + } + + $calendars = $this->calendar->driver->list_calendars(); + foreach ((array)$calendars as $id => $prop) { + if (!$prop['color']) + continue; + $color = $prop['color']; + $class = 'cal-' . asciiwords($id, true); + $css .= "li." . $class . ", "; + $css .= "#eventshow ." . $class . " { "; + $css .= "color: #" . $color . " }\n"; + $css .= ".fc-event-" . $class . ", "; + $css .= ".fc-event-" . $class . " .fc-event-inner, "; + $css .= ".fc-event-" . $class . " .fc-event-time {\n"; + $css .= "background-color: #" . $color . ";\n"; + $css .= "border-color: #" . $color . ";\n"; + $css .= "}\n"; + } + + return html::tag('style', array('type' => 'text/css'), $css); + } + + /** + * + */ + function calendar_list($attrib = array()) + { + $calendars = $this->calendar->driver->list_calendars(); + + $li = ''; + foreach ((array)$calendars as $id => $prop) { + unset($prop['user_id']); + $prop['attendees'] = $this->calendar->driver->attendees; + $prop['attachments'] = $this->calendar->driver->attachments; + $jsenv[$id] = $prop; + + $html_id = html_identifier($id); + $li .= html::tag('li', array('id' => 'rcmlical' . $html_id, 'class' =>'cal-' . asciiwords($id, true)), + html::tag('input', array('type' => 'checkbox', 'name' => '_cal[]', 'value' => $id, 'checked' => true), '') . html::span(null, Q($prop['name']))); + } + + $this->rc->output->set_env('calendars', $jsenv); + $this->rc->output->add_gui_object('folderlist', $attrib['id']); + + unset($attrib['name']); + return html::tag('ul', $attrib, $li); + } + + /** + * Render a HTML select box for calendar selection + */ + function calendar_select($attrib = array()) + { + $attrib['name'] = 'calendar'; + $select = new html_select($attrib); + foreach ((array)$this->calendar->driver->list_calendars() as $id => $prop) { + $select->add($prop['name'], $id); + } + + return $select->show(null); + } + + /** + * Render a HTML select box to select an event category + */ + function category_select($attrib = array()) + { + $attrib['name'] = 'categories'; + $select = new html_select($attrib); + $select->add('---', ''); + foreach ((array)$this->calendar->driver->list_categories() as $cat => $color) { + $select->add($cat, $cat); + } + + return $select->show(null); + } + + /** + * Render a HTML select box for free/busy/out-of-office property + */ + function freebusy_select($attrib = array()) + { + $attrib['name'] = 'freebusy'; + $select = new html_select($attrib); + $select->add($this->calendar->gettext('free'), 'free'); + $select->add($this->calendar->gettext('busy'), 'busy'); + $select->add($this->calendar->gettext('outofoffice'), 'outofoffice'); + return $select->show(null); + } + + /** + * Render a HTML select for event priorities + */ + function priority_select($attrib = array()) + { + $attrib['name'] = 'priority'; + $select = new html_select($attrib); + $select->add($this->calendar->gettext('normal'), '1'); + $select->add($this->calendar->gettext('low'), '0'); + $select->add($this->calendar->gettext('high'), '2'); + return $select->show(null); + } + + /** + * Render HTML form for alarm configuration + */ + function alarm_select($attrib = array()) + { + unset($attrib['name']); + $select_type = new html_select(array('name' => 'alarmtype[]', 'class' => 'edit-alarm-type')); + $select_type->add( + array($this->calendar->gettext('none'), $this->calendar->gettext('showmessage'), $this->calendar->gettext('byemail')), + array('','message','email')); + + $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_offset = new html_select(array('name' => 'alarmoffset[]', 'class' => 'edit-alarm-offset')); + $select_offset->add( + array( + $this->calendar->gettext('minutesbefore'), $this->calendar->gettext('hoursbefore'), $this->calendar->gettext('daysbefore'), + $this->calendar->gettext('minutesafter'), $this->calendar->gettext('hoursafter'), $this->calendar->gettext('daysafter'), + $this->calendar->gettext('ondate'), + ), + array('-m','-h','-d','+m','+h','+d','@')); + + // TODO: pre-set with default values from user settings + $hidden = array('style' => 'display:none'); + $html = html::span('edit-alarm-set', + $select_type->show('') . ' ' . + html::span(array('class' => 'edit-alarm-values', 'style' => 'display:none'), + $input_value->show(15) . ' ' . + $select_offset->show('-m') . ' ' . + $input_date->show('', $hidden) . ' ' . + $input_time->show('', $hidden) + ) + ); + + // TODO: support adding more alarms + #$html .= html::a(array('href' => '#', 'id' => 'edit-alam-add', 'title' => $this->calendar->gettext('addalarm')), + # $attrib['addicon'] ? html::img(array('src' => $attrib['addicon'], 'alt' => 'add')) : '(+)'); + + return $html; + } + + /** + * Generate the form for recurrence settings + */ + 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->calendar->gettext('never'), ''); + $select->add($this->calendar->gettext('daily'), 'DAILY'); + $select->add($this->calendar->gettext('weekly'), 'WEEKLY'); + $select->add($this->calendar->gettext('monthly'), 'MONTHLY'); + $select->add($this->calendar->gettext('yearly'), 'YEARLY'); + $html = html::label('edit-frequency', $this->calendar->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(null, $this->calendar->gettext('every')) . $select->show(1) . html::span('label-after', $this->calendar->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(null, $this->calendar->gettext('every')) . $select->show(1) . html::span('label-after', $this->calendar->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->calendar->gettext($daymap[$d])) . ' '; + } + $html .= html::div($attrib, html::label(null, $this->calendar->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(null, $this->calendar->gettext('every')) . $select->show(1) . html::span('label-after', $this->calendar->gettext('months'))); + // day of month selection + $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 topalign', html::label(null, $radio->show('BYMONTHDAY', array('value' => 'BYMONTHDAY')) . ' ' . $this->calendar->gettext('each'))); + $table->add(null, $monthdays); + $table->add('label', html::label(null, $radio->show('', array('value' => 'BYDAY')) . ' ' . $this->calendar->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(null, $this->calendar->gettext('every')) . $select->show(1) . html::span('label-after', $this->calendar->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('', array('value' => $m)) . $this->calendar->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->calendar->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")); + + $table = new html_table(array('cols' => 2, 'border' => 0, 'cellpadding' => 0, 'class' => 'formtable')); + + $table->add('label', $this->calendar->gettext('recurrencend')); + $table->add(null, html::label(null, $radio->show('', array('value' => '', 'id' => 'edit-recurrence-repeat-forever')) . ' ' . + $this->calendar->gettext('forever'))); + + $table->add('label', ''); + $table->add(null, html::label(null, $radio->show('', array('value' => 'count', 'id' => 'edit-recurrence-repeat-count')) . ' ' . + $this->calendar->gettext(array( + 'name' => 'forntimes', + 'vars' => array('nr' => $select->show(1))) + ))); + + $table->add('label', ''); + $table->add(null, $radio->show('', array('value' => 'until', 'id' => 'edit-recurrence-repeat-until')) . ' ' . + $this->calendar->gettext('until') . ' ' . $input->show('')); + $html = $table->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->calendar->gettext('first'), + $this->calendar->gettext('second'), + $this->calendar->gettext('third'), + $this->calendar->gettext('fourth'), + $this->calendar->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->calendar->gettext($daymap[$d]), strtoupper(substr($daymap[$d], 0, 2))); + } + if ($part == 'monthly') + $select_wday->add($this->calendar->gettext('dayofmonth'), ''); + + return $select_prefix->show() . ' ' . $select_wday->show(); + } +} \ No newline at end of file diff --git a/plugins/calendar/lib/js/fullcalendar.js b/plugins/calendar/lib/js/fullcalendar.js new file mode 100644 index 00000000..bbcd2fb8 --- /dev/null +++ b/plugins/calendar/lib/js/fullcalendar.js @@ -0,0 +1,5208 @@ +/** + * @preserve + * FullCalendar v1.5.1 + * http://arshaw.com/fullcalendar/ + * + * Use fullcalendar.css for basic styling. + * For event drag & drop, requires jQuery UI draggable. + * For event resizing, requires jQuery UI resizable. + * + * Copyright (c) 2011 Adam Shaw + * Dual licensed under the MIT and GPL licenses, located in + * MIT-LICENSE.txt and GPL-LICENSE.txt respectively. + * + * Date: Sat Apr 9 14:09:51 2011 -0700 + * + */ + +(function($, undefined) { + + +var defaults = { + + // display + defaultView: 'month', + aspectRatio: 1.35, + header: { + left: 'title', + center: '', + right: 'today prev,next' + }, + weekends: true, + + // editing + //editable: false, + //disableDragging: false, + //disableResizing: false, + + allDayDefault: true, + ignoreTimezone: true, + + // event ajax + lazyFetching: true, + startParam: 'start', + endParam: 'end', + + // time formats + titleFormat: { + month: 'MMMM yyyy', + week: "MMM d[ yyyy]{ '—'[ MMM] d yyyy}", + day: 'dddd, MMM d, yyyy' + }, + columnFormat: { + month: 'ddd', + week: 'ddd M/d', + day: 'dddd M/d' + }, + timeFormat: { // for event elements + '': 'h(:mm)t' // default + }, + + // locale + isRTL: false, + firstDay: 0, + monthNames: ['January','February','March','April','May','June','July','August','September','October','November','December'], + monthNamesShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'], + dayNames: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'], + dayNamesShort: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'], + buttonText: { + prev: ' ◄ ', + next: ' ► ', + prevYear: ' << ', + nextYear: ' >> ', + today: 'today', + month: 'month', + week: 'week', + day: 'day' + }, + + // jquery-ui theming + theme: false, + buttonIcons: { + prev: 'circle-triangle-w', + next: 'circle-triangle-e' + }, + + //selectable: false, + unselectAuto: true, + + dropAccept: '*' + +}; + +// right-to-left defaults +var rtlDefaults = { + header: { + left: 'next,prev today', + center: '', + right: 'title' + }, + buttonText: { + prev: ' ► ', + next: ' ◄ ', + prevYear: ' >> ', + nextYear: ' << ' + }, + buttonIcons: { + prev: 'circle-triangle-e', + next: 'circle-triangle-w' + } +}; + + + +var fc = $.fullCalendar = { version: "1.5.1" }; +var fcViews = fc.views = {}; + + +$.fn.fullCalendar = function(options) { + + + // method calling + if (typeof options == 'string') { + var args = Array.prototype.slice.call(arguments, 1); + var res; + this.each(function() { + var calendar = $.data(this, 'fullCalendar'); + if (calendar && $.isFunction(calendar[options])) { + var r = calendar[options].apply(calendar, args); + if (res === undefined) { + res = r; + } + if (options == 'destroy') { + $.removeData(this, 'fullCalendar'); + } + } + }); + if (res !== undefined) { + return res; + } + return this; + } + + + // would like to have this logic in EventManager, but needs to happen before options are recursively extended + var eventSources = options.eventSources || []; + delete options.eventSources; + if (options.events) { + eventSources.push(options.events); + delete options.events; + } + + + options = $.extend(true, {}, + defaults, + (options.isRTL || options.isRTL===undefined && defaults.isRTL) ? rtlDefaults : {}, + options + ); + + + this.each(function(i, _element) { + var element = $(_element); + var calendar = new Calendar(element, options, eventSources); + element.data('fullCalendar', calendar); // TODO: look into memory leak implications + calendar.render(); + }); + + + return this; + +}; + + +// function for adding/overriding defaults +function setDefaults(d) { + $.extend(true, defaults, d); +} + + + + +function Calendar(element, options, eventSources) { + var t = this; + + + // exports + t.options = options; + t.render = render; + t.destroy = destroy; + t.refetchEvents = refetchEvents; + t.reportEvents = reportEvents; + t.reportEventChange = reportEventChange; + t.rerenderEvents = rerenderEvents; + t.changeView = changeView; + t.select = select; + t.unselect = unselect; + t.prev = prev; + t.next = next; + t.prevYear = prevYear; + t.nextYear = nextYear; + t.today = today; + t.gotoDate = gotoDate; + t.incrementDate = incrementDate; + t.formatDate = function(format, date) { return formatDate(format, date, options) }; + t.formatDates = function(format, date1, date2) { return formatDates(format, date1, date2, options) }; + t.getDate = getDate; + t.getView = getView; + t.option = option; + t.trigger = trigger; + + + // imports + EventManager.call(t, options, eventSources); + var isFetchNeeded = t.isFetchNeeded; + var fetchEvents = t.fetchEvents; + + + // locals + var _element = element[0]; + var header; + var headerElement; + var content; + var tm; // for making theme classes + var currentView; + var viewInstances = {}; + var elementOuterWidth; + var suggestedViewHeight; + var absoluteViewElement; + var resizeUID = 0; + var ignoreWindowResize = 0; + var date = new Date(); + var events = []; + var _dragElement; + + + + /* Main Rendering + -----------------------------------------------------------------------------*/ + + + setYMD(date, options.year, options.month, options.date); + + + function render(inc) { + if (!content) { + initialRender(); + }else{ + calcSize(); + markSizesDirty(); + markEventsDirty(); + renderView(inc); + } + } + + + function initialRender() { + tm = options.theme ? 'ui' : 'fc'; + element.addClass('fc'); + if (options.isRTL) { + element.addClass('fc-rtl'); + } + if (options.theme) { + element.addClass('ui-widget'); + } + content = $("
") + .prependTo(element); + header = new Header(t, options); + headerElement = header.render(); + if (headerElement) { + element.prepend(headerElement); + } + changeView(options.defaultView); + $(window).resize(windowResize); + // needed for IE in a 0x0 iframe, b/c when it is resized, never triggers a windowResize + if (!bodyVisible()) { + lateRender(); + } + } + + + // called when we know the calendar couldn't be rendered when it was initialized, + // but we think it's ready now + function lateRender() { + setTimeout(function() { // IE7 needs this so dimensions are calculated correctly + if (!currentView.start && bodyVisible()) { // !currentView.start makes sure this never happens more than once + renderView(); + } + },0); + } + + + function destroy() { + $(window).unbind('resize', windowResize); + header.destroy(); + content.remove(); + element.removeClass('fc fc-rtl ui-widget'); + } + + + + function elementVisible() { + return _element.offsetWidth !== 0; + } + + + function bodyVisible() { + return $('body')[0].offsetWidth !== 0; + } + + + + /* View Rendering + -----------------------------------------------------------------------------*/ + + // TODO: improve view switching (still weird transition in IE, and FF has whiteout problem) + + function changeView(newViewName) { + if (!currentView || newViewName != currentView.name) { + ignoreWindowResize++; // because setMinHeight might change the height before render (and subsequently setSize) is reached + + unselect(); + + var oldView = currentView; + var newViewElement; + + if (oldView) { + (oldView.beforeHide || noop)(); // called before changing min-height. if called after, scroll state is reset (in Opera) + setMinHeight(content, content.height()); + oldView.element.hide(); + }else{ + setMinHeight(content, 1); // needs to be 1 (not 0) for IE7, or else view dimensions miscalculated + } + content.css('overflow', 'hidden'); + + currentView = viewInstances[newViewName]; + if (currentView) { + currentView.element.show(); + }else{ + currentView = viewInstances[newViewName] = new fcViews[newViewName]( + newViewElement = absoluteViewElement = + $("
") + .appendTo(content), + t // the calendar object + ); + } + + if (oldView) { + header.deactivateButton(oldView.name); + } + header.activateButton(newViewName); + + renderView(); // after height has been set, will make absoluteViewElement's position=relative, then set to null + + content.css('overflow', ''); + if (oldView) { + setMinHeight(content, 1); + } + + if (!newViewElement) { + (currentView.afterShow || noop)(); // called after setting min-height/overflow, so in final scroll state (for Opera) + } + + ignoreWindowResize--; + } + } + + + + function renderView(inc) { + if (elementVisible()) { + ignoreWindowResize++; // because renderEvents might temporarily change the height before setSize is reached + + unselect(); + + if (suggestedViewHeight === undefined) { + calcSize(); + } + + var forceEventRender = false; + if (!currentView.start || inc || date < currentView.start || date >= currentView.end) { + // view must render an entire new date range (and refetch/render events) + currentView.render(date, inc || 0); // responsible for clearing events + setSize(true); + forceEventRender = true; + } + else if (currentView.sizeDirty) { + // view must resize (and rerender events) + currentView.clearEvents(); + setSize(); + forceEventRender = true; + } + else if (currentView.eventsDirty) { + currentView.clearEvents(); + forceEventRender = true; + } + currentView.sizeDirty = false; + currentView.eventsDirty = false; + updateEvents(forceEventRender); + + elementOuterWidth = element.outerWidth(); + + header.updateTitle(currentView.title); + var today = new Date(); + if (today >= currentView.start && today < currentView.end) { + header.disableButton('today'); + }else{ + header.enableButton('today'); + } + + ignoreWindowResize--; + currentView.trigger('viewDisplay', _element); + } + } + + + + /* Resizing + -----------------------------------------------------------------------------*/ + + + function updateSize() { + markSizesDirty(); + if (elementVisible()) { + calcSize(); + setSize(); + unselect(); + currentView.clearEvents(); + currentView.renderEvents(events); + currentView.sizeDirty = false; + } + } + + + function markSizesDirty() { + $.each(viewInstances, function(i, inst) { + inst.sizeDirty = true; + }); + } + + + function calcSize() { + if (options.contentHeight) { + suggestedViewHeight = options.contentHeight; + } + else if (options.height) { + suggestedViewHeight = options.height - (headerElement ? headerElement.height() : 0) - vsides(content); + } + else { + suggestedViewHeight = Math.round(content.width() / Math.max(options.aspectRatio, .5)); + } + } + + + function setSize(dateChanged) { // todo: dateChanged? + ignoreWindowResize++; + currentView.setHeight(suggestedViewHeight, dateChanged); + if (absoluteViewElement) { + absoluteViewElement.css('position', 'relative'); + absoluteViewElement = null; + } + currentView.setWidth(content.width(), dateChanged); + ignoreWindowResize--; + } + + + function windowResize() { + if (!ignoreWindowResize) { + if (currentView.start) { // view has already been rendered + var uid = ++resizeUID; + setTimeout(function() { // add a delay + if (uid == resizeUID && !ignoreWindowResize && elementVisible()) { + if (elementOuterWidth != (elementOuterWidth = element.outerWidth())) { + ignoreWindowResize++; // in case the windowResize callback changes the height + updateSize(); + currentView.trigger('windowResize', _element); + ignoreWindowResize--; + } + } + }, 200); + }else{ + // calendar must have been initialized in a 0x0 iframe that has just been resized + lateRender(); + } + } + } + + + + /* Event Fetching/Rendering + -----------------------------------------------------------------------------*/ + + + // fetches events if necessary, rerenders events if necessary (or if forced) + function updateEvents(forceRender) { + if (!options.lazyFetching || isFetchNeeded(currentView.visStart, currentView.visEnd)) { + refetchEvents(); + } + else if (forceRender) { + rerenderEvents(); + } + } + + + function refetchEvents() { + fetchEvents(currentView.visStart, currentView.visEnd); // will call reportEvents + } + + + // called when event data arrives + function reportEvents(_events) { + events = _events; + rerenderEvents(); + } + + + // called when a single event's data has been changed + function reportEventChange(eventID) { + rerenderEvents(eventID); + } + + + // attempts to rerenderEvents + function rerenderEvents(modifiedEventID) { + markEventsDirty(); + if (elementVisible()) { + currentView.clearEvents(); + currentView.renderEvents(events, modifiedEventID); + currentView.eventsDirty = false; + } + } + + + function markEventsDirty() { + $.each(viewInstances, function(i, inst) { + inst.eventsDirty = true; + }); + } + + + + /* Selection + -----------------------------------------------------------------------------*/ + + + function select(start, end, allDay) { + currentView.select(start, end, allDay===undefined ? true : allDay); + } + + + function unselect() { // safe to be called before renderView + if (currentView) { + currentView.unselect(); + } + } + + + + /* Date + -----------------------------------------------------------------------------*/ + + + function prev() { + renderView(-1); + } + + + function next() { + renderView(1); + } + + + function prevYear() { + addYears(date, -1); + renderView(); + } + + + function nextYear() { + addYears(date, 1); + renderView(); + } + + + function today() { + date = new Date(); + renderView(); + } + + + function gotoDate(year, month, dateOfMonth) { + if (year instanceof Date) { + date = cloneDate(year); // provided 1 argument, a Date + }else{ + setYMD(date, year, month, dateOfMonth); + } + renderView(); + } + + + function incrementDate(years, months, days) { + if (years !== undefined) { + addYears(date, years); + } + if (months !== undefined) { + addMonths(date, months); + } + if (days !== undefined) { + addDays(date, days); + } + renderView(); + } + + + function getDate() { + return cloneDate(date); + } + + + + /* Misc + -----------------------------------------------------------------------------*/ + + + function getView() { + return currentView; + } + + + function option(name, value) { + if (value === undefined) { + return options[name]; + } + if (name == 'height' || name == 'contentHeight' || name == 'aspectRatio') { + options[name] = value; + updateSize(); + } + } + + + function trigger(name, thisObj) { + if (options[name]) { + return options[name].apply( + thisObj || _element, + Array.prototype.slice.call(arguments, 2) + ); + } + } + + + + /* External Dragging + ------------------------------------------------------------------------*/ + + if (options.droppable) { + $(document) + .bind('dragstart', function(ev, ui) { + var _e = ev.target; + var e = $(_e); + if (!e.parents('.fc').length) { // not already inside a calendar + var accept = options.dropAccept; + if ($.isFunction(accept) ? accept.call(_e, e) : e.is(accept)) { + _dragElement = _e; + currentView.dragStart(_dragElement, ev, ui); + } + } + }) + .bind('dragstop', function(ev, ui) { + if (_dragElement) { + currentView.dragStop(_dragElement, ev, ui); + _dragElement = null; + } + }); + } + + +} + +function Header(calendar, options) { + var t = this; + + + // exports + t.render = render; + t.destroy = destroy; + t.updateTitle = updateTitle; + t.activateButton = activateButton; + t.deactivateButton = deactivateButton; + t.disableButton = disableButton; + t.enableButton = enableButton; + + + // locals + var element = $([]); + var tm; + + + + function render() { + tm = options.theme ? 'ui' : 'fc'; + var sections = options.header; + if (sections) { + element = $("") + .append( + $("") + .append(renderSection('left')) + .append(renderSection('center')) + .append(renderSection('right')) + ); + return element; + } + } + + + function destroy() { + element.remove(); + } + + + function renderSection(position) { + var e = $("" + + ""; + for (i=0; i"; // need fc- for setDayID + } + s += + "" + + "" + + ""; + for (i=0; i"; + for (j=0; j" + // need fc- for setDayID + "
" + + (showNumbers ? + "
" : + '' + ) + + "
" + + "
 
" + + "
" + + "
" + + ""; + } + s += + ""; + } + s += + "
" + + "
"); + var buttonStr = options.header[position]; + if (buttonStr) { + $.each(buttonStr.split(' '), function(i) { + if (i > 0) { + e.append(""); + } + var prevButton; + $.each(this.split(','), function(j, buttonName) { + if (buttonName == 'title') { + e.append("

 

"); + if (prevButton) { + prevButton.addClass(tm + '-corner-right'); + } + prevButton = null; + }else{ + var buttonClick; + if (calendar[buttonName]) { + buttonClick = calendar[buttonName]; // calendar method + } + else if (fcViews[buttonName]) { + buttonClick = function() { + button.removeClass(tm + '-state-hover'); // forget why + calendar.changeView(buttonName); + }; + } + if (buttonClick) { + var icon = options.theme ? smartProperty(options.buttonIcons, buttonName) : null; // why are we using smartProperty here? + var text = smartProperty(options.buttonText, buttonName); // why are we using smartProperty here? + var button = $( + "" + + "" + + "" + + (icon ? + "" + + "" + + "" : + text + ) + + "" + + "" + + "" + + "" + ); + if (button) { + button + .click(function() { + if (!button.hasClass(tm + '-state-disabled')) { + buttonClick(); + } + }) + .mousedown(function() { + button + .not('.' + tm + '-state-active') + .not('.' + tm + '-state-disabled') + .addClass(tm + '-state-down'); + }) + .mouseup(function() { + button.removeClass(tm + '-state-down'); + }) + .hover( + function() { + button + .not('.' + tm + '-state-active') + .not('.' + tm + '-state-disabled') + .addClass(tm + '-state-hover'); + }, + function() { + button + .removeClass(tm + '-state-hover') + .removeClass(tm + '-state-down'); + } + ) + .appendTo(e); + if (!prevButton) { + button.addClass(tm + '-corner-left'); + } + prevButton = button; + } + } + } + }); + if (prevButton) { + prevButton.addClass(tm + '-corner-right'); + } + }); + } + return e; + } + + + function updateTitle(html) { + element.find('h2') + .html(html); + } + + + function activateButton(buttonName) { + element.find('span.fc-button-' + buttonName) + .addClass(tm + '-state-active'); + } + + + function deactivateButton(buttonName) { + element.find('span.fc-button-' + buttonName) + .removeClass(tm + '-state-active'); + } + + + function disableButton(buttonName) { + element.find('span.fc-button-' + buttonName) + .addClass(tm + '-state-disabled'); + } + + + function enableButton(buttonName) { + element.find('span.fc-button-' + buttonName) + .removeClass(tm + '-state-disabled'); + } + + +} + +fc.sourceNormalizers = []; +fc.sourceFetchers = []; + +var ajaxDefaults = { + dataType: 'json', + cache: false +}; + +var eventGUID = 1; + + +function EventManager(options, _sources) { + var t = this; + + + // exports + t.isFetchNeeded = isFetchNeeded; + t.fetchEvents = fetchEvents; + t.addEventSource = addEventSource; + t.removeEventSource = removeEventSource; + t.updateEvent = updateEvent; + t.renderEvent = renderEvent; + t.removeEvents = removeEvents; + t.clientEvents = clientEvents; + t.normalizeEvent = normalizeEvent; + + + // imports + var trigger = t.trigger; + var getView = t.getView; + var reportEvents = t.reportEvents; + + + // locals + var stickySource = { events: [] }; + var sources = [ stickySource ]; + var rangeStart, rangeEnd; + var currentFetchID = 0; + var pendingSourceCnt = 0; + var loadingLevel = 0; + var cache = []; + + + for (var i=0; i<_sources.length; i++) { + _addEventSource(_sources[i]); + } + + + + /* Fetching + -----------------------------------------------------------------------------*/ + + + function isFetchNeeded(start, end) { + return !rangeStart || start < rangeStart || end > rangeEnd; + } + + + function fetchEvents(start, end) { + rangeStart = start; + rangeEnd = end; + cache = []; + var fetchID = ++currentFetchID; + var len = sources.length; + pendingSourceCnt = len; + for (var i=0; i)), return null instead + return null; +} + + +function parseISO8601(s, ignoreTimezone) { // ignoreTimezone defaults to false + // derived from http://delete.me.uk/2005/03/iso8601.html + // TODO: for a know glitch/feature, read tests/issue_206_parseDate_dst.html + var m = 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, 1); + if (ignoreTimezone || !m[14]) { + var check = new Date(m[1], 0, 1, 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); + }else{ + date.setUTCFullYear( + m[1], + m[3] ? m[3] - 1 : 0, + m[5] || 1 + ); + date.setUTCHours( + m[7] || 0, + m[8] || 0, + m[10] || 0, + m[12] ? Number("0." + m[12]) * 1000 : 0 + ); + var offset = Number(m[16]) * 60 + (m[18] ? Number(m[18]) : 0); + offset *= m[15] == '-' ? 1 : -1; + date = new Date(+date + (offset * 60 * 1000)); + } + return date; +} + + +function parseTime(s) { // returns minutes since start of day + if (typeof s == 'number') { // an hour + return s * 60; + } + if (typeof s == 'object') { // a Date object + return s.getHours() * 60 + s.getMinutes(); + } + var m = s.match(/(\d+)(?::(\d+))?\s*(\w+)?/); + if (m) { + var h = parseInt(m[1], 10); + if (m[3]) { + h %= 12; + if (m[3].toLowerCase().charAt(0) == 'p') { + h += 12; + } + } + return h * 60 + (m[2] ? parseInt(m[2], 10) : 0); + } +} + + + +/* Date Formatting +-----------------------------------------------------------------------------*/ +// TODO: use same function formatDate(date, [date2], format, [options]) + + +function formatDate(date, format, options) { + return formatDates(date, null, format, options); +} + + +function formatDates(date1, date2, format, options) { + options = options || defaults; + var date = date1, + otherDate = date2, + i, len = format.length, c, + i2, formatter, + res = ''; + for (i=0; ii; i2--) { + if (formatter = dateFormatters[format.substring(i, i2)]) { + if (date) { + res += formatter(date, options); + } + i = i2 - 1; + break; + } + } + if (i2 == i) { + if (date) { + res += c; + } + } + } + } + return res; +}; + + +var dateFormatters = { + 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()) }, + d : function(d) { return d.getDate() }, + dd : function(d) { return zeroPad(d.getDate()) }, + ddd : function(d,o) { return o.dayNamesShort[d.getDay()] }, + dddd: function(d,o) { return o.dayNames[d.getDay()] }, + M : function(d) { return d.getMonth() + 1 }, + MM : function(d) { return zeroPad(d.getMonth() + 1) }, + MMM : function(d,o) { return o.monthNamesShort[d.getMonth()] }, + MMMM: function(d,o) { return o.monthNames[d.getMonth()] }, + yy : function(d) { return (d.getFullYear()+'').substring(2) }, + yyyy: function(d) { return d.getFullYear() }, + 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' }, + u : function(d) { return formatDate(d, "yyyy-MM-dd'T'HH:mm:ss'Z'") }, + S : function(d) { + var date = d.getDate(); + if (date > 10 && date < 20) { + return 'th'; + } + return ['st', 'nd', 'rd'][date%10-1] || 'th'; + } +}; + + + +fc.applyAll = applyAll; + + +/* Event Date Math +-----------------------------------------------------------------------------*/ + + +function exclEndDay(event) { + if (event.end) { + return _exclEndDay(event.end, event.allDay); + }else{ + return addDays(cloneDate(event.start), 1); + } +} + + +function _exclEndDay(end, allDay) { + end = cloneDate(end); + return allDay || end.getHours() || end.getMinutes() ? addDays(end, 1) : clearTime(end); +} + + +function segCmp(a, b) { + return (b.msLength - a.msLength) * 100 + (a.event.start - b.event.start); +} + + +function segsCollide(seg1, seg2) { + return seg1.end > seg2.start && seg1.start < seg2.end; +} + + + +/* Event Sorting +-----------------------------------------------------------------------------*/ + + +// event rendering utilities +function sliceSegs(events, visEventEnds, start, end) { + var segs = [], + i, len=events.length, event, + eventStart, eventEnd, + segStart, segEnd, + isStart, isEnd; + for (i=0; i start && eventStart < end) { + if (eventStart < start) { + segStart = cloneDate(start); + isStart = false; + }else{ + segStart = eventStart; + isStart = true; + } + if (eventEnd > end) { + segEnd = cloneDate(end); + isEnd = false; + }else{ + segEnd = eventEnd; + isEnd = true; + } + segs.push({ + event: event, + start: segStart, + end: segEnd, + isStart: isStart, + isEnd: isEnd, + msLength: segEnd - segStart + }); + } + } + return segs.sort(segCmp); +} + + +// event rendering calculation utilities +function stackSegs(segs) { + var levels = [], + i, len = segs.length, seg, + j, collide, k; + for (i=0; i=0; i--) { + res = obj[parts[i].toLowerCase()]; + if (res !== undefined) { + return res; + } + } + return obj['']; +} + + +function htmlEscape(s) { + return s.replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"') + .replace(/\n/g, '
'); +} + + +function cssKey(_element) { + return _element.id + '/' + _element.className + '/' + _element.style.cssText.replace(/(^|;)\s*(top|left|width|height)\s*:[^;]*/ig, ''); +} + + +function disableTextSelection(element) { + element + .attr('unselectable', 'on') + .css('MozUserSelect', 'none') + .bind('selectstart.ui', function() { return false; }); +} + + +/* +function enableTextSelection(element) { + element + .attr('unselectable', 'off') + .css('MozUserSelect', '') + .unbind('selectstart.ui'); +} +*/ + + +function markFirstLast(e) { + e.children() + .removeClass('fc-first fc-last') + .filter(':first-child') + .addClass('fc-first') + .end() + .filter(':last-child') + .addClass('fc-last'); +} + + +function setDayID(cell, date) { + cell.each(function(i, _cell) { + _cell.className = _cell.className.replace(/^fc-\w*/, 'fc-' + dayIDs[date.getDay()]); + // TODO: make a way that doesn't rely on order of classes + }); +} + + +function getSkinCss(event, opt) { + var source = event.source || {}; + var eventColor = event.color; + var sourceColor = source.color; + var optionColor = opt('eventColor'); + var backgroundColor = + event.backgroundColor || + eventColor || + source.backgroundColor || + sourceColor || + opt('eventBackgroundColor') || + optionColor; + var borderColor = + event.borderColor || + eventColor || + source.borderColor || + sourceColor || + opt('eventBorderColor') || + optionColor; + var textColor = + event.textColor || + source.textColor || + opt('eventTextColor'); + var statements = []; + if (backgroundColor) { + statements.push('background-color:' + backgroundColor); + } + if (borderColor) { + statements.push('border-color:' + borderColor); + } + if (textColor) { + statements.push('color:' + textColor); + } + return statements.join(';'); +} + + +function applyAll(functions, thisObj, args) { + if ($.isFunction(functions)) { + functions = [ functions ]; + } + if (functions) { + var i; + var ret; + for (i=0; i" + + "
"; + table = $(s).appendTo(element); + + head = table.find('thead'); + headCells = head.find('th'); + body = table.find('tbody'); + bodyRows = body.find('tr'); + bodyCells = body.find('td'); + bodyFirstCells = bodyCells.filter(':first-child'); + bodyCellTopInners = bodyRows.eq(0).find('div.fc-day-content div'); + + markFirstLast(head.add(head.find('tr'))); // marks first+last tr/th's + markFirstLast(bodyRows); // marks first+last td's + bodyRows.eq(0).addClass('fc-first'); // fc-last is done in updateCells + + dayBind(bodyCells); + + daySegmentContainer = + $("
") + .appendTo(element); + } + + + + function updateCells(firstTime) { + var dowDirty = firstTime || rowCnt == 1; // could the cells' day-of-weeks need updating? + var month = t.start.getMonth(); + var today = clearTime(new Date()); + var cell; + var date; + var row; + + if (dowDirty) { + headCells.each(function(i, _cell) { + cell = $(_cell); + date = indexDate(i); + cell.html(formatDate(date, colFormat)); + setDayID(cell, date); + }); + } + + bodyCells.each(function(i, _cell) { + cell = $(_cell); + date = indexDate(i); + if (date.getMonth() == month) { + cell.removeClass('fc-other-month'); + }else{ + cell.addClass('fc-other-month'); + } + if (+date == +today) { + cell.addClass(tm + '-state-highlight fc-today'); + }else{ + cell.removeClass(tm + '-state-highlight fc-today'); + } + cell.find('div.fc-day-number').text(date.getDate()); + if (dowDirty) { + setDayID(cell, date); + } + }); + + bodyRows.each(function(i, _row) { + row = $(_row); + if (i < rowCnt) { + row.show(); + if (i == rowCnt-1) { + row.addClass('fc-last'); + }else{ + row.removeClass('fc-last'); + } + }else{ + row.hide(); + } + }); + } + + + + function setHeight(height) { + viewHeight = height; + + var bodyHeight = viewHeight - head.height(); + var rowHeight; + var rowHeightLast; + var cell; + + if (opt('weekMode') == 'variable') { + rowHeight = rowHeightLast = Math.floor(bodyHeight / (rowCnt==1 ? 2 : 6)); + }else{ + rowHeight = Math.floor(bodyHeight / rowCnt); + rowHeightLast = bodyHeight - rowHeight * (rowCnt-1); + } + + bodyFirstCells.each(function(i, _cell) { + if (i < rowCnt) { + cell = $(_cell); + setMinHeight( + cell.find('> div'), + (i==rowCnt-1 ? rowHeightLast : rowHeight) - vsides(cell) + ); + } + }); + + } + + + function setWidth(width) { + viewWidth = width; + colContentPositions.clear(); + colWidth = Math.floor(viewWidth / colCnt); + setOuterWidth(headCells.slice(0, -1), colWidth); + } + + + + /* Day clicking and binding + -----------------------------------------------------------*/ + + + function dayBind(days) { + days.click(dayClick) + .mousedown(daySelectionMousedown); + } + + + function dayClick(ev) { + if (!opt('selectable')) { // if selectable, SelectionManager will worry about dayClick + var index = parseInt(this.className.match(/fc\-day(\d+)/)[1]); // TODO: maybe use .data + var date = indexDate(index); + trigger('dayClick', this, date, true, ev); + } + } + + + + /* Semi-transparent Overlay Helpers + ------------------------------------------------------*/ + + + function renderDayOverlay(overlayStart, overlayEnd, refreshCoordinateGrid) { // overlayEnd is exclusive + if (refreshCoordinateGrid) { + coordinateGrid.build(); + } + var rowStart = cloneDate(t.visStart); + var rowEnd = addDays(cloneDate(rowStart), colCnt); + for (var i=0; i" + + "" + + "" + + " "; + for (i=0; i"; // fc- needed for setDayID + } + s += + " " + + "" + + "" + + "" + + "" + + " "; + for (i=0; i" + // fc- needed for setDayID + "
" + + "
" + + "
 
" + + "
" + + "
" + + ""; + } + s += + " " + + "" + + "" + + ""; + dayTable = $(s).appendTo(element); + dayHead = dayTable.find('thead'); + dayHeadCells = dayHead.find('th').slice(1, -1); + dayBody = dayTable.find('tbody'); + dayBodyCells = dayBody.find('td').slice(0, -1); + dayBodyCellInners = dayBodyCells.find('div.fc-day-content div'); + dayBodyFirstCell = dayBodyCells.eq(0); + dayBodyFirstCellStretcher = dayBodyFirstCell.find('> div'); + + markFirstLast(dayHead.add(dayHead.find('tr'))); + markFirstLast(dayBody.add(dayBody.find('tr'))); + + axisFirstCells = dayHead.find('th:first'); + gutterCells = dayTable.find('.fc-agenda-gutter'); + + slotLayer = + $("
") + .appendTo(element); + + if (opt('allDaySlot')) { + + daySegmentContainer = + $("
") + .appendTo(slotLayer); + + s = + "" + + "" + + "" + + "" + + "" + + "" + + "
" + opt('allDayText') + "" + + "
" + + "
 
"; + allDayTable = $(s).appendTo(slotLayer); + allDayRow = allDayTable.find('tr'); + + dayBind(allDayRow.find('td')); + + axisFirstCells = axisFirstCells.add(allDayTable.find('th:first')); + gutterCells = gutterCells.add(allDayTable.find('th.fc-agenda-gutter')); + + slotLayer.append( + "
" + + "
" + + "
" + ); + + }else{ + + daySegmentContainer = $([]); // in jQuery 1.4, we can just do $() + + } + + slotScroller = + $("
") + .appendTo(slotLayer); + + slotContent = + $("
") + .appendTo(slotScroller); + + slotSegmentContainer = + $("
") + .appendTo(slotContent); + + s = + "" + + ""; + d = zeroDate(); + maxd = addMinutes(cloneDate(d), maxMinute); + addMinutes(d, minMinute); + slotCnt = 0; + for (i=0; d < maxd; i++) { + minutes = d.getMinutes(); + s += + "" + + "" + + "" + + ""; + addMinutes(d, opt('slotMinutes')); + slotCnt++; + } + s += + "" + + "
" + + ((!slotNormal || !minutes) ? formatDate(d, opt('axisFormat')) : ' ') + + "" + + "
 
" + + "
"; + slotTable = $(s).appendTo(slotContent); + slotTableFirstInner = slotTable.find('div:first'); + + slotBind(slotTable.find('td')); + + axisFirstCells = axisFirstCells.add(slotTable.find('th:first')); + } + + + + function updateCells() { + var i; + var headCell; + var bodyCell; + var date; + var today = clearTime(new Date()); + for (i=0; i= 0) { + addMinutes(d, minMinute + slotIndex * opt('slotMinutes')); + } + return d; + } + + + function colDate(col) { // returns dates with 00:00:00 + return addDays(cloneDate(t.visStart), col*dis+dit); + } + + + function cellIsAllDay(cell) { + return opt('allDaySlot') && !cell.row; + } + + + function dayOfWeekCol(dayOfWeek) { + return ((dayOfWeek - Math.max(firstDay, nwe) + colCnt) % colCnt)*dis+dit; + } + + + + + // get the Y coordinate of the given time on the given day (both Date objects) + function timePosition(day, time) { // both date objects. day holds 00:00 of current day + day = cloneDate(day, true); + if (time < addMinutes(cloneDate(day), minMinute)) { + return 0; + } + if (time >= addMinutes(cloneDate(day), maxMinute)) { + return slotTable.height(); + } + var slotMinutes = opt('slotMinutes'), + minutes = time.getHours()*60 + time.getMinutes() - minMinute, + slotI = Math.floor(minutes / slotMinutes), + slotTop = slotTopCache[slotI]; + if (slotTop === undefined) { + slotTop = slotTopCache[slotI] = slotTable.find('tr:eq(' + slotI + ') td div')[0].offsetTop; //.position().top; // need this optimization??? + } + return Math.max(0, Math.round( + slotTop - 1 + slotHeight * ((minutes % slotMinutes) / slotMinutes) + )); + } + + + function allDayBounds() { + return { + left: axisWidth, + right: viewWidth - gutterWidth + } + } + + + function getAllDayRow(index) { + return allDayRow; + } + + + function defaultEventEnd(event) { + var start = cloneDate(event.start); + if (event.allDay) { + return start; + } + return addMinutes(start, opt('defaultEventMinutes')); + } + + + + /* Selection + ---------------------------------------------------------------------------------*/ + + + function defaultSelectionEnd(startDate, allDay) { + if (allDay) { + return cloneDate(startDate); + } + return addMinutes(cloneDate(startDate), opt('slotMinutes')); + } + + + function renderSelection(startDate, endDate, allDay) { // only for all-day + if (allDay) { + if (opt('allDaySlot')) { + renderDayOverlay(startDate, addDays(cloneDate(endDate), 1), true); + } + }else{ + renderSlotSelection(startDate, endDate); + } + } + + + function renderSlotSelection(startDate, endDate) { + var helperOption = opt('selectHelper'); + coordinateGrid.build(); + if (helperOption) { + var col = dayDiff(startDate, t.visStart) * dis + dit; + if (col >= 0 && col < colCnt) { // only works when times are on same day + var rect = coordinateGrid.rect(0, col, 0, col, slotContent); // only for horizontal coords + var top = timePosition(startDate, startDate); + var bottom = timePosition(startDate, endDate); + if (bottom > top) { // protect against selections that are entirely before or after visible range + rect.top = top; + rect.height = bottom - top; + rect.left += 2; + rect.width -= 5; + if ($.isFunction(helperOption)) { + var helperRes = helperOption(startDate, endDate); + if (helperRes) { + rect.position = 'absolute'; + rect.zIndex = 8; + selectionHelper = $(helperRes) + .css(rect) + .appendTo(slotContent); + } + }else{ + rect.isStart = true; // conside rect a "seg" now + rect.isEnd = true; // + selectionHelper = $(slotSegHtml( + { + title: '', + start: startDate, + end: endDate, + className: ['fc-select-helper'], + editable: false + }, + rect + )); + selectionHelper.css('opacity', opt('dragOpacity')); + } + if (selectionHelper) { + slotBind(selectionHelper); + slotContent.append(selectionHelper); + setOuterWidth(selectionHelper, rect.width, true); // needs to be after appended + setOuterHeight(selectionHelper, rect.height, true); + } + } + } + }else{ + renderSlotOverlay(startDate, endDate); + } + } + + + function clearSelection() { + clearOverlays(); + if (selectionHelper) { + selectionHelper.remove(); + selectionHelper = null; + } + } + + + function slotSelectionMousedown(ev) { + if (ev.which == 1 && opt('selectable')) { // ev.which==1 means left mouse button + unselect(ev); + var dates; + hoverListener.start(function(cell, origCell) { + clearSelection(); + if (cell && cell.col == origCell.col && !cellIsAllDay(cell)) { + var d1 = cellDate(origCell); + var d2 = cellDate(cell); + dates = [ + d1, + addMinutes(cloneDate(d1), opt('slotMinutes')), + d2, + addMinutes(cloneDate(d2), opt('slotMinutes')) + ].sort(cmp); + renderSlotSelection(dates[0], dates[3]); + }else{ + dates = null; + } + }, ev); + $(document).one('mouseup', function(ev) { + hoverListener.stop(); + if (dates) { + if (+dates[0] == +dates[1]) { + reportDayClick(dates[0], false, ev); + } + reportSelection(dates[0], dates[3], false, ev); + } + }); + } + } + + + function reportDayClick(date, allDay, ev) { + trigger('dayClick', dayBodyCells[dayOfWeekCol(date.getDay())], date, allDay, ev); + } + + + + /* External Dragging + --------------------------------------------------------------------------------*/ + + + function dragStart(_dragElement, ev, ui) { + hoverListener.start(function(cell) { + clearOverlays(); + if (cell) { + if (cellIsAllDay(cell)) { + renderCellOverlay(cell.row, cell.col, cell.row, cell.col); + }else{ + var d1 = cellDate(cell); + var d2 = addMinutes(cloneDate(d1), opt('defaultEventMinutes')); + renderSlotOverlay(d1, d2); + } + } + }, ev); + } + + + function dragStop(_dragElement, ev, ui) { + var cell = hoverListener.stop(); + clearOverlays(); + if (cell) { + trigger('drop', _dragElement, cellDate(cell), cellIsAllDay(cell), ev, ui); + } + } + + +} + +function AgendaEventRenderer() { + var t = this; + + + // exports + t.renderEvents = renderEvents; + t.compileDaySegs = compileDaySegs; // for DayEventRenderer + t.clearEvents = clearEvents; + t.slotSegHtml = slotSegHtml; + t.bindDaySeg = bindDaySeg; + + + // imports + DayEventRenderer.call(t); + var opt = t.opt; + var trigger = t.trigger; + //var setOverflowHidden = t.setOverflowHidden; + var isEventDraggable = t.isEventDraggable; + var isEventResizable = t.isEventResizable; + var eventEnd = t.eventEnd; + var reportEvents = t.reportEvents; + var reportEventClear = t.reportEventClear; + var eventElementHandlers = t.eventElementHandlers; + var setHeight = t.setHeight; + var getDaySegmentContainer = t.getDaySegmentContainer; + var getSlotSegmentContainer = t.getSlotSegmentContainer; + var getHoverListener = t.getHoverListener; + var getMaxMinute = t.getMaxMinute; + var getMinMinute = t.getMinMinute; + var timePosition = t.timePosition; + var colContentLeft = t.colContentLeft; + var colContentRight = t.colContentRight; + var renderDaySegs = t.renderDaySegs; + var resizableDayEvent = t.resizableDayEvent; // TODO: streamline binding architecture + var getColCnt = t.getColCnt; + var getColWidth = t.getColWidth; + var getSlotHeight = t.getSlotHeight; + var getBodyContent = t.getBodyContent; + var reportEventElement = t.reportEventElement; + var showEvents = t.showEvents; + var hideEvents = t.hideEvents; + var eventDrop = t.eventDrop; + var eventResize = t.eventResize; + var renderDayOverlay = t.renderDayOverlay; + var clearOverlays = t.clearOverlays; + var calendar = t.calendar; + var formatDate = calendar.formatDate; + var formatDates = calendar.formatDates; + + + + /* Rendering + ----------------------------------------------------------------------------*/ + + + function renderEvents(events, modifiedEventId) { + reportEvents(events); + var i, len=events.length, + dayEvents=[], + slotEvents=[]; + for (i=0; i" + + "
" + + "
" + + "
" + + htmlEscape(formatDates(event.start, event.end, opt('timeFormat'))) + + "
" + + "
" + + "
" + + "
" + + htmlEscape(event.title) + + "
" + + "
" + + "
" + + "
"; // close inner + if (seg.isEnd && isEventResizable(event)) { + html += + "
=
"; + } + html += + ""; + return html; + } + + + function bindDaySeg(event, eventElement, seg) { + if (isEventDraggable(event)) { + draggableDayEvent(event, eventElement, seg.isStart); + } + if (seg.isEnd && isEventResizable(event)) { + resizableDayEvent(event, eventElement, seg); + } + eventElementHandlers(event, eventElement); + // needs to be after, because resizableDayEvent might stopImmediatePropagation on click + } + + + function bindSlotSeg(event, eventElement, seg) { + var timeElement = eventElement.find('div.fc-event-time'); + if (isEventDraggable(event)) { + draggableSlotEvent(event, eventElement, timeElement); + } + if (seg.isEnd && isEventResizable(event)) { + resizableSlotEvent(event, eventElement, timeElement); + } + eventElementHandlers(event, eventElement); + } + + + + /* Dragging + -----------------------------------------------------------------------------------*/ + + + // when event starts out FULL-DAY + + function draggableDayEvent(event, eventElement, isStart) { + var origWidth; + var revert; + var allDay=true; + var dayDelta; + var dis = opt('isRTL') ? -1 : 1; + var hoverListener = getHoverListener(); + var colWidth = getColWidth(); + var slotHeight = getSlotHeight(); + var minMinute = getMinMinute(); + eventElement.draggable({ + zIndex: 9, + opacity: opt('dragOpacity', 'month'), // use whatever the month view was using + revertDuration: opt('dragRevertDuration'), + start: function(ev, ui) { + trigger('eventDragStart', eventElement, event, ev, ui); + hideEvents(event, eventElement); + origWidth = eventElement.width(); + hoverListener.start(function(cell, origCell, rowDelta, colDelta) { + clearOverlays(); + if (cell) { + //setOverflowHidden(true); + revert = false; + dayDelta = colDelta * dis; + if (!cell.row) { + // on full-days + renderDayOverlay( + addDays(cloneDate(event.start), dayDelta), + addDays(exclEndDay(event), dayDelta) + ); + resetElement(); + }else{ + // mouse is over bottom slots + if (isStart) { + if (allDay) { + // convert event to temporary slot-event + eventElement.width(colWidth - 10); // don't use entire width + setOuterHeight( + eventElement, + slotHeight * Math.round( + (event.end ? ((event.end - event.start) / MINUTE_MS) : opt('defaultEventMinutes')) + / opt('slotMinutes') + ) + ); + eventElement.draggable('option', 'grid', [colWidth, 1]); + allDay = false; + } + }else{ + revert = true; + } + } + revert = revert || (allDay && !dayDelta); + }else{ + resetElement(); + //setOverflowHidden(false); + revert = true; + } + eventElement.draggable('option', 'revert', revert); + }, ev, 'drag'); + }, + stop: function(ev, ui) { + hoverListener.stop(); + clearOverlays(); + trigger('eventDragStop', eventElement, event, ev, ui); + if (revert) { + // hasn't moved or is out of bounds (draggable has already reverted) + resetElement(); + eventElement.css('filter', ''); // clear IE opacity side-effects + showEvents(event, eventElement); + }else{ + // changed! + var minuteDelta = 0; + if (!allDay) { + minuteDelta = Math.round((eventElement.offset().top - getBodyContent().offset().top) / slotHeight) + * opt('slotMinutes') + + minMinute + - (event.start.getHours() * 60 + event.start.getMinutes()); + } + eventDrop(this, event, dayDelta, minuteDelta, allDay, ev, ui); + } + //setOverflowHidden(false); + } + }); + function resetElement() { + if (!allDay) { + eventElement + .width(origWidth) + .height('') + .draggable('option', 'grid', null); + allDay = true; + } + } + } + + + // when event starts out IN TIMESLOTS + + function draggableSlotEvent(event, eventElement, timeElement) { + var origPosition; + var allDay=false; + var dayDelta; + var minuteDelta; + var prevMinuteDelta; + var dis = opt('isRTL') ? -1 : 1; + var hoverListener = getHoverListener(); + var colCnt = getColCnt(); + var colWidth = getColWidth(); + var slotHeight = getSlotHeight(); + eventElement.draggable({ + zIndex: 9, + scroll: false, + grid: [colWidth, slotHeight], + axis: colCnt==1 ? 'y' : false, + opacity: opt('dragOpacity'), + revertDuration: opt('dragRevertDuration'), + start: function(ev, ui) { + trigger('eventDragStart', eventElement, event, ev, ui); + hideEvents(event, eventElement); + origPosition = eventElement.position(); + minuteDelta = prevMinuteDelta = 0; + hoverListener.start(function(cell, origCell, rowDelta, colDelta) { + eventElement.draggable('option', 'revert', !cell); + clearOverlays(); + if (cell) { + dayDelta = colDelta * dis; + if (opt('allDaySlot') && !cell.row) { + // over full days + if (!allDay) { + // convert to temporary all-day event + allDay = true; + timeElement.hide(); + eventElement.draggable('option', 'grid', null); + } + renderDayOverlay( + addDays(cloneDate(event.start), dayDelta), + addDays(exclEndDay(event), dayDelta) + ); + }else{ + // on slots + resetElement(); + } + } + }, ev, 'drag'); + }, + drag: function(ev, ui) { + minuteDelta = Math.round((ui.position.top - origPosition.top) / slotHeight) * opt('slotMinutes'); + if (minuteDelta != prevMinuteDelta) { + if (!allDay) { + updateTimeText(minuteDelta); + } + prevMinuteDelta = minuteDelta; + } + }, + stop: function(ev, ui) { + var cell = hoverListener.stop(); + clearOverlays(); + trigger('eventDragStop', eventElement, event, ev, ui); + if (cell && (dayDelta || minuteDelta || allDay)) { + // changed! + eventDrop(this, event, dayDelta, allDay ? 0 : minuteDelta, allDay, ev, ui); + }else{ + // either no change or out-of-bounds (draggable has already reverted) + resetElement(); + eventElement.css('filter', ''); // clear IE opacity side-effects + eventElement.css(origPosition); // sometimes fast drags make event revert to wrong position + updateTimeText(0); + showEvents(event, eventElement); + } + } + }); + function updateTimeText(minuteDelta) { + var newStart = addMinutes(cloneDate(event.start), minuteDelta); + var newEnd; + if (event.end) { + newEnd = addMinutes(cloneDate(event.end), minuteDelta); + } + timeElement.text(formatDates(newStart, newEnd, opt('timeFormat'))); + } + function resetElement() { + // convert back to original slot-event + if (allDay) { + timeElement.css('display', ''); // show() was causing display=inline + eventElement.draggable('option', 'grid', [colWidth, slotHeight]); + allDay = false; + } + } + } + + + + /* Resizing + --------------------------------------------------------------------------------------*/ + + + function resizableSlotEvent(event, eventElement, timeElement) { + var slotDelta, prevSlotDelta; + var slotHeight = getSlotHeight(); + eventElement.resizable({ + handles: { + s: 'div.ui-resizable-s' + }, + grid: slotHeight, + start: function(ev, ui) { + slotDelta = prevSlotDelta = 0; + hideEvents(event, eventElement); + eventElement.css('z-index', 9); + trigger('eventResizeStart', this, event, ev, ui); + }, + resize: function(ev, ui) { + // don't rely on ui.size.height, doesn't take grid into account + slotDelta = Math.round((Math.max(slotHeight, eventElement.height()) - ui.originalSize.height) / slotHeight); + if (slotDelta != prevSlotDelta) { + timeElement.text( + formatDates( + event.start, + (!slotDelta && !event.end) ? null : // no change, so don't display time range + addMinutes(eventEnd(event), opt('slotMinutes')*slotDelta), + opt('timeFormat') + ) + ); + prevSlotDelta = slotDelta; + } + }, + stop: function(ev, ui) { + trigger('eventResizeStop', this, event, ev, ui); + if (slotDelta) { + eventResize(this, event, 0, opt('slotMinutes')*slotDelta, ev, ui); + }else{ + eventElement.css('z-index', 8); + showEvents(event, eventElement); + // BUG: if event was really short, need to put title back in span + } + } + }); + } + + +} + + +function countForwardSegs(levels) { + var i, j, k, level, segForward, segBack; + for (i=levels.length-1; i>0; i--) { + level = levels[i]; + for (j=0; j"); + var elements; + var segmentContainer = getDaySegmentContainer(); + var i; + var segCnt = segs.length; + var element; + tempContainer[0].innerHTML = daySegHTML(segs); // faster than .html() + elements = tempContainer.children(); + segmentContainer.append(elements); + daySegElementResolve(segs, elements); + daySegCalcHSides(segs); + daySegSetWidths(segs); + daySegCalcHeights(segs); + daySegSetTops(segs, getRowTops(getRowDivs())); + elements = []; + for (i=0; i" + + ""; + if (!event.allDay && seg.isStart) { + html += + "" + + htmlEscape(formatDates(event.start, event.end, opt('timeFormat'))) + + ""; + } + html += + "" + htmlEscape(event.title) + "" + + "
"; + if (seg.isEnd && isEventResizable(event)) { + html += + "
" + + "   " + // makes hit area a lot better for IE6/7 + "
"; + } + html += + ""; + seg.left = left; + seg.outerWidth = right - left; + seg.startCol = leftCol; + seg.endCol = rightCol + 1; // needs to be exclusive + } + return html; + } + + + function daySegElementResolve(segs, elements) { // sets seg.element + var i; + var segCnt = segs.length; + var seg; + var event; + var element; + var triggerRes; + for (i=0; i div'); // optimal selector? + } + return rowDivs; + } + + + function getRowTops(rowDivs) { + var i; + var rowCnt = rowDivs.length; + var tops = []; + for (i=0; i selection for IE + element + .mousedown(function(ev) { // prevent native selection for others + ev.preventDefault(); + }) + .click(function(ev) { + if (isResizing) { + ev.preventDefault(); // prevent link from being visited (only method that worked in IE6) + ev.stopImmediatePropagation(); // prevent fullcalendar eventClick handler from being called + // (eventElementHandlers needs to be bound after resizableDayEvent) + } + }); + + handle.mousedown(function(ev) { + if (ev.which != 1) { + return; // needs to be left mouse button + } + isResizing = true; + var hoverListener = t.getHoverListener(); + var rowCnt = getRowCnt(); + var colCnt = getColCnt(); + var dis = rtl ? -1 : 1; + var dit = rtl ? colCnt-1 : 0; + var elementTop = element.css('top'); + var dayDelta; + var helpers; + var eventCopy = $.extend({}, event); + var minCell = dateCell(event.start); + clearSelection(); + $('body') + .css('cursor', direction + '-resize') + .one('mouseup', mouseup); + trigger('eventResizeStart', this, event, ev); + hoverListener.start(function(cell, origCell) { + if (cell) { + var r = Math.max(minCell.row, cell.row); + var c = cell.col; + if (rowCnt == 1) { + r = 0; // hack for all-day area in agenda views + } + if (r == minCell.row) { + if (rtl) { + c = Math.min(minCell.col, c); + }else{ + c = Math.max(minCell.col, c); + } + } + dayDelta = (r*7 + c*dis+dit) - (origCell.row*7 + origCell.col*dis+dit); + var newEnd = addDays(eventEnd(event), dayDelta, true); + if (dayDelta) { + eventCopy.end = newEnd; + var oldHelpers = helpers; + helpers = renderTempDaySegs(compileDaySegs([eventCopy]), seg.row, elementTop); + helpers.find('*').css('cursor', direction + '-resize'); + if (oldHelpers) { + oldHelpers.remove(); + } + hideEvents(event); + }else{ + if (helpers) { + showEvents(event); + helpers.remove(); + helpers = null; + } + } + clearOverlays(); + renderDayOverlay(event.start, addDays(cloneDate(newEnd), 1)); // coordinate grid already rebuild at hoverListener.start + } + }, ev); + + function mouseup(ev) { + trigger('eventResizeStop', this, event, ev); + $('body').css('cursor', ''); + hoverListener.stop(); + clearOverlays(); + if (dayDelta) { + eventResize(this, event, dayDelta, 0, ev); + // event redraw will clear helpers + } + // otherwise, the drag handler already restored the old events + + setTimeout(function() { // make this happen after the element's click event + isResizing = false; + },0); + } + + }); + } + + +} + +//BUG: unselect needs to be triggered when events are dragged+dropped + +function SelectionManager() { + var t = this; + + + // exports + t.select = select; + t.unselect = unselect; + t.reportSelection = reportSelection; + t.daySelectionMousedown = daySelectionMousedown; + + + // imports + var opt = t.opt; + var trigger = t.trigger; + var defaultSelectionEnd = t.defaultSelectionEnd; + var renderSelection = t.renderSelection; + var clearSelection = t.clearSelection; + + + // locals + var selected = false; + + + + // unselectAuto + if (opt('selectable') && opt('unselectAuto')) { + $(document).mousedown(function(ev) { + var ignore = opt('unselectCancel'); + if (ignore) { + if ($(ev.target).parents(ignore).length) { // could be optimized to stop after first match + return; + } + } + unselect(ev); + }); + } + + + function select(startDate, endDate, allDay) { + unselect(); + if (!endDate) { + endDate = defaultSelectionEnd(startDate, allDay); + } + renderSelection(startDate, endDate, allDay); + reportSelection(startDate, endDate, allDay); + } + + + function unselect(ev) { + if (selected) { + selected = false; + clearSelection(); + trigger('unselect', null, ev); + } + } + + + function reportSelection(startDate, endDate, allDay, ev) { + selected = true; + trigger('select', null, startDate, endDate, allDay, ev); + } + + + function daySelectionMousedown(ev) { // not really a generic manager method, oh well + var cellDate = t.cellDate; + var cellIsAllDay = t.cellIsAllDay; + var hoverListener = t.getHoverListener(); + var reportDayClick = t.reportDayClick; // this is hacky and sort of weird + if (ev.which == 1 && opt('selectable')) { // which==1 means left mouse button + unselect(ev); + var _mousedownElement = this; + var dates; + hoverListener.start(function(cell, origCell) { // TODO: maybe put cellDate/cellIsAllDay info in cell + clearSelection(); + if (cell && cellIsAllDay(cell)) { + dates = [ cellDate(origCell), cellDate(cell) ].sort(cmp); + renderSelection(dates[0], dates[1], true); + }else{ + dates = null; + } + }, ev); + $(document).one('mouseup', function(ev) { + hoverListener.stop(); + if (dates) { + if (+dates[0] == +dates[1]) { + reportDayClick(dates[0], true, ev); + } + reportSelection(dates[0], dates[1], true, ev); + } + }); + } + } + + +} + +function OverlayManager() { + var t = this; + + + // exports + t.renderOverlay = renderOverlay; + t.clearOverlays = clearOverlays; + + + // locals + var usedOverlays = []; + var unusedOverlays = []; + + + function renderOverlay(rect, parent) { + var e = unusedOverlays.shift(); + if (!e) { + e = $("
"); + } + if (e[0].parentNode != parent[0]) { + e.appendTo(parent); + } + usedOverlays.push(e.css(rect).show()); + return e; + } + + + function clearOverlays() { + var e; + while (e = usedOverlays.shift()) { + unusedOverlays.push(e.hide().unbind()); + } + } + + +} + +function CoordinateGrid(buildFunc) { + + var t = this; + var rows; + var cols; + + + t.build = function() { + rows = []; + cols = []; + buildFunc(rows, cols); + }; + + + t.cell = function(x, y) { + var rowCnt = rows.length; + var colCnt = cols.length; + var i, r=-1, c=-1; + for (i=0; i= rows[i][0] && y < rows[i][1]) { + r = i; + break; + } + } + for (i=0; i= cols[i][0] && x < cols[i][1]) { + c = i; + break; + } + } + return (r>=0 && c>=0) ? { row:r, col:c } : null; + }; + + + t.rect = function(row0, col0, row1, col1, originElement) { // row1,col1 is inclusive + var origin = originElement.offset(); + return { + top: rows[row0][0] - origin.top, + left: cols[col0][0] - origin.left, + width: cols[col1][1] - cols[col0][0], + height: rows[row1][1] - rows[row0][0] + }; + }; + +} + +function HoverListener(coordinateGrid) { + + + var t = this; + var bindType; + var change; + var firstCell; + var cell; + + + t.start = function(_change, ev, _bindType) { + change = _change; + firstCell = cell = null; + coordinateGrid.build(); + mouse(ev); + bindType = _bindType || 'mousemove'; + $(document).bind(bindType, mouse); + }; + + + function mouse(ev) { + var newCell = coordinateGrid.cell(ev.pageX, ev.pageY); + if (!newCell != !cell || newCell && (newCell.row != cell.row || newCell.col != cell.col)) { + if (newCell) { + if (!firstCell) { + firstCell = newCell; + } + change(newCell, firstCell, newCell.row-firstCell.row, newCell.col-firstCell.col); + }else{ + change(newCell, firstCell); + } + cell = newCell; + } + } + + + t.stop = function() { + $(document).unbind(bindType, mouse); + return cell; + }; + + +} + +function HorizontalPositionCache(getElement) { + + var t = this, + elements = {}, + lefts = {}, + rights = {}; + + function e(i) { + return elements[i] = elements[i] || getElement(i); + } + + t.left = function(i) { + return lefts[i] = lefts[i] === undefined ? e(i).position().left : lefts[i]; + }; + + t.right = function(i) { + return rights[i] = rights[i] === undefined ? t.left(i) + e(i).width() : rights[i]; + }; + + t.clear = function() { + elements = {}; + lefts = {}; + rights = {}; + }; + +} + +})(jQuery); \ No newline at end of file diff --git a/plugins/calendar/localization/bg_BG.inc b/plugins/calendar/localization/bg_BG.inc new file mode 100644 index 00000000..79a79eeb --- /dev/null +++ b/plugins/calendar/localization/bg_BG.inc @@ -0,0 +1,38 @@ + \ No newline at end of file diff --git a/plugins/calendar/localization/cs_CZ.inc b/plugins/calendar/localization/cs_CZ.inc new file mode 100644 index 00000000..79341675 --- /dev/null +++ b/plugins/calendar/localization/cs_CZ.inc @@ -0,0 +1,38 @@ + \ No newline at end of file diff --git a/plugins/calendar/localization/de_DE.inc b/plugins/calendar/localization/de_DE.inc new file mode 100644 index 00000000..f9d0881d --- /dev/null +++ b/plugins/calendar/localization/de_DE.inc @@ -0,0 +1,51 @@ + \ No newline at end of file diff --git a/plugins/calendar/localization/en_US.inc b/plugins/calendar/localization/en_US.inc new file mode 100644 index 00000000..10c58eb5 --- /dev/null +++ b/plugins/calendar/localization/en_US.inc @@ -0,0 +1,99 @@ + \ No newline at end of file diff --git a/plugins/calendar/localization/es_ES.inc b/plugins/calendar/localization/es_ES.inc new file mode 100644 index 00000000..bfa4865e --- /dev/null +++ b/plugins/calendar/localization/es_ES.inc @@ -0,0 +1,39 @@ + diff --git a/plugins/calendar/localization/fr_FR.inc b/plugins/calendar/localization/fr_FR.inc new file mode 100644 index 00000000..880fd61f --- /dev/null +++ b/plugins/calendar/localization/fr_FR.inc @@ -0,0 +1,38 @@ + \ No newline at end of file diff --git a/plugins/calendar/localization/hu_HU.inc b/plugins/calendar/localization/hu_HU.inc new file mode 100644 index 00000000..fbd2bd98 --- /dev/null +++ b/plugins/calendar/localization/hu_HU.inc @@ -0,0 +1,38 @@ + diff --git a/plugins/calendar/localization/it_IT.inc b/plugins/calendar/localization/it_IT.inc new file mode 100644 index 00000000..c03b4b90 --- /dev/null +++ b/plugins/calendar/localization/it_IT.inc @@ -0,0 +1,38 @@ + \ No newline at end of file diff --git a/plugins/calendar/localization/nl_NL.inc b/plugins/calendar/localization/nl_NL.inc new file mode 100644 index 00000000..502ab3c7 --- /dev/null +++ b/plugins/calendar/localization/nl_NL.inc @@ -0,0 +1,39 @@ + \ No newline at end of file diff --git a/plugins/calendar/localization/pl_PL.inc b/plugins/calendar/localization/pl_PL.inc new file mode 100644 index 00000000..c65b7617 --- /dev/null +++ b/plugins/calendar/localization/pl_PL.inc @@ -0,0 +1,39 @@ + \ No newline at end of file diff --git a/plugins/calendar/localization/pt_BR.inc b/plugins/calendar/localization/pt_BR.inc new file mode 100644 index 00000000..4d00697f --- /dev/null +++ b/plugins/calendar/localization/pt_BR.inc @@ -0,0 +1,38 @@ + diff --git a/plugins/calendar/localization/ru_RU.inc b/plugins/calendar/localization/ru_RU.inc new file mode 100644 index 00000000..443d502c --- /dev/null +++ b/plugins/calendar/localization/ru_RU.inc @@ -0,0 +1,32 @@ + \ No newline at end of file diff --git a/plugins/calendar/skins/default/README.txt b/plugins/calendar/skins/default/README.txt new file mode 100644 index 00000000..ccd513ae --- /dev/null +++ b/plugins/calendar/skins/default/README.txt @@ -0,0 +1,5 @@ +Icons by Fugue Icons + +Copyright (C) 2010 Yusuke Kamiyamane. All rights reserved. +The icons are licensed under a Creative Commons Attribution +3.0 license. diff --git a/plugins/calendar/skins/default/calendar.css b/plugins/calendar/skins/default/calendar.css new file mode 100644 index 00000000..913daf34 --- /dev/null +++ b/plugins/calendar/skins/default/calendar.css @@ -0,0 +1,392 @@ +/*** Style for Calendar plugin ***/ + +body.calendarmain { + overflow: hidden; +} + +#taskbar a.button-calendar { + background: url('images/calendar.png') 0px 1px no-repeat; +} + +#main { + position: absolute; + clear: both; + top: 85px; + left: 0; + right: 0; + bottom: 10px; +} + +#sidebar { + position: absolute; + top: 37px; + left: 10px; + bottom: 0; + width: 230px; +} + +#datepicker { + width: 100%; +} + +#datepicker .ui-datepicker { + width: 97% !important; + -moz-box-shadow: none; + -webkit-box-shadow: none; +} + +#datepicker .ui-priority-secondary { + opacity: 0.4; +} + +#sidebartoggle { + position: absolute; + left: 246px; + width: 8px; + top: 37px; + bottom: 0; + background: url('images/toggle.gif') 0 48% no-repeat transparent; + cursor: pointer; +} + +div.sidebarclosed { + background-position: -8px 48% !important; +} + +#sidebartoggle:hover { + background-color: #ddd; +} + +#calendar { + position: absolute; + top: 0; + left: 260px; + right: 10px; + bottom: 0; +} + +#print { + width: 680px; +} + +pre { + font-family: "Lucida Grande", Verdana, Arial, Helvetica, sans-serif; +} + +#calendars { + position: absolute; + top: 230px; + left: 0; + bottom: 0; + right: 0; + background-color: #F9F9F9; + border: 1px solid #999999; + overflow: hidden; +} + +#calendarslist { + list-style: none; + margin: 0; + padding: 0; +} + +#calendarslist li { + margin: 0; + padding: 2px; + display: block; + background: #fff; + border-bottom: 1px solid #EBEBEB; +} + +#calendarslist li label { + display: block; +} + +#calendarslist li span { + cursor: default; +} + +#calendarslist li input { + margin-right: 5px; +} + +#calendarslist li.selected { + background-color: #ccc; + border-bottom: 1px solid #999; +} + +#calendarslist li.selected span { + font-weight: bold; +} + +#agendalist { + width: 100%; + margin: 0 auto; + margin-top: 60px; + border: 1px solid #C1DAD7; + display: none; +} + +#agendalist table { + width: 100%; +} + +#agendalist td, th { + border-right: 1px solid #C1DAD7; + border-bottom: 1px solid #C1DAD7; + background: #fff; + padding: 6px 6px 6px 12px; +} + +#agendalist tr { + vertical-align: top; +} + +#agendalist th { + font-weight: bold; +} + +#calendartoolbar { + position: absolute; + top: 45px; + left: 260px; + height: 35px; +} + +#calendartoolbar a { + padding-right: 10px; +} + +#calendartoolbar a.button, +#calendartoolbar a.buttonPas { + display: block; + float: left; + width: 32px; + height: 32px; + padding: 0; + margin-right: 10px; + overflow: hidden; + background: url('images/toolbar.png') 0 0 no-repeat transparent; + opacity: 0.99; /* this is needed to make buttons appear correctly in Chrome */ +} + +#calendartoolbar a.buttonPas { + opacity: 0.35; +} + +#calendartoolbar a.addeventSel { + background-position: 0 -32px; +} + +#calendartoolbar a.delete { + background-position: -32px 0; +} + +#calendartoolbar a.deleteSel { + background-position: -32px -32px; +} + +#calendartoolbar a.print { + background-position: -64px 0; +} + +#calendartoolbar a.printSel { + background-position: -64px -32px; +} + +#calendartoolbar a.export { + background-position: -128px 0; +} + +#calendartoolbar a.exportSel { + background-position: -128px -32px; +} + +#quicksearchbar { + right: 10px; +} + +#eventshow, +#eventedit { + display: none; +} + +#user { + position: absolute; + top: 10px; + right: 100px; + left: 100px; + text-align: center; +} + +/* jQuery UI overrides */ + +#eventshow h1 { + font-size: 20px; + margin: 0.1em 0 0.4em 0; +} + +#eventshow label, +#eventshow h5.label { + font-weight: normal; + font-size: 0.9em; + color: #999; + margin: 0 0 0.2em 0; +} + +#eventshow div.event-line { + margin-top: 0.1em; + margin-bottom: 0.3em; +} + +#eventedit { + padding: 0.5em 0.1em; +} + +#eventedit input.text, +#eventedit textarea { + width: 97%; +} + +#eventtabs { + position: relative; + padding: 0; + border: 0; + border-radius: 0; +} + +#eventshow div.event-section, +#eventtabs div.event-section { + margin-top: 0.2em; + margin-bottom: 0.8em; +} + +#eventtabs .tabsbar { + position: absolute; + top: 0; +} + +#eventtabs .ui-tabs-panel { + padding: 1em 0.8em; + border: 1px solid #aaa; + border-width: 0 1px 1px 1px; +} + +#eventtabs .ui-tabs-nav { + background: none; + padding: 0; + border-width: 0 0 1px 0; + border-radius: 0; +} + +#eventtabs .border-after { + padding-bottom: 0.6em; + margin-bottom: 0.6em; + border-bottom: 1px solid #999; +} + +#eventshow label, +#eventedit label { + display: inline-block; + min-width: 7em; + padding-right: 0.5em; +} + +#eventedit .formtable td.label { + min-width: 6em; +} + +td.topalign { + vertical-align: top; +} + +#eventedit label.weekday, +#eventedit label.monthday { + min-width: 3em; +} + +#eventedit label.month { + min-width: 5em; +} + +#edit-recurrence-yearly-bymonthblock { + margin-left: 7.5em; +} + +#eventedit .recurrence-form { + display: none; +} + +#eventedit .formtable td { + padding: 0.2em 0; +} + +a.dropdown-link { + color: #CC0000; + font-size: 12px; + text-decoration: none; +} + +a.dropdown-link:after { + content: ' ▼'; + font-size: 11px; + color: #666; +} + +#eventedit .ui-tabs-panel { + min-height: 20em; +} + +.ui-dialog-buttonset a.dropdown-link { + margin-right: 1em; +} + +.ui-datepicker-calendar .ui-datepicker-today .ui-state-default { + border-color: #cccccc; + background: #ffffcc; + color: #000; +} + +.ui-datepicker-calendar .ui-datepicker-week-col { + text-align: right; + padding-right: 0.6em; +} + +.ui-autocomplete { + max-height: 160px; + overflow-y: auto; + overflow-x: hidden; +} + +* html .ui-autocomplete { + height: 160px; +} + +/* fullcalendar style overrides */ + +.fc-event-title { + font-weight: bold; +} + +.fc-event-cateories { + font-style:italic; +} + +.fc-agenda-slots td div { + height: 22px; +} + +.fc-mon, .fc-tue, .fc-wed, .fc-thu, .fc-fri { + background-color: #fdfdfd; +} + +.fc-widget-header { + background-color: #fff; +} + +/* Settings section */ + +fieldset #calendarcategories div { + margin-bottom: 0.3em; +} + diff --git a/plugins/calendar/skins/default/fullcalendar.css b/plugins/calendar/skins/default/fullcalendar.css new file mode 100644 index 00000000..3a3ee5b6 --- /dev/null +++ b/plugins/calendar/skins/default/fullcalendar.css @@ -0,0 +1,618 @@ +/* + * FullCalendar v1.5.1 Stylesheet + * + * Copyright (c) 2011 Adam Shaw + * Dual licensed under the MIT and GPL licenses, located in + * MIT-LICENSE.txt and GPL-LICENSE.txt respectively. + * + * Date: Sat Apr 9 14:09:51 2011 -0700 + * + */ + + +.fc { + direction: ltr; + text-align: left; + } + +.fc table { + border-collapse: collapse; + border-spacing: 0; + } + +html .fc, +.fc table { + font-size: 1em; + } + +.fc td, +.fc th { + padding: 0; + vertical-align: top; + } + + + +/* Header +------------------------------------------------------------------------*/ + +.fc-header td { + white-space: nowrap; + } + +.fc-header-left { + width: 25%; + text-align: left; + } + +.fc-header-center { + text-align: center; + } + +.fc-header-right { + width: 25%; + text-align: right; + } + +.fc-header-title { + display: inline-block; + vertical-align: top; + } + +.fc-header-title h2 { + margin-top: 0; + white-space: nowrap; + } + +.fc .fc-header-space { + padding-left: 10px; + } + +.fc-header .fc-button { + margin-bottom: 1em; + vertical-align: top; + } + +/* buttons edges butting together */ + +.fc-header .fc-button { + margin-right: -1px; + } + +.fc-header .fc-corner-right { + margin-right: 1px; /* back to normal */ + } + +.fc-header .ui-corner-right { + margin-right: 0; /* back to normal */ + } + +/* button layering (for border precedence) */ + +.fc-header .fc-state-hover, +.fc-header .ui-state-hover { + z-index: 2; + } + +.fc-header .fc-state-down { + z-index: 3; + } + +.fc-header .fc-state-active, +.fc-header .ui-state-active { + z-index: 4; + } + + + +/* Content +------------------------------------------------------------------------*/ + +.fc-content { + clear: both; + } + +.fc-view { + width: 100%; /* needed for view switching (when view is absolute) */ + overflow: hidden; + } + + + +/* Cell Styles +------------------------------------------------------------------------*/ + +.fc-widget-header, /* , usually */ +.fc-widget-content { /* , usually */ + border: 1px solid #ccc; + } + +.fc-state-highlight { /* today cell */ /* TODO: add .fc-today to */ + background: #ffc; + } + +.fc-cell-overlay { /* semi-transparent rectangle while dragging */ + background: #9cf; + opacity: .2; + filter: alpha(opacity=20); /* for IE */ + } + + + +/* Buttons +------------------------------------------------------------------------*/ + +.fc-button { + position: relative; + display: inline-block; + cursor: pointer; + } + +.fc-state-default { /* non-theme */ + border-style: solid; + border-width: 1px 0; + } + +.fc-button-inner { + position: relative; + float: left; + overflow: hidden; + } + +.fc-state-default .fc-button-inner { /* non-theme */ + border-style: solid; + border-width: 0 1px; + } + +.fc-button-content { + position: relative; + float: left; + height: 1.9em; + line-height: 1.9em; + padding: 0 .6em; + white-space: nowrap; + } + +/* icon (for jquery ui) */ + +.fc-button-content .fc-icon-wrap { + position: relative; + float: left; + top: 50%; + } + +.fc-button-content .ui-icon { + position: relative; + float: left; + margin-top: -50%; + *margin-top: 0; + *top: -50%; + } + +/* gloss effect */ + +.fc-state-default .fc-button-effect { + position: absolute; + top: 50%; + left: 0; + } + +.fc-state-default .fc-button-effect span { + position: absolute; + top: -100px; + left: 0; + width: 500px; + height: 100px; + border-width: 100px 0 0 1px; + border-style: solid; + border-color: #fff; + background: #444; + opacity: .09; + filter: alpha(opacity=9); + } + +/* button states (determines colors) */ + +.fc-state-default, +.fc-state-default .fc-button-inner { + border-style: solid; + border-color: #ccc #bbb #aaa; + background: #F3F3F3; + color: #000; + } + +.fc-state-hover, +.fc-state-hover .fc-button-inner { + border-color: #999; + } + +.fc-state-down, +.fc-state-down .fc-button-inner { + border-color: #555; + background: #777; + } + +.fc-state-active, +.fc-state-active .fc-button-inner { + border-color: #555; + background: #777; + color: #fff; + } + +.fc-state-disabled, +.fc-state-disabled .fc-button-inner { + color: #999; + border-color: #ddd; + } + +.fc-state-disabled { + cursor: default; + } + +.fc-state-disabled .fc-button-effect { + display: none; + } + + + +/* Global Event Styles +------------------------------------------------------------------------*/ + +.fc-event { + border-style: solid; + border-width: 0; + font-size: .85em; + cursor: default; + } + +a.fc-event, +.fc-event-draggable { + cursor: pointer; + } + +a.fc-event { + text-decoration: none; + } + +.fc-rtl .fc-event { + text-align: right; + } + +.fc-event-skin { + border-color: #36c; /* default BORDER color */ + background-color: #36c; /* default BACKGROUND color */ + color: #fff; /* default TEXT color */ + } + +.fc-event-inner { + position: relative; + width: 100%; + height: 100%; + border-style: solid; + border-width: 0; + overflow: hidden; + } + +.fc-event-time, +.fc-event-title { + padding: 0 1px; + } + +.fc .ui-resizable-handle { /*** TODO: don't use ui-resizable anymore, change class ***/ + display: block; + position: absolute; + z-index: 99999; + overflow: hidden; /* hacky spaces (IE6/7) */ + font-size: 300%; /* */ + line-height: 50%; /* */ + } + + + +/* Horizontal Events +------------------------------------------------------------------------*/ + +.fc-event-hori { + border-width: 1px 0; + margin-bottom: 1px; + } + +/* resizable */ + +.fc-event-hori .ui-resizable-e { + top: 0 !important; /* importants override pre jquery ui 1.7 styles */ + right: -3px !important; + width: 7px !important; + height: 100% !important; + cursor: e-resize; + } + +.fc-event-hori .ui-resizable-w { + top: 0 !important; + left: -3px !important; + width: 7px !important; + height: 100% !important; + cursor: w-resize; + } + +.fc-event-hori .ui-resizable-handle { + _padding-bottom: 14px; /* IE6 had 0 height */ + } + + + +/* Fake Rounded Corners (for buttons and events) +------------------------------------------------------------*/ + +.fc-corner-left { + margin-left: 1px; + } + +.fc-corner-left .fc-button-inner, +.fc-corner-left .fc-event-inner { + margin-left: -1px; + } + +.fc-corner-right { + margin-right: 1px; + } + +.fc-corner-right .fc-button-inner, +.fc-corner-right .fc-event-inner { + margin-right: -1px; + } + +.fc-corner-top { + margin-top: 1px; + } + +.fc-corner-top .fc-event-inner { + margin-top: -1px; + } + +.fc-corner-bottom { + margin-bottom: 1px; + } + +.fc-corner-bottom .fc-event-inner { + margin-bottom: -1px; + } + + + +/* Fake Rounded Corners SPECIFICALLY FOR EVENTS +-----------------------------------------------------------------*/ + +.fc-corner-left .fc-event-inner { + border-left-width: 1px; + } + +.fc-corner-right .fc-event-inner { + border-right-width: 1px; + } + +.fc-corner-top .fc-event-inner { + border-top-width: 1px; + } + +.fc-corner-bottom .fc-event-inner { + border-bottom-width: 1px; + } + + + +/* Reusable Separate-border Table +------------------------------------------------------------*/ + +table.fc-border-separate { + border-collapse: separate; + } + +.fc-border-separate th, +.fc-border-separate td { + border-width: 1px 0 0 1px; + } + +.fc-border-separate th.fc-last, +.fc-border-separate td.fc-last { + border-right-width: 1px; + } + +.fc-border-separate tr.fc-last th, +.fc-border-separate tr.fc-last td { + border-bottom-width: 1px; + } + +.fc-border-separate tbody tr.fc-first td, +.fc-border-separate tbody tr.fc-first th { + border-top-width: 0; + } + + + +/* Month View, Basic Week View, Basic Day View +------------------------------------------------------------------------*/ + +.fc-grid th { + text-align: center; + } + +.fc-grid .fc-day-number { + float: right; + padding: 0 2px; + } + +.fc-grid .fc-other-month .fc-day-number { + opacity: 0.3; + filter: alpha(opacity=30); /* for IE */ + /* opacity with small font can sometimes look too faded + might want to set the 'color' property instead + making day-numbers bold also fixes the problem */ + } + +.fc-grid .fc-day-content { + clear: both; + padding: 2px 2px 1px; /* distance between events and day edges */ + } + +/* event styles */ + +.fc-grid .fc-event-time { + font-weight: bold; + } + +/* right-to-left */ + +.fc-rtl .fc-grid .fc-day-number { + float: left; + } + +.fc-rtl .fc-grid .fc-event-time { + float: right; + } + + + +/* Agenda Week View, Agenda Day View +------------------------------------------------------------------------*/ + +.fc-agenda table { + border-collapse: separate; + } + +.fc-agenda-days th { + text-align: center; + } + +.fc-agenda .fc-agenda-axis { + width: 50px; + padding: 0 4px; + vertical-align: middle; + text-align: right; + white-space: nowrap; + font-weight: normal; + } + +.fc-agenda .fc-day-content { + padding: 2px 2px 1px; + } + +/* make axis border take precedence */ + +.fc-agenda-days .fc-agenda-axis { + border-right-width: 1px; + } + +.fc-agenda-days .fc-col0 { + border-left-width: 0; + } + +/* all-day area */ + +.fc-agenda-allday th { + border-width: 0 1px; + } + +.fc-agenda-allday .fc-day-content { + min-height: 34px; /* TODO: doesnt work well in quirksmode */ + _height: 34px; + } + +/* divider (between all-day and slots) */ + +.fc-agenda-divider-inner { + height: 2px; + overflow: hidden; + } + +.fc-widget-header .fc-agenda-divider-inner { + background: #eee; + } + +/* slot rows */ + +.fc-agenda-slots th { + border-width: 1px 1px 0; + } + +.fc-agenda-slots td { + border-width: 1px 0 0; + background: none; + } + +.fc-agenda-slots td div { + height: 20px; + } + +.fc-agenda-slots tr.fc-slot0 th, +.fc-agenda-slots tr.fc-slot0 td { + border-top-width: 0; + } + +.fc-agenda-slots tr.fc-minor th, +.fc-agenda-slots tr.fc-minor td { + border-top-style: dotted; + } + +.fc-agenda-slots tr.fc-minor th.ui-widget-header { + *border-top-style: solid; /* doesn't work with background in IE6/7 */ + } + + + +/* Vertical Events +------------------------------------------------------------------------*/ + +.fc-event-vert { + border-width: 0 1px; + } + +.fc-event-vert .fc-event-head, +.fc-event-vert .fc-event-content { + position: relative; + z-index: 2; + width: 100%; + overflow: hidden; + } + +.fc-event-vert .fc-event-time { + white-space: nowrap; + font-size: 10px; + } + +.fc-event-vert .fc-event-bg { /* makes the event lighter w/ a semi-transparent overlay */ + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #fff; + opacity: .3; + filter: alpha(opacity=30); + } + +.fc .ui-draggable-dragging .fc-event-bg, /* TODO: something nicer like .fc-opacity */ +.fc-select-helper .fc-event-bg { + display: none\9; /* for IE6/7/8. nested opacity filters while dragging don't work */ + } + +/* resizable */ + +.fc-event-vert .ui-resizable-s { + bottom: 0 !important; /* importants override pre jquery ui 1.7 styles */ + width: 100% !important; + height: 8px !important; + overflow: hidden !important; + line-height: 8px !important; + font-size: 11px !important; + font-family: monospace; + text-align: center; + cursor: s-resize; + } + +.fc-agenda .ui-resizable-resizing { /* TODO: better selector */ + _overflow: hidden; + } + + diff --git a/plugins/calendar/skins/default/fullcalendar.print.css b/plugins/calendar/skins/default/fullcalendar.print.css new file mode 100644 index 00000000..98d0e0b3 --- /dev/null +++ b/plugins/calendar/skins/default/fullcalendar.print.css @@ -0,0 +1,61 @@ +/* + * FullCalendar v1.5.1 Print Stylesheet + * + * Include this stylesheet on your page to get a more printer-friendly calendar. + * When including this stylesheet, use the media='print' attribute of the tag. + * Make sure to include this stylesheet IN ADDITION to the regular fullcalendar.css. + * + * Copyright (c) 2011 Adam Shaw + * Dual licensed under the MIT and GPL licenses, located in + * MIT-LICENSE.txt and GPL-LICENSE.txt respectively. + * + * Date: Sat Apr 9 14:09:51 2011 -0700 + * + */ + + + /* Events +-----------------------------------------------------*/ + +.fc-event-skin { + background: none !important; + color: #000 !important; + } + +/* horizontal events */ + +.fc-event-hori { + border-width: 0 0 1px 0 !important; + border-bottom-style: dotted !important; + border-bottom-color: #000 !important; + padding: 1px 0 0 0 !important; + } + +.fc-event-hori .fc-event-inner { + border-width: 0 !important; + padding: 0 1px !important; + } + +/* vertical events */ + +.fc-event-vert { + border-width: 0 0 0 1px !important; + border-left-style: dotted !important; + border-left-color: #000 !important; + padding: 0 1px 0 0 !important; + } + +.fc-event-vert .fc-event-inner { + border-width: 0 !important; + padding: 1px 0 !important; + } + +.fc-event-bg { + display: none !important; + } + +.fc-event .ui-resizable-handle { + display: none !important; + } + + diff --git a/plugins/calendar/skins/default/images/calendar-blue.png b/plugins/calendar/skins/default/images/calendar-blue.png new file mode 100644 index 0000000000000000000000000000000000000000..95c9452cae5306914be81b78de3f6916dbd9fa75 GIT binary patch literal 896 zcmV-`1AqL9P)`?_PK7O5p`^G4OH|b8~a3J6+z;)O;MeZa@ec)*R;3DQMw}#em&i1AaAv%gLX{bhai#p~oe`crHsx1s_2~UngO;3K^et$(m=(s&(X6BrWmHz9 z`b;f^ONFpv#`cznarKURYO*G&sP+RbJxj>Q2;gDpI|Au`JPsQ;k%PmTra5?SODiW( zRCE#|t|Q?0Q=bI4z7F&LEoo2e;t+a_aROY|?YIG^tW3>1auw8S2KSYCD>m?ajv z6`!=^VtY?4Y+2q}aydOvlr*P+)mFc5RX-L8q;?L>;dct~&oY++9*+m literal 0 HcmV?d00001 diff --git a/plugins/calendar/skins/default/images/calendar.png b/plugins/calendar/skins/default/images/calendar.png new file mode 100644 index 0000000000000000000000000000000000000000..caab8a3842ca95f280fb4a5b2a7f69aba751a05c GIT binary patch literal 888 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 literal 0 HcmV?d00001 diff --git a/plugins/calendar/skins/default/images/spacer.gif b/plugins/calendar/skins/default/images/spacer.gif new file mode 100644 index 0000000000000000000000000000000000000000..c749aaf73f7ab4dfbee94f96a60be4eff9f5ac17 GIT binary patch literal 807 zcmYjQO=uHg5PcgqNqf*kSjY+Og^}XFK&A{ZF@0&OCW@aZB=Fb;SEn2X! z0|Y?;$uX)u%wWg*+^C_7Mcl&!Ji!T+v4n^8woNhR2#QBZ-{g8O_U?m|Su`yNz26yn z9ZaWvlczOzKzIk6H3w_-3IF3y2JUv-6o?^rAneJ0hCIeo-4osTYleQTR{dCweV2EA7p_wwQjX2*SVnYxg4kVgU~@+XLC zpJjexB;vY1V!O;eMVxs1F|YX4b0pCmRHwKlE$S7HG|6+9y>>sj_|BZ_waPr|Rdc(0 z$T!ca-uf$3)yZB8j8T3J{yps}h6PT>b7~T!SYb8G1kcf0Dz~%fu3JbmM?LPL9>8`z4;%XgjZ8dV_ol5D|L7($eJu`o!`3<-m;?$0O zU5WY_BW^%D>O*n+QuSyOk%lXl7>e}D%3IAleC&p= Nh|`n8lO7BV)&PTMCu{%! literal 0 HcmV?d00001 diff --git a/plugins/calendar/skins/default/images/toolbar.png b/plugins/calendar/skins/default/images/toolbar.png new file mode 100644 index 0000000000000000000000000000000000000000..94beddb33ca666dacd3582422a0e718777006c23 GIT binary patch literal 14308 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 y;XV%42e!Jpn(HN!f%EvA6(qEt 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 S8l7 + + +<roundcube:object name="pagetitle" /> + + + + + + + +
+ +
+
+
+ +
+

Event Title

+
Location
+
From-To
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Default +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ + +   + +
+
+ +   + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+
+
+
+ + + + +
+ +
+ + + +
+ + + + \ No newline at end of file