/* -*- Mode: javascript; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ (function() { 'use strict'; /** * @name Component * @constructor * @param {object} futureComponentData - either an object literal or a promise */ function Component(futureComponentData) { // Data is immediately available if (typeof futureComponentData.then !== 'function') { this.init(futureComponentData); if (this.pid && !this.id) { // Prepare for the creation of a new component; // Get UID from the server. var newComponentData = Component.$$resource.newguid(this.pid); this.$unwrap(newComponentData); this.isNew = true; } } else { // The promise will be unwrapped first this.$unwrap(futureComponentData); } } /** * @memberof Component * @desc The factory we'll use to register with Angular * @returns the Component constructor */ Component.$factory = ['$q', '$timeout', '$log', 'sgSettings', 'Preferences', 'Gravatar', 'Resource', function($q, $timeout, $log, Settings, Preferences, Gravatar, Resource) { angular.extend(Component, { $q: $q, $timeout: $timeout, $log: $log, $Preferences: Preferences, $gravatar: Gravatar, $$resource: new Resource(Settings.baseURL(), Settings.activeUser()), timeFormat: "%H:%M", // Filter parameters common to events and tasks $query: { value: '', search: 'title_Category_Location' }, // Filter paramaters specific to events $queryEvents: { sort: 'start', asc: 1, filterpopup: 'view_next7' }, // Filter parameters specific to tasks $queryTasks: { sort: 'status', asc: 1, filterpopup: 'view_next7' } //'view_incomplete' } }); Preferences.ready().then(function() { // Initialize filter parameters from user's settings Component.$queryEvents.filterpopup = Preferences.settings.CalendarDefaultFilter; Component.$queryTasks.show_completed = parseInt(Preferences.settings.ShowCompletedTasks); // Initialize categories from user's defaults Component.$categories = Preferences.defaults.SOGoCalendarCategoriesColors; // Initialize time format from user's defaults if (Preferences.defaults.SOGoTimeFormat) { Component.timeFormat = Preferences.defaults.SOGoTimeFormat; } }); return Component; // return constructor }]; /** * @module SOGo.SchedulerUI * @desc Factory registration of Component in Angular module. */ try { angular.module('SOGo.SchedulerUI'); } catch(e) { angular.module('SOGo.SchedulerUI', ['SOGo.Common']); } angular.module('SOGo.SchedulerUI') .factory('Component', Component.$factory); /** * @function $filter * @memberof Component.prototype * @desc Search for components matching some criterias * @param {string} type - either 'events' or 'tasks' * @param {object} [options] - additional options to the query * @returns a collection of Components instances */ Component.$filter = function(type, options) { var _this = this, now = new Date(), day = now.getDate(), month = now.getMonth() + 1, year = now.getFullYear(), queryKey = '$query' + type.capitalize(), params = { day: '' + year + (month < 10?'0':'') + month + (day < 10?'0':'') + day, }; return this.$Preferences.ready().then(function() { var futureComponentData, dirty = false, otherType; angular.extend(_this.$query, params); if (options) { _.each(_.keys(options), function(key) { // Query parameters common to events and tasks are compared dirty |= (_this.$query[key] && options[key] != Component.$query[key]); if (key == 'reload' && options[key]) dirty = true; // Update either the common parameters or the type-specific parameters else if (angular.isDefined(_this.$query[key])) _this.$query[key] = options[key]; else _this[queryKey][key] = options[key]; }); } // Perform query with both common and type-specific parameters futureComponentData = _this.$$resource.fetch(null, type + 'list', angular.extend(_this[queryKey], _this.$query)); // Invalidate cached results of other type if $query has changed otherType = (type == 'tasks')? '$events' : '$tasks'; if (dirty) { delete Component[otherType]; Component.$log.debug('force reload of ' + otherType); } return _this.$unwrapCollection(type, futureComponentData); }); }; /** * @function $find * @desc Fetch a component from a specific calendar. * @param {string} calendarId - the calendar ID * @param {string} componentId - the component ID * @param {string} [occurrenceId] - the component ID * @see {@link Calendar.$getComponent} */ Component.$find = function(calendarId, componentId, occurrenceId) { var futureComponentData, path = [calendarId, componentId]; if (occurrenceId) path.push(occurrenceId); futureComponentData = this.$$resource.fetch(path.join('/'), 'view'); return new Component(futureComponentData); }; /** * @function filterCategories * @desc Search for categories matching some criterias * @param {string} search - the search string to match * @returns a collection of strings */ Component.filterCategories = function(query) { var re = new RegExp(query, 'i'); return _.filter(_.keys(Component.$categories), function(category) { return category.search(re) != -1; }); }; /** * @function $eventsBlocksForView * @desc Events blocks for a specific week * @param {string} view - Either 'day' or 'week' * @param {Date} type - Date of any day of the desired period * @returns a promise of a collection of objects describing the events blocks */ Component.$eventsBlocksForView = function(view, date) { var viewAction, startDate, endDate, params; if (view == 'day') { viewAction = 'dayView'; startDate = endDate = date; } else if (view == 'week') { viewAction = 'weekView'; startDate = date.beginOfWeek(); endDate = new Date(); endDate.setTime(startDate.getTime()); endDate.addDays(6); } else if (view == 'month') { viewAction = 'monthView'; startDate = date; startDate.setDate(1); startDate = startDate.beginOfWeek(); endDate = new Date(); endDate.setTime(startDate.getTime()); endDate.setMonth(endDate.getMonth() + 1); endDate.addDays(-1); endDate = endDate.endOfWeek(); } return this.$eventsBlocks(viewAction, startDate, endDate); }; /** * @function $eventsBlocks * @desc Events blocks for a specific view and period * @param {string} view - Either 'day' or 'week' * @param {Date} startDate - period's start date * @param {Date} endDate - period's end date * @returns a promise of a collection of objects describing the events blocks */ Component.$eventsBlocks = function(view, startDate, endDate) { var params, futureComponentData, i, deferred = Component.$q.defer(); params = { view: view.toLowerCase(), sd: startDate.getDayString(), ed: endDate.getDayString() }; Component.$log.debug('eventsblocks ' + JSON.stringify(params, undefined, 2)); futureComponentData = this.$$resource.fetch(null, 'eventsblocks', params); futureComponentData.then(function(data) { Component.$timeout(function() { var components = [], blocks = {}; // Instantiate Component objects _.reduce(data.events, function(objects, eventData, i) { var componentData = _.object(data.eventsFields, eventData), start = new Date(componentData.c_startdate * 1000); componentData.hour = start.getHourString(); objects.push(new Component(componentData)); return objects; }, components); // Associate Component objects to blocks positions _.each(_.flatten(data.blocks), function(block) { block.component = components[block.nbr]; }); // Convert array of blocks to object with days as keys for (i = 0; i < data.blocks.length; i++) { blocks[startDate.getDayString()] = data.blocks[i]; startDate.addDays(1); } Component.$log.debug('blocks ready (' + _.keys(blocks).length + ')'); // Save the blocks to the object model Component.$blocks = blocks; deferred.resolve(blocks); }); }, deferred.reject); return deferred.promise; }; /** * @function $unwrapCollection * @desc Unwrap a promise and instanciate new Component objects using received data. * @param {string} type - either 'events' or 'tasks' * @param {promise} futureComponentData - a promise of the components' metadata * @returns a promise of the HTTP operation */ Component.$unwrapCollection = function(type, futureComponentData) { var _this = this, components = []; return futureComponentData.then(function(data) { return Component.$timeout(function() { var fields = _.invoke(data.fields, 'toLowerCase'); // Instanciate Component objects _.reduce(data[type], function(components, componentData, i) { var data = _.object(fields, componentData); components.push(new Component(data)); return components; }, components); Component.$log.debug('list of ' + type + ' ready (' + components.length + ')'); // Save the list of components to the object model Component['$' + type] = components; return components; }); }); }; /** * @function init * @memberof Component.prototype * @desc Extend instance with required attributes and new data. * @param {object} data - attributes of component */ Component.prototype.init = function(data) { var _this = this; this.categories = []; this.repeat = {}; this.alarm = { action: 'display', quantity: 5, unit: 'MINUTES', reference: 'BEFORE', relation: 'START' }; this.status = 'not-specified'; angular.extend(this, data); Component.$Preferences.ready().then(function() { var type = (_this.type == 'appointment')? 'Events' : 'Tasks'; // Set default values from user's defaults _this.classification = _this.classification || Component.$Preferences.defaults['SOGoCalendar' + type + 'DefaultClassification'].toLowerCase(); }); if (this.startDate) this.start = new Date(this.startDate.substring(0,10) + ' ' + this.startDate.substring(11,16)); if (this.endDate) this.end = new Date(this.endDate.substring(0,10) + ' ' + this.endDate.substring(11,16)); if (this.dueDate) this.due = new Date(this.dueDate.substring(0,10) + ' ' + this.dueDate.substring(11,16)); // Parse recurrence rule definition and initialize default values this.$isRecurrent = angular.isDefined(data.repeat); if (this.repeat.days) { var byDayMask = _.find(this.repeat.days, function(o) { return angular.isDefined(o.occurrence); }); if (byDayMask) if (this.repeat.frequency == 'yearly') this.repeat.year = { byday: true }; this.repeat.month = { type: 'byday', occurrence: byDayMask.occurrence.toString(), day: byDayMask.day }; } else { this.repeat.days = []; } if (angular.isUndefined(this.repeat.frequency)) this.repeat.frequency = 'never'; if (angular.isUndefined(this.repeat.interval)) this.repeat.interval = 1; if (angular.isUndefined(this.repeat.month)) this.repeat.month = { occurrence: '1', day: 'SU', type: 'bymonthday' }; if (angular.isUndefined(this.repeat.monthdays)) this.repeat.monthdays = []; if (angular.isUndefined(this.repeat.months)) this.repeat.months = []; if (angular.isUndefined(this.repeat.year)) this.repeat.year = {}; if (this.repeat.count) this.repeat.end = 'count'; else if (this.repeat.until) { this.repeat.end = 'until'; this.repeat.until = this.repeat.until.substring(0,10).asDate(); } else this.repeat.end = 'never'; this.$hasCustomRepeat = this.hasCustomRepeat(); if (this.isNew) { // Set default alarm Component.$Preferences.ready().then(function() { var units = { M: 'MINUTES', H: 'HOURS', D: 'DAYS', W: 'WEEKS' }; var match = /-PT?([0-9]+)([MHDW])/.exec(Component.$Preferences.defaults.SOGoCalendarDefaultReminder); if (match) { _this.$hasAlarm = true; _this.alarm.quantity = parseInt(match[1]); _this.alarm.unit = units[match[2]]; } }); } else { this.$hasAlarm = angular.isDefined(data.alarm); } // Allow the component to be moved to a different calendar this.destinationCalendar = this.pid; if (this.organizer && this.organizer.email) { this.organizer.$image = Component.$gravatar(this.organizer.email, 32); } // Load freebusy of attendees this.freebusy = this.updateFreeBusyCoverage(); if (this.attendees) { _.each(this.attendees, function(attendee) { attendee.image = Component.$gravatar(attendee.email, 32); _this.updateFreeBusy(attendee); }); } }; /** * @function hasCustomRepeat * @memberof Component.prototype * @desc Check if the component has a custom recurrence rule. * @returns true if the recurrence rule requires the full recurrence editor */ Component.prototype.hasCustomRepeat = function() { var b = angular.isDefined(this.repeat) && (this.repeat.interval > 1 || this.repeat.days && this.repeat.days.length > 0 || this.repeat.monthdays && this.repeat.monthdays.length > 0 || this.repeat.months && this.repeat.months.length > 0); return b; }; /** * @function isEditable * @memberof Component.prototype * @desc Check if the component is editable and not an occurrence of a recurrent component * @returns true or false */ Component.prototype.isEditable = function() { return (!this.occurrenceId && !this.isReadOnly); }; /** * @function isEditableOccurrence * @memberof Component.prototype * @desc Check if the component is editable and an occurrence of a recurrent component * @returns true or false */ Component.prototype.isEditableOccurrence = function() { return (this.occurrenceId && !this.isReadOnly); }; /** * @function isInvitation * @memberof Component.prototype * @desc Check if the component an invitation and not an occurrence of a recurrent component * @returns true or false */ Component.prototype.isInvitation = function() { return (!this.occurrenceId && this.userHasRSVP); }; /** * @function isInvitationOccurrence * @memberof Component.prototype * @desc Check if the component an invitation and an occurrence of a recurrent component * @returns true or false */ Component.prototype.isInvitationOccurrence = function() { return (this.occurrenceId && this.userHasRSVP); }; /** * @function isReadOnly * @memberof Component.prototype * @desc Check if the component is not editable and not an invitation * @returns true or false */ Component.prototype.isReadOnly = function() { return (this.isReadOnly && !this.userHasRSVP); }; /** * @function enablePercentComplete * @memberof Component.prototype * @desc Check if the percent completion should be enabled with respect to the * component's type and status. * @returns true if the percent completion should be displayed */ Component.prototype.enablePercentComplete = function() { return (this.component = 'vtodo' && this.status != 'not-specified' && this.status != 'cancelled'); }; /** * @function coversFreeBusy * @memberof Component.prototype * @desc Check if a specific quarter matches the component's period * @returns true if the quarter covers the component's period */ Component.prototype.coversFreeBusy = function(day, hour, quarter) { var b = (angular.isDefined(this.freebusy[day]) && angular.isDefined(this.freebusy[day][hour]) && this.freebusy[day][hour][quarter] == 1); return b; }; /** * @function updateFreeBusyCoverage * @memberof Component.prototype * @desc Build a 15-minute-based representation of the component's period. * @returns an object literal hashed by days and hours and arrays of four 1's and 0's */ Component.prototype.updateFreeBusyCoverage = function() { var _this = this, freebusy = {}; if (this.start && this.end) { var roundedStart = new Date(this.start.getTime()), roundedEnd = new Date(this.end.getTime()), startQuarter = parseInt(roundedStart.getMinutes()/15 + 0.5), endQuarter = parseInt(roundedEnd.getMinutes()/15 + 0.5); roundedStart.setMinutes(15*startQuarter); roundedEnd.setMinutes(15*endQuarter); _.each(roundedStart.daysUpTo(roundedEnd), function(date, index) { var currentDay = date.getDate(), dayKey = date.getDayString(), hourKey; if (dayKey == _this.start.getDayString()) { hourKey = date.getHours().toString(); freebusy[dayKey] = {}; freebusy[dayKey][hourKey] = []; while (startQuarter > 0) { freebusy[dayKey][hourKey].push(0); startQuarter--; } } else { date = date.beginOfDay(); freebusy[dayKey] = {}; } while (date.getTime() < _this.end.getTime() && date.getDate() == currentDay) { hourKey = date.getHours().toString(); if (angular.isUndefined(freebusy[dayKey][hourKey])) freebusy[dayKey][hourKey] = []; freebusy[dayKey][hourKey].push(1); date.addMinutes(15); } }); return freebusy; } }; /** * @function updateFreeBusy * @memberof Component.prototype * @desc Update the freebusy information for the component's period for a specific attendee. * @param {Object} card - an Card object instance of the attendee */ Component.prototype.updateFreeBusy = function(attendee) { var params, url, days; if (attendee.uid) { params = { sday: this.start.getDayString(), eday: this.end.getDayString() }; url = ['..', '..', attendee.uid, 'freebusy.ifb']; days = _.map(this.start.daysUpTo(this.end), function(day) { return day.getDayString(); }); if (angular.isUndefined(attendee.freebusy)) attendee.freebusy = {}; // Fetch FreeBusy information Component.$$resource.fetch(url.join('/'), 'ajaxRead', params).then(function(data) { _.each(days, function(day) { var hour; if (angular.isUndefined(attendee.freebusy[day])) attendee.freebusy[day] = {}; if (angular.isUndefined(data[day])) data[day] = {}; for (var i = 0; i <= 23; i++) { hour = i.toString(); if (data[day][hour]) attendee.freebusy[day][hour] = [ data[day][hour]["0"], data[day][hour]["15"], data[day][hour]["30"], data[day][hour]["45"] ]; else attendee.freebusy[day][hour] = [0, 0, 0, 0]; } }); }); } }; /** * @function getClassName * @memberof Component.prototype * @desc Return the component CSS class name based on its container (calendar) ID. * @param {string} [base] - the prefix to add to the class name (defaults to "fg") * @returns a string representing the foreground CSS class name */ Component.prototype.getClassName = function(base) { if (angular.isUndefined(base)) base = 'fg'; return base + '-folder' + (this.destinationCalendar || this.c_folder); }; /** * @function addAttendee * @memberof Component.prototype * @desc Add an attendee and fetch his freebusy info. * @param {Object} card - an Card object instance to be added to the attendees list */ Component.prototype.addAttendee = function(card) { var attendee, url, params; if (card) { attendee = { name: card.c_cn, email: card.$preferredEmail(), role: 'req-participant', status: 'needs-action', uid: card.c_uid }; if (!_.find(this.attendees, function(o) { return o.email == attendee.email; })) { attendee.image = Component.$gravatar(attendee.email, 32); if (this.attendees) this.attendees.push(attendee); else this.attendees = [attendee]; this.updateFreeBusy(attendee); } } }; /** * @function hasAttendee * @memberof Component.prototype * @desc Verify if one of the email addresses of a Card instance matches an attendee. * @param {Object} card - an Card object instance * @returns true if the Card matches an attendee */ Component.prototype.hasAttendee = function(card) { var attendee = _.find(this.attendees, function(attendee) { return _.find(card.emails, function(email) { return email.value == attendee.email; }); }); return angular.isDefined(attendee); }; /** * @function canRemindAttendeesByEmail * @memberof Component.prototype * @desc Verify if the component's reminder must be send by email and if it has at least one attendee. * @returns true if attendees can receive a reminder by email */ Component.prototype.canRemindAttendeesByEmail = function() { return this.alarm.action == 'email' && !this.isReadOnly && this.attendees && this.attendees.length > 0; }; /** * @function addAttachUrl * @memberof Component.prototype * @desc Add a new attach URL if not already defined * @param {string} attachUrl - the URL * @returns the number of values in the list of attach URLs */ Component.prototype.addAttachUrl = function(attachUrl) { if (angular.isUndefined(this.attachUrls)) { this.attachUrls = [{value: attachUrl}]; } else { for (var i = 0; i < this.attachUrls.length; i++) { if (this.attachUrls[i].value == attachUrl) { break; } } if (i == this.attachUrls.length) this.attachUrls.push({value: attachUrl}); } return this.attachUrls.length - 1; }; /** * @function deleteAttachUrl * @memberof Component.prototype * @desc Remove an attach URL * @param {number} index - the URL index in the list of attach URLs */ Component.prototype.deleteAttachUrl = function(index) { if (index > -1 && this.attachUrls.length > index) { this.attachUrls.splice(index, 1); } }; /** * @function $reset * @memberof Component.prototype * @desc Reset the original state the component's data. */ Component.prototype.$reset = function() { var _this = this; angular.forEach(this, function(value, key) { if (key != 'constructor' && key[0] != '$') { delete _this[key]; } }); angular.extend(this, this.$shadowData); this.$shadowData = this.$omit(true); }; /** * @function reply * @memberof Component.prototype * @desc Reply to an invitation. * @returns a promise of the HTTP operation */ Component.prototype.$reply = function() { var _this = this, data, path = [this.pid, this.id]; if (this.occurrenceId) path.push(this.occurrenceId); data = { reply: this.reply, delegatedTo: this.delegatedTo, alarm: this.$hasAlarm? this.alarm : {} }; return Component.$$resource.save(path.join('/'), data, { action: 'rsvpAppointment' }) .then(function(data) { // Make a copy of the data for an eventual reset _this.$shadowData = _this.$omit(true); return data; }); }; /** * @function $save * @memberof Component.prototype * @desc Save the component to the server. */ Component.prototype.$save = function() { var _this = this, options, path = [this.pid, this.id]; if (this.isNew) options = { action: 'saveAs' + this.type.capitalize() }; if (this.occurrenceId) path.push(this.occurrenceId); return Component.$$resource.save(path.join('/'), this.$omit(), options) .then(function(data) { // Make a copy of the data for an eventual reset _this.$shadowData = _this.$omit(true); return data; }); }; /** * @function $unwrap * @memberof Component.prototype * @desc Unwrap a promise. * @param {promise} futureComponentData - a promise of some of the Component's data */ Component.prototype.$unwrap = function(futureComponentData) { var _this = this; // Expose the promise this.$futureComponentData = futureComponentData; // Resolve the promise this.$futureComponentData.then(function(data) { _this.init(data); // Make a copy of the data for an eventual reset _this.$shadowData = _this.$omit(); }, function(data) { angular.extend(_this, data); _this.isError = true; Component.$log.error(_this.error); }); }; /** * @function $omit * @memberof Component.prototype * @desc Return a sanitized object used to send to the server. * @return an object literal copy of the Component instance */ Component.prototype.$omit = function() { var component = {}, date; angular.forEach(this, function(value, key) { if (key != 'constructor' && key[0] != '$') { component[key] = angular.copy(value); } }); // Format times component.startTime = component.startDate ? formatTime(component.startDate) : ''; component.endTime = component.endDate ? formatTime(component.endDate) : ''; // Update recurrence definition depending on selections if (this.$hasCustomRepeat) { if (this.repeat.frequency == 'monthly' && this.repeat.month.type && this.repeat.month.type == 'byday' || this.repeat.frequency == 'yearly' && this.repeat.year.byday) { // BYDAY mask for a monthly or yearly recurrence delete component.repeat.monthdays; component.repeat.days = [{ day: this.repeat.month.day, occurrence: this.repeat.month.occurrence.toString() }]; } else if (this.repeat.month.type) { // montly recurrence by month days or yearly by month delete component.repeat.days; } } else if (this.repeat.frequency) { component.repeat = { frequency: this.repeat.frequency }; } if (this.repeat.frequency) { if (this.repeat.end == 'until' && this.repeat.until) component.repeat.until = this.repeat.until.stringWithSeparator('-'); else if (this.repeat.end == 'count' && this.repeat.count) component.repeat.count = this.repeat.count; else { delete component.repeat.until; delete component.repeat.count; } } else { delete component.repeat; } if (this.$hasAlarm) { if (this.alarm.action && this.alarm.action == 'email' && !(this.attendees && this.attendees.length > 0)) { // No attendees; email reminder must be sent to organizer only this.alarm.attendees = 0; this.alarm.organizer = 1; } } else { component.alarm = {}; } function formatTime(dateString) { // YYYY-MM-DDTHH:MM-ZZ:00 => YYYY-MM-DD HH:MM var date = new Date(dateString.substring(0,10) + ' ' + dateString.substring(11,16)), hours = date.getHours(), minutes = date.getMinutes(); if (hours < 10) hours = '0' + hours; if (minutes < 10) minutes = '0' + minutes; return hours + ':' + minutes; } return component; }; })();