diff --git a/calendar/js/View.ts b/calendar/js/View.ts
new file mode 100644
index 0000000000..6502defdbe
--- /dev/null
+++ b/calendar/js/View.ts
@@ -0,0 +1,432 @@
+/**
+ * Super class for the different views.
+ *
+ * Each separate view overrides what it needs
+ */
+import {etemplate2} from "../../api/js/etemplate/etemplate2";
+
+export abstract class View
+{
+ // List of etemplates to show for this view
+ public static etemplates : (string | etemplate2)[] = ['calendar.view'];
+
+ /**
+ * Translated label for header
+ * @param {Object} state
+ * @returns {string}
+ */
+ static header(state)
+ {
+ let formatDate = new Date(state.date);
+ formatDate = new Date(formatDate.valueOf() + formatDate.getTimezoneOffset() * 60 * 1000);
+ return View._owner(state) + date(egw.preference('dateformat'), formatDate);
+ }
+
+ /**
+ * If one owner, get the owner text
+ *
+ * @param {object} state
+ */
+ static _owner(state)
+ {
+ let owner = '';
+ if(state.owner.length && state.owner.length == 1 && app.calendar.sidebox_et2)
+ {
+ var own = app.calendar.sidebox_et2.getWidgetById('owner').getDOMNode();
+ if(own.selectedIndex >= 0)
+ {
+ owner = own.options[own.selectedIndex].innerHTML + ": ";
+ }
+ }
+ return owner;
+ }
+
+ /**
+ * Get the start date for this view
+ * @param {Object} state
+ * @returns {Date}
+ */
+ static start_date(state)
+ {
+ const d = state.date ? new Date(state.date) : new Date();
+ d.setUTCHours(0);
+ d.setUTCMinutes(0);
+ d.setUTCSeconds(0);
+ d.setUTCMilliseconds(0);
+ return d;
+ }
+
+ /**
+ * Get the end date for this view
+ * @param {Object} state
+ * @returns {Date}
+ */
+ static end_date(state)
+ {
+ const d = state.date ? new Date(state.date) : new Date();
+ d.setUTCHours(23);
+ d.setUTCMinutes(59);
+ d.setUTCSeconds(59);
+ d.setUTCMilliseconds(0);
+ return d;
+ }
+
+ /**
+ * Get the owner for this view
+ *
+ * This is always the owner from the given state, we use a function
+ * to trigger setting the widget value.
+ *
+ * @param {number[]|String} state state.owner List of owner IDs, or a comma seperated list
+ * @returns {number[]|String}
+ */
+ static owner(state)
+ {
+ return state.owner || 0;
+ }
+
+ /**
+ * Should the view show the weekends
+ *
+ * @param {object} state
+ * @returns {boolean} Current preference to show 5 or 7 days in weekview
+ */
+ static show_weekend(state)
+ {
+ return state.weekend;
+ }
+
+ /**
+ * How big or small are the displayed time chunks?
+ *
+ * @param {object} state
+ */
+ static granularity(state)
+ {
+ var list = egw.preference('use_time_grid', 'calendar');
+ if(list == '0' || typeof list === 'undefined')
+ {
+ return parseInt('' + egw.preference('interval', 'calendar')) || 30;
+ }
+ if(typeof list == 'string') list = list.split(',');
+ if(!(list).indexOf && jQuery.isPlainObject(list))
+ {
+ list = jQuery.map(list, function (el)
+ {
+ return el;
+ });
+ }
+ return list.indexOf(state.view) >= 0 ?
+ 0 :
+ parseInt(egw.preference('interval', 'calendar')) || 30;
+ }
+
+ static extend(sub)
+ {
+ return jQuery.extend({}, this, {_super: this}, sub);
+ }
+
+ /**
+ * Determines the new date after scrolling. The default is 1 week.
+ *
+ * @param {number} delta Integer for how many 'ticks' to move, positive for
+ * forward, negative for backward
+ * @returns {Date}
+ */
+ static scroll(delta)
+ {
+ var d = new Date(app.calendar.state.date);
+ d.setUTCDate(d.getUTCDate() + (7 * delta));
+ return d;
+ }
+}
+
+/**
+ * Etemplates and settings for the different views. Some (day view)
+ * use more than one template, some use the same template as others,
+ * most need different handling for their various attributes.
+ */
+
+export class day extends View
+{
+ public static etemplates : (string | etemplate2)[] = ['calendar.view', 'calendar.todo'];
+
+ static header(state)
+ {
+ var formatDate = new Date(state.date);
+ formatDate = new Date(formatDate.valueOf() + formatDate.getTimezoneOffset() * 60 * 1000);
+ return date('l, ', formatDate) + super.header(state);
+ }
+
+ static start_date(state)
+ {
+ var d = super.start_date(state);
+ state.date = app.calendar.date.toString(d);
+ return d;
+ }
+
+ static show_weekend(state)
+ {
+ state.days = '1';
+ return true;
+ }
+
+ static scroll(delta)
+ {
+ var d = new Date(app.calendar.state.date);
+ d.setUTCDate(d.getUTCDate() + (delta));
+ return d;
+ }
+}
+
+export class day4 extends View
+{
+ static end_date(state)
+ {
+ var d = super.end_date(state);
+ state.days = '4';
+ d.setUTCHours(24 * 4 - 1);
+ d.setUTCMinutes(59);
+ d.setUTCSeconds(59);
+ d.setUTCMilliseconds(0);
+ return d;
+ }
+
+ static show_weekend(state)
+ {
+ state.weekend = 'true';
+ return true;
+ }
+
+ static scroll(delta)
+ {
+ var d = new Date(app.calendar.state.date);
+ d.setUTCDate(d.getUTCDate() + (4 * delta));
+ return d;
+ }
+}
+
+export class week extends View
+{
+ static header(state)
+ {
+ var end_date = state.last;
+ if(!week.show_weekend(state))
+ {
+ end_date = new Date(state.last);
+ end_date.setUTCDate(end_date.getUTCDate() - 2);
+ }
+ return super._owner(state) + app.calendar.egw.lang('Week') + ' ' +
+ app.calendar.date.week_number(state.first) + ': ' +
+ app.calendar.date.long_date(state.first, end_date);
+ }
+
+ static start_date(state)
+ {
+ return app.calendar.date.start_of_week(super.start_date(state));
+ }
+
+ static end_date(state)
+ {
+ var d = app.calendar.date.start_of_week(state.date || new Date());
+ // Always 7 days, we just turn weekends on or off
+ d.setUTCHours(24 * 7 - 1);
+ d.setUTCMinutes(59);
+ d.setUTCSeconds(59);
+ d.setUTCMilliseconds(0);
+ return d;
+ }
+}
+
+export class weekN extends View
+{
+ static header(state)
+ {
+ return super._owner(state) + app.calendar.egw.lang('Week') + ' ' +
+ app.calendar.date.week_number(state.first) + ' - ' +
+ app.calendar.date.week_number(state.last) + ': ' +
+ app.calendar.date.long_date(state.first, state.last);
+ }
+
+ static start_date(state)
+ {
+ return app.calendar.date.start_of_week(super.start_date(state));
+ }
+
+ static end_date(state)
+ {
+ state.days = '' + (state.days >= 5 ? state.days : egw.preference('days_in_weekview', 'calendar') || 7);
+
+ var d = app.calendar.date.start_of_week(app.calendar.View.start_date.call(this, state));
+ // Always 7 days, we just turn weekends on or off
+ d.setUTCHours(24 * 7 * (parseInt(this.egw.preference('multiple_weeks', 'calendar')) || 3) - 1);
+ return d;
+ }
+}
+
+export class month extends View
+{
+ static header(state)
+ {
+ var formatDate = new Date(state.date);
+ formatDate = new Date(formatDate.valueOf() + formatDate.getTimezoneOffset() * 60 * 1000);
+ return super._owner(state) + app.calendar.egw.lang(date('F', formatDate)) + ' ' + date('Y', formatDate);
+ }
+
+ static start_date(state)
+ {
+ var d = super.start_date(state);
+ d.setUTCDate(1);
+ return app.calendar.date.start_of_week(d);
+ }
+
+ static end_date(state)
+ {
+ var d = super.end_date(state);
+ d = new Date(d.getFullYear(), d.getUTCMonth() + 1, 1, 0, -d.getTimezoneOffset(), 0);
+ d.setUTCSeconds(d.getUTCSeconds() - 1);
+ return app.calendar.date.end_of_week(d);
+ }
+
+ static scroll(delta)
+ {
+ var d = new Date(app.calendar.state.date);
+ // Set day to 15 so we don't get overflow on short months
+ // eg. Aug 31 + 1 month = Sept 31 -> Oct 1
+ d.setUTCDate(15);
+ d.setUTCMonth(d.getUTCMonth() + delta);
+ return d;
+ }
+}
+
+export class planner extends View
+{
+ public static etemplates : (string | etemplate2)[] = ['calendar.planner'];
+
+ static header(state)
+ {
+ var startDate = new Date(state.first);
+ startDate = new Date(startDate.valueOf() + startDate.getTimezoneOffset() * 60 * 1000);
+
+ var endDate = new Date(state.last);
+ endDate = new Date(endDate.valueOf() + endDate.getTimezoneOffset() * 60 * 1000);
+ return super._owner(state) + date(egw.preference('dateformat'), startDate) +
+ (startDate == endDate ? '' : ' - ' + date(egw.preference('dateformat'), endDate));
+ }
+
+ static group_by(state)
+ {
+ return state.sortby ? state.sortby : 0;
+ }
+
+ // Note: Planner uses the additional value of planner_view to determine
+ // the start & end dates using other view's functions
+ static start_date(state)
+ {
+ // Start here, in case we can't find anything better
+ var d = super.start_date( state);
+
+ if(state.sortby && state.sortby === 'month')
+ {
+ d.setUTCDate(1);
+ }
+ else if(state.planner_view && app.classes.calendar.views[state.planner_view])
+ {
+ d = app.classes.calendar.views[state.planner_view].start_date.call(this, state);
+ }
+ else
+ {
+ d = app.calendar.date.start_of_week(d);
+ d.setUTCHours(0);
+ d.setUTCMinutes(0);
+ d.setUTCSeconds(0);
+ d.setUTCMilliseconds(0);
+ return d;
+ }
+ return d;
+ }
+
+ static end_date(state)
+ {
+
+ var d = super.end_date( state);
+ if(state.sortby && state.sortby === 'month')
+ {
+ d.setUTCDate(0);
+ d.setUTCFullYear(d.getUTCFullYear() + 1);
+ }
+ else if(state.planner_view && app.classes.calendar.views[state.planner_view])
+ {
+ d = app.classes.calendar.views[state.planner_view].end_date(state);
+ }
+ else if(state.days)
+ {
+ // This one comes from a grid view, but we'll use it
+ d.setUTCDate(d.getUTCDate() + parseInt(state.days) - 1);
+ delete state.days;
+ }
+ else
+ {
+ d = app.calendar.date.end_of_week(d);
+ }
+ return d;
+ }
+
+ static hide_empty(state)
+ {
+ var check = state.sortby == 'user' ? ['user', 'both'] : ['cat', 'both'];
+ return (check.indexOf(egw.preference('planner_show_empty_rows', 'calendar') + '') === -1);
+ }
+
+ static scroll(delta)
+ {
+ if(app.calendar.state.planner_view)
+ {
+ return app.classes.calendar.views[app.calendar.state.planner_view].scroll(delta);
+ }
+ var d = new Date(app.calendar.state.date);
+ var days = 1;
+
+ // Yearly view, grouped by month - scroll 1 month
+ if(app.calendar.state.sortby === 'month')
+ {
+ d.setUTCMonth(d.getUTCMonth() + delta);
+ d.setUTCDate(1);
+ d.setUTCHours(0);
+ d.setUTCMinutes(0);
+ return d;
+ }
+ // Need to set the day count, or auto date ranging takes over and
+ // makes things buggy
+ if(app.calendar.state.first && app.calendar.state.last)
+ {
+ var diff = new Date(app.calendar.state.last) - new Date(app.calendar.state.first);
+ days = Math.round(diff / (1000 * 3600 * 24));
+ }
+ d.setUTCDate(d.getUTCDate() + (days * delta));
+ if(days > 8)
+ {
+ d = app.calendar.date.start_of_week(d);
+ }
+ return d;
+ }
+}
+
+export class listview extends View
+{
+ public static etemplates : (string | etemplate2)[] = ['calendar.list'];
+
+ static header(state)
+ {
+ var startDate = new Date(state.first || state.date);
+ startDate = new Date(startDate.valueOf() + startDate.getTimezoneOffset() * 60 * 1000);
+ var start_check = '' + startDate.getFullYear() + startDate.getMonth() + startDate.getDate();
+
+ var endDate = new Date(state.last || state.date);
+ endDate = new Date(endDate.valueOf() + endDate.getTimezoneOffset() * 60 * 1000);
+ var end_check = '' + endDate.getFullYear() + endDate.getMonth() + endDate.getDate();
+ return super._owner(state) +
+ date(egw.preference('dateformat'), startDate) +
+ (start_check == end_check ? '' : ' - ' + date(egw.preference('dateformat'), endDate));
+ }
+}
diff --git a/calendar/js/et2_widget_daycol.ts b/calendar/js/et2_widget_daycol.ts
new file mode 100644
index 0000000000..5ea852bdde
--- /dev/null
+++ b/calendar/js/et2_widget_daycol.ts
@@ -0,0 +1,1221 @@
+/*
+ * Egroupware
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package etemplate
+ * @subpackage api
+ * @link http://www.egroupware.org
+ * @author Nathan Gray
+ * @version $Id$
+ */
+
+
+/*egw:uses
+ et2_core_valueWidget;
+ /calendar/js/et2_widget_event.js;
+*/
+
+import {et2_register_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget";
+import {et2_valueWidget} from "../../api/js/etemplate/et2_core_valueWidget";
+import {et2_calendar_timegrid} from "./et2_widget_timegrid";
+import {et2_calendar_view} from "./et2_widget_view";
+import {et2_calendar_event} from "./et2_widget_event";
+import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance";
+import {et2_date} from "../../api/js/etemplate/et2_widget_date";
+
+/**
+ * Class which implements the "calendar-timegrid" XET-Tag for displaying a single days
+ *
+ * This widget is responsible mostly for positioning its events
+ *
+ */
+export class et2_calendar_daycol extends et2_valueWidget implements et2_IDetachedDOM, et2_IResizeable
+{
+
+ static readonly _attributes: any = {
+ date: {
+ name: "Date",
+ type: "any",
+ description: "What date is this daycol for. YYYYMMDD or Date",
+ default: et2_no_init
+ },
+ owner: {
+ name: "Owner",
+ type: "any", // Integer, string, or array of either
+ default: et2_no_init,
+ description: "Account ID number of the calendar owner, if not the current user"
+ },
+ display_birthday_as_event: {
+ name: "Birthdays",
+ type: "boolean",
+ default: false,
+ description: "Display birthdays as events"
+ },
+ display_holiday_as_event: {
+ name: "Holidays",
+ type: "boolean",
+ default: false,
+ description: "Display holidays as events"
+ }
+ };
+ private div: JQuery;
+ private header: JQuery;
+ private title: JQuery;
+ private event_wrapper: JQuery;
+ private user_spacer: JQuery;
+ private all_day: JQuery;
+
+ private _date_helper : et2_date;
+ private registeredUID: string = null;
+
+ // Init to defaults, just in case - they will be updated from parent
+ private display_settings: any = {
+ wd_start: 60*9,
+ wd_end: 60*17,
+ granularity: 30,
+ rowsToDisplay: 10,
+ rowHeight: 20,
+ // Percentage; not yet available
+ titleHeight: 2.0
+ };
+ private date: Date;
+ private class: string;
+
+ /**
+ * Constructor
+ */
+ constructor(_parent, _attrs? : WidgetConfig, _child? : object)
+ {
+ // Call the inherited constructor
+ super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_daycol._attributes, _child || {}));
+
+ // Main container
+ this.div = jQuery(document.createElement("div"))
+ .addClass("calendar_calDayCol")
+ .css('width',this.options.width)
+ .css('left', this.options.left);
+ this.header = jQuery(document.createElement('div'))
+ .addClass("calendar_calDayColHeader")
+ .css('width',this.options.width)
+ .css('left', this.options.left);
+ this.title = jQuery(document.createElement('div'))
+ .addClass('et2_clickable et2_link')
+ .appendTo(this.header);
+ this.user_spacer = jQuery(document.createElement('div'))
+ .addClass("calendar_calDayColHeader_spacer")
+ .appendTo(this.header);
+ this.all_day = jQuery(document.createElement('div'))
+ .addClass("calendar_calDayColAllDay")
+ .css('max-height', (egw.preference('limit_all_day_lines', 'calendar') || 3 ) * 1.4 + 'em')
+ .appendTo(this.header);
+ this.event_wrapper = jQuery(document.createElement('div'))
+ .addClass("event_wrapper")
+ .appendTo(this.div);
+
+ this.setDOMNode(this.div[0]);
+
+ // Used for its date calculations - note this is a datetime, parent
+ // uses just a date
+ this._date_helper = et2_createWidget('date-time', {}, null);
+ this._date_helper.loadingFinished();
+ }
+
+ doLoadingFinished( )
+ {
+ let result = super.doLoadingFinished();
+
+ // Parent will have everything we need, just load it from there
+
+ if(this.getParent() && this.getParent().options.owner)
+ {
+ this.set_owner(this.getParent().options.owner);
+ }
+ if(this.title.text() === '' && this.options.date &&
+ this.getParent() && this.getParent().instanceOf(et2_calendar_timegrid))
+ {
+ // Forces an update
+ const date = this.options.date;
+ this.options.date = '';
+ this.set_date(date);
+ }
+
+ return result;
+ }
+
+ destroy( )
+ {
+ super.destroy();
+ this.div.off();
+ this.header.off().remove();
+ this.title.off();
+ this.div = null;
+ this.header = null;
+ this.title = null;
+ this.user_spacer = null;
+
+ // date_helper has no parent, so we must explicitly remove it
+ this._date_helper.destroy();
+ this._date_helper = null;
+
+ egw.dataUnregisterUID(this.registeredUID,null,this);
+ }
+
+ getDOMNode(sender)
+ {
+ if(!sender || sender === this) return this.div[0];
+ if(sender.instanceOf && sender.instanceOf(et2_calendar_event))
+ {
+ if(this.display_settings.granularity === 0)
+ {
+ return this.event_wrapper[0];
+ }
+ if(sender.options.value.whole_day_on_top ||
+ sender.options.value.whole_day && sender.options.value.non_blocking === true)
+ {
+ return this.all_day[0];
+ }
+ return this.div[0];
+ }
+ }
+
+ /**
+ * Draw the individual divs for clicking to add an event
+ */
+ _draw( )
+ {
+ // Remove any existing
+ jQuery('.calendar_calAddEvent',this.div).remove();
+
+ // Grab real values from parent
+ if(this.getParent() && this.getParent().instanceOf(et2_calendar_timegrid))
+ {
+ this.display_settings.wd_start = 60*this.getParent().options.day_start;
+ this.display_settings.wd_end = 60*this.getParent().options.day_end;
+ this.display_settings.granularity = this.getParent().options.granularity;
+ const header = this.getParent().dayHeader.children();
+
+ // Figure out insert index
+ let idx = 0;
+ const siblings = this.getParent().getDOMNode(this).childNodes;
+ while(idx < siblings.length && siblings[idx] != this.getDOMNode())
+ {
+ idx++;
+ }
+ // Stick header in the right place
+ if(idx == 0)
+ {
+ this.getParent().dayHeader.prepend(this.header);
+ }
+ else if(header.length)
+ {
+ header.eq(Math.min(header.length,idx)-1).after(this.header);
+ }
+ }
+
+ this.div.attr('data-date', this.options.date);
+ }
+
+ getDate() : Date
+ {
+ return this.date;
+ }
+
+ get date_helper(): et2_date
+ {
+ return this._date_helper;
+ }
+
+ /**
+ * Set the date
+ *
+ * @param {string|Date} _date New date
+ * @param {Object[]} events =false List of event data to be displayed, or false to
+ * automatically fetch data from content array
+ * @param {boolean} force_redraw =false Redraw even if the date is the same.
+ * Used for when new data is available.
+ */
+ set_date(_date, events?, force_redraw?)
+ {
+ if(typeof events === 'undefined' || !events)
+ {
+ events = false;
+ }
+ if(typeof force_redraw === 'undefined' || !force_redraw)
+ {
+ force_redraw = false;
+ }
+ if(!this.getParent() || !this.getParent().date_helper)
+ {
+ egw.debug('warn', 'Day col widget "' + this.id + '" is missing its parent.');
+ return false;
+ }
+ if(typeof _date === "object")
+ {
+ this.getParent().date_helper.set_value(_date);
+ }
+ else if(typeof _date === "string")
+ {
+ // Need a new date to avoid invalid month/date combinations when setting
+ // month then day. Use a string to avoid browser timezone.
+ this.getParent().date_helper.set_value(_date.substring(0,4)+'-'+(_date.substring(4,6))+'-'+_date.substring(6,8)+'T00:00:00Z');
+ }
+
+ this.date = new Date(this.getParent().date_helper.getValue());
+
+ // Keep internal option in Ymd format, it gets passed around in this format
+ const new_date = "" + this.getParent().date_helper.get_year() +
+ sprintf("%02d", this.getParent().date_helper.get_month()) +
+ sprintf("%02d", this.getParent().date_helper.get_date());
+
+ // Set label
+ if(!this.options.label)
+ {
+ // Add timezone offset back in, or formatDate will lose those hours
+ const formatDate = new Date(this.date.valueOf() + this.date.getTimezoneOffset() * 60 * 1000);
+
+ this.title.html(''+jQuery.datepicker.formatDate('DD',formatDate)+
+ ''+jQuery.datepicker.formatDate('D',formatDate)+''+
+ jQuery.datepicker.formatDate('d',formatDate));
+ }
+ this.title
+ .attr("data-date", new_date)
+ .toggleClass('et2_label', !!this.options.label);
+ this.header
+ .attr('data-date',new_date)
+ .attr('data-whole_day',true);
+
+ // Avoid redrawing if date is the same
+ if(new_date === this.options.date &&
+ this.display_settings.granularity === this.getParent().options.granularity &&
+ !force_redraw
+ )
+ {
+ return;
+ }
+
+ const cache_id = CalendarApp._daywise_cache_id(new_date, this.options.owner);
+ if(this.options.date && this.registeredUID &&
+ cache_id !== this.registeredUID)
+ {
+ egw.dataUnregisterUID(this.registeredUID,null,this);
+
+ // Remove existing events
+ while(this._children.length > 0)
+ {
+ const node = this._children[this._children.length - 1];
+ this.removeChild(node);
+ node.destroy();
+ }
+ }
+
+ this.options.date = new_date;
+
+ // Set holiday and today classes
+ this.day_class_holiday();
+
+ // Update all the little boxes
+ this._draw();
+
+
+ // Register for updates on events for this day
+ if(this.registeredUID !== cache_id)
+ {
+ this.registeredUID = cache_id;
+ egw.dataRegisterUID(this.registeredUID, this._data_callback,this,this.getInstanceManager().execId,this.id);
+ }
+ }
+
+ /**
+ * Set the owner of this day
+ *
+ * @param {number|number[]|string|string[]} _owner - Owner ID, which can
+ * be an account ID, a resource ID (as defined in calendar_bo, not
+ * necessarily an entry from the resource app), or a list containing a
+ * combination of both.
+ */
+ set_owner( _owner)
+ {
+
+ this.title
+ .attr("data-owner", _owner);
+ this.header.attr('data-owner',_owner);
+ this.div.attr('data-owner',_owner);
+
+ // Simple comparison, both numbers
+ if(_owner === this.options.owner) return;
+
+ // More complicated comparison, one or the other is an array
+ if((typeof _owner == 'object' || typeof this.options.owner == 'object') &&
+ _owner.toString() == this.options.owner.toString())
+ {
+ return;
+ }
+
+ this.options.owner = typeof _owner !== 'object' ? [_owner] : _owner;
+
+ const cache_id = CalendarApp._daywise_cache_id(this.options.date, _owner);
+ if(this.options.date && this.registeredUID &&
+ cache_id !== this.registeredUID)
+ {
+ egw.dataUnregisterUID(this.registeredUID,null,this);
+ }
+
+ if(this.registeredUID !== cache_id)
+ {
+ this.registeredUID = cache_id;
+ egw.dataRegisterUID(this.registeredUID, this._data_callback,this,this.getInstanceManager().execId,this.id);
+ }
+ }
+
+ set_class( classnames)
+ {
+ this.header.removeClass(this.class);
+ super.set_class(classnames);
+ this.header.addClass(classnames);
+ }
+
+ /**
+ * Callback used when the daywise data changes
+ *
+ * Events should update themselves when their data changes, here we are
+ * dealing with a change in which events are displayed on this day.
+ *
+ * @param {String[]} event_ids
+ * @returns {undefined}
+ */
+ _data_callback( event_ids)
+ {
+ const events = [];
+ if(event_ids == null || typeof event_ids.length == 'undefined') event_ids = [];
+ for(let i = 0; i < event_ids.length; i++)
+ {
+ let event : any = egw.dataGetUIDdata('calendar::'+event_ids[i]);
+ event = event && event.data || false;
+ if(event && event.date && et2_calendar_event.owner_check(event, this) && (
+ event.date === this.options.date ||
+ // Accept multi-day events
+ new Date(event.start) <= this.date //&& new Date(event.end) >= this.date
+ ))
+ {
+ events.push(event);
+ }
+ else if (event)
+ {
+ // Got an ID that doesn't belong
+ event_ids.splice(i--,1);
+ }
+ }
+ if(!this.getParent().disabled)
+ this._update_events(events);
+ }
+
+ set_label( label)
+ {
+ this.options.label = label;
+ this.title.text(label);
+ this.title.toggleClass('et2_clickable et2_link',label === '');
+ }
+ set_left( left)
+ {
+ if(this.div)
+ {
+ this.div.css('left',left);
+ }
+ }
+ set_width(width)
+ {
+ this.options.width = width;
+
+ if(this.div)
+ {
+ this.div.outerWidth(this.options.width);
+ this.header.outerWidth(this.options.width);
+ }
+ }
+
+ /**
+ * Applies class for today, and any holidays for current day
+ */
+ day_class_holiday( )
+ {
+ this.title
+ // Remove all special day classes
+ .removeClass('calendar_calToday calendar_calBirthday calendar_calHoliday')
+ // Except this one...
+ .addClass("et2_clickable et2_link");
+ this.title.attr('data-holiday','');
+
+ // Set today class - note +1 when dealing with today, as months in JS are 0-11
+ const today = new Date();
+ today.setUTCMinutes(today.getUTCMinutes() - today.getTimezoneOffset());
+
+ this.title.toggleClass("calendar_calToday", this.options.date === ''+today.getUTCFullYear()+
+ sprintf("%02d",today.getUTCMonth()+1)+
+ sprintf("%02d",today.getUTCDate())
+ );
+
+ // Holidays and birthdays
+ let holidays = et2_calendar_view.get_holidays(this, this.options.date.substring(0, 4));
+ const holiday_list = [];
+ let holiday_pref = (egw.preference('birthdays_as_events', 'calendar') || []);
+ if(typeof holiday_pref === 'string')
+ {
+ holiday_pref = holiday_pref.split(',');
+ }
+ else
+ {
+ holiday_pref = jQuery.extend([], holiday_pref);
+ }
+
+ // Show holidays as events on mobile or by preference
+ const holidays_as_events = egwIsMobile() || egw.preference('birthdays_as_events', 'calendar') === true ||
+ holiday_pref.indexOf('holiday') >= 0;
+
+ const birthdays_as_events = egwIsMobile() || holiday_pref.indexOf('birthday') >= 0;
+
+ if(holidays && holidays[this.options.date])
+ {
+ holidays = holidays[this.options.date];
+ for(let i = 0; i < holidays.length; i++)
+ {
+ if (typeof holidays[i]['birthyear'] !== 'undefined')
+ {
+ // Show birthdays as events on mobile or by preference
+ if(birthdays_as_events)
+ {
+ // Create event
+ this.getParent().date_helper.set_value(this.options.date.substring(0,4)+'-'+
+ (this.options.date.substring(4,6))+'-'+this.options.date.substring(6,8)+
+ 'T00:00:00Z');
+ var event = et2_createWidget('calendar-event',{
+ id:'event_'+holidays[i].name,
+ value: {
+ title: holidays[i].name,
+ whole_day: true,
+ whole_day_on_top: true,
+ start: new Date(this.getParent().date_helper.get_value()),
+ end: this.options.date,
+ owner: this.options.owner,
+ participants: this.options.owner,
+ app: 'calendar',
+ class: 'calendar_calBirthday'
+ },
+ readonly: true,
+ class: 'calendar_calBirthday'
+ },this);
+ event.doLoadingFinished();
+ event._update();
+ }
+ if (!egwIsMobile())
+ {
+ //If the birthdays are already displayed as event, don't
+ //show them in the caption
+ this.title.addClass('calendar_calBirthday');
+ holiday_list.push(holidays[i]['name']);
+ }
+ }
+ else
+ {
+ // Show holidays as events on mobile
+ if(holidays_as_events)
+ {
+ // Create event
+ this.getParent().date_helper.set_value(this.options.date.substring(0,4)+'-'+
+ (this.options.date.substring(4,6))+'-'+this.options.date.substring(6,8)+
+ 'T00:00:00Z');
+ var event = et2_createWidget('calendar-event',{
+ id:'event_'+holidays[i].name,
+ value: {
+ title: holidays[i].name,
+ whole_day: true,
+ whole_day_on_top: true,
+ start: new Date(this.getParent().date_helper.get_value()),
+ end: this.options.date,
+ owner: this.options.owner,
+ participants: this.options.owner,
+ app: 'calendar',
+ class: 'calendar_calHoliday'
+ },
+ readonly: true,
+ class: 'calendar_calHoliday'
+ },this);
+ event.doLoadingFinished();
+ event._update();
+ }
+ else
+ {
+ this.title.addClass('calendar_calHoliday');
+ this.title.attr('data-holiday', holidays[i]['name']);
+
+ //If the birthdays are already displayed as event, don't
+ //show them in the caption
+ if (!this.options.display_holiday_as_event)
+ {
+ holiday_list.push(holidays[i]['name']);
+ }
+ }
+ }
+ }
+ }
+ this.title.attr('title', holiday_list.join(', '));
+ }
+
+ /**
+ * Load the event data for this day and create event widgets for each.
+ *
+ * If event information is not provided, it will be pulled from the content array.
+ *
+ * @param {Object[]} [_events] Array of event information, one per event.
+ */
+ _update_events(_events)
+ {
+ let c;
+ const events = _events || this.getArrayMgr('content').getEntry(this.options.date) || [];
+
+ // Remove extra events
+ while(this._children.length > 0)
+ {
+ const node = this._children[this._children.length - 1];
+ this.removeChild(node);
+ node.destroy();
+ }
+
+ // Make sure children are in cronological order, or columns are backwards
+ events.sort(function(a,b) {
+ const start = new Date(a.start) - new Date(b.start);
+ const end = new Date(a.end) - new Date(b.end);
+ // Whole day events sorted by ID, normal events by start / end time
+ if(a.whole_day && b.whole_day)
+ {
+ return (a.app_id - b.app_id);
+ }
+ else if (a.whole_day || b.whole_day)
+ {
+ return a.whole_day ? -1 : 1;
+ }
+ return start ? start : end;
+ });
+
+ for(c = 0; c < events.length; c++)
+ {
+ // Create event
+ var event = et2_createWidget('calendar-event',{
+ id:'event_'+events[c].id,
+ value: events[c]
+ },this);
+ }
+
+ // Seperate loop so column sorting finds all children in the right place
+ for(c = 0; c < events.length && c < this._children.length; c++)
+ {
+ let event = this.getWidgetById('event_'+events[c].id);
+ if(!event) continue;
+ if(this.isInTree())
+ {
+ event.doLoadingFinished();
+ }
+ }
+
+
+ // Show holidays as events on mobile or by preference
+ if(egwIsMobile() || egw.preference('birthdays_as_events','calendar'))
+ {
+ this.day_class_holiday();
+ }
+
+ // Apply styles to hidden events
+ this._out_of_view();
+ }
+
+ /**
+ * Apply styles for out-of-view and partially hidden events
+ *
+ * There are 3 different states or modes of display:
+ *
+ * - 'Normal' - When showing events positioned by time, the indicator is just
+ * a bar colored by the last category color. On hover it shows either the
+ * title of a single event or "x event(s)" if more than one are hidden.
+ * Clicking adjusts the current view to show the earliest / latest hidden
+ * event
+ *
+ * - Fixed - When showing events positioned by time but in a fixed-height
+ * week (not auto-sized to fit screen) the indicator is the same as sized.
+ * On hover it shows the titles of the hidden events, clicking changes
+ * the view to the selected day.
+ *
+ * - GridList - When showing just a list, the indicator shows "x event(s)",
+ * and on hover shows the category color, title & time. Clicking changes
+ * the view to the selected day, and opens the event for editing.
+ */
+ _out_of_view()
+ {
+ // Reset
+ this.header.children('.hiddenEventBefore').remove();
+ this.div.children('.hiddenEventAfter').remove();
+ this.event_wrapper.css('overflow','visible');
+ this.all_day.removeClass('overflown');
+ jQuery('.calendar_calEventBody', this.div).css({'padding-top': '','margin-top':''});
+
+ const timegrid = this.getParent();
+
+ // elem is jquery div of event
+ function isHidden(elem) {
+ // Add an extra 5px top and bottom to include events just on the
+ // edge of visibility
+ const docViewTop = timegrid.scrolling.scrollTop() + 5,
+ docViewBottom = docViewTop + (
+ this.display_settings.granularity === 0 ?
+ this.event_wrapper.height() :
+ timegrid.scrolling.height() - 10
+ ),
+ elemTop = elem.position().top,
+ elemBottom = elemTop + elem.outerHeight(true);
+ if((elemBottom <= docViewBottom) && (elemTop >= docViewTop))
+ {
+ // Entirely visible
+ return false;
+ }
+ const visible = {
+ hidden: elemTop > docViewTop ? 'bottom' : 'top',
+ completely: false
+ };
+ visible.completely = visible.hidden == 'top' ? elemBottom < docViewTop : elemTop > docViewBottom;
+ return visible;
+ }
+
+ // In gridlist view, we can quickly check if we need it at all
+ if(this.display_settings.granularity === 0 && this._children.length)
+ {
+ jQuery('div.calendar_calEvent',this.div).show(0);
+ if(Math.ceil(this.div.height() / this._children[0].div.height()) > this._children.length)
+ {
+ return;
+ }
+ }
+ // Check all day overflow
+ this.all_day.toggleClass('overflown',
+ this.all_day[0].scrollHeight - this.all_day.innerHeight() > 5
+ );
+
+ // Check each event
+ this.iterateOver(function(event) {
+ // Skip whole day events and events missing value
+ if(this.display_settings.granularity && (
+ (!event.options || !event.options.value || event.options.value.whole_day_on_top))
+ )
+ {
+ return;
+ }
+ // Reset
+ event.title.css({'top':'','background-color':''});
+ event.body.css({'padding-top':'','margin-top':''});
+ const hidden = isHidden.call(this, event.div);
+ const day = this;
+ if(!hidden)
+ {
+ return;
+ }
+ // Only top is hidden, move label
+ // Bottom hidden is fine
+ if(hidden.hidden === 'top' && !hidden.completely && !event.div.hasClass('calendar_calEventSmall'))
+ {
+ const title_height = event.title.outerHeight();
+ event.title.css({
+ 'top': timegrid.scrolling.scrollTop() - event.div.position().top,
+ 'background-color': 'transparent'
+ });
+ event.body.css({
+ 'padding-top': timegrid.scrolling.scrollTop() - event.div.position().top + title_height,
+ 'margin-top' : -title_height
+ });
+ }
+ // Too many in gridlist view, show indicator
+ else if (this.display_settings.granularity === 0 && hidden)
+ {
+ if(jQuery('.hiddenEventAfter',this.div).length == 0)
+ {
+ this.event_wrapper.css('overflow','hidden');
+ }
+ this._hidden_indicator(event, false, function() {
+ app.calendar.update_state({view: 'day', date: day.date});
+ });
+ // Avoid partially visible events
+ // We need to hide all, or the next row will be visible
+ event.div.hide(0);
+ }
+ // Completely out of view, show indicator
+ else if (hidden.completely)
+ {
+ this._hidden_indicator(event, hidden.hidden == 'top',false);
+ }
+ }, this, et2_calendar_event);
+ }
+
+ /**
+ * Show an indicator that there are hidden events
+ *
+ * The indicator works 3 different ways, depending on if the day can be
+ * scrolled, is fixed, or if in gridview.
+ *
+ * @see _out_of_view()
+ *
+ * @param {et2_calendar_event} event Event we're creating the indicator for
+ * @param {boolean} top Events hidden at the top (true) or bottom (false)
+ * @param {function} [onclick] Callback for when user clicks on the indicator
+ */
+ _hidden_indicator(event, top, onclick)
+ {
+ let indicator = null;
+ const day = this;
+ const timegrid = this.getParent();
+ const fixed_height = timegrid.div.hasClass('calendar_calTimeGridFixed');
+
+ // Event is before the displayed times
+ if(top)
+ {
+ // Create if not already there
+ if(jQuery('.hiddenEventBefore',this.header).length === 0)
+ {
+ indicator = jQuery('')
+ .appendTo(this.header)
+ .attr('data-hidden_count', 1);
+ if(!fixed_height)
+ {
+ indicator
+ .text(event.options.value.title)
+ .on('click', typeof onclick === 'function' ? onclick : function() {
+ jQuery('.calendar_calEvent',day.div).first()[0].scrollIntoView();
+ return false;
+ });
+ }
+ }
+ else
+ {
+ indicator = jQuery('.hiddenEventBefore',this.header);
+ indicator.attr('data-hidden_count', parseInt(indicator.attr('data-hidden_count')) + 1);
+
+ if (!fixed_height)
+ {
+ indicator.text(day.egw().lang('%1 event(s) %2',indicator.attr('data-hidden_count'),''));
+ }
+ }
+ }
+ // Event is after displayed times
+ else
+ {
+ indicator = jQuery('.hiddenEventAfter',this.div);
+ // Create if not already there
+ if(indicator.length === 0)
+ {
+ indicator = jQuery('')
+ .attr('data-hidden_count', 0)
+ .appendTo(this.div);
+ if(!fixed_height)
+ {
+ indicator
+ .on('click', typeof onclick === 'function' ? onclick : function() {
+ jQuery('.calendar_calEvent',day.div).last()[0].scrollIntoView(false);
+ // Better re-run this to clean up
+ day._out_of_view();
+ return false;
+ });
+ }
+ else
+ {
+ indicator
+ .on('mouseover', function() {
+ indicator.css({
+ 'height': (indicator.attr('data-hidden_count')*1.2) + 'em',
+ 'margin-top': -(indicator.attr('data-hidden_count')*1.2) + 'em'
+ });
+ })
+ .on('mouseout', function() {
+ indicator.css({
+ 'height': '',
+ 'margin-top': ''
+ });
+ });
+ }
+ }
+ const count = parseInt(indicator.attr('data-hidden_count')) + 1;
+ indicator.attr('data-hidden_count', count);
+ if(this.display_settings.granularity === 0)
+ {
+ indicator.append(event.div.clone());
+ indicator.attr('data-hidden_label', day.egw().lang('%1 event(s) %2',indicator.attr('data-hidden_count'),''));
+ }
+ else if (!fixed_height)
+ {
+ indicator.text(day.egw().lang('%1 event(s) %2',indicator.attr('data-hidden_count'),''));
+ }
+ indicator.css('top',timegrid.scrolling.height() + timegrid.scrolling.scrollTop()-indicator.innerHeight());
+ }
+ // Show different stuff for fixed height
+ if(fixed_height)
+ {
+ indicator
+ .append(""+
+ event.options.value.title+
+ "
"
+ );
+ }
+ // Match color to the event
+ if(indicator !== null)
+ {
+ // Avoid white, which is hard to see
+ // Use border-bottom-color, Firefox doesn't give a value with border-color
+ const color = jQuery.Color(event.div.css('background-color')).toString() !== jQuery.Color('white').toString() ?
+ event.div.css('background-color') : event.div.css('border-bottom-color');
+ if(color !== 'rgba(0, 0, 0, 0)')
+ {
+ indicator.css('border-color', color);
+ }
+ }
+ }
+
+ /**
+ * Sort a day's events into minimally overlapping columns
+ *
+ * @returns {Array[]} Events sorted into columns
+ */
+ _spread_events()
+ {
+ if(!this.date) return [];
+
+ let day_start = this.date.valueOf() / 1000;
+ const dst_check = new Date(this.date);
+ dst_check.setUTCHours(12);
+
+ // if daylight saving is switched on or off, correct $day_start
+ // gives correct times after 2am, times between 0am and 2am are wrong
+ const daylight_diff = day_start + 12 * 60 * 60 - (dst_check.valueOf() / 1000);
+ if(daylight_diff)
+ {
+ day_start -= daylight_diff;
+ }
+
+ const eventCols = [], col_ends = [];
+
+ // Make sure children are in cronological order, or columns are backwards
+ this._children.sort(function(a,b) {
+ const start = new Date(a.options.value.start) - new Date(b.options.value.start);
+ const end = new Date(a.options.value.end) - new Date(b.options.value.end);
+ // Whole day events sorted by ID, normal events by start / end time
+ if(a.options.value.whole_day && b.options.value.whole_day)
+ {
+ // Longer duration comes first so we have nicer bars across the top
+ const duration =
+ (new Date(b.options.value.end) - new Date(b.options.value.start)) -
+ (new Date(a.options.value.end) - new Date(a.options.value.start));
+
+ return duration ? duration : (a.options.value.app_id - b.options.value.app_id);
+ }
+ else if (a.options.value.whole_day || b.options.value.whole_day)
+ {
+ return a.options.value.whole_day ? -1 : 1;
+ }
+ return start ? start : end;
+ });
+
+ for(let i = 0; i < this._children.length; i++)
+ {
+ const event = this._children[i].options.value || false;
+ if(!event) continue;
+ if(event.date && event.date != this.options.date &&
+ // Multi-day events date may be different
+ (new Date(event.start) >= this.date || new Date(event.end) < this.date )
+ )
+ {
+ // Still have a child event that has changed date (DnD)
+ this._children[i].destroy();
+ this.removeChild(this._children[i]);
+ continue;
+ }
+
+ let c = 0;
+ event['multiday'] = false;
+ if(typeof event.start !== 'object')
+ {
+ event.start = new Date(event.start);
+ }
+ if(typeof event.end !== 'object')
+ {
+ event.end = new Date(event.end);
+ }
+ event['start_m'] = (event.start.valueOf()/1000 - day_start) / 60;
+ if (event['start_m'] < 0)
+ {
+ event['start_m'] = 0;
+ event['multiday'] = true;
+ }
+ event['end_m'] = (event.end.valueOf()/1000 - day_start) / 60;
+ if (event['end_m'] >= 24*60)
+ {
+ event['end_m'] = 24*60-1;
+ event['multiday'] = true;
+ }
+ if(!event.start.getUTCHours() && !event.start.getUTCMinutes() && event.end.getUTCHours() == 23 && event.end.getUTCMinutes() == 59)
+ {
+ event.whole_day_on_top = (event.non_blocking && event.non_blocking != '0');
+ }
+ if (!event['whole_day_on_top'])
+ {
+ for(c = 0; event['start_m'] < col_ends[c]; ++c);
+ col_ends[c] = event['end_m'];
+ }
+ if(typeof eventCols[c] === 'undefined')
+ {
+ eventCols[c] = [];
+ }
+ eventCols[c].push(this._children[i]);
+ }
+ return eventCols;
+ }
+
+ /**
+ * Position the event according to its time and how this widget is laid
+ * out.
+ *
+ * @param {et2_calendar_event} [event] - Event to be updated
+ * If a single event is not provided, all events are repositioned.
+ */
+ position_event(event?)
+ {
+ // If hidden, skip it - it takes too long
+ if(!this.div.is(':visible')) return;
+
+ // Sort events into minimally-overlapping columns
+ const columns = this._spread_events();
+
+ for(let c = 0; c < columns.length; c++)
+ {
+ // Calculate horizontal positioning
+ let left = Math.ceil(5 + (1.5 * 100 / (parseFloat(this.options.width) || 100)));
+ let right = 2;
+ if (columns.length !== 1)
+ {
+ right = !c ? 30 : 2;
+ left += c * (100.0-left) / columns.length;
+ }
+
+ for(let i = 0; (columns[c].indexOf(event) >= 0 || !event) && i < columns[c].length; i++)
+ {
+ // Calculate vertical positioning
+ let top = 0;
+ let height = 0;
+ // Position the event
+ if(this.display_settings.granularity === 0)
+ {
+ if(this.all_day.has(columns[c][i].div).length)
+ {
+ columns[c][i].div.prependTo(this.event_wrapper);
+ }
+ columns[c][i].div.css('top', '');
+ columns[c][i].div.css('height', '');
+ columns[c][i].div.css('left', '');
+ columns[c][i].div.css('right', '');
+ // Strip out of view padding
+ columns[c][i].body.css('padding-top','');
+ continue;
+ }
+ if(columns[c][i].options.value.whole_day_on_top)
+ {
+ if(!this.all_day.has(columns[c][i].div).length)
+ {
+ columns[c][i].div.css('top', '');
+ columns[c][i].div.css('height','');
+ columns[c][i].div.css('left', '');
+ columns[c][i].div.css('right', '');
+ columns[c][i].body.css('padding-top','');
+ columns[c][i].div
+ .appendTo(this.all_day);
+ this.getParent().resizeTimes();
+ }
+ continue;
+ }
+ else
+ {
+ if(this.all_day.has(columns[c][i].div).length)
+ {
+ columns[c][i].div.appendTo(this.event_wrapper);
+ this.getParent().resizeTimes();
+ }
+ top = this._time_to_position(columns[c][i].options.value.start_m);
+ height = this._time_to_position(columns[c][i].options.value.end_m)-top;
+ }
+
+ // Position the event
+ if(event && columns[c].indexOf(event) >= 0 || !event)
+ {
+ columns[c][i].div.css('top', top+'%');
+ columns[c][i].div.css('height', height+'%');
+ // Remove spacing from border, but only if visible or the height will be wrong
+ if(columns[c][i].div.is(':visible'))
+ {
+ const border_diff = columns[c][i].div.outerHeight() - columns[c][i].div.height();
+ columns[c][i].div.css('height','calc('+height+'% - ' +border_diff+')');
+ }
+ // This gives the wrong height
+ //columns[c][i].div.outerHeight(height+'%');
+ columns[c][i].div.css('left', left.toFixed(1)+'%');
+ columns[c][i].div.css('right', right.toFixed(1)+'%');
+ columns[c][i].div.css('z-index',parseInt(20)+c);
+ columns[c][i]._small_size();
+ }
+ }
+ // Only wanted to position this event, leave the other columns alone
+ if(event && columns[c].indexOf(event) >= 0)
+ {
+ return;
+ }
+ }
+ }
+
+ /**
+ * Calculates the vertical position based on the time
+ *
+ * This calculation is a percentage from 00:00 to 23:59
+ *
+ * @param {int} time in minutes from midnight
+ * @return {float} position in percent
+ */
+ _time_to_position(time)
+ {
+ let pos = 0.0;
+
+ // 24h
+ pos = ((time / 60) / 24) * 100;
+
+ return pos.toFixed(1);
+ }
+
+ attachToDOM()
+ {
+ let result = super.attachToDOM();
+
+ // Remove the binding for the click handler, unless there's something
+ // custom here.
+ if (!this.onclick)
+ {
+ jQuery(this.node).off("click");
+ }
+ // But we do want to listen to certain clicks, and handle them internally
+ jQuery(this.node).on('click.et2_daycol',
+ '.calendar_calDayColHeader,.calendar_calAddEvent',
+ jQuery.proxy(this.click, this)
+ );
+
+ return result;
+ }
+
+ /**
+ * Click handler calling custom handler set via onclick attribute to this.onclick,
+ * or the default which is to open a new event at that time.
+ *
+ * Normally, you don't bind to this one, but the attribute is supported if you
+ * can get a reference to the widget.
+ *
+ * @param {Event} _ev
+ * @returns {boolean}
+ */
+ click(_ev)
+ {
+ if(this.getParent().options.readonly ) return;
+
+ // Drag to create in progress
+ if(this.getParent().drag_create.start !== null) return;
+
+ // Click on the title
+ if (jQuery(_ev.target).hasClass('calendar_calAddEvent'))
+ {
+ if(this.header.has(_ev.target).length == 0 && !_ev.target.dataset.whole_day)
+ {
+ // Default handler to open a new event at the selected time
+ var options = {
+ date: _ev.target.dataset.date || this.options.date,
+ hour: _ev.target.dataset.hour || this.getParent().options.day_start,
+ minute: _ev.target.dataset.minute || 0,
+ owner: this.options.owner
+ };
+ app.calendar.add(options);
+ return false;
+ }
+ // Header, all day non-blocking
+ else if (this.header.has(_ev.target).length && !jQuery('.hiddenEventBefore',this.header).has(_ev.target).length ||
+ this.header.is(_ev.target)
+ )
+ {
+ // Click on the header, but not the title. That's an all-day non-blocking
+ const end = this.date.getFullYear() + '-' + (this.date.getUTCMonth() + 1) + '-' + this.date.getUTCDate() + 'T23:59';
+ let options = {
+ start: this.date.toJSON(),
+ end: end,
+ non_blocking: true,
+ owner: this.options.owner
+ };
+ app.calendar.add(options);
+ return false;
+ }
+ }
+ // Day label
+ else if(this.title.is(_ev.target) || this.title.has(_ev.target).length)
+ {
+ app.calendar.update_state({view: 'day',date: this.date.toJSON()});
+ return false;
+ }
+
+ }
+
+ /**
+ * Code for implementing et2_IDetachedDOM
+ *
+ * @param {array} _attrs array to add further attributes to
+ */
+ getDetachedAttributes( _attrs)
+ {
+
+ }
+
+ getDetachedNodes() {
+ return [this.getDOMNode(this)];
+ }
+
+ setDetachedAttributes( _nodes, _values)
+ {
+
+ }
+
+ // Resizable interface
+ /**
+ * Resize
+ *
+ * Parent takes care of setting proper width & height for the containing div
+ * here we just need to adjust the events to fit the new size.
+ */
+ resize ()
+ {
+ if(this.disabled || !this.div.is(':visible') || this.getParent().disabled)
+ {
+ return;
+ }
+
+ if(this.display_settings.granularity !== this.getParent().options.granularity)
+ {
+ // Layout has changed
+ this._draw();
+
+ // Resize & position all events
+ this.position_event();
+ }
+ else
+ {
+ // Don't need to resize & reposition, just clear some stuff
+ // to reset for _out_of_view()
+ this.iterateOver(function(widget) {
+ widget._small_size();
+ }, this, et2_calendar_event);
+ }
+ this._out_of_view();
+ }
+}
+et2_register_widget(et2_calendar_daycol, ["calendar-daycol"]);
diff --git a/calendar/js/et2_widget_event.ts b/calendar/js/et2_widget_event.ts
new file mode 100644
index 0000000000..c41f772cf5
--- /dev/null
+++ b/calendar/js/et2_widget_event.ts
@@ -0,0 +1,1317 @@
+/*
+ * Egroupware Calendar event widget
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package etemplate
+ * @subpackage api
+ * @link http://www.egroupware.org
+ * @author Nathan Gray
+ * @version $Id$
+ */
+
+
+/*egw:uses
+ /etemplate/js/et2_core_valueWidget;
+*/
+
+import {et2_register_widget, et2_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget";
+import {et2_valueWidget} from "../../api/js/etemplate/et2_core_valueWidget";
+import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance";
+import {et2_action_object_impl, et2_DOMWidget} from "../../api/js/etemplate/et2_core_DOMWidget";
+import {et2_calendar_daycol} from "./et2_widget_daycol";
+import {et2_calendar_planner_row} from "./et2_widget_planner_row";
+
+/**
+ * Class for a single event, displayed in either the timegrid or planner view
+ *
+ * It is possible to directly provide all information directly, but calendar
+ * uses egw.data for caching, so ID is all that is needed.
+ *
+ * Note that there are several pieces of information that have 'ID' in them:
+ * - row_id - used by both et2_calendar_event and the nextmatch to uniquely
+ * identify a particular entry or entry ocurrence
+ * - id - Recurring events may have their recurrence as a timestamp after their ID,
+ * such as '194:1453318200', or not. It's usually (always?) the same as row ID.
+ * - app_id - the ID according to the source application. For calendar, this
+ * is the same as ID (but always with the recurrence), for other apps this is
+ * usually just an integer. With app_id and app, you should be able to call
+ * egw.open() and get the specific entry.
+ * - Events from other apps will have their app name prepended to their ID, such
+ * as 'infolog123', so app_id and id will be different for these events
+ * - Cache ID is the same as other apps, and looks like 'calendar::'
+ * - The DOM ID for the containing div is event_
+ *
+ * Events are expected to be added to either et2_calendar_daycol or
+ * et2_calendar_planner_row rather than either et2_calendar_timegrid or
+ * et2_calendar_planner directly.
+ *
+ */
+export class et2_calendar_event extends et2_valueWidget implements et2_IDetachedDOM
+{
+
+ static readonly _attributes : any = {
+ "value": {
+ type: "any",
+ default: et2_no_init
+ },
+ "onclick": {
+ "description": "JS code which is executed when the element is clicked. " +
+ "If no handler is provided, or the handler returns true and the event is not read-only, the " +
+ "event will be opened according to calendar settings."
+ }
+ };
+ private div: JQuery;
+ private title: JQuery;
+ private body: JQuery;
+ private icons: JQuery;
+ private _need_actions_linked: boolean = false;
+ private _actionObject: egwActionObject;
+
+ /**
+ * Constructor
+ */
+ constructor(_parent, _attrs? : WidgetConfig, _child? : object)
+ {
+ // Call the inherited constructor
+ super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_event._attributes, _child || {}));
+
+ const event = this;
+
+ // Main container
+ this.div = jQuery(document.createElement("div"))
+ .addClass("calendar_calEvent")
+ .addClass(this.options.class)
+ .css('width',this.options.width)
+ .on('mouseenter', function() {
+ // Bind actions on first mouseover for faster creation
+ if(event._need_actions_linked)
+ {
+ event._copy_parent_actions();
+ }
+ // Tooltip
+ if(!event._tooltipElem)
+ {
+ event.options.statustext_html = true;
+ event.set_statustext(event._tooltip());
+ if(event.statustext)
+ {
+ return event.div.trigger('mouseenter');
+ }
+ }
+ // Hacky to remove egw's tooltip border and let the mouse in
+ window.setTimeout(function() {
+ jQuery('body .egw_tooltip')
+ .css('border','none')
+ .on('mouseenter', function() {
+ event.div.off('mouseleave.tooltip');
+ jQuery('body.egw_tooltip').remove();
+ jQuery('body').append(this);
+ jQuery(this).stop(true).fadeTo(400, 1)
+ .on('mouseleave', function() {
+ jQuery(this).fadeOut('400', function() {
+ jQuery(this).remove();
+ // Set up to work again
+ event.set_statustext(event._tooltip());
+ });
+ });
+ });
+
+ },105);
+ });
+ this.title = jQuery(document.createElement('div'))
+ .addClass("calendar_calEventHeader")
+ .appendTo(this.div);
+ this.body = jQuery(document.createElement('div'))
+ .addClass("calendar_calEventBody")
+ .appendTo(this.div);
+ this.icons = jQuery(document.createElement('div'))
+ .addClass("calendar_calEventIcons")
+ .appendTo(this.title);
+
+ this.setDOMNode(this.div[0]);
+ }
+
+ doLoadingFinished( )
+ {
+ super.doLoadingFinished();
+
+ // Already know what is needed to hook to cache
+ if(this.options.value && this.options.value.row_id)
+ {
+ egw.dataRegisterUID(
+ 'calendar::'+this.options.value.row_id,
+ this._UID_callback,
+ this,
+ this.getInstanceManager().execId,
+ this.id
+ );
+ }
+ return true;
+ }
+
+ destroy( )
+ {
+ super.destroy();
+
+ if(this._actionObject)
+ {
+ this._actionObject.remove();
+ this._actionObject = null;
+ }
+
+ this.div.off();
+ this.title.remove();
+ this.title = null;
+ this.body.remove();
+ this.body = null;
+ this.icons = null;
+ this.div.remove();
+ this.div = null;
+
+ jQuery('body.egw_tooltip').remove();
+
+ // Unregister, or we'll continue to be notified...
+ if(this.options.value)
+ {
+ const old_app_id = this.options.value.row_id;
+ egw.dataUnregisterUID('calendar::'+old_app_id,null,this);
+ }
+ }
+
+ set_value( _value)
+ {
+ // Un-register for updates
+ if(this.options.value)
+ {
+ var old_id = this.options.value.row_id;
+ if(!_value || !_value.row_id || old_id !== _value.row_id)
+ {
+ egw.dataUnregisterUID('calendar::'+old_id,null,this);
+ }
+ }
+ this.options.value = _value;
+
+ // Register for updates
+ const id = this.options.value.row_id;
+ if(!old_id || old_id !== id)
+ {
+ egw.dataRegisterUID('calendar::'+id, this._UID_callback ,this,this.getInstanceManager().execId,this.id);
+ }
+ if(_value && !egw.dataHasUID('calendar::'+id))
+ {
+ egw.dataStoreUID('calendar::'+id, _value);
+ }
+ }
+
+ /**
+ * Callback for changes in cached data
+ */
+ _UID_callback(event)
+ {
+ // Copy to avoid changes, which may cause nm problems
+ const value = event === null ? null : jQuery.extend({}, event);
+ let parent = this.getParent();
+
+ // Make sure id is a string, check values
+ if(value)
+ {
+ this._values_check(value);
+ }
+
+ // Check for changing days in the grid view
+ if(!this._sameday_check(value))
+ {
+ // May need to update parent to remove out-of-view events
+ parent.removeChild(this);
+ if(event === null && parent && parent.instanceOf(et2_calendar_daycol))
+ {
+ (parent)._out_of_view();
+ }
+
+ // This should now cease to exist, as new events have been created
+ this.destroy();
+ return;
+ }
+
+ // Copy to avoid changes, which may cause nm problems
+ this.options.value = jQuery.extend({},value);
+
+ if(this.getParent().options.date)
+ {
+ this.options.value.date = this.getParent().options.date;
+ }
+
+ // Let parent position - could also be et2_calendar_planner_row
+ (this.getParent()).position_event(this);
+
+ // Parent may remove this if the date isn't the same
+ if(this.getParent())
+ {
+ this._update();
+ }
+ }
+
+ /**
+ * Draw the event
+ */
+ _update( )
+ {
+
+ // Update to reflect new information
+ const event = this.options.value;
+
+ const id = event.row_id ? event.row_id : event.id + (event.recur_type ? ':' + event.recur_date : '');
+ const formatted_start = event.start.toJSON();
+
+ this.set_id('event_' + id);
+ if(this._actionObject)
+ {
+ this._actionObject.id = 'calendar::' + id;
+ }
+
+ this._need_actions_linked = !this.options.readonly;
+
+ // Make sure category stuff is there
+ // Fake it to use the cache / call - if already there, these will return
+ // immediately.
+ const im = this.getInstanceManager();
+ et2_selectbox.cat_options({
+ _type:'select-cat',
+ getInstanceManager: function() {return im;}
+ },
+ {application:event.app||'calendar'}
+ );
+
+ // Need cleaning? (DnD helper removes content)
+ // @ts-ignore
+ if(!this.div.has(this.title).length)
+ {
+ this.div
+ .empty()
+ .append(this.title)
+ .append(this.body);
+ }
+ if(!this.getParent().options.readonly && !this.options.readonly && this.div.droppable('instance'))
+ {
+ this.div
+ // Let timegrid always get the drag
+ .droppable('option','greedy',false);
+ }
+ // DOM nodes
+ this.div
+ // Set full day flag
+ .attr('data-full_day', event.whole_day)
+
+ // Put everything we need for basic interaction here, so it's available immediately
+ .attr('data-id', event.id)
+ .attr('data-app', event.app || 'calendar')
+ .attr('data-app_id', event.app_id)
+ .attr('data-start', formatted_start)
+ .attr('data-owner', event.owner)
+ .attr('data-recur_type', event.recur_type)
+ .attr('data-resize', event.whole_day ? 'WD' : '' + (event.recur_type ? 'S':''))
+ .attr('data-priority', event.priority)
+ // Remove any category classes
+ .removeClass(function(index, css) {
+ return (css.match (/(^|\s)cat_\S+/g) || []).join(' ');
+ })
+ // Remove any status classes
+ .removeClass(function(index, css) {
+ return (css.match(/calendar_calEvent\S+/g) || []).join(' ');
+ })
+ .removeClass('calendar_calEventSmall')
+ .addClass(event.class)
+ .toggleClass('calendar_calEventPrivate', typeof event.private !== 'undefined' && event.private);
+ this.options.class = event.class;
+ const status_class = this._status_class();
+
+ // Add category classes, if real categories are set
+ if(event.category && event.category != '0')
+ {
+ const cats = event.category.split(',');
+ for(let i = 0; i < cats.length; i++)
+ {
+ this.div.addClass('cat_' + cats[i]);
+ }
+ }
+
+ this.div.toggleClass('calendar_calEventUnknown', event.participants[egw.user('account_id')] ? event.participants[egw.user('account_id')][0] === 'U' : false);
+ this.div.addClass(status_class);
+
+ this.body.toggleClass('calendar_calEventBodySmall', event.whole_day_on_top || false);
+
+ // Header
+ const title = !event.is_private ? egw.htmlspecialchars(event['title']) : egw.lang('private');
+
+ this.title
+ .html(''+this._get_timespan(event) + '
')
+ .append(''+title+'');
+
+ // Colors - don't make them transparent if there is no color
+ // @ts-ignore
+ if(jQuery.Color("rgba(0,0,0,0)").toRgbaString() != jQuery.Color(this.div,'background-color').toRgbaString())
+ {
+ // Most statuses use colored borders
+ this.div.css('border-color',this.div.css('background-color') );
+ }
+
+ this.icons.appendTo(this.title)
+ .html(this._icons().join(''));
+
+ // Body
+ if(event.whole_day_on_top)
+ {
+ this.body.html(title);
+ }
+ else
+ {
+ // @ts-ignore
+ const start_time = jQuery.datepicker.formatTime(
+ egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
+ {
+ hour: event.start_m / 60,
+ minute: event.start_m % 60,
+ seconds: 0,
+ timezone: 0
+ },
+ {"ampm": (egw.preference("timeformat") === "12")}
+ ).trim();
+
+ this.body
+ .html(''+title+'')
+ .append(''+start_time + '');
+ if(this.options.value.description.trim())
+ {
+ this.body
+ .append(''+egw.htmlspecialchars(this.options.value.description)+'
');
+ }
+ }
+
+ // Clear tooltip for regeneration
+ this.set_statustext('');
+
+ // Height specific section
+ // This can take an unreasonable amount of time if parent is hidden
+ if(jQuery((this.getParent()).getDOMNode(this)).is(':visible'))
+ {
+ this._small_size();
+ }
+ }
+
+ /**
+ * Calculate display variants for when event is too short for full display
+ *
+ * Display is based on the number of visible lines, calculated off the header
+ * height:
+ * 1 - show just the event title, with ellipsis
+ * 2 - Show timespan and title, with ellipsis
+ * > 4 - Show description as well, truncated to fit
+ */
+ _small_size( )
+ {
+
+ if(this.options.value.whole_day_on_top) return;
+
+ // Skip for planner view, it's always small
+ if(this.getParent() && this.getParent().instanceOf(et2_calendar_planner_row)) return;
+
+ // Pre-calculation reset
+ this.div.removeClass('calendar_calEventSmall');
+ this.body.css('height', 'auto');
+
+ const line_height = parseFloat(this.div.css('line-height'));
+ let visible_lines = Math.floor(this.div.innerHeight() / line_height);
+
+ if(!this.title.height())
+ {
+ // Handle sizing while hidden, such as when calendar is not the active tab
+ visible_lines = Math.floor(egw.getHiddenDimensions(this.div).h / egw.getHiddenDimensions(this.title).h);
+ }
+ visible_lines = Math.max(1,visible_lines);
+
+ if(this.getParent() && this.getParent().instanceOf(et2_calendar_daycol))
+ {
+ this.div.toggleClass('calendar_calEventSmall',visible_lines < 4);
+ this.div
+ .attr('data-visible_lines', visible_lines);
+ }
+ else if (this.getParent() && this.getParent().instanceOf(et2_calendar_planner_row))
+ {
+ // Less than 8 hours is small
+ this.div.toggleClass('calendar_calEventSmall',this.options.value.end.valueOf() - this.options.value.start.valueOf() < 28800000);
+ }
+
+
+ if(this.body.height() > this.div.height() - this.title.height() && visible_lines >= 4)
+ {
+ this.body.css('height', Math.floor((visible_lines-1)*line_height - this.title.height()) + 'px');
+ }
+ else
+ {
+ this.body.css('height', '');
+ }
+ }
+
+ /**
+ * Examines the participants & returns CSS classname for status
+ *
+ * @returns {String}
+ */
+ _status_class( )
+ {
+ let status_class = 'calendar_calEventAllAccepted';
+ for(let id in this.options.value.participants)
+ {
+ let status = this.options.value.participants[id];
+
+ status = et2_calendar_event.split_status(status);
+
+ switch (status)
+ {
+ case 'A':
+ case '': // app without status
+ break;
+ case 'U':
+ status_class = 'calendar_calEventSomeUnknown';
+ return status_class; // break for
+ default:
+ status_class = 'calendar_calEventAllAnswered';
+ break;
+ }
+ }
+ return status_class;
+ }
+
+ /**
+ * Create tooltip shown on hover
+ *
+ * @return {String}
+ */
+ _tooltip( )
+ {
+ if(!this.div || !this.options.value || !this.options.value.app_id) return '';
+
+ const border = this.div.css('borderTopColor');
+ const bg_color = this.div.css('background-color');
+ const header_color = this.title.css('color');
+ const timespan = this._get_timespan(this.options.value);
+ const parent = this.getParent() instanceof et2_calendar_daycol ? (this.getParent()) : (this.getParent());
+
+ parent.date_helper.set_value(this.options.value.start.valueOf ? new Date(this.options.value.start) : this.options.value.start);
+ const start = parent.date_helper.input_date.val();
+ parent.date_helper.set_value(this.options.value.end.valueOf ? new Date(this.options.value.end) : this.options.value.end);
+ const end = parent.date_helper.input_date.val();
+
+ const times = !this.options.value.multiday ?
+ '' + this.egw().lang('Time') + ':' + timespan :
+ '' + this.egw().lang('Start') + ':' + start + ' ' +
+ '' + this.egw().lang('End') + ':' + end;
+ let cat_label = '';
+ if(this.options.value.category)
+ {
+ const cat = et2_createWidget('select-cat', {'readonly': true}, this);
+ cat.set_value(this.options.value.category);
+ let cat_label : (string | string[]) = this.options.value.category.indexOf(',') <= 0 ? cat.span.text() : [];
+ if(typeof cat_label != 'string')
+ {
+ cat.span.children().each(function() {
+ (cat_label).push(jQuery(this).text());
+ });
+ cat_label = cat_label.join(', ');
+ }
+ cat.destroy();
+ }
+ let participants = '';
+ if(this.options.value.participant_types[''])
+ {
+ participants += this.options.value.participant_types[''].join("
");
+ }
+ for(let type_name in this.options.value.participant_types)
+ {
+ if(type_name)
+ {
+ participants += '
'+type_name+':
';
+ participants += this.options.value.participant_types[type_name].join("
");
+ }
+ }
+
+ return '
';
+ }
+
+ /**
+ * Generate participant summary line
+ *
+ * @returns {String}
+ */
+ _participant_summary(participants)
+ {
+ if( Object.keys(this.options.value.participants).length < 2)
+ {
+ return '';
+ }
+
+ const participant_status = {A: 0, R: 0, T: 0, U: 0, D: 0};
+ const status_label = {A: 'accepted', R: 'rejected', T: 'tentative', U: 'unknown', D: 'delegated'};
+ const participant_summary = Object.keys(this.options.value.participants).length + ' ' + this.egw().lang('Participants') + ': ';
+ const status_totals = [];
+
+ for(let id in this.options.value.participants)
+ {
+ var status = this.options.value.participants[id].substr(0,1);
+ participant_status[status]++;
+ }
+ for(let status in participant_status)
+ {
+ if(participant_status[status] > 0)
+ {
+ status_totals.push(participant_status[status] + ' ' + this.egw().lang(status_label[status]));
+ }
+ }
+ return participant_summary + status_totals.join(', ');
+ }
+
+ /**
+ * Get actual icons from list
+ */
+ _icons( ) : string[]
+ {
+ const icons = [];
+
+ if(this.options.value.is_private)
+ {
+ // Hide everything
+ icons.push('');
+ }
+ else
+ {
+ if(this.options.value.app !== 'calendar')
+ {
+ icons.push('');
+ }
+ if(this.options.value.priority == 3)
+ {
+ icons.push('');
+ }
+ if(this.options.value.public == '0')
+ {
+ // Show private flag
+ icons.push('');
+ }
+ if(this.options.value['recur_type'])
+ {
+ icons.push('');
+ }
+ // icons for single user, multiple users or group(s) and resources
+ const single = '';
+ const multiple = '';
+ for(const uid in this.options.value['participants'])
+ {
+ // @ts-ignore
+ if(Object.keys(this.options.value.participants).length == 1 && !isNaN(uid))
+ {
+ icons.push(single);
+ break;
+ }
+ // @ts-ignore
+ if(!isNaN(uid) && icons.indexOf(multiple) === -1)
+ {
+ icons.push(multiple);
+ }
+ /*
+ * TODO: resource icons
+ elseif(!isset($icons[$uid[0]]) && isset($this->bo->resources[$uid[0]]) && isset($this->bo->resources[$uid[0]]['icon']))
+ {
+ $icons[$uid[0]] = html::image($this->bo->resources[$uid[0]]['app'],
+ ($this->bo->resources[$uid[0]]['icon'] ? $this->bo->resources[$uid[0]]['icon'] : 'navbar'),
+ lang($this->bo->resources[$uid[0]]['app']),
+ 'width="16px" height="16px"');
+ }
+ */
+ }
+
+ if(this.options.value.alarm && !jQuery.isEmptyObject(this.options.value.alarm) && !this.options.value.is_private)
+ {
+ icons.push('');
+ }
+ if(this.options.value.participants[egw.user('account_id')] && this.options.value.participants[egw.user('account_id')][0] == 'U')
+ {
+ icons.push('');
+ }
+ }
+
+ // Always include non-blocking, regardless of privacy
+ if(this.options.value.non_blocking)
+ {
+ icons.push('');
+ }
+ return icons;
+ }
+
+ /**
+ * Get a text representation of the timespan of the event. Either start
+ * - end, or 'all day'
+ *
+ * @param {Object} event Event to get the timespan for
+ * @param {number} event.start_m Event start, in minutes from midnight
+ * @param {number} event.end_m Event end, in minutes from midnight
+ *
+ * @return {string} Timespan
+ */
+ _get_timespan( event)
+ {
+ let timespan = '';
+ if (event['start_m'] === 0 && event['end_m'] >= 24*60-1)
+ {
+ if (event['end_m'] > 24*60)
+ {
+ // @ts-ignore
+ timespan = jQuery.datepicker.formatTime(
+ egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
+ {
+ hour: event.start_m / 60,
+ minute: event.start_m % 60,
+ seconds: 0,
+ timezone: 0
+ },
+ {"ampm": (egw.preference("timeformat") === "12")}
+ // @ts-ignore
+ ).trim()+' - '+jQuery.datepicker.formatTime(
+ egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
+ {
+ hour: event.end_m / 60,
+ minute: event.end_m % 60,
+ seconds: 0,
+ timezone: 0
+ },
+ {"ampm": (egw.preference("timeformat") === "12")}
+ ).trim();
+ }
+ else
+ {
+ timespan = this.egw().lang('Whole day');
+ }
+ }
+ else
+ {
+ let duration : string | number = event.multiday ?
+ (event.end - event.start) / 60000 :
+ (event.end_m - event.start_m);
+ duration = Math.floor(duration/60) + this.egw().lang('h')+(duration%60 ? duration%60 : '');
+
+ // @ts-ignore
+ timespan = jQuery.datepicker.formatTime(
+ egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
+ {
+ hour: event.start_m / 60,
+ minute: event.start_m % 60,
+ seconds: 0,
+ timezone: 0
+ },
+ {"ampm": (egw.preference("timeformat") === "12")}
+ ).trim();
+
+ // @ts-ignore
+ timespan += ' - ' + jQuery.datepicker.formatTime(
+ egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
+ {
+ hour: event.end_m / 60,
+ minute: event.end_m % 60,
+ seconds: 0,
+ timezone: 0
+ },
+ {"ampm": (egw.preference("timeformat") === "12")}
+ ).trim();
+
+ timespan += ': ' + duration;
+ }
+ return timespan;
+ }
+
+ /**
+ * Make sure event data has all proper values, and format them as expected
+ * @param {Object} event
+ */
+ _values_check(event)
+ {
+ // Make sure ID is a string
+ if(event.id)
+ {
+ event.id = ''+event.id;
+ }
+
+ // Parent might be a daycol or a planner_row
+ let parent = this.getParent();
+
+ // Use dates as objects
+ if(typeof event.start !== 'object')
+ {
+ parent.date_helper.set_value(event.start);
+ event.start = new Date(parent.date_helper.getValue());
+ }
+ if(typeof event.end !== 'object')
+ {
+ parent.date_helper.set_value(event.end);
+ event.end = new Date(parent.date_helper.getValue());
+ }
+
+ // We need minutes for durations
+ if(typeof event.start_m === 'undefined')
+ {
+ event.start_m = event.start.getUTCHours() * 60 + event.start.getUTCMinutes();
+ event.end_m = event.end.getUTCHours() * 60 + event.end.getUTCMinutes();
+ }
+ if(typeof event.multiday === 'undefined')
+ {
+ event.multiday = (event.start.getUTCFullYear() !== event.end.getUTCFullYear() ||
+ event.start.getUTCMonth() !== event.end.getUTCMonth() ||
+ event.start.getUTCDate() != event.end.getUTCDate());
+ }
+ if(!event.start.getUTCHours() && !event.start.getUTCMinutes() && event.end.getUTCHours() == 23 && event.end.getUTCMinutes() == 59)
+ {
+ event.whole_day_on_top = (event.non_blocking && event.non_blocking != '0');
+ }
+ }
+
+ /**
+ * Check to see if the provided event information is for the same date as
+ * what we're currently expecting, and that it has not been changed.
+ *
+ * If the date has changed, we adjust the associated daywise caches to move
+ * the event's ID to where it should be. This check allows us to be more
+ * directly reliant on the data cache, and less on any other control logic
+ * elsewhere first.
+ *
+ * @param {Object} event Map of event data from cache
+ * @param {string} event.date For non-recurring, single day events, this is
+ * the date the event is on.
+ * @param {string} event.start Start of the event (used for multi-day events)
+ * @param {string} event.end End of the event (used for multi-day events)
+ *
+ * @return {Boolean} Provided event data is for the same date
+ */
+ _sameday_check(event)
+ {
+ // Event somehow got orphaned, or deleted
+ if(!this.getParent() || event === null)
+ {
+ return false;
+ }
+
+ // Also check participants against owner
+ const owner_match = et2_calendar_event.owner_check(event, this.getParent());
+
+ // Simple, same day
+ if(owner_match && this.options.value.date && event.date == this.options.value.date)
+ {
+ return true;
+ }
+
+ // Multi-day non-recurring event spans days - date does not match
+ const event_start = new Date(event.start);
+ const event_end = new Date(event.end);
+ const parent = this.getParent();
+ if(owner_match && (parent instanceof et2_calendar_daycol) && parent.getDate() >= event_start && parent.getDate() <= event_end)
+ {
+ return true;
+ }
+
+ // Delete all old actions
+ if(this._actionObject)
+ {
+ this._actionObject.clear();
+ this._actionObject.unregisterActions();
+ this._actionObject = null;
+ }
+
+ // Update daywise caches
+ const new_cache_id = CalendarApp._daywise_cache_id(event.date, this.getParent().options.owner);
+ let new_daywise: any = egw.dataGetUIDdata(new_cache_id);
+ new_daywise = new_daywise && new_daywise.data ? new_daywise.data : [];
+ let old_cache_id = '';
+ if(this.options.value && this.options.value.date)
+ {
+ old_cache_id = CalendarApp._daywise_cache_id(this.options.value.date,parent.options.owner);
+ }
+
+ if(new_cache_id != old_cache_id)
+ {
+ let old_daywise: any = egw.dataGetUIDdata(old_cache_id);
+ old_daywise = old_daywise && old_daywise.data ? old_daywise.data : [];
+ old_daywise.splice(old_daywise.indexOf(this.options.value.row_id),1);
+ egw.dataStoreUID(old_cache_id,old_daywise);
+
+ if (new_daywise.indexOf(event.row_id) < 0)
+ {
+ new_daywise.push(event.row_id);
+ }
+ if(egw.dataHasUID(new_cache_id))
+ {
+ egw.dataStoreUID(new_cache_id,new_daywise);
+ }
+ }
+
+ return false;
+ }
+
+ attachToDOM()
+ {
+ let result = super.attachToDOM();
+
+ // Remove the binding for the click handler, unless there's something
+ // custom here.
+ if (!this.onclick)
+ {
+ jQuery(this.node).off("click");
+ }
+ return result;
+ }
+
+ /**
+ * Click handler calling custom handler set via onclick attribute to this.onclick.
+ * All other handling is done by the timegrid widget.
+ *
+ * @param {Event} _ev
+ * @returns {boolean}
+ */
+ click( _ev)
+ {
+ let result = true;
+ if(typeof this.onclick == 'function')
+ {
+ // Make sure function gets a reference to the widget, splice it in as 2. argument if not
+ const args = Array.prototype.slice.call(arguments);
+ if(args.indexOf(this) == -1) args.splice(1, 0, this);
+
+ result = this.onclick.apply(this, args);
+ }
+ return result;
+ }
+
+ /**
+ * Show the recur prompt for this event
+ *
+ * Calls et2_calendar_event.recur_prompt with this event's value.
+ *
+ * @param {et2_calendar_event~prompt_callback} callback
+ * @param {Object} [extra_data]
+ */
+ recur_prompt(callback, extra_data)
+ {
+ et2_calendar_event.recur_prompt(this.options.value,callback,extra_data);
+ }
+
+ /**
+ * Show the series split prompt for this event
+ *
+ * Calls et2_calendar_event.series_split_prompt with this event's value.
+ *
+ * @param {et2_calendar_event~prompt_callback} callback
+ */
+ series_split_prompt(callback)
+ {
+ et2_calendar_event.series_split_prompt(this.options.value,this.options.value.recur_date, callback);
+ }
+
+ /**
+ * Copy the actions set on the parent, apply them to self
+ *
+ * This can take a while to do, so we try to do it only when needed - on mouseover
+ */
+ _copy_parent_actions()
+ {
+ // Copy actions set in parent
+ if(!this.options.readonly && !this.getParent().options.readonly)
+ {
+ let action_parent : et2_widget = this;
+ while(action_parent != null && !action_parent.options.actions &&
+ !(action_parent instanceof et2_container)
+ )
+ {
+ action_parent = action_parent.getParent();
+ }
+ try {
+ this._link_actions(action_parent.options.actions||{});
+ this._need_actions_linked = false;
+ } catch (e) {
+ // something went wrong, but keep quiet about it
+ }
+ }
+ }
+
+ /**
+ * Link the actions to the DOM nodes / widget bits.
+ *
+ * @param {object} actions {ID: {attributes..}+} map of egw action information
+ */
+ _link_actions(actions)
+ {
+ if(!this._actionObject)
+ {
+ // Get the top level element - timegrid or so
+ var objectManager = this.getParent()._actionObject || this.getParent().getParent()._actionObject ||
+ egw_getAppObjectManager(true).getObjectById(this.getParent().getParent().getParent().id) || egw_getAppObjectManager(true);
+ this._actionObject = objectManager.getObjectById('calendar::'+this.options.value.row_id);
+ }
+
+ if (this._actionObject == null) {
+ // Add a new container to the object manager which will hold the widget
+ // objects
+ this._actionObject = objectManager.insertObject(false, new egwActionObject(
+ 'calendar::'+this.options.value.row_id, objectManager, et2_calendar_event.et2_event_action_object_impl(this,this.getDOMNode()),
+ this._actionManager || objectManager.manager.getActionById('calendar::'+this.options.value.row_id) || objectManager.manager
+ ));
+ }
+ else
+ {
+ this._actionObject.setAOI(et2_calendar_event.et2_event_action_object_impl(this, this.getDOMNode(this)));
+ }
+
+ // Delete all old objects
+ this._actionObject.clear();
+ this._actionObject.unregisterActions();
+
+ // Go over the widget & add links - this is where we decide which actions are
+ // 'allowed' for this widget at this time
+ const action_links = this._get_action_links(actions);
+ action_links.push('egw_link_drag');
+ action_links.push('egw_link_drop');
+ if(this._actionObject.parent.getActionLink('invite'))
+ {
+ action_links.push('invite');
+ }
+ this._actionObject.updateActionLinks(action_links);
+ }
+
+ /**
+ * Code for implementing et2_IDetachedDOM
+ *
+ * @param {array} _attrs array to add further attributes to
+ */
+ getDetachedAttributes( _attrs)
+ {
+
+ }
+
+ getDetachedNodes( )
+ {
+ return [this.getDOMNode()];
+ }
+
+ setDetachedAttributes( _nodes, _values)
+ {
+
+ }
+
+ // Static class stuff
+ /**
+ * Check event owner against a parent object
+ *
+ * As an event is edited, its participants may change. Also, as the state
+ * changes we may change which events are displayed and show the same event
+ * in several places for different users. Here we check the event participants
+ * against an owner value (which may be an array) to see if the event should be
+ * displayed or included.
+ *
+ * @param {Object} event - Event information
+ * @param {et2_widget_daycol|et2_widget_planner_row} parent - potential parent object
+ * that has an owner option
+ * @param {boolean} [owner_too] - Include the event owner in consideration, or only
+ * event participants
+ *
+ * @return {boolean} Should the event be displayed
+ */
+ static owner_check(event, parent, owner_too?)
+ {
+ let owner_match = true;
+ if(typeof owner_too === 'undefined' && app.calendar.state.status_filter)
+ {
+ owner_too = app.calendar.state.status_filter === 'owner';
+ }
+ let options : any = null;
+ if(app.calendar && app.calendar.sidebox_et2 && app.calendar.sidebox_et2.getWidgetById('owner'))
+ {
+ options = app.calendar.sidebox_et2.getWidgetById('owner').taglist.getSelection();
+ }
+ else
+ {
+ options = parent.getArrayMgr("sel_options").getRoot().getEntry('owner');
+ }
+ if(event.participants && typeof parent.options.owner != 'undefined' && parent.options.owner.length > 0)
+ {
+ var parent_owner = jQuery.extend([], typeof parent.options.owner !== 'object' ?
+ [parent.options.owner] :
+ parent.options.owner);
+ owner_match = false;
+ const length = parent_owner.length;
+ for(var i = 0; i < length; i++ )
+ {
+ // Handle groups & grouped resources like mailing lists, they won't match so
+ // we need the list - pull it from sidebox owner
+ if((isNaN(parent_owner[i]) || parent_owner[i] < 0) && options && typeof options.find == "function")
+ {
+ var resource = options.find(function(element) {return element.id == parent_owner[i];}) || {};
+ if(resource && resource.resources)
+ {
+ parent_owner.splice(i,1);
+ parent_owner = parent_owner.concat(resource.resources);
+
+ }
+ }
+ }
+ let participants = jQuery.extend([], Object.keys(event.participants));
+ for(var i = 0; i < participants.length; i++ )
+ {
+ const id = participants[i];
+ // Expand group invitations
+ if (parseInt(id) < 0)
+ {
+ // Add in groups, if we can get them from options, great
+ var resource;
+ if(options && options.find && (resource = options.find(function(element) {return element.id === id;})) && resource.resources)
+ {
+ participants = participants.concat(resource.resources);
+ }
+ else
+ {
+ // Add in groups, if we can get them (this is asynchronous)
+ egw.accountData(id,'account_id',true,function(members) {
+ participants = participants.concat(Object.keys(members));
+ }, this);
+ }
+ }
+ if(parent.options.owner == id ||
+ parent_owner.indexOf &&
+ parent_owner.indexOf(id) >= 0)
+ {
+ owner_match = true;
+ break;
+ }
+ }
+ }
+ if(owner_too && !owner_match)
+ {
+ owner_match = (parent.options.owner == event.owner ||
+ parent_owner.indexOf &&
+ parent_owner.indexOf(event.owner) >= 0);
+ }
+ return owner_match;
+ }
+
+ /**
+ * @callback et2_calendar_event~prompt_callback
+ * @param {string} button_id - One of ok, exception, series, single or cancel
+ * depending on which buttons are on the prompt
+ * @param {Object} event_data - Event information - whatever you passed in to
+ * the prompt.
+ */
+ /**
+ * Recur prompt
+ * If the event is recurring, asks the user if they want to edit the event as
+ * an exception, or change the whole series. Then the callback is called.
+ *
+ * If callback is not provided, egw.open() will be used to open an edit dialog.
+ *
+ * If you call this on a single (non-recurring) event, the callback will be
+ * executed immediately, with the passed button_id as 'single'.
+ *
+ * @param {Object} event_data - Event information
+ * @param {string} event_data.id - Unique ID for the event, possibly with a
+ * timestamp
+ * @param {string|Date} event_data.start - Start date/time for the event
+ * @param {number} event_data.recur_type - Recur type, or 0 for a non-recurring event
+ * @param {et2_calendar_event~prompt_callback} [callback] - Callback is
+ * called with the button (exception, series, single or cancel) and the event
+ * data.
+ * @param {Object} [extra_data] - Additional data passed to the callback, used
+ * as extra parameters for default callback
+ *
+ * @augments {et2_calendar_event}
+ */
+ public static recur_prompt(event_data, callback?, extra_data?)
+ {
+ let egw;
+ const edit_id = event_data.app_id;
+ const edit_date = event_data.start;
+
+ // seems window.opener somehow in certain conditions could be from different origin
+ // we try to catch the exception and in this case retrieve the egw object from current window.
+ try
+ {
+ egw = this.egw ? (typeof this.egw == 'function' ? this.egw() : this.egw) : window.opener && typeof window.opener.egw != 'undefined' ? window.opener.egw('calendar'):window.egw('calendar');
+ }
+ catch(e){
+ egw = window.egw('calendar');
+ }
+
+ const that = this;
+
+ const extra_params = extra_data && typeof extra_data == 'object' ? extra_data : {};
+ extra_params.date = edit_date.toJSON ? edit_date.toJSON() : edit_date;
+ if(typeof callback != 'function')
+ {
+ callback = function(_button_id)
+ {
+ switch(_button_id)
+ {
+ case 'exception':
+ extra_params.exception = '1';
+ egw.open(edit_id, event_data.app||'calendar', 'edit', extra_params);
+ break;
+ case 'series':
+ case 'single':
+ egw.open(edit_id, event_data.app||'calendar', 'edit', extra_params);
+ break;
+ case 'cancel':
+ default:
+ break;
+ }
+ };
+ }
+ if(parseInt(event_data.recur_type))
+ {
+ const buttons = [
+ {text: egw.lang("Edit exception"), id: "exception", class: "ui-priority-primary", "default": true},
+ {text: egw.lang("Edit series"), id: "series"},
+ {text: egw.lang("Cancel"), id: "cancel"}
+ ];
+ et2_dialog.show_dialog(
+ function(button_id) {callback.call(that, button_id, event_data);},
+ (!event_data.is_private ? event_data['title'] : egw.lang('private')) + "\n" +
+ egw.lang("Do you want to edit this event as an exception or the whole series?"),
+ egw.lang("This event is part of a series"), {}, buttons, et2_dialog.QUESTION_MESSAGE
+ );
+ }
+ else
+ {
+ callback.call(this,'single',event_data);
+ }
+ }
+
+ /**
+ * Split series prompt
+ *
+ * If the event is recurring and the user adjusts the time or duration, we may need
+ * to split the series, ending the current one and creating a new one with the changes.
+ * This prompts the user if they really want to do that.
+ *
+ * There is no default callback, and nothing happens if you call this on a
+ * single (non-recurring) event
+ *
+ * @param {Object} event_data - Event information
+ * @param {string} event_data.id - Unique ID for the event, possibly with a timestamp
+ * @param {string|Date} instance_date - The date of the edited instance of the event
+ * @param {et2_calendar_event~prompt_callback} callback - Callback is
+ * called with the button (ok or cancel) and the event data.
+ * @augments {et2_calendar_event}
+ */
+ public static series_split_prompt(event_data, instance_date, callback)
+ {
+ let egw;
+ // seems window.opener somehow in certian conditions could be from different origin
+ // we try to catch the exception and in this case retrieve the egw object from current window.
+ try {
+ egw = this.egw ? (typeof this.egw == 'function' ? this.egw() : this.egw) : window.opener && typeof window.opener.egw != 'undefined' ? window.opener.egw('calendar'):window.egw('calendar');
+ }
+ catch(e){
+ egw = window.egw('calendar');
+ }
+
+ const that = this;
+
+ if(typeof instance_date == 'string')
+ {
+ instance_date = new Date(instance_date);
+ }
+
+ // Check for modifying a series that started before today
+ const tempDate = new Date();
+ const today = new Date(tempDate.getFullYear(), tempDate.getMonth(), tempDate.getDate(), tempDate.getHours(), -tempDate.getTimezoneOffset(), tempDate.getSeconds());
+ const termination_date = instance_date < today ? egw.lang('today') : date(egw.preference('dateformat'), instance_date);
+
+ if(parseInt(event_data.recur_type))
+ {
+ et2_dialog.show_dialog(
+ function(button_id) {callback.call(that, button_id, event_data);},
+ (!event_data.is_private ? event_data['title'] : egw.lang('private')) + "\n" +
+ egw.lang("Do you really want to change the start of this series? If you do, the original series will be terminated as of %1 and a new series for the future reflecting your changes will be created.", termination_date),
+ egw.lang("This event is part of a series"), {}, et2_dialog.BUTTONS_OK_CANCEL , et2_dialog.WARNING_MESSAGE
+ );
+ }
+ }
+
+ public static drag_helper (event,ui) {
+ ui.helper.width(ui.width());
+ };
+
+ /**
+ * splits the combined status, quantity and role
+ *
+ * @param {string} status - combined value, O: status letter: U, T, A, R
+ * @param {int} [quantity] - quantity
+ * @param {string} [role]
+ * @return string status U, T, A or R, same as $status parameter on return
+ */
+ public static split_status (status,quantity?,role?)
+ {
+ quantity = 1;
+ role = 'REQ-PARTICIPANT';
+ //error_log(__METHOD__.__LINE__.array2string($status));
+ let matches = null;
+ if (typeof status === 'string' && status.length > 1)
+ {
+ matches = status.match(/^.([0-9]*)(.*)$/gi);
+ }
+ if(matches)
+ {
+ if (parseInt(matches[1]) > 0) quantity = parseInt(matches[1]);
+ if (matches[2]) role = matches[2];
+ status = status[0];
+ }
+ else if (status === true)
+ {
+ status = 'U';
+ }
+ return status;
+ }
+
+ /**
+ * The egw_action system requires an egwActionObjectInterface Interface implementation
+ * to tie actions to DOM nodes. I'm not sure if we need this.
+ *
+ * The class extension is different than the widgets
+ *
+ * @param {et2_DOMWidget} widget
+ * @param {Object} node
+ *
+ */
+ public static et2_event_action_object_impl(widget, node)
+ {
+ const aoi = new et2_action_object_impl(widget, node).getAOI();
+
+ // _outerCall may be used to determine, whether the state change has been
+ // evoked from the outside and the stateChangeCallback has to be called
+ // or not.
+ aoi.doSetState = function(_state, _outerCall) {
+ };
+
+ return aoi;
+ };
+
+}
+et2_register_widget(et2_calendar_event, ["calendar-event"]);
\ No newline at end of file
diff --git a/calendar/js/et2_widget_owner.ts b/calendar/js/et2_widget_owner.ts
new file mode 100644
index 0000000000..c2682150a5
--- /dev/null
+++ b/calendar/js/et2_widget_owner.ts
@@ -0,0 +1,142 @@
+/*
+ * Egroupware
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package
+ * @subpackage
+ * @link http://www.egroupware.org
+ * @author Nathan Gray
+ * @version $Id$
+ */
+
+
+/*egw:uses
+ et2_widget_taglist;
+*/
+
+import {et2_register_widget} from "../../api/js/etemplate/et2_core_widget";
+
+/**
+ * Tag list widget customised for calendar owner, which can be a user
+ * account or group, or an entry from almost any app, or an email address
+ *
+ * A cross between auto complete, selectbox and chosen multiselect
+ *
+ * Uses MagicSuggest library
+ * @see http://nicolasbize.github.io/magicsuggest/
+ * @augments et2_selectbox
+ */
+export class et2_calendar_owner extends et2_taglist_email
+{
+ static readonly _attributes = {
+ "autocomplete_url": {
+ "default": "calendar_owner_etemplate_widget::ajax_owner"
+ },
+ "autocomplete_params": {
+ "name": "Autocomplete parameters",
+ "type": "any",
+ "default": {},
+ "description": "Extra parameters passed to autocomplete URL. It should be a stringified JSON object."
+ },
+ allowFreeEntries: {
+ "default": false,
+ ignore: true
+ },
+ select_options: {
+ "type": "any",
+ "name": "Select options",
+ // Set to empty object to use selectbox's option finding
+ "default": {},
+ "description": "Internally used to hold the select options."
+ }
+ };
+
+ // Allows sub-widgets to override options to the library
+ lib_options = {
+ autoSelect: false,
+ groupBy: 'app',
+ minChars: 2,
+ selectFirst: true,
+ // This option will also expand when the selection is changed
+ // via code, which we do not want
+ //expandOnFocus: true
+ toggleOnClick: true
+ };
+
+
+ doLoadingFinished()
+ {
+ super.doLoadingFinished();
+
+ var widget = this;
+ // onChange fired when losing focus, which is different from normal
+ this._oldValue = this.taglist.getValue();
+
+ return true;
+ }
+
+ selectionRenderer(item)
+ {
+ if(this && this.options && this.options.allowFreeEntries)
+ {
+ return super.selectionRenderer(item);
+ }
+ else
+ {
+ var label = jQuery('').text(item.label);
+ if (item.class) label.addClass(item.class);
+ if (typeof item.title != 'undefined') label.attr('title', item.title);
+ if (typeof item.data != 'undefined') label.attr('data', item.data);
+ if (typeof item.icon != 'undefined')
+ {
+ var wrapper = jQuery('').addClass('et2_taglist_tags_icon_wrapper');
+ jQuery('
')
+ .addClass('et2_taglist_tags_icon')
+ .css({"background-image": "url("+(item.icon.match(/^(http|https|\/)/) ? item.icon : egw.image(item.icon, item.app))+")"})
+ .appendTo(wrapper);
+ label.appendTo(wrapper);
+ return wrapper;
+ }
+ return label;
+ }
+ }
+
+ getValue()
+ {
+ if(this.taglist == null) return null;
+ return this.taglist.getValue();
+ }
+
+ /**
+ * Override parent to handle our special additional data types (c#,r#,etc.) when they
+ * are not available client side.
+ *
+ * @param {string|string[]} _value array of selected owners, which can be a number,
+ * or a number prefixed with one character indicating the resource type.
+ */
+ set_value(_value)
+ {
+ super.set_value(_value);
+
+ // If parent didn't find a label, label will be the same as ID so we
+ // can find them that way
+ for(var i = 0; i < this.options.value.length; i++)
+ {
+ var value = this.options.value[i];
+ if(value.id == value.label)
+ {
+ // Proper label was not fount by parent - ask directly
+ egw.json('calendar_owner_etemplate_widget::ajax_owner',value.id,function(data) {
+ this.widget.options.value[this.i].label = data;
+ this.widget.set_value(this.widget.options.value);
+ }, this,true,{widget: this, i: i}).sendRequest();
+ }
+ }
+
+ if(this.taglist)
+ {
+ this.taglist.clear(true);
+ this.taglist.addToSelection(this.options.value,true);
+ }
+ }
+}
+et2_register_widget(et2_calendar_owner, ["calendar-owner"]);
\ No newline at end of file
diff --git a/calendar/js/et2_widget_planner.ts b/calendar/js/et2_widget_planner.ts
new file mode 100644
index 0000000000..bdfac4a1ae
--- /dev/null
+++ b/calendar/js/et2_widget_planner.ts
@@ -0,0 +1,2500 @@
+/*
+ * Egroupware Calendar timegrid
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package etemplate
+ * @subpackage api
+ * @link http://www.egroupware.org
+ * @author Nathan Gray
+ * @version $Id$
+ */
+
+
+/*egw:uses
+ /calendar/js/et2_widget_view.js;
+ /calendar/js/et2_widget_planner_row.js;
+ /calendar/js/et2_widget_event.js;
+*/
+
+import {et2_register_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget";
+import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance";
+import {et2_calendar_view} from "./et2_widget_view";
+import {et2_action_object_impl} from "../../api/js/etemplate/et2_core_DOMWidget";
+import {et2_calendar_event} from "./et2_widget_event";
+import {et2_calendar_planner_row} from "./et2_widget_planner_row";
+
+/**
+ * Class which implements the "calendar-planner" XET-Tag for displaying a longer
+ * ( > 10 days) span of time. Events can be grouped into rows by either user,
+ * category, or month. Their horizontal position and size in the row is determined
+ * by their start date and duration relative to the displayed date range.
+ *
+ * @augments et2_calendar_view
+ */
+export class et2_calendar_planner extends et2_calendar_view implements et2_IDetachedDOM, et2_IResizeable, et2_IPrint
+{
+ static readonly _attributes: any = {
+ group_by: {
+ name: "Group by",
+ type: "string", // or category ID
+ default: "0",
+ description: "Display planner by 'user', 'month', or the given category"
+ },
+ filter: {
+ name: "Filter",
+ type: "string",
+ default: '',
+ description: 'A filter that is used to select events. It is passed along when events are queried.'
+ },
+ show_weekend: {
+ name: "Weekends",
+ type: "boolean",
+ default: egw.preference('days_in_weekview','calendar') != 5,
+ description: "Display weekends. The date range should still include them for proper scrolling, but they just won't be shown."
+ },
+ hide_empty: {
+ name: "Hide empty rows",
+ type: "boolean",
+ default: false,
+ description: "Hide rows with no events."
+ },
+ value: {
+ type: "any",
+ description: "A list of events, optionally you can set start_date, end_date and group_by as keys and events will be fetched"
+ },
+ "onchange": {
+ "name": "onchange",
+ "type": "js",
+ "default": et2_no_init,
+ "description": "JS code which is executed when the date range changes."
+ },
+ "onevent_change": {
+ "name": "onevent_change",
+ "type": "js",
+ "default": et2_no_init,
+ "description": "JS code which is executed when an event changes."
+ }
+ };
+
+ public static readonly DEFERRED_ROW_TIME: number = 100;
+
+ private gridHeader: JQuery;
+ private headerTitle: JQuery;
+ private headers: JQuery;
+ private rows: JQuery;
+ private grid: JQuery;
+ private vertical_bar: JQuery;
+
+ private doInvalidate: boolean;
+
+ private registeredCallbacks: any[];
+ private cache: {};
+ private _deferred_row_updates: {};
+ private grouper: any;
+
+ /**
+ * Constructor
+ */
+ constructor(_parent, _attrs? : WidgetConfig, _child? : object)
+ {
+ // Call the inherited constructor
+ super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_planner._attributes, _child || {}));
+
+
+ // Main container
+ this.div = jQuery(document.createElement("div"))
+ .addClass("calendar_plannerWidget");
+
+ // Header
+ this.gridHeader = jQuery(document.createElement("div"))
+ .addClass("calendar_plannerHeader")
+ .appendTo(this.div);
+ this.headerTitle = jQuery(document.createElement("div"))
+ .addClass("calendar_plannerHeaderTitle")
+ .appendTo(this.gridHeader);
+ this.headers = jQuery(document.createElement("div"))
+ .addClass("calendar_plannerHeaderRows")
+ .appendTo(this.gridHeader);
+
+ this.rows = jQuery(document.createElement("div"))
+ .addClass("calendar_plannerRows")
+ .appendTo(this.div);
+ this.grid = jQuery(document.createElement("div"))
+ .addClass("calendar_plannerGrid")
+ .appendTo(this.div);
+
+ this.vertical_bar = jQuery(document.createElement("div"))
+ .addClass('verticalBar')
+ .appendTo(this.div);
+
+ this.value = [];
+
+ // Update timer, to avoid redrawing twice when changing start & end date
+ this.update_timer = null;
+ this.doInvalidate = true;
+
+ this.setDOMNode(this.div[0]);
+
+ this.registeredCallbacks = [];
+ this.cache = {};
+ this._deferred_row_updates = {};
+ }
+
+ destroy( )
+ {
+ super.destroy();
+
+ this.div.off();
+
+ for(var i = 0; i < this.registeredCallbacks.length; i++)
+ {
+ egw.dataUnregisterUID(this.registeredCallbacks[i],null,this);
+ }
+ }
+
+ doLoadingFinished( )
+ {
+ super.doLoadingFinished();
+
+ // Don't bother to draw anything if there's no date yet
+ if(this.options.start_date)
+ {
+ this._drawGrid();
+ }
+
+ // Automatically bind drag and resize for every event using jQuery directly
+ // - no action system -
+ var planner = this;
+
+ this.cache = {};
+ this._deferred_row_updates = {};
+
+ /**
+ * If user puts the mouse over an event, then we'll set up resizing so
+ * they can adjust the length. Should be a little better on resources
+ * than binding it for every calendar event.
+ */
+ this.div.on('mouseover', '.calendar_calEvent:not(.ui-resizable):not(.rowNoEdit)', function() {
+ // Load the event
+ planner._get_event_info(this);
+ var that = this;
+
+ //Resizable event handler
+ jQuery(this).resizable
+ ({
+ distance: 10,
+ grid: [5, 10000],
+ autoHide: false,
+ handles: 'e',
+ containment:'parent',
+
+ /**
+ * Triggered when the resizable is created.
+ *
+ * @param {event} event
+ * @param {Object} ui
+ */
+ create:function(event, ui)
+ {
+ var resizeHelper = event.target.getAttribute('data-resize');
+ if (resizeHelper == 'WD' || resizeHelper == 'WDS')
+ {
+ jQuery(this).resizable('destroy');
+ }
+ },
+
+ /**
+ * If dragging to resize an event, abort drag to create
+ *
+ * @param {jQuery.Event} event
+ * @param {Object} ui
+ */
+ start: function(event, ui)
+ {
+ if(planner.drag_create.start)
+ {
+ // Abort drag to create, we're dragging to resize
+ planner._drag_create_end({});
+ }
+ },
+
+ /**
+ * Triggered at the end of resizing the calEvent.
+ *
+ * @param {event} event
+ * @param {Object} ui
+ */
+ stop:function(event, ui)
+ {
+ var e = new jQuery.Event('change');
+ e.originalEvent = event;
+ e.data = {duration: 0};
+ var event_data = planner._get_event_info(this);
+ var event_widget = planner.getWidgetById(event_data.widget_id);
+ var sT = event_widget.options.value.start_m;
+ if (typeof this.dropEnd != 'undefined')
+ {
+ var eT = parseInt(this.dropEnd.getUTCHours() * 60) + parseInt(this.dropEnd.getUTCMinutes());
+ e.data.duration = ((eT - sT)/60) * 3600;
+
+ if(event_widget)
+ {
+ event_widget.options.value.end_m = eT;
+ event_widget.options.value.duration = e.data.duration;
+ }
+
+ // Leave the helper there until the update is done
+ var loading = ui.helper.clone().appendTo(ui.helper.parent());
+
+ // and add a loading icon so user knows something is happening
+ jQuery('.calendar_timeDemo',loading).after('
');
+
+ jQuery(this).trigger(e);
+
+ // That cleared the resize handles, so remove for re-creation...
+ jQuery(this).resizable('destroy');
+
+ // Remove loading, done or not
+ loading.remove();
+ }
+ // Clear the helper, re-draw
+ if(event_widget)
+ {
+ (
event_widget.getParent()).position_event(event_widget);
+ }
+ },
+
+ /**
+ * Triggered during the resize, on the drag of the resize handler
+ *
+ * @param {event} event
+ * @param {Object} ui
+ */
+ resize:function(event, ui)
+ {
+ let position;
+ if(planner.options.group_by == 'month')
+ {
+ position = {left: event.clientX, top: event.clientY};
+ }
+ else
+ {
+ position = {top: ui.position.top, left: ui.position.left + ui.helper.width()};
+ }
+ planner._drag_helper(this,position,ui.helper.outerHeight());
+ }
+ });
+ })
+ .on('mousemove', function(event) {
+ // Ignore headers
+ if(planner.headers.has(event.target).length !== 0)
+ {
+ planner.vertical_bar.hide();
+ return;
+ }
+ // Position bar by mouse
+ planner.vertical_bar.position({
+ my: 'right-1',
+ of: event,
+ collision: 'fit'
+ });
+ planner.vertical_bar.css('top','0px');
+
+ // Get time at mouse
+ if(jQuery(event.target).closest('.calendar_eventRows').length == 0)
+ {
+ // "Invalid" times, from space after the last planner row, or header
+ var time = planner._get_time_from_position(event.pageX - planner.grid.offset().left, 10);
+ }
+ else if(planner.options.group_by == 'month')
+ {
+ var time = planner._get_time_from_position(event.clientX, event.clientY);
+ }
+ else
+ {
+ var time = planner._get_time_from_position(event.offsetX, event.offsetY);
+ }
+ // Passing to formatter, cancel out timezone
+ if(time)
+ {
+ const formatDate = new Date(time.valueOf() + time.getTimezoneOffset() * 60 * 1000);
+ planner.vertical_bar
+ .html(''+date(egw.preference('timeformat','calendar') == 12 ? 'h:ia' : 'H:i',formatDate)+'')
+ .show();
+
+ if(planner.drag_create.event && planner.drag_create.parent && planner.drag_create.end)
+ {
+
+ planner.drag_create.end.date = time.toJSON();
+ planner._drag_update_event();
+ }
+ }
+ else
+ {
+ // No (valid) time, just hide
+ planner.vertical_bar.hide();
+ }
+ })
+ .on('mousedown', jQuery.proxy(this._mouse_down, this))
+ .on('mouseup', jQuery.proxy(this._mouse_up, this));
+
+ // Actions may be set on a parent, so we need to explicitly get in here
+ // and get ours
+ this._link_actions(this.options.actions || this.getParent().options.actions || []);
+
+ // Customize and override some draggable settings
+ this.div.on('dragcreate','.calendar_calEvent', function(event, ui) {
+ jQuery(this).draggable('option','cancel','.rowNoEdit');
+ // Act like you clicked the header, makes it easier to position
+ jQuery(this).draggable('option','cursorAt', {top: 5, left: 5});
+ })
+ .on('dragstart', '.calendar_calEvent', function(event,ui) {
+ jQuery('.calendar_calEvent',ui.helper).width(jQuery(this).width())
+ .height(jQuery(this).outerHeight())
+ .css('top', '').css('left','')
+ .appendTo(ui.helper);
+ ui.helper.width(jQuery(this).width());
+
+ // Cancel drag to create, we're dragging an existing event
+ planner._drag_create_end();
+ });
+ return true;
+ }
+
+ _createNamespace() {
+ return true;
+ }
+
+ /**
+ * These handle the differences between the different group types.
+ * They provide the different titles, labels and grouping
+ */
+ groupers = {
+ // Group by user has one row for each user
+ user :
+ {
+ // Title in top left corner
+ title: function() : string
+ {
+ return this.egw().lang('User');
+ },
+ // Column headers
+ headers: function()
+ {
+ var start = new Date(this.options.start_date);
+ var end = new Date(this.options.end_date);
+ var start_date = new Date(start.getUTCFullYear(), start.getUTCMonth(),start.getUTCDate());
+ var end_date = new Date(end.getUTCFullYear(), end.getUTCMonth(),end.getUTCDate());
+ var day_count = Math.round((end_date - start_date) /(1000*3600*24))+1;
+ if(day_count >= 6)
+ {
+ this.headers.append(this._header_months(start, day_count));
+ }
+ if(day_count < 120)
+ {
+ var weeks = this._header_weeks(start, day_count);
+ this.headers.append(weeks);
+ this.grid.append(weeks);
+ }
+ if(day_count < 60)
+ {
+ var days = this._header_days(start, day_count);
+ this.headers.append(days);
+ this.grid.append(days);
+ }
+ if(day_count <= 7)
+ {
+ var hours = this._header_hours(start, day_count);
+ this.headers.append(hours);
+ this.grid.append(hours);
+ }
+ },
+ // Labels for the rows
+ row_labels: function() {
+ var labels = [];
+ var already_added = [];
+ var options = false;
+ var resource = null;
+ if(app.calendar && app.calendar.sidebox_et2 && app.calendar.sidebox_et2.getWidgetById('owner'))
+ {
+ options = app.calendar.sidebox_et2.getWidgetById('owner').taglist.getSelection();
+ }
+ else
+ {
+ options = this.getArrayMgr("sel_options").getRoot().getEntry('owner');
+ }
+ for(var i = 0; i < this.options.owner.length; i++)
+ {
+ var user = this.options.owner[i];
+ // Handle grouped resources like mailing lists - pull it from sidebox owner
+ // and expand to their contents
+ if(options && options.find &&
+ ((resource = options.find(function(element) {return element.id == user;}) || {}) || isNaN(user)))
+ {
+ if(resource && resource.resources)
+ {
+ for(var j = 0; j < resource.resources.length; j++)
+ {
+ var id = resource.resources[j];
+ if(already_added.indexOf(''+id) < 0)
+ {
+ labels.push({
+ id: id,
+ label: this._get_owner_name(id)||'',
+ data: {participants:id,owner:id}
+ });
+ already_added.push(''+id);
+ }
+ }
+ }
+ else if(already_added.indexOf(''+user) < 0)
+ {
+ labels.push({
+ id: user,
+ label: this._get_owner_name(user),
+ data: {participants:user,owner:user}
+ });
+ already_added.push(''+user);
+ }
+ }
+ else if (user < 0) // groups
+ {
+ egw.accountData(parseInt(user),'account_fullname',true,function(result) {
+ for(var id in result)
+ {
+ if(already_added.indexOf(''+id) < 0)
+ {
+ this.push({id: id, label: result[id]||'', data: {participants:id,owner:id}});
+ already_added.push(''+id);
+ }
+ }
+ },labels);
+ }
+ else // users
+ {
+ if(already_added.indexOf(user) < 0)
+ {
+ var label = this._get_owner_name(user)||'';
+ labels.push({id: user, label: label, data: {participants:user,owner:''}});
+ already_added.push(''+user);
+ }
+ }
+ }
+
+ return labels.sort(function(a,b) {
+ return a.label.localeCompare(b.label);
+ });
+ },
+ // Group the events into the rows
+ group: function(labels, rows, event) {
+ // convert filter to allowed status
+ var status_to_show = ['U','A','T','D','G'];
+ switch(this.options.filter)
+ {
+ case 'unknown':
+ status_to_show = ['U','G']; break;
+ case 'accepted':
+ status_to_show = ['A']; break;
+ case 'tentative':
+ status_to_show = ['T']; break;
+ case 'rejected':
+ status_to_show = ['R']; break;
+ case 'delegated':
+ status_to_show = ['D']; break;
+ case 'all':
+ status_to_show = ['U','A','T','D','G','R']; break;
+ default:
+ status_to_show = ['U','A','T','D','G']; break;
+ }
+ var participants = event.participants;
+ var add_row = function(user, participant) {
+ var label_index = false;
+ for(var i = 0; i < labels.length; i++)
+ {
+ if(labels[i].id == user)
+ {
+ label_index = i;
+ break;
+ }
+ }
+ if(participant && label_index !== false && status_to_show.indexOf(participant.substr(0,1)) >= 0 ||
+ !participant && label_index !== false ||
+ this.options.filter === 'owner' && event.owner === user)
+ {
+ if(typeof rows[label_index] === 'undefined')
+ {
+ rows[label_index] = [];
+ }
+ rows[label_index].push(event);
+ }
+ };
+ for(var user in participants)
+ {
+ var participant = participants[user];
+ if (parseInt(user) < 0) // groups
+ {
+ var planner = this;
+ egw.accountData(user,'account_fullname',true,function(result) {
+ for(var id in result)
+ {
+ if(!participants[id]) add_row.call(planner,id,participant);
+ }
+ },labels);
+ continue;
+ }
+ add_row.call(this, user, participant);
+ }
+ },
+ // Draw a single row
+ draw_row: function(sort_key, label, events) {
+ var row = this._drawRow(sort_key, label,events,this.options.start_date, this.options.end_date);
+ if(this.options.hide_empty && !events.length)
+ {
+ row.set_disabled(true);
+ }
+ // Highlight current user, sort_key is account_id
+ if(sort_key === egw.user('account_id'))
+ {
+ row.set_class('current_user')
+ }
+ // Since the daywise cache is by user, we can tap in here
+ var t = new Date(this.options.start_date);
+ var end = new Date(this.options.end_date);
+ do
+ {
+ var cache_id = CalendarApp._daywise_cache_id(t, sort_key);
+ egw.dataRegisterUID(cache_id, row._data_callback, row);
+
+ t.setUTCDate(t.getUTCDate() + 1);
+ }
+ while(t < end);
+ return row;
+ }
+ },
+
+ // Group by month has one row for each month
+ month:
+ {
+ title: function() { return this.egw().lang('Month');},
+ headers: function() {
+ this.headers.append(this._header_day_of_month());
+ },
+ row_labels: function() {
+ var labels = [];
+ var d = new Date(this.options.start_date);
+ d = new Date(d.valueOf() + d.getTimezoneOffset() * 60 * 1000);
+ for(var i = 0; i < 12; i++)
+ {
+ // Not using UTC because we corrected for timezone offset
+ labels.push({id:sprintf('%04d-%02d', d.getFullYear(), d.getMonth()), label:this.egw().lang(date('F',d))+' '+d.getFullYear()});
+ d.setMonth(d.getMonth()+1);
+ }
+ return labels;
+ },
+ group: function(labels, rows,event) {
+ // Yearly planner does not show infologs
+ if(event && event.app && event.app == 'infolog') return;
+
+ var start = new Date(event.start);
+ start = new Date(start.valueOf() + start.getTimezoneOffset() * 60 * 1000);
+ var key = sprintf('%04d-%02d', start.getFullYear(), start.getMonth());
+ var label_index = false;
+ for(var i = 0; i < labels.length; i++)
+ {
+ if(labels[i].id == key)
+ {
+ label_index = i;
+ break;
+ }
+ }
+ if(typeof rows[label_index] === 'undefined')
+ {
+ rows[label_index] = [];
+ }
+ rows[label_index].push(event);
+
+ // end in a different month?
+ var end = new Date(event.end);
+ end = new Date(end.valueOf() + end.getTimezoneOffset() * 60 * 1000);
+ var end_key = sprintf('%04d-%02d',end.getFullYear(),end.getMonth());
+ var year = start.getFullYear();
+ var month = start.getMonth();
+ key = sprintf('%04d-%02d',year,month);
+
+ do
+ {
+ var end_label_index = label_index;
+
+ for(var i = end_label_index; i < labels.length; i++)
+ {
+ if(labels[i].id == key)
+ {
+ end_label_index = i;
+ if(typeof rows[end_label_index] === 'undefined')
+ {
+ rows[end_label_index] = [];
+ }
+ break;
+ }
+ }
+ if(end_label_index != label_index)
+ {
+ rows[label_index].push(event);
+ }
+ if (++month > 11)
+ {
+ ++year;
+ month = 0;
+ }
+ key = sprintf('%04d-%02d',year,month);
+ } while(key <= end_key)
+ },
+ // Draw a single row, but split up the dates
+ draw_row: function(sort_key, label, events)
+ {
+ var key = sort_key.split('-');
+ var start = new Date(key[0]+"-"+sprintf("%02d",parseInt(key[1])+1)+"-01T00:00:00Z");
+ // Use some care to avoid issues with timezones and daylight savings
+ var end = new Date(start);
+ end.setUTCMonth(start.getUTCMonth() + 1);
+ end.setUTCDate(1);
+ end.setUTCHours(0);
+ end.setUTCMinutes(0);
+ end = new Date(end.valueOf() - 1000);
+ end.setUTCMonth(start.getUTCMonth());
+ this._drawRow(sort_key, label, events, start, end);
+ }
+ },
+ // Group by category has one row for each [sub]category
+ category:
+ {
+ title: function() { return this.egw().lang('Category');},
+ headers: function() {
+ var start = new Date(this.options.start_date);
+ var end = new Date(this.options.end_date);
+ var start_date = new Date(start.getUTCFullYear(), start.getUTCMonth(),start.getUTCDate());
+ var end_date = new Date(end.getUTCFullYear(), end.getUTCMonth(),end.getUTCDate());
+ var day_count = Math.round((end_date - start_date) /(1000*3600*24))+1;
+
+ if(day_count >= 6)
+ {
+ this.headers.append(this._header_months(start, day_count));
+ }
+ if(day_count < 120)
+ {
+ var weeks = this._header_weeks(start, day_count);
+ this.headers.append(weeks);
+ this.grid.append(weeks);
+ }
+ if(day_count < 60)
+ {
+ var days = this._header_days(start, day_count);
+ this.headers.append(days);
+ this.grid.append(days);
+ }
+ if(day_count <= 7)
+ {
+ var hours = this._header_hours(start, day_count);
+ this.headers.append(hours);
+ this.grid.append(hours);
+ }
+ },
+ row_labels: function() {
+ var im = this.getInstanceManager();
+ var categories = et2_selectbox.cat_options({
+ _type:'select-cat',
+ getInstanceManager: function() {return im;}
+ },{application: 'calendar'});
+
+ var labels = [];
+ if(!app.calendar.state.cat_id ||
+ app.calendar.state.cat_id.toString() === '' ||
+ app.calendar.state.cat_id.toString() == '0'
+ )
+ {
+ app.calendar.state.cat_id = '';
+ labels.push({id:'',value:'',label: egw.lang('none'), main: '', data: {}});
+ labels = labels.concat(categories);
+ }
+ else
+ {
+ var cat_id = app.calendar.state.cat_id;
+ if(typeof cat_id == 'string')
+ {
+ cat_id = cat_id.split(',');
+ }
+ for(var i = 0; i < cat_id.length; i++)
+ {
+ // Find label for that category
+ for(var j = 0; j < categories.length; j++)
+ {
+ if(categories[j].value == cat_id[i])
+ {
+ categories[j].id = categories[j].value;
+ labels.push(categories[j]);
+ break;
+ }
+ }
+
+ // Get its children immediately
+ egw.json(
+ 'EGroupware\\Api\\Etemplate\\Widget\\Select::ajax_get_options',
+ ['select-cat',',,,calendar,'+cat_id[i]],
+ function(data) {
+ labels = labels.concat(data);
+ }
+ ).sendRequest(false);
+ }
+ }
+
+ for(var i = labels.length -1; i >= 0; i--)
+ {
+ labels[i].id = labels[i].value;
+ labels[i].data = {
+ cat_id: labels[i].id,
+ main: labels[i].value==labels[i].main
+ };
+ if(labels[i].children && labels[i].children.length)
+ {
+ labels[i].data.has_children = true;
+ }
+ }
+ return labels;
+ },
+ group: function(labels, rows, event) {
+ var cats = event.category;
+ if(typeof event.category === 'string')
+ {
+ cats = cats.split(',');
+ }
+ for(var cat = 0; cat < cats.length; cat++)
+ {
+ var label_index = false;
+ var category = cats[cat] ? parseInt(cats[cat],10) : false;
+ if(category == 0 || !category) category = '';
+ for(var i = 0; i < labels.length; i++)
+ {
+ if(labels[i].id == category)
+ {
+ // If there's no cat filter, only show the top level
+ if(!app.calendar.state.cat_id)
+ {
+ for(var j = 0; j < labels.length; j++)
+ {
+ if(labels[j].id == labels[i].main)
+ {
+ label_index = j;
+ break;
+ }
+ }
+ break;
+ }
+ label_index = i;
+ break;
+ }
+ }
+ if(label_index !== false && typeof rows[label_index] === 'undefined')
+ {
+ rows[label_index] = [];
+ }
+ if(label_index !== false && rows[label_index].indexOf(event) === -1)
+ {
+ rows[label_index].push(event);
+ }
+ }
+ },
+ draw_row: function(sort_key, label, events) {
+ var row = this._drawRow(sort_key, label,events,this.options.start_date, this.options.end_date);
+ if(this.options.hide_empty && !events.length)
+ {
+ row.set_disabled(true);
+ }
+ return row;
+ }
+ }
+ };
+
+ /**
+ * Something changed, and the planner needs to be re-drawn. We wait a bit to
+ * avoid re-drawing twice if start and end date both changed, then recreate.
+ *
+ * @param {boolean} trigger =false Trigger an event once things are done.
+ * Waiting until invalidate completes prevents 2 updates when changing the date range.
+ * @returns {undefined}
+ */
+ invalidate( trigger?)
+ {
+
+ // Busy
+ if(!this.doInvalidate) return;
+
+ // Not yet ready
+ if(!this.options.start_date || !this.options.end_date) return;
+
+ // Wait a bit to see if anything else changes, then re-draw the days
+ if(this.update_timer !== null)
+ {
+ window.clearTimeout(this.update_timer);
+ }
+ this.update_timer = window.setTimeout(jQuery.proxy(function() {
+ this.widget.doInvalidate = false;
+
+ // Show AJAX loader
+ this.widget.loader.show();
+
+ this.widget.cache = {};
+ this._deferred_row_updates = {};
+
+ this.widget._fetch_data();
+
+ this.widget._drawGrid();
+
+ if(this.trigger)
+ {
+ this.widget.change();
+ }
+ this.widget.update_timer = null;
+ this.widget.doInvalidate = true;
+
+ window.setTimeout(jQuery.proxy(function() {if(this.loader) this.loader.hide();},this.widget),500);
+ },{widget:this,"trigger":trigger}),et2_dataview_grid.ET2_GRID_INVALIDATE_TIMEOUT);
+ }
+
+ detachFromDOM()
+ {
+ // Remove the binding to the change handler
+ jQuery(this.div).off("change.et2_calendar_timegrid");
+
+ return super.detachFromDOM();
+ }
+
+ attachToDOM( )
+ {
+ let result = super.attachToDOM();
+
+ // Add the binding for the event change handler
+ jQuery(this.div).on("change.et2_calendar_timegrid", '.calendar_calEvent', this, function(e) {
+ // Make sure function gets a reference to the widget
+ var args = Array.prototype.slice.call(arguments);
+ if(args.indexOf(this) == -1) args.push(this);
+
+ return e.data.event_change.apply(e.data, args);
+ });
+
+ // Add the binding for the change handler
+ jQuery(this.div).on("change.et2_calendar_timegrid", '*:not(.calendar_calEvent)', this, function(e) {
+ return e.data.change.call(e.data, e, this);
+ });
+
+ return result;
+ }
+
+ getDOMNode(_sender)
+ {
+ if(_sender === this || !_sender)
+ {
+ return this.div[0];
+ }
+ if(_sender._parent === this)
+ {
+ return this.rows[0];
+ }
+ }
+
+ /**
+ * Creates all the DOM nodes for the planner grid
+ *
+ * Any existing nodes (& children) are removed, the headers & labels are
+ * determined according to the current group_by value, and then the rows
+ * are created.
+ *
+ * @method
+ * @private
+ *
+ */
+ _drawGrid()
+ {
+
+ this.div.css('height', this.options.height);
+
+ // Clear old events
+ var delete_index = this._children.length - 1;
+ while(this._children.length > 0 && delete_index >= 0)
+ {
+ this._children[delete_index].destroy();
+ this.removeChild(this._children[delete_index--]);
+ }
+
+ // Clear old rows
+ this.rows.empty()
+ .append(this.grid);
+ this.grid.empty();
+
+ var grouper = this.grouper;
+ if(!grouper) return;
+
+ // Headers
+ this.headers.empty();
+ this.headerTitle.text(grouper.title.apply(this));
+ grouper.headers.apply(this);
+ this.grid.find('*').contents().filter(function(){
+ return this.nodeType === 3;
+ }).remove();
+
+ // Get the rows / labels
+ var labels = grouper.row_labels.call(this);
+
+ // Group the events
+ var events = {};
+ for(var i = 0; i < this.value.length; i++)
+ {
+ grouper.group.call(this, labels, events, this.value[i]);
+ }
+
+ // Set height for rows
+ this.rows.height(this.div.height() - this.headers.outerHeight());
+
+ // Draw the rows
+ for(var key in labels)
+ {
+ if (!labels.hasOwnProperty(key)) continue;
+
+ // Skip sub-categories (events are merged into top level)
+ if(this.options.group_by == 'category' &&
+ (!app.calendar.state.cat_id || app.calendar.state.cat_id == '') &&
+ labels[key].id != labels[key].main
+ )
+ {
+ continue;
+ }
+ var row = grouper.draw_row.call(this,labels[key].id, labels[key].label, events[key] || []);
+
+ // Add extra data for clicking on row
+ if(row)
+ {
+ for(var extra in labels[key].data)
+ {
+ row.getDOMNode().dataset[extra] = labels[key].data[extra];
+ }
+ }
+ }
+
+ // Adjust header if there's a scrollbar
+ if(this.rows.children().last().length)
+ {
+ this.gridHeader.css('margin-right', (this.rows.width() - this.rows.children().last().width()) + 'px');
+ }
+ // Add actual events
+ for(var key in this._deferred_row_updates)
+ {
+ window.clearTimeout(key);
+ }
+ window.setTimeout(jQuery.proxy(function() {
+ this._deferred_row_update();
+ }, this ),et2_calendar_planner.DEFERRED_ROW_TIME);
+ this.value = [];
+ }
+
+ /**
+ * Draw a single row of the planner
+ *
+ * @param {string} key Index into the grouped labels & events
+ * @param {string} label
+ * @param {Array} events
+ * @param {Date} start
+ * @param {Date} end
+ */
+ _drawRow(key, label, events, start, end)
+ {
+ let row = et2_createWidget('calendar-planner_row',{
+ id: 'planner_row_'+key,
+ label: label,
+ start_date: start,
+ end_date: end,
+ value: events,
+ readonly: this.options.readonly
+ },this);
+
+
+ if(this.isInTree())
+ {
+ row.doLoadingFinished();
+ }
+
+ return row;
+ }
+
+
+ _header_day_of_month()
+ {
+ let day_width = 3.23; // 100.0 / 31;
+
+ // month scale with navigation
+ var content = '';
+ var start = new Date(this.options.start_date);
+ start = new Date(start.valueOf() + start.getTimezoneOffset() * 60 * 1000);
+ var end = new Date(this.options.end_date);
+ end = new Date(end.valueOf() + end.getTimezoneOffset() * 60 * 1000);
+
+ var title = this.egw().lang(date('F',start))+' '+date('Y',start)+' - '+
+ this.egw().lang(date('F',end))+' '+date('Y',end);
+
+ content += '
'+
+ title+"
";
+ content += "
"; // end of plannerScale
+
+ // day of month scale
+ content +='';
+
+ for(var left = 0, i = 0; i < 31; left += day_width,++i)
+ {
+ content += '
'+
+ (1+i)+"
\n";
+ }
+ content += "
\n";
+
+ return content;
+ }
+
+ /**
+ * Make a header showing the months
+ * @param {Date} start
+ * @param {number} days
+ * @returns {string} HTML snippet
+ */
+ _header_months(start, days)
+ {
+ var content = '';
+ var days_in_month = 0;
+ var day_width = 100 / days;
+ var end = new Date(start);
+ end.setUTCDate(end.getUTCDate()+days);
+ var t = new Date(start.valueOf());
+ for(var left = 0,i = 0; i < days;t.setUTCDate(1),t.setUTCMonth(t.getUTCMonth()+1),left += days_in_month*day_width,i += days_in_month)
+ {
+ var u = new Date(t.getUTCFullYear(),t.getUTCMonth()+1,0,-t.getTimezoneOffset()/60);
+ days_in_month = 1+ ((u-t) / (24*3600*1000));
+
+ var first = new Date(t.getUTCFullYear(),t.getUTCMonth(),1,-t.getTimezoneOffset()/60);
+ if(days_in_month <= 0) break;
+
+ if (i + days_in_month > days)
+ {
+ days_in_month = days - i;
+ }
+ var title = this.egw().lang(date('F',new Date(t.valueOf() + t.getTimezoneOffset() * 60 * 1000)));
+ if (days_in_month > 10)
+ {
+ title += '
'+t.getUTCFullYear();
+ }
+ else if (days_in_month < 5)
+ {
+ title = ' ';
+ }
+ content += ''+
+ title+"
";
+ }
+ content += " "; // end of plannerScale
+
+ return content;
+ }
+
+ /**
+ * Make a header showing the week numbers
+ *
+ * @param {Date} start
+ * @param {number} days
+ * @returns {string} HTML snippet
+ */
+ _header_weeks(start, days)
+ {
+
+ var content = '';
+ var state = '';
+
+ // we're not using UTC so date() formatting function works
+ var t = new Date(start.valueOf());
+
+ // Make sure we're lining up on the week
+ var week_end = app.calendar.date.end_of_week(start);
+ var days_in_week = Math.floor(((week_end-start ) / (24*3600*1000))+1);
+ var week_width = 100 / days * (days <= 7 ? days : days_in_week);
+ for(var left = 0,i = 0; i < days; t.setUTCDate(t.getUTCDate() + 7),left += week_width)
+ {
+ // Avoid overflow at the end
+ if(days - i < 7)
+ {
+ days_in_week = days-i;
+ }
+ var usertime = new Date(t.valueOf());
+ if(start.getTimezoneOffset() < 0)
+ {
+ // Gets the right week # east of GMT. West does not need it(?)
+ usertime.setUTCMinutes(usertime.getUTCMinutes() - start.getTimezoneOffset());
+ }
+
+ week_width = 100 / days * Math.min(days, days_in_week);
+
+ var title = this.egw().lang('Week')+' '+app.calendar.date.week_number(usertime);
+
+ if(start.getTimezoneOffset() > 0)
+ {
+ // Gets the right week start west of GMT
+ usertime.setUTCMinutes(usertime.getUTCMinutes() +start.getTimezoneOffset());
+ }
+ state = app.calendar.date.start_of_week(usertime);
+ state.setUTCHours(0);
+ state.setUTCMinutes(0);
+ state = state.toJSON();
+
+ if(days_in_week > 1 || days == 1)
+ {
+ content += '
'+title+"
";
+ }
+ i+= days_in_week;
+ if(days_in_week != 7)
+ {
+ t.setUTCDate(t.getUTCDate() - (7 - days_in_week));
+ days_in_week = 7;
+ }
+ }
+ content += "
"; // end of plannerScale
+
+ return content;
+ }
+
+ /**
+ * Make a header for some days
+ *
+ * @param {Date} start
+ * @param {number} days
+ * @returns {string} HTML snippet
+ */
+ _header_days(start, days)
+ {
+ var day_width = 100 / days;
+ var content = '';
+
+ // we're not using UTC so date() formatting function works
+ var t = new Date(start.valueOf() + start.getTimezoneOffset() * 60 * 1000);
+ for(var left = 0,i = 0; i < days; t.setDate(t.getDate()+1),left += day_width,++i)
+ {
+ if(!this.options.show_weekend && [0,6].indexOf(t.getDay()) !== -1 ) continue;
+ var holidays = [];
+ var tempDate = new Date(t);
+ tempDate.setMinutes(tempDate.getMinutes()-tempDate.getTimezoneOffset());
+ var title = '';
+ let state = new Date(t.valueOf() - t.getTimezoneOffset() * 60 * 1000);
+ var day_class = this.day_class_holiday(state,holidays, days);
+
+ if (days <= 3)
+ {
+ title = this.egw().lang(date('l',t))+', '+date('j',t)+'. '+this.egw().lang(date('F',t));
+ }
+ else if (days <= 7)
+ {
+ title = this.egw().lang(date('l',t))+' '+date('j',t);
+ }
+ else
+ {
+ title = this.egw().lang(date('D',t)).substr(0,2)+'
'+date('j',t);
+ }
+
+ content += '
'+title+"
\n";
+ }
+ content += "
"; // end of plannerScale
+
+ return content;
+ }
+
+ /**
+ * Create a header with hours
+ *
+ * @param {Date} start
+ * @param {number} days
+ * @returns {string} HTML snippet for the header
+ */
+ _header_hours(start,days)
+ {
+ var divisors = [1,2,3,4,6,8,12];
+ var decr = 1;
+ for(var i = 0; i < divisors.length; i++) // numbers dividing 24 without rest
+ {
+ if (divisors[i] > days) break;
+ decr = divisors[i];
+ }
+ var hours = days * 24;
+ if (days === 1) // for a single day we calculate the hours of a days, to take into account daylight saving changes (23 or 25 hours)
+ {
+ var t = new Date(start.getUTCFullYear(),start.getUTCMonth(),start.getUTCDate(),-start.getTimezoneOffset()/60);
+ var s = new Date(start);
+ s.setUTCHours(23);
+ s.setUTCMinutes(59);
+ s.setUTCSeconds(59);
+ hours = Math.ceil((s.getTime() - t.getTime()) / 3600000);
+ }
+ var cell_width = 100 / hours * decr;
+
+ var content = '';
+
+ // we're not using UTC so date() formatting function works
+ var t = new Date(start.valueOf() + start.getTimezoneOffset() * 60 * 1000);
+ for(var left = 0,i = 0; i < hours; left += cell_width,i += decr)
+ {
+ if(!this.options.show_weekend && [0,6].indexOf(t.getDay()) !== -1 ) continue;
+ var title = date(egw.preference('timeformat','calendar') == 12 ? 'ha' : 'H',t);
+
+ content += '
'+title+"
";
+ t.setHours(t.getHours()+decr);
+ }
+ content += "
"; // end of plannerScale
+
+ return content;
+ }
+
+ /**
+ * Applies class for today, and any holidays for current day
+ *
+ * @param {Date} date
+ * @param {string[]} holiday_list Filled with a list of holidays for that day
+ * @param {integer} days Number of days shown in the day header
+ *
+ * @return {string} CSS Classes for the day. calendar_calBirthday, calendar_calHoliday, calendar_calToday and calendar_weekend as appropriate
+ */
+ day_class_holiday( date,holiday_list, days?)
+ {
+
+ if(!date) return '';
+
+ var day_class = '';
+
+ // Holidays and birthdays
+ var holidays = et2_calendar_view.get_holidays(this,date.getUTCFullYear());
+
+ // Pass a string rather than the date object, to make sure it doesn't get changed
+ this.date_helper.set_value(date.toJSON());
+ var date_key = ''+this.date_helper.get_year() + sprintf('%02d',this.date_helper.get_month()) + sprintf('%02d',this.date_helper.get_date());
+ if(holidays && holidays[date_key])
+ {
+ holidays = holidays[date_key];
+ for(var i = 0; i < holidays.length; i++)
+ {
+ if (typeof holidays[i]['birthyear'] !== 'undefined')
+ {
+ day_class += ' calendar_calBirthday ';
+ if(typeof days == 'undefined' || days <= 21)
+ {
+ day_class += ' calendar_calBirthdayIcon ';
+ }
+
+ holiday_list.push(holidays[i]['name']);
+ }
+ else
+ {
+ day_class += 'calendar_calHoliday ';
+
+ holiday_list.push(holidays[i]['name']);
+ }
+ }
+ }
+ holidays = holiday_list.join(',');
+ var today = new Date();
+ if(date_key === ''+today.getFullYear()+
+ sprintf("%02d",today.getMonth()+1)+
+ sprintf("%02d",today.getDate())
+ )
+ {
+ day_class += "calendar_calToday ";
+ }
+ if(date.getUTCDay() == 0 || date.getUTCDay() == 6)
+ {
+ day_class += "calendar_weekend ";
+ }
+ return day_class;
+ }
+
+ /**
+ * Link the actions to the DOM nodes / widget bits.
+ *
+ * @todo This currently does nothing
+ * @param {object} actions {ID: {attributes..}+} map of egw action information
+ */
+ _link_actions(actions)
+ {
+ if(!this._actionObject)
+ {
+ // Get the parent? Might be a grid row, might not. Either way, it is
+ // just a container with no valid actions
+ var objectManager = egw_getObjectManager(this.getInstanceManager().app,true,1);
+ objectManager = objectManager.getObjectById(this.getInstanceManager().uniqueId,2) || objectManager;
+ var parent = objectManager.getObjectById(this.id,3) || objectManager.getObjectById(this._parent.id,3) || objectManager;
+ if(!parent)
+ {
+ debugger;
+ egw.debug('error','No parent objectManager found');
+ return;
+ }
+
+ for(var i = 0; i < parent.children.length; i++)
+ {
+ var parent_finder = jQuery('#'+this.div.id, parent.children[i].iface.doGetDOMNode());
+ if(parent_finder.length > 0)
+ {
+ parent = parent.children[i];
+ break;
+ }
+ }
+ }
+
+ // This binds into the egw action system. Most user interactions (drag to move, resize)
+ // are handled internally using jQuery directly.
+ var widget_object = this._actionObject || parent.getObjectById(this.id);
+ var aoi = new et2_action_object_impl(this,this.getDOMNode(this)).getAOI();
+
+ /**
+ * Determine if we allow a dropped event to use the invite/change actions,
+ * and enable or disable them appropriately
+ *
+ * @param {egwAction} action
+ * @param {et2_calendar_event} event The event widget being dragged
+ * @param {egwActionObject} target Planner action object
+ */
+ var _invite_enabled = function(action, event, target)
+ {
+ var event = event.iface.getWidget();
+ var planner = target.iface.getWidget() || false;
+ //debugger;
+ if(event === planner || !event || !planner ||
+ !event.options || !event.options.value.participants || !planner.options.owner
+ )
+ {
+ return false;
+ }
+ var owner_match = false;
+ var own_row = false;
+
+ for(var id in event.options.value.participants)
+ {
+ planner.iterateOver(function(row) {
+ // Check scroll section or header section
+ if(row.div.hasClass('drop-hover') || row.div.has(':hover'))
+ {
+ owner_match = owner_match || row.node.dataset[planner.options.group_by] === ''+id;
+ own_row = (row === event.getParent());
+ }
+ }, this, et2_calendar_planner_row);
+
+ }
+ var enabled = !owner_match &&
+ // Not inside its own row
+ !own_row;
+
+ widget_object.getActionLink('invite').enabled = enabled;
+ widget_object.getActionLink('change_participant').enabled = enabled;
+
+ // If invite or change participant are enabled, drag is not
+ widget_object.getActionLink('egw_link_drop').enabled = !enabled;
+ };
+
+ aoi.doTriggerEvent = function(_event, _data) {
+
+ // Determine target node
+ var event = _data.event || false;
+ if(!event) return;
+ if(_data.ui.draggable.hasClass('rowNoEdit')) return;
+
+ /*
+ We have to handle the drop in the normal event stream instead of waiting
+ for the egwAction system so we can get the helper, and destination
+ */
+ if(event.type === 'drop')
+ {
+ this.getWidget()._event_drop.call(jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper)[0],this.getWidget(),event, _data.ui);
+ }
+ var drag_listener = function(event, ui) {
+ aoi.getWidget()._drag_helper(jQuery('.calendar_d-n-d_timeCounter',ui.helper)[0],{
+ top:ui.position.top,
+ left: ui.position.left - jQuery(this).parent().offset().left
+ },0);
+ };
+ var time = jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper);
+ switch(_event)
+ {
+ // Triggered once, when something is dragged into the timegrid's div
+ case EGW_AI_DRAG_OVER:
+ // Listen to the drag and update the helper with the time
+ // This part lets us drag between different timegrids
+ _data.ui.draggable.on('drag.et2_timegrid'+widget_object.id, drag_listener);
+ _data.ui.draggable.on('dragend.et2_timegrid'+widget_object.id, function() {
+ _data.ui.draggable.off('drag.et2_timegrid' + widget_object.id);
+ });
+ if(time.length)
+ {
+ // The out will trigger after the over, so we count
+ time.data('count',time.data('count')+1);
+ }
+ else
+ {
+ _data.ui.helper.prepend('
');
+ }
+
+ break;
+
+ // Triggered once, when something is dragged out of the timegrid
+ case EGW_AI_DRAG_OUT:
+ // Stop listening
+ _data.ui.draggable.off('drag.et2_timegrid'+widget_object.id);
+ // Remove any highlighted time squares
+ jQuery('[data-date]',this.doGetDOMNode()).removeClass("ui-state-active");
+
+ // Out triggers after the over, count to not accidentally remove
+ time.data('count',time.data('count')-1);
+ if(time.length && time.data('count') <= 0)
+ {
+ time.remove();
+ }
+ break;
+ }
+ };
+
+ if (widget_object == null) {
+ // Add a new container to the object manager which will hold the widget
+ // objects
+ widget_object = parent.insertObject(false, new egwActionObject(
+ this.id, parent, aoi,
+ this._actionManager || parent.manager.getActionById(this.id) || parent.manager
+ ),EGW_AO_FLAG_IS_CONTAINER);
+ }
+ else
+ {
+ widget_object.setAOI(aoi);
+ }
+ // Go over the widget & add links - this is where we decide which actions are
+ // 'allowed' for this widget at this time
+ var action_links = this._get_action_links(actions);
+
+ this._init_links_dnd(widget_object.manager, action_links);
+
+ widget_object.updateActionLinks(action_links);
+ this._actionObject = widget_object;
+ }
+
+ /**
+ * Automatically add dnd support for linking
+ *
+ * @param {type} mgr
+ * @param {type} actionLinks
+ */
+ _init_links_dnd( mgr,actionLinks)
+ {
+
+ if (this.options.readonly) return;
+
+ var self = this;
+
+ var drop_action = mgr.getActionById('egw_link_drop');
+ var drop_change_participant = mgr.getActionById('change_participant');
+ var drop_invite = mgr.getActionById('invite');
+ var drag_action = mgr.getActionById('egw_link_drag');
+ var paste_action = mgr.getActionById('egw_paste');
+
+ // Disable paste action
+ if(paste_action == null)
+ {
+ paste_action = mgr.addAction('popup', 'egw_paste', egw.lang('Paste'), egw.image('editpaste'), function(){},true);
+ }
+ paste_action.set_enabled(false);
+
+ // Check if this app supports linking
+ if(!egw.link_get_registry(this.dataStorePrefix || 'calendar', 'query') ||
+ egw.link_get_registry(this.dataStorePrefix || 'calendar', 'title'))
+ {
+ if(drop_action)
+ {
+ drop_action.remove();
+ if(actionLinks.indexOf(drop_action.id) >= 0)
+ {
+ actionLinks.splice(actionLinks.indexOf(drop_action.id),1);
+ }
+ }
+ if(drag_action)
+ {
+ drag_action.remove();
+ if(actionLinks.indexOf(drag_action.id) >= 0)
+ {
+ actionLinks.splice(actionLinks.indexOf(drag_action.id),1);
+ }
+ }
+ return;
+ }
+
+ // Don't re-add
+ if(drop_action == null)
+ {
+ // Create the drop action that links entries
+ drop_action = mgr.addAction('drop', 'egw_link_drop', egw.lang('Create link'), egw.image('link'), function(action, source, dropped) {
+ // Extract link IDs
+ var links = [];
+ var id = '';
+ for(var i = 0; i < source.length; i++)
+ {
+ if(!source[i].id) continue;
+ id = source[i].id.split('::');
+ links.push({app: id[0] == 'filemanager' ? 'link' : id[0], id: id[1]});
+ }
+ if(!links.length)
+ {
+ return;
+ }
+ if(links.length && dropped && dropped.iface.getWidget() && dropped.iface.getWidget().instanceOf(et2_calendar_event))
+ {
+ // Link the entries
+ egw.json(self.egw().getAppName()+".etemplate_widget_link.ajax_link.etemplate",
+ dropped.id.split('::').concat([links]),
+ function(result) {
+ if(result)
+ {
+ this.egw().message('Linked');
+ }
+ },
+ self,
+ true,
+ self
+ ).sendRequest();
+ }
+ },true);
+
+ drop_action.acceptedTypes = ['default','link'];
+ drop_action.hideOnDisabled = true;
+
+ // Create the drop action for moving events between planner rows
+ var invite_action = function(action, source, target) {
+
+ // Extract link IDs
+ var links = [];
+ var id = '';
+ for(var i = 0; i < source.length; i++)
+ {
+ // Check for no ID (invalid) or same manager (dragging an event)
+ if(!source[i].id) continue;
+ if(source[i].manager === target.manager)
+ {
+
+ // Find the row, could have dropped on an event
+ var row = target.iface.getWidget();
+ while(target.parent && row.instanceOf && !row.instanceOf(et2_calendar_planner_row))
+ {
+ target = target.parent;
+ row = target.iface.getWidget();
+ }
+
+ // Leave the helper there until the update is done
+ var loading = action.ui.helper.clone(true).appendTo(jQuery('body'));
+
+ // and add a loading icon so user knows something is happening
+ if(jQuery('.calendar_timeDemo',loading).length == 0)
+ {
+ jQuery('.calendar_calEventHeader',loading).addClass('loading');
+ }
+ else
+ {
+ jQuery('.calendar_timeDemo',loading).after('');
+ }
+
+ var event_data = egw.dataGetUIDdata(source[i].id).data;
+ et2_calendar_event.recur_prompt(event_data, function(button_id) {
+ if(button_id === 'cancel' || !button_id)
+ {
+ return;
+ }
+ var add_owner = [row.node.dataset.participants];
+
+ egw().json('calendar.calendar_uiforms.ajax_invite', [
+ button_id==='series' ? event_data.id : event_data.app_id,
+ add_owner,
+ action.id === 'change_participant' ?
+ [source[i].iface.getWidget().getParent().node.dataset.participants] :
+ []
+ ],
+ function() { loading.remove();}
+ ).sendRequest(true);
+ });
+ // Ok, stop.
+ return false;
+ }
+ }
+ };
+
+ drop_change_participant = mgr.addAction('drop', 'change_participant', egw.lang('Move to'), egw.image('participant'), invite_action,true);
+ drop_change_participant.acceptedTypes = ['calendar'];
+ drop_change_participant.hideOnDisabled = true;
+
+ drop_invite = mgr.addAction('drop', 'invite', egw.lang('Invite'), egw.image('participant'), invite_action,true);
+ drop_invite.acceptedTypes = ['calendar'];
+ drop_invite.hideOnDisabled = true;
+ }
+ if(actionLinks.indexOf(drop_action.id) < 0)
+ {
+ actionLinks.push(drop_action.id);
+ }
+ actionLinks.push(drop_invite.id);
+ actionLinks.push(drop_change_participant.id);
+
+ // Accept other links, and files dragged from the filemanager
+ // This does not handle files dragged from the desktop. They are
+ // handled by et2_nextmatch, since it needs DOM stuff
+ if(drop_action.acceptedTypes.indexOf('link') == -1)
+ {
+ drop_action.acceptedTypes.push('link');
+ }
+
+ // Don't re-add
+ if(drag_action == null)
+ {
+ // Create drag action that allows linking
+ drag_action = mgr.addAction('drag', 'egw_link_drag', egw.lang('link'), 'link', function(action, selected) {
+ // As we wanted to have a general defaul helper interface, we return null here and not using customize helper for links
+ // TODO: Need to decide if we need to create a customized helper interface for links anyway
+ //return helper;
+ return null;
+ },true);
+ }
+ // The planner itself is not draggable, the action is there for the children
+ if(false && actionLinks.indexOf(drag_action.id) < 0)
+ {
+ actionLinks.push(drag_action.id);
+ }
+ drag_action.set_dragType(['link','calendar']);
+ }
+
+ /**
+ * Get all action-links / id's of 1.-level actions from a given action object
+ *
+ * Here we are only interested in drop events.
+ *
+ * @param actions
+ * @returns {Array}
+ */
+ _get_action_links(actions)
+ {
+ var action_links = [];
+
+ // Only these actions are allowed without a selection (empty actions)
+ var empty_actions = ['add'];
+
+ for(var i in actions)
+ {
+ var action = actions[i];
+ if(empty_actions.indexOf(action.id) !== -1 || action.type === 'drop')
+ {
+ action_links.push(typeof action.id !== 'undefined' ? action.id : i);
+ }
+ }
+ // Disable automatic paste action, it doesn't have what is needed to work
+ action_links.push({
+ "actionObj": 'egw_paste',
+ "enabled": false,
+ "visible": false
+ });
+ return action_links;
+ }
+
+ /**
+ * Show the current time while dragging
+ * Used for resizing as well as drag & drop
+ *
+ * @param {type} element
+ * @param {type} position
+ * @param {type} height
+ */
+ _drag_helper(element, position ,height)
+ {
+ var time = this._get_time_from_position(position.left, position.top);
+ element.dropEnd = time;
+ var formatted_time = jQuery.datepicker.formatTime(
+ egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
+ {
+ hour: time.getUTCHours(),
+ minute: time.getUTCMinutes(),
+ seconds: 0,
+ timezone: 0
+ },
+ {"ampm": (egw.preference("timeformat") === "12")}
+ );
+
+ element.innerHTML = ''+formatted_time+'
';
+
+ //jQuery(element).width(jQuery(helper).width());
+ }
+
+ /**
+ * Handler for dropping an event on the timegrid
+ *
+ * @param {type} planner
+ * @param {type} event
+ * @param {type} ui
+ */
+ _event_drop( planner, event,ui)
+ {
+ var e = new jQuery.Event('change');
+ e.originalEvent = event;
+ e.data = {start: 0};
+ if (typeof this.dropEnd != 'undefined')
+ {
+ var drop_date = this.dropEnd.toJSON() ||false;
+
+ var event_data = planner._get_event_info(ui.draggable);
+ var event_widget = planner.getWidgetById(event_data.widget_id);
+ if(event_widget)
+ {
+ event_widget._parent.date_helper.set_value(drop_date);
+ event_widget.options.value.start = new Date(event_widget._parent.date_helper.getValue());
+
+ // Leave the helper there until the update is done
+ var loading = ui.helper.clone().appendTo(ui.helper.parent());
+ // and add a loading icon so user knows something is happening
+ jQuery('.calendar_timeDemo',loading).after('');
+
+ event_widget.recur_prompt(function(button_id) {
+ if(button_id === 'cancel' || !button_id) return;
+ //Get infologID if in case if it's an integrated infolog event
+ if (event_data.app === 'infolog')
+ {
+ // If it is an integrated infolog event we need to edit infolog entry
+ egw().json('stylite_infolog_calendar_integration::ajax_moveInfologEvent',
+ [event_data.id, event_widget.options.value.start||false],
+ function() {loading.remove();}
+ ).sendRequest(true);
+ }
+ else
+ {
+ //Edit calendar event
+ egw().json('calendar.calendar_uiforms.ajax_moveEvent', [
+ button_id==='series' ? event_data.id : event_data.app_id,event_data.owner,
+ event_widget.options.value.start,
+ planner.options.owner||egw.user('account_id')
+ ],
+ function() { loading.remove();}
+ ).sendRequest(true);
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * Use the egw.data system to get data from the calendar list for the
+ * selected time span.
+ *
+ */
+ _fetch_data()
+ {
+ var value = [];
+ var fetch = false;
+ this.doInvalidate = false;
+
+ for(var i = 0; i < this.registeredCallbacks.length; i++)
+ {
+ egw.dataUnregisterUID(this.registeredCallbacks[i],false,this);
+ }
+ this.registeredCallbacks.splice(0,this.registeredCallbacks.length);
+
+ // Remember previous day to avoid multi-days duplicating
+ var last_data = [];
+
+ var t = new Date(this.options.start_date);
+ var end = new Date(this.options.end_date);
+ do
+ {
+ value = value.concat(this._cache_register(t, this.options.owner, last_data));
+
+ t.setUTCDate(t.getUTCDate() + 1);
+ }
+ while(t < end);
+
+ this.doInvalidate = true;
+ return value;
+ }
+
+ /**
+ * Deal with registering for data cache
+ *
+ * @param Date t
+ * @param String owner Calendar owner
+ */
+ _cache_register(t, owner, last_data)
+ {
+ // Cache is by date (and owner, if seperate)
+ var date = t.getUTCFullYear() + sprintf('%02d',t.getUTCMonth()+1) + sprintf('%02d',t.getUTCDate());
+ var cache_id = CalendarApp._daywise_cache_id(date, owner);
+ var value = [];
+
+ if(egw.dataHasUID(cache_id))
+ {
+ var c = egw.dataGetUIDdata(cache_id);
+ if(c.data && c.data !== null)
+ {
+ // There is data, pass it along now
+ for(var j = 0; j < c.data.length; j++)
+ {
+ if(last_data.indexOf(c.data[j]) === -1 && egw.dataHasUID('calendar::'+c.data[j]))
+ {
+ value.push(egw.dataGetUIDdata('calendar::'+c.data[j]).data);
+ }
+ }
+ last_data = c.data;
+ }
+ }
+ else
+ {
+ fetch = true;
+ // Assume it's empty, if there is data it will be filled later
+ egw.dataStoreUID(cache_id, []);
+ }
+ this.registeredCallbacks.push(cache_id);
+
+ egw.dataRegisterUID(cache_id, function(data) {
+
+ if(data && data.length)
+ {
+ var invalidate = true;
+
+ // Try to determine rows interested
+ var labels = [];
+ var events = {};
+ if(this.grouper)
+ {
+ labels = this.grouper.row_labels.call(this);
+ invalidate = false;
+ }
+
+ var im = this.getInstanceManager();
+ for(var i = 0; i < data.length; i++)
+ {
+ var event = egw.dataGetUIDdata('calendar::'+data[i]);
+
+ if(!event) continue;
+ events = {};
+
+ // Try to determine rows interested
+ if(event.data && this.grouper)
+ {
+ this.grouper.group.call(this, labels, events, event.data);
+ }
+ if(Object.keys(events).length > 0 )
+ {
+ for(var label_id in events)
+ {
+ var id = ""+labels[label_id].id;
+ if(typeof this.cache[id] === 'undefined')
+ {
+ this.cache[id] = [];
+ }
+ if(this.cache[id].indexOf(event.data.row_id) === -1)
+ {
+ this.cache[id].push(event.data.row_id);
+ }
+ if (this._deferred_row_updates[id])
+ {
+ window.clearTimeout(this._deferred_row_updates[id]);
+ }
+ this._deferred_row_updates[id] = window.setTimeout(jQuery.proxy(this._deferred_row_update,this,id),this.DEFERRED_ROW_TIME);
+ }
+ }
+ else
+ {
+ // Could be an event no row is interested in, could be a problem.
+ // Just redraw everything
+ invalidate = true;
+ continue;
+ }
+
+ // If displaying by category, we need the infolog (or other app) categories too
+ if(event && event.data && event.data.app && this.options.group_by == 'category')
+ {
+ // Fake it to use the cache / call
+ et2_selectbox.cat_options({
+ _type:'select-cat',
+ getInstanceManager: function() {return im;}
+ }, {application:event.data.app||'calendar'});
+ }
+ }
+
+ if(invalidate)
+ {
+ this.invalidate(false);
+ }
+ }
+ }, this, this.getInstanceManager().execId,this.id);
+
+ return value;
+ }
+
+ /**
+ * Because users may be participants in various events and the time it takes
+ * to create many events, we don't want to update a row too soon - we may have
+ * to re-draw it if we find the user / category in another event. Pagination
+ * makes this worse. We wait a bit before updating the row to avoid
+ * having to re-draw it multiple times.
+ *
+ * @param {type} id
+ * @returns {undefined}
+ */
+ _deferred_row_update( id)
+ {
+ // Something's in progress, skip
+ if(!this.doInvalidate) return;
+
+ this.grid.height(0);
+
+ var id_list = typeof id === 'undefined' ? Object.keys(this.cache) : [id];
+ for(var i = 0; i < id_list.length; i++)
+ {
+ var cache_id = id_list[i];
+ var row = this.getWidgetById('planner_row_'+cache_id);
+
+ window.clearTimeout(this._deferred_row_updates[cache_id]);
+ delete this._deferred_row_updates[cache_id];
+
+ if(row)
+ {
+ row._data_callback(this.cache[cache_id]);
+ row.set_disabled(this.options.hide_empty && this.cache[cache_id].length === 0);
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ // Updating the row may push things longer, update length
+ // Add 1 to keep the scrollbar, otherwise we need to recalculate the
+ // header widths too.
+ this.grid.height(this.rows[0].scrollHeight+1);
+
+ // Adjust header if there's a scrollbar - Firefox needs this re-calculated,
+ // otherwise the header will be missing the margin space for the scrollbar
+ // in some cases
+ if(this.rows.children().last().length)
+ {
+ this.gridHeader.css('margin-right', (this.rows.width() - this.rows.children().last().width()) + 'px');
+ }
+ }
+
+ /**
+ * Provide specific data to be displayed.
+ * This is a way to set start and end dates, owner and event data in once call.
+ *
+ * @param {Object[]} events Array of events, indexed by date in Ymd format:
+ * {
+ * 20150501: [...],
+ * 20150502: [...]
+ * }
+ * Days should be in order.
+ *
+ */
+ set_value(events)
+ {
+ if(typeof events !== 'object') return false;
+
+ super.set_value(events);
+
+ // Planner uses an array, not map
+ var val = this.value;
+ var array = [];
+ Object.keys(this.value).forEach(function (key) {
+ array.push(val[key]);
+ });
+ this.value = array;
+ }
+
+ /**
+ * Change the start date
+ * Planner view uses a date object internally
+ *
+ * @param {string|number|Date} new_date New starting date
+ * @returns {undefined}
+ */
+ set_start_date(new_date)
+ {
+ super.set_start_date(new_date);
+ this.options.start_date = new Date(this.options.start_date);
+ }
+
+ /**
+ * Change the end date
+ * Planner view uses a date object internally
+ *
+ * @param {string|number|Date} new_date New end date
+ * @returns {undefined}
+ */
+ set_end_date(new_date)
+ {
+ super.set_end_date(new_date);
+ this.options.end_date = new Date(this.options.end_date);
+ }
+
+ /**
+ * Change how the planner is grouped
+ *
+ * @param {string|number} group_by 'user', 'month', or an integer category ID
+ * @returns {undefined}
+ */
+ set_group_by(group_by)
+ {
+ if(isNaN(group_by) && typeof this.groupers[group_by] === 'undefined')
+ {
+ throw new Error('Invalid group_by "'+group_by+'"');
+ }
+ var old = this.options.group_by;
+ this.options.group_by = ''+group_by;
+
+ this.grouper = this.groupers[isNaN(this.options.group_by) ? this.options.group_by : 'category'];
+
+ if(old !== this.options.group_by && this.isAttached())
+ {
+ this.invalidate(true);
+ }
+ }
+
+ /**
+ * Turn on or off the visibility of weekends
+ *
+ * @param {boolean} weekends
+ */
+ set_show_weekend(weekends)
+ {
+ weekends = weekends ? true : false;
+ if(this.options.show_weekend !== weekends)
+ {
+ this.options.show_weekend = weekends;
+ if(this.isAttached())
+ {
+ this.invalidate();
+ }
+ }
+ }
+
+ /**
+ * Turn on or off the visibility of hidden (empty) rows
+ *
+ * @param {boolean} hidden
+ */
+ set_hide_empty(hidden)
+ {
+ this.options.hide_empty = hidden;
+ }
+
+ /**
+ * Call change handler, if set
+ *
+ * @param {type} event
+ */
+ change( event)
+ {
+ if (this.onchange)
+ {
+ if(typeof this.onchange == 'function')
+ {
+ // Make sure function gets a reference to the widget
+ var args = Array.prototype.slice.call(arguments);
+ if(args.indexOf(this) == -1) args.push(this);
+
+ return this.onchange.apply(this, args);
+ } else {
+ return (et2_compileLegacyJS(this.options.onchange, this, _node))();
+ }
+ }
+ }
+
+ /**
+ * Call event change handler, if set
+ *
+ * @param {type} event
+ * @param {type} dom_node
+ */
+ event_change( event, dom_node)
+ {
+ if (this.onevent_change)
+ {
+ var event_data = this._get_event_info(dom_node);
+ var event_widget = this.getWidgetById(event_data.widget_id);
+ et2_calendar_event.recur_prompt(event_data, jQuery.proxy(function(button_id, event_data) {
+ // No need to continue
+ if(button_id === 'cancel') return false;
+
+ if(typeof this.onevent_change == 'function')
+ {
+ // Make sure function gets a reference to the widget
+ var args = Array.prototype.slice.call(arguments);
+
+ if(args.indexOf(event_widget) == -1) args.push(event_widget);
+
+ // Put button ID in event
+ event.button_id = button_id;
+
+ return this.onevent_change.apply(this, [event, event_widget, button_id]);
+ } else {
+ return (et2_compileLegacyJS(this.options.onevent_change, event_widget, dom_node))();
+ }
+ },this));
+ }
+ return false;
+ }
+
+ /**
+ * Click handler calling custom handler set via onclick attribute to this.onclick
+ *
+ * This also handles all its own actions, including navigation. If there is
+ * an event associated with the click, it will be found and passed to the
+ * onclick function.
+ *
+ * @param {Event} _ev
+ * @returns {boolean} Continue processing event (true) or stop (false)
+ */
+ click(_ev)
+ {
+ var result = true;
+
+ // Drag to create in progress
+ if(this.drag_create.start !== null) return;
+
+ // Is this click in the event stuff, or in the header?
+ if(!this.options.readonly && this.gridHeader.has(_ev.target).length === 0 && !jQuery(_ev.target).hasClass('calendar_plannerRowHeader'))
+ {
+ // Event came from inside, maybe a calendar event
+ var event = this._get_event_info(_ev.originalEvent.target);
+ if(typeof this.onclick == 'function')
+ {
+ // Make sure function gets a reference to the widget, splice it in as 2. argument if not
+ var args = Array.prototype.slice.call(arguments);
+ if(args.indexOf(this) == -1) args.splice(1, 0, this);
+
+ result = this.onclick.apply(this, args);
+ }
+
+ if(event.id && result && !this.options.disabled && !this.options.readonly)
+ {
+ et2_calendar_event.recur_prompt(event);
+
+ return false;
+ }
+ else if (!event.id)
+ {
+ // Clicked in row, but not on an event
+ // Default handler to open a new event at the selected time
+ if(jQuery(event.target).closest('.calendar_eventRows').length == 0)
+ {
+ // "Invalid" times, from space after the last planner row, or header
+ var date = this._get_time_from_position(_ev.pageX - this.grid.offset().left, _ev.pageY - this.grid.offset().top);
+ }
+ else if(this.options.group_by == 'month')
+ {
+ var date = this._get_time_from_position(_ev.clientX, _ev.clientY);
+ }
+ else
+ {
+ var date = this._get_time_from_position(_ev.offsetX, _ev.offsetY);
+ }
+ var row = jQuery(_ev.target).closest('.calendar_plannerRowWidget');
+ var data = row.length ? row[0].dataset : {};
+ if(date)
+ {
+ app.calendar.add(jQuery.extend({
+ start: date.toJSON(),
+ hour: date.getUTCHours(),
+ minute: date.getUTCMinutes()
+ }, data));
+ return false;
+ }
+ }
+ return result;
+ }
+ else if (this.gridHeader.has(_ev.target).length > 0 && !jQuery.isEmptyObject(_ev.target.dataset) ||
+ jQuery(_ev.target).hasClass('calendar_plannerRowHeader') && !jQuery.isEmptyObject(_ev.target.dataset))
+ {
+ // Click on a header, we can go there
+ _ev.data = jQuery.extend({},_ev.target.parentNode.dataset, _ev.target.dataset);
+ for(var key in _ev.data)
+ {
+ if(!_ev.data[key])
+ {
+ delete _ev.data[key];
+ }
+ }
+ app.calendar.update_state(_ev.data);
+ }
+ else if (!this.options.readonly)
+ {
+ // Default handler to open a new event at the selected time
+ // TODO: Determine date / time more accurately from position
+ app.calendar.add({
+ date: _ev.target.dataset.date || this.options.start_date.toJSON(),
+ hour: _ev.target.dataset.hour || this.options.day_start,
+ minute: _ev.target.dataset.minute || 0
+ });
+ return false;
+ }
+ }
+
+ /**
+ * Get time from position
+ *
+ * @param {number} x
+ * @param {number} y
+ * @returns {Date|Boolean} A time for the given position, or false if one
+ * could not be determined.
+ */
+ _get_time_from_position( x,y)
+ {
+
+ x = Math.round(x);
+ y = Math.round(y);
+
+ // Round to user's preferred event interval
+ var interval = egw.preference('interval','calendar') || 30;
+
+ // Relative horizontal position, as a percentage
+ var width = 0;
+ jQuery('.calendar_eventRows',this.div).each(function() {width = Math.max(width,jQuery(this).width());});
+ var rel_x = Math.min(x / width,1);
+
+ // Relative time, in minutes from start
+ var rel_time = 0;
+
+ var day_header = jQuery('.calendar_plannerScaleDay',this.headers);
+
+ // Simple math, the x is offset from start date
+ if(this.options.group_by !== 'month' && (
+ // Either all days are visible, or only 1 day (no day header)
+ this.options.show_weekend || day_header.length === 0
+ ))
+ {
+ rel_time = (new Date(this.options.end_date) - new Date(this.options.start_date))*rel_x/1000;
+ this.date_helper.set_value(this.options.start_date.toJSON());
+ }
+ // Not so simple math, need to account for missing days
+ else if(this.options.group_by !== 'month' && !this.options.show_weekend)
+ {
+ // Find which day
+ if(day_header.length === 0) return false;
+ var day = document.elementFromPoint(
+ day_header.offset().left + rel_x * this.headers.innerWidth(),
+ day_header.offset().top
+ );
+
+ // Use day, and find time in that day
+ if(day && day.dataset && day.dataset.date)
+ {
+ this.date_helper.set_value(day.dataset.date);
+ rel_time = ((x - jQuery(day).position().left) / jQuery(day).outerWidth(true)) * 24*60;
+ this.date_helper.set_minutes(Math.round(rel_time/interval) * interval);
+ return new Date(this.date_helper.getValue());
+ }
+ return false;
+ }
+ else
+ {
+ // Find the correct row so we know which month, then get the offset
+ var hidden_nodes = [];
+ var row = null;
+ // Hide any drag or tooltips that may interfere
+ do
+ {
+ row = document.elementFromPoint(x, y);
+ if(this.div.has(row).length == 0)
+ {
+ hidden_nodes.push(jQuery(row).hide());
+ }
+ else
+ {
+ break;
+ }
+ } while(row && row.nodeName !== 'BODY');
+ if(!row) return false;
+
+ // Restore hidden nodes
+ for(var i = 0; i < hidden_nodes.length; i++)
+ {
+ hidden_nodes[i].show();
+ }
+ row = jQuery(row).closest('.calendar_plannerRowWidget');
+
+
+ var row_widget = null;
+ for(var i = 0; i < this._children.length && row.length > 0; i++)
+ {
+ if(this._children[i].div[0] == row[0])
+ {
+ row_widget = this._children[i];
+ break;
+ }
+ }
+ if(row_widget)
+ {
+ // Not sure where the extra -1 and +2 are coming from, but it makes it work out
+ // in FF & Chrome
+ rel_x = Math.min((x-row_widget.rows.offset().left-1)/(row_widget.rows.width()+2),1);
+
+ // 2678400 is the number of seconds in 31 days
+ rel_time = (2678400)*rel_x;
+ this.date_helper.set_value(row_widget.options.start_date.toJSON());
+ }
+ else
+ {
+ return false;
+ }
+ }
+ if(rel_time < 0) return false;
+
+ this.date_helper.set_minutes(Math.round(rel_time / (60 * interval))*interval);
+
+ return new Date(this.date_helper.getValue());
+ }
+
+ /**
+ * Mousedown handler to support drag to create
+ *
+ * @param {jQuery.Event} event
+ */
+ _mouse_down(event)
+ {
+ // Only left mouse button
+ if(event.which !== 1) return;
+
+ // Ignore headers
+ if(this.headers.has(event.target).length !== 0) return false;
+
+ // Get time at mouse
+ if(this.options.group_by === 'month')
+ {
+ var time = this._get_time_from_position(event.clientX, event.clientY);
+ }
+ else
+ {
+ var time = this._get_time_from_position(event.offsetX, event.offsetY);
+ }
+ if(!time) return false;
+
+ // Find the correct row so we know the parent
+ var row = event.target.closest('.calendar_plannerRowWidget');
+ for(var i = 0; i < this._children.length && row; i++)
+ {
+ if(this._children[i].div[0] === row)
+ {
+ this.drag_create.parent = this._children[i];
+ // Clear cached events for re-layout
+ this._children[i]._cached_rows = [];
+ break;
+ }
+ }
+ if(!this.drag_create.parent) return false;
+
+ this.div.css('cursor', 'ew-resize');
+
+ return this._drag_create_start(jQuery.extend({},this.drag_create.parent.node.dataset,{date: time.toJSON()}));
+ }
+
+ /**
+ * Mouseup handler to support drag to create
+ *
+ * @param {jQuery.Event} event
+ */
+ _mouse_up(event)
+ {
+ // Get time at mouse
+ if(this.options.group_by === 'month')
+ {
+ var time = this._get_time_from_position(event.clientX, event.clientY);
+ }
+ else
+ {
+ var time = this._get_time_from_position(event.offsetX, event.offsetY);
+ }
+
+ return this._drag_create_end(time ? {date: time.toJSON()} : false);
+ }
+
+ /**
+ * Code for implementing et2_IDetachedDOM
+ *
+ * @param {array} _attrs array to add further attributes to
+ */
+ getDetachedAttributes( _attrs)
+ {
+ _attrs.push('start_date','end_date');
+ }
+
+ getDetachedNodes()
+ {
+ return [this.getDOMNode()];
+ }
+
+ setDetachedAttributes( _nodes, _values)
+ {
+ this.div = jQuery(_nodes[0]);
+
+ if(_values.start_date)
+ {
+ this.set_start_date(_values.start_date);
+ }
+ if(_values.end_date)
+ {
+ this.set_end_date(_values.end_date);
+ }
+ }
+
+ // Resizable interface
+ resize()
+ {
+ // Take the whole tab height
+ var height = Math.min(jQuery(this.getInstanceManager().DOMContainer).height(),jQuery(this.getInstanceManager().DOMContainer).parent().innerHeight());
+
+ // Allow for toolbar
+ height -= jQuery('#calendar-toolbar',this.div.parents('.egw_fw_ui_tab_content')).outerHeight(true);
+
+ this.options.height = height;
+ this.div.css('height', this.options.height);
+ // Set height for rows
+ this.rows.height(this.div.height() - this.headers.outerHeight());
+
+ this.grid.height(this.rows[0].scrollHeight);
+ }
+
+ /**
+ * Set up for printing
+ *
+ * @return {undefined|Deferred} Return a jQuery Deferred object if not done setting up
+ * (waiting for data)
+ */
+ beforePrint( )
+ {
+
+ if(this.disabled || !this.div.is(':visible'))
+ {
+ return;
+ }
+ this.rows.css('overflow-y', 'visible');
+
+ var rows = jQuery('.calendar_eventRows');
+ var width = rows.width();
+ var events = jQuery('.calendar_calEvent', rows)
+ .each(function() {
+ var event = jQuery(this);
+ event.width((event.width() / width) * 100 + '%')
+ });
+
+ }
+
+ /**
+ * Reset after printing
+ */
+ afterPrint( )
+ {
+ this.rows.css('overflow-y', 'auto');
+ }
+}
+et2_register_widget(et2_calendar_planner, ["calendar-planner"]);
diff --git a/calendar/js/et2_widget_planner_row.ts b/calendar/js/et2_widget_planner_row.ts
new file mode 100644
index 0000000000..c747aded76
--- /dev/null
+++ b/calendar/js/et2_widget_planner_row.ts
@@ -0,0 +1,839 @@
+/*
+ * Egroupware
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package
+ * @subpackage
+ * @link http://www.egroupware.org
+ * @author Nathan Gray
+ * @version $Id$
+ */
+
+/*egw:uses
+ /calendar/js/et2_widget_view.js;
+ /calendar/js/et2_widget_daycol.js;
+ /calendar/js/et2_widget_event.js;
+*/
+
+
+import {et2_register_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget";
+import {et2_valueWidget} from "../../api/js/etemplate/et2_core_valueWidget";
+import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance";
+import {et2_date} from "../../api/js/etemplate/et2_widget_date";
+import {et2_action_object_impl} from "../../api/js/etemplate/et2_core_DOMWidget";
+import {et2_calendar_planner} from "./et2_widget_planner";
+
+/**
+ * Class for one row of a planner
+ *
+ * This widget is responsible for the label on the side
+ *
+ */
+export class et2_calendar_planner_row extends et2_valueWidget implements et2_IResizeable
+{
+ static readonly _attributes: any = {
+ start_date: {
+ name: "Start date",
+ type: "any"
+ },
+ end_date: {
+ name: "End date",
+ type: "any"
+ },
+ value: {
+ type: "any"
+ }
+ };
+ private div: JQuery;
+ private title: JQuery;
+ private rows: JQuery;
+ private _date_helper: et2_date;
+ private _cached_rows: any[];
+ private _row_height = 20;
+ private _actionObject: egwActionObject;
+
+ /**
+ * Constructor
+ */
+ constructor(_parent, _attrs? : WidgetConfig, _child? : object)
+ {
+ // Call the inherited constructor
+ super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_planner_row._attributes, _child || {}));
+
+ // Main container
+ this.div = jQuery(document.createElement("div"))
+ .addClass("calendar_plannerRowWidget")
+ .css('width',this.options.width);
+ this.title = jQuery(document.createElement('div'))
+ .addClass("calendar_plannerRowHeader")
+ .appendTo(this.div);
+ this.rows = jQuery(document.createElement('div'))
+ .addClass("calendar_eventRows")
+ .appendTo(this.div);
+
+ this.setDOMNode(this.div[0]);
+
+ // Used for its date calculations
+ this._date_helper = et2_createWidget('date-time', {}, null);
+ this._date_helper.loadingFinished();
+
+ this.set_start_date(this.options.start_date);
+ this.set_end_date(this.options.end_date);
+
+ this._cached_rows = [];
+ }
+
+ doLoadingFinished( )
+ {
+ super.doLoadingFinished();
+
+ this.set_label(this.options.label);
+ this._draw();
+
+ // Actions are set on the parent, so we need to explicitly get in here
+ // and get ours
+ this._link_actions(this.getParent().options.actions || []);
+ return true;
+ }
+
+ destroy( )
+ {
+ super.destroy();
+
+ // date_helper has no parent, so we must explicitly remove it
+ this._date_helper.destroy();
+ this._date_helper = null;
+ }
+
+ getDOMNode(_sender)
+ {
+ if(_sender === this || !_sender)
+ {
+ return this.div[0];
+ }
+ if(_sender._parent === this)
+ {
+ return this.rows[0];
+ }
+ }
+
+ /**
+ * Link the actions to the DOM nodes / widget bits.
+ *
+ * @param {object} actions {ID: {attributes..}+} map of egw action information
+ */
+ _link_actions(actions)
+ {
+ // Get the parent? Might be a grid row, might not. Either way, it is
+ // just a container with no valid actions
+ let objectManager = egw_getObjectManager(this.getInstanceManager().app, true, 1);
+ objectManager = objectManager.getObjectById(this.getInstanceManager().uniqueId,2) || objectManager;
+ let parent = objectManager.getObjectById(this.id, 1) || objectManager.getObjectById(this.getParent().id, 1) || objectManager;
+ if(!parent)
+ {
+ egw.debug('error','No parent objectManager found');
+ return;
+ }
+
+ // This binds into the egw action system. Most user interactions (drag to move, resize)
+ // are handled internally using jQuery directly.
+ let widget_object = this._actionObject || parent.getObjectById(this.id);
+ const aoi = new et2_action_object_impl(this, this.getDOMNode(this)).getAOI();
+ const planner = this.getParent();
+
+ for(let i = 0; i < parent.children.length; i++)
+ {
+ const parent_finder = jQuery(parent.children[i].iface.doGetDOMNode()).find(this.div);
+ if(parent_finder.length > 0)
+ {
+ parent = parent.children[i];
+ break;
+ }
+ }
+
+ // Determine if we allow a dropped event to use the invite/change actions
+ const _invite_enabled = function (action, event, target)
+ {
+ var event = event.iface.getWidget();
+ const row = target.iface.getWidget() || false;
+ if(event === row || !event || !row ||
+ !event.options || !event.options.value.participants
+ )
+ {
+ return false;
+ }
+
+ let owner_match = false;
+ const own_row = event.getParent() === row;
+
+ for (let id in event.options.value.participants)
+ {
+ owner_match = owner_match || row.node.dataset.participants === '' + id;
+ }
+
+ const enabled = !owner_match &&
+ // Not inside its own timegrid
+ !own_row;
+
+ widget_object.getActionLink('invite').enabled = enabled;
+ widget_object.getActionLink('change_participant').enabled = enabled;
+
+ // If invite or change participant are enabled, drag is not
+ widget_object.getActionLink('egw_link_drop').enabled = !enabled;
+ };
+
+ aoi.doTriggerEvent = function(_event, _data) {
+
+ // Determine target node
+ var event = _data.event || false;
+ if(!event) return;
+ if(_data.ui.draggable.hasClass('rowNoEdit')) return;
+ /*
+ We have to handle the drop in the normal event stream instead of waiting
+ for the egwAction system so we can get the helper, and destination
+ */
+ if(event.type === 'drop' && widget_object.getActionLink('egw_link_drop').enabled)
+ {
+ this.getWidget().getParent()._event_drop.call(
+ jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper)[0],
+ this.getWidget().getParent(), event, _data.ui,
+ this.getWidget()
+ );
+ }
+ const drag_listener = function (_event, ui)
+ {
+ if(planner.options.group_by === 'month')
+ {
+ var position = {left: _event.clientX, top: _event.clientY};
+ }
+ else
+ {
+ var position = {top: ui.position.top, left: ui.position.left - jQuery(this).parent().offset().left};
+ }
+ aoi.getWidget().getParent()._drag_helper(
+ jQuery('.calendar_d-n-d_timeCounter', ui.helper)[0],
+ position, 0
+ );
+
+ let event = _data.ui.draggable.data('selected')[0];
+ if(!event || event.id && event.id.indexOf('calendar') !== 0)
+ {
+ event = false;
+ }
+ if(event)
+ {
+ _invite_enabled(
+ widget_object.getActionLink('invite').actionObj,
+ event,
+ widget_object
+ );
+ }
+ };
+ const time = jQuery('.calendar_d-n-d_timeCounter', _data.ui.helper);
+ switch(_event)
+ {
+ // Triggered once, when something is dragged into the timegrid's div
+ case EGW_AI_DRAG_OVER:
+ // Listen to the drag and update the helper with the time
+ // This part lets us drag between different timegrids
+ _data.ui.draggable.on('drag.et2_timegrid_row'+widget_object.id, drag_listener);
+ _data.ui.draggable.on('dragend.et2_timegrid_row'+widget_object.id, function() {
+ _data.ui.draggable.off('drag.et2_timegrid_row' + widget_object.id);
+ });
+ widget_object.iface.getWidget().div.addClass('drop-hover');
+
+ // Disable invite / change actions for same calendar or already participant
+ var event = _data.ui.draggable.data('selected')[0];
+ if(!event || event.id && event.id.indexOf('calendar') !== 0)
+ {
+ event = false;
+ }
+ if(event)
+ {
+ _invite_enabled(
+ widget_object.getActionLink('invite').actionObj,
+ event,
+ widget_object
+ );
+ }
+ if(time.length)
+ {
+ // The out will trigger after the over, so we count
+ time.data('count',time.data('count')+1);
+ }
+ else
+ {
+ _data.ui.helper.prepend('
');
+ }
+
+
+ break;
+
+ // Triggered once, when something is dragged out of the timegrid
+ case EGW_AI_DRAG_OUT:
+ // Stop listening
+ _data.ui.draggable.off('drag.et2_timegrid_row'+widget_object.id);
+ // Remove highlight
+ widget_object.iface.getWidget().div.removeClass('drop-hover');
+
+ // Out triggers after the over, count to not accidentally remove
+ time.data('count',time.data('count')-1);
+ if(time.length && time.data('count') <= 0)
+ {
+ time.remove();
+ }
+ break;
+ }
+ };
+
+ if (widget_object == null) {
+ // Add a new container to the object manager which will hold the widget
+ // objects
+ widget_object = parent.insertObject(false, new egwActionObject(
+ this.id, parent, aoi,
+ this._actionManager|| parent.manager.getActionById(this.id) || parent.manager
+ ));
+ }
+ else
+ {
+ widget_object.setAOI(aoi);
+ }
+ this._actionObject = widget_object;
+
+ // Delete all old objects
+ widget_object.clear();
+ widget_object.unregisterActions();
+
+ // Go over the widget & add links - this is where we decide which actions are
+ // 'allowed' for this widget at this time
+ const action_links = this._get_action_links(actions);
+
+ this.getParent()._init_links_dnd(widget_object.manager, action_links);
+
+ widget_object.updateActionLinks(action_links);
+ }
+
+ /**
+ * Get all action-links / id's of 1.-level actions from a given action object
+ *
+ * Here we are only interested in drop events.
+ *
+ * @param actions
+ * @returns {Array}
+ */
+ _get_action_links(actions)
+ {
+ const action_links = [];
+
+ // Only these actions are allowed without a selection (empty actions)
+ const empty_actions = ['add'];
+
+ for(let i in actions)
+ {
+ const action = actions[i];
+ if(empty_actions.indexOf(action.id) !== -1 || action.type == 'drop')
+ {
+ action_links.push(typeof action.id != 'undefined' ? action.id : i);
+ }
+ }
+ return action_links;
+ }
+
+ /**
+ * Draw the individual divs for weekends and events
+ */
+ _draw( )
+ {
+ // Remove any existing
+ this.rows.remove('.calendar_eventRowsMarkedDay,.calendar_eventRowsFiller').nextAll().remove();
+
+ let days = 31;
+ let width = '100';
+ if (this.getParent().options.group_by === 'month')
+ {
+ days = this.options.end_date.getUTCDate();
+
+ if(days < 31)
+ {
+ const diff = 31 - days;
+ width = 'calc('+(diff * 3.23) + '% - ' + (diff * 7) + 'px)';
+ }
+ }
+
+ // mark weekends and other special days in yearly planner
+ if (this.getParent().options.group_by == 'month')
+ {
+ this.rows.append(this._yearlyPlannerMarkDays(this.options.start_date, days));
+ }
+
+ if (this.getParent().options.group_by === 'month' && days < 31)
+ {
+ // add a filler for non existing days in that month
+ this.rows.after('');
+ }
+ }
+
+ set_label(label)
+ {
+ this.options.label = label;
+ this.title.text(label);
+ if(this.getParent().options.group_by === 'month')
+ {
+ this.title.attr('data-date', this.options.start_date.toJSON());
+ this.title.attr('data-sortby', 'user');
+ this.title.addClass('et2_clickable et2_link');
+ }
+ else
+ {
+ this.title.attr('data-date','');
+ this.title.removeClass('et2_clickable');
+ }
+ }
+
+ /**
+ * Change the start date
+ *
+ * @param {Date} new_date New end date
+ * @returns {undefined}
+ */
+ set_start_date(new_date)
+ {
+ if(!new_date || new_date === null)
+ {
+ throw new TypeError('Invalid end date. ' + new_date.toString());
+ }
+
+ this.options.start_date = new Date(typeof new_date == 'string' ? new_date : new_date.toJSON());
+ this.options.start_date.setUTCHours(0);
+ this.options.start_date.setUTCMinutes(0);
+ this.options.start_date.setUTCSeconds(0);
+ }
+ /**
+ * Change the end date
+ *
+ * @param {string|number|Date} new_date New end date
+ * @returns {undefined}
+ */
+ set_end_date(new_date)
+ {
+ if(!new_date || new_date === null)
+ {
+ throw new TypeError('Invalid end date. ' + new_date.toString());
+ }
+
+ this.options.end_date = new Date(typeof new_date == 'string' ? new_date : new_date.toJSON());
+ this.options.end_date.setUTCHours(23);
+ this.options.end_date.setUTCMinutes(59);
+ this.options.end_date.setUTCSeconds(59);
+ }
+
+ /**
+ * Mark special days (birthdays, holidays) on the planner
+ *
+ * @param {Date} start Start of the month
+ * @param {number} days How many days in the month
+ */
+ _yearlyPlannerMarkDays(start,days)
+ {
+ const day_width = 3.23;
+ const t = new Date(start);
+ let content = '';
+ for(let i = 0; i < days; i++)
+ {
+ const holidays = [];
+ // TODO: implement this, pull / copy data from et2_widget_timegrid
+ const day_class = (this.getParent()).day_class_holiday(t, holidays);
+
+ if (day_class) // no regular weekday
+ {
+ content += '';
+ }
+ t.setUTCDate(t.getUTCDate()+1);
+ }
+ return content;
+ }
+
+ /**
+ * Callback used when the daywise data changes
+ *
+ * Events should update themselves when their data changes, here we are
+ * dealing with a change in which events are displayed on this row.
+ *
+ * @param {String[]} event_ids
+ * @returns {undefined}
+ */
+ _data_callback( event_ids)
+ {
+ const events = [];
+ if(event_ids == null || typeof event_ids.length == 'undefined') event_ids = [];
+ for(let i = 0; i < event_ids.length; i++)
+ {
+ let event = egw.dataGetUIDdata('calendar::' + event_ids[i]);
+ event = event && event.data || false;
+ if(event && event.date)
+ {
+ events.push(event);
+ }
+ else if (event)
+ {
+ // Got an ID that doesn't belong
+ event_ids.splice(i--,1);
+ }
+ }
+ if(!this.getParent().disabled && event_ids.length > 0)
+ {
+ this.resize();
+ this._update_events(events);
+ }
+ }
+
+ get date_helper(): et2_date
+ {
+ return this._date_helper;
+ }
+
+ /**
+ * Load the event data for this day and create event widgets for each.
+ *
+ * If event information is not provided, it will be pulled from the content array.
+ *
+ * @param {Object[]} [events] Array of event information, one per event.
+ */
+ _update_events(events)
+ {
+ // Remove all events
+ while(this._children.length > 0)
+ {
+ const node = this._children[this._children.length - 1];
+ this.removeChild(node);
+ node.destroy();
+ }
+ this._cached_rows = [];
+
+ for(var c = 0; c < events.length; c++)
+ {
+ // Create event
+ var event = et2_createWidget('calendar-event',{
+ id:'event_'+events[c].row_id,
+ value: events[c]
+ },this);
+ }
+
+ // Seperate loop so column sorting finds all children in the right place
+ for(var c = 0; c < events.length; c++)
+ {
+ let event = this.getWidgetById('event_'+events[c].row_id);
+ if(!event) continue;
+ if(this.isInTree())
+ {
+ event.doLoadingFinished();
+ }
+ }
+ }
+
+ /**
+ * Position the event according to it's time and how this widget is laid
+ * out.
+ *
+ * @param {undefined|Object|et2_calendar_event} event
+ */
+ position_event(event?)
+ {
+ const rows = this._spread_events();
+ const height = rows.length * this._row_height;
+ let row_width = this.rows.width();
+ if(row_width == 0)
+ {
+ // Not rendered yet or something
+ row_width = this.getParent().gridHeader.width() - this.title.width()
+ }
+ row_width -= 15;
+
+ for(let c = 0; c < rows.length; c++)
+ {
+ // Calculate vertical positioning
+ const top = c * (100.0 / rows.length);
+
+ for(let i = 0; (rows[c].indexOf(event) >=0 || !event) && i < rows[c].length; i++)
+ {
+ // Calculate horizontal positioning
+ const left = this._time_to_position(rows[c][i].options.value.start);
+ const width = this._time_to_position(rows[c][i].options.value.end) - left;
+
+ // Position the event
+ rows[c][i].div.css('top', top+'%');
+ rows[c][i].div.css('height', (100/rows.length)+'%');
+ rows[c][i].div.css('left', left.toFixed(1)+'%');
+ rows[c][i].div.outerWidth((width/100 * row_width) +'px');
+ }
+ }
+ if(height)
+ {
+ this.div.height(height+'px');
+ }
+ }
+
+ /**
+ * Sort a day's events into non-overlapping rows
+ *
+ * @returns {Array[]} Events sorted into rows
+ */
+ _spread_events()
+ {
+ // Keep it so we don't have to re-do it when the next event asks
+ let cached_length = 0;
+ this._cached_rows.map(function(row) {cached_length+=row.length;});
+ if(cached_length === this._children.length)
+ {
+ return this._cached_rows;
+ }
+
+ // sorting the events in non-overlapping rows
+ const rows = [];
+ const row_end = [0];
+
+ // Sort in chronological order, so earliest ones are at the top
+ this._children.sort(function(a,b) {
+ const start = new Date(a.options.value.start) - new Date(b.options.value.start);
+ const end = new Date(a.options.value.end) - new Date(b.options.value.end);
+ // Whole day events sorted by ID, normal events by start / end time
+ if(a.options.value.whole_day && b.options.value.whole_day)
+ {
+ // Longer duration comes first so we have nicer bars across the top
+ const duration =
+ (new Date(b.options.value.end) - new Date(b.options.value.start)) -
+ (new Date(a.options.value.end) - new Date(a.options.value.start));
+
+ return duration ? duration : (a.options.value.app_id - b.options.value.app_id);
+ }
+ else if (a.options.value.whole_day || b.options.value.whole_day)
+ {
+ return a.options.value.whole_day ? -1 : 1;
+ }
+ return start ? start : end;
+ });
+
+ for(let n = 0; n < this._children.length; n++)
+ {
+ const event = this._children[n].options.value || false;
+ if(typeof event.start !== 'object')
+ {
+ this._date_helper.set_value(event.start);
+ event.start = new Date(this._date_helper.getValue());
+ }
+ if(typeof event.end !== 'object')
+ {
+ this._date_helper.set_value(event.end);
+ event.end = new Date(this._date_helper.getValue());
+ }
+ if(typeof event['start_m'] === 'undefined')
+ {
+ let day_start = event.start.valueOf() / 1000;
+ const dst_check = new Date(event.start);
+ dst_check.setUTCHours(12);
+
+ // if daylight saving is switched on or off, correct $day_start
+ // gives correct times after 2am, times between 0am and 2am are wrong
+ const daylight_diff = day_start + 12 * 60 * 60 - (dst_check.valueOf() / 1000);
+ if(daylight_diff)
+ {
+ day_start -= daylight_diff;
+ }
+
+ event['start_m'] = event.start.getUTCHours() * 60 + event.start.getUTCMinutes();
+ if (event['start_m'] < 0)
+ {
+ event['start_m'] = 0;
+ event['multiday'] = true;
+ }
+ event['end_m'] = event.end.getUTCHours() * 60 + event.end.getUTCMinutes();
+ if (event['end_m'] >= 24*60)
+ {
+ event['end_m'] = 24*60-1;
+ event['multiday'] = true;
+ }
+ if(!event.start.getUTCHours() && !event.start.getUTCMinutes() && event.end.getUTCHours() == 23 && event.end.getUTCMinutes() == 59)
+ {
+ event.whole_day_on_top = (event.non_blocking && event.non_blocking != '0');
+ }
+ }
+
+ // Skip events entirely on hidden weekends
+ if(this._hidden_weekend_event(event))
+ {
+ const node = this._children[n];
+ this.removeChild(n--);
+ node.destroy();
+ continue;
+ }
+
+ const event_start = new Date(event.start).valueOf();
+ for(var row = 0; row_end[row] > event_start; ++row); // find a "free" row (no other event)
+ if(typeof rows[row] === 'undefined') rows[row] = [];
+ rows[row].push(this._children[n]);
+ row_end[row] = new Date(event['end']).valueOf();
+ }
+ this._cached_rows = rows;
+ return rows;
+ }
+
+ /**
+ * Check to see if the event is entirely on a hidden weekend
+ *
+ * @param values Array of event values, not an et2_widget_event
+ */
+ _hidden_weekend_event(values)
+ {
+ if(!this.getParent() || this.getParent().options.group_by == 'month' || this.getParent().options.show_weekend)
+ {
+ return false;
+ }
+ // Starts on Saturday or Sunday, ends Sat or Sun, less than 2 days long
+ else if([0,6].indexOf(values.start.getUTCDay()) !== -1 && [0,6].indexOf(values.end.getUTCDay()) !== -1
+ && values.end - values.start < 2 * 24 * 3600 * 1000)
+ {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Calculates the horizontal position based on the time given, as a percentage
+ * between the start and end times
+ *
+ * @param {int|Date|string} time in minutes from midnight, or a Date in string or object form
+ * @param {int|Date|string} start Earliest possible time (0%)
+ * @param {int|Date|string} end Latest possible time (100%)
+ * @return {float} position in percent
+ */
+ _time_to_position(time, start?, end?)
+ {
+ let pos = 0.0;
+
+ // Handle the different value types
+ start = this.options.start_date;
+ end = this.options.end_date;
+
+ if(typeof start === 'string')
+ {
+ start = new Date(start);
+ end = new Date(end);
+ }
+ const wd_start = 60 * (parseInt(''+egw.preference('workdaystarts', 'calendar')) || 9);
+ const wd_end = 60 * (parseInt(''+egw.preference('workdayends', 'calendar')) || 17);
+
+ let t = time;
+ if(typeof time === 'number' && time < 3600)
+ {
+ t = new Date(start.valueOf() + wd_start * 3600*1000);
+ }
+ else
+ {
+ t = new Date(time);
+ }
+
+ // Limits
+ if(t <= start) return 0; // We are left of our scale
+ if(t >= end) return 100; // We are right of our scale
+
+ // Remove space for weekends, if hidden
+ let weekend_count = 0;
+ let weekend_before = 0;
+ let partial_weekend = 0;
+ if(this.getParent().options.group_by !== 'month' && this.getParent() && !this.getParent().options.show_weekend)
+ {
+
+ const counter_date = new Date(start);
+ do
+ {
+ if([0,6].indexOf(counter_date.getUTCDay()) !== -1)
+ {
+ if(counter_date.getUTCDate() === t.getUTCDate() && counter_date.getUTCMonth() === t.getUTCMonth())
+ {
+ // Event is partially on a weekend
+ partial_weekend += (t.getUTCHours() *60 + t.getUTCMinutes())*60*1000;
+ }
+ else if(counter_date < t)
+ {
+ weekend_before++;
+ }
+ weekend_count++;
+ }
+ counter_date.setUTCDate(counter_date.getUTCDate() + 1);
+ } while(counter_date < end);
+ // Put it in ms
+ weekend_before *= 24 * 3600 * 1000;
+ weekend_count *= 24 * 3600 * 1000;
+ }
+
+ // Basic scaling, doesn't consider working times
+ pos = (t - start - weekend_before-partial_weekend) / (end - start - weekend_count);
+
+ // Month view
+ if(this.getParent().options.group_by !== 'month')
+ {
+ // Daywise scaling
+ /* Needs hourly scales that consider working hours
+ var start_date = new Date(start.getUTCFullYear(), start.getUTCMonth(),start.getUTCDate());
+ var end_date = new Date(end.getUTCFullYear(), end.getUTCMonth(),end.getUTCDate());
+ var t_date = new Date(t.getUTCFullYear(), t.getUTCMonth(),t.getUTCDate());
+
+ var days = Math.round((end_date - start_date) / (24 * 3600 * 1000))+1;
+ pos = 1 / days * Math.round((t_date - start_date) / (24*3600 * 1000));
+
+ var time_of_day = typeof t === 'object' ? 60 * t.getUTCHours() + t.getUTCMinutes() : t;
+
+ if (time_of_day >= wd_start)
+ {
+ var day_percentage = 0.1;
+ if (time_of_day > wd_end)
+ {
+ day_percentage = 1;
+ }
+ else
+ {
+ var wd_length = wd_end - wd_start;
+ if (wd_length <= 0) wd_length = 24*60;
+ day_percentage = (time_of_day-wd_start) / wd_length; // between 0 and 1
+ }
+ pos += day_percentage / days;
+ }
+ */
+ }
+ else
+ {
+ // 2678400 is the number of seconds in 31 days
+ pos = (t - start) / 2678400000;
+ }
+ pos = 100 * pos;
+
+ return pos;
+ }
+
+ // Resizable interface
+ /**
+ * Resize
+ *
+ * Parent takes care of setting proper width & height for the containing div
+ * here we just need to adjust the events to fit the new size.
+ */
+ resize ()
+ {
+ if(this.disabled || !this.div.is(':visible') || this.getParent().disabled)
+ {
+ return;
+ }
+
+ const row = jQuery('').appendTo(this.rows);
+ this._row_height = (parseInt(window.getComputedStyle(row[0]).getPropertyValue("height")) || 20);
+ row.remove();
+
+ // Resize & position all events
+ this.position_event();
+ }
+
+}
+et2_register_widget(et2_calendar_planner_row, ["calendar-planner_row"]);
\ No newline at end of file
diff --git a/calendar/js/et2_widget_timegrid.ts b/calendar/js/et2_widget_timegrid.ts
new file mode 100644
index 0000000000..de5bb08933
--- /dev/null
+++ b/calendar/js/et2_widget_timegrid.ts
@@ -0,0 +1,2389 @@
+/*
+ * Egroupware Calendar timegrid
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package etemplate
+ * @subpackage api
+ * @link http://www.egroupware.org
+ * @author Nathan Gray
+ * @version $Id$
+ */
+
+
+/*egw:uses
+ /calendar/js/et2_widget_view.js;
+*/
+
+import {et2_register_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget";
+import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance";
+import {et2_calendar_view} from "./et2_widget_view";
+import {et2_action_object_impl} from "../../api/js/etemplate/et2_core_DOMWidget";
+import {et2_dataview_grid} from "../../api/js/etemplate/et2_dataview_view_grid";
+
+/**
+ * Class which implements the "calendar-timegrid" XET-Tag for displaying a span of days
+ *
+ * This widget is responsible for the times on the side, and it is also the
+ * controller for both positioning and setting the day columns. Day columns are
+ * recycled rather than removed and re-created to reduce reloading. Similarly,
+ * the horizontal time grid (when used - see granularity attribute) is only
+ * redrawn or resized when needed. Unfortunately resizing is needed every time
+ * the all day section has an event added or removed so the full work day from
+ * start time to end time is properly displayed.
+ *
+ *
+ * @augments et2_calendar_view
+ */
+export class et2_calendar_timegrid extends et2_calendar_view implements et2_IDetachedDOM, et2_IResizeable,et2_IPrint
+{
+ static readonly _attributes : any = {
+ value: {
+ type: "any",
+ description: "An array of events, indexed by date (Ymd format)."
+ },
+ day_start: {
+ name: "Day start time",
+ type: "string",
+ default: parseInt(''+egw.preference('workdaystarts','calendar')) || 9,
+ description: "Work day start time. If unset, this will default to the current user's preference"
+ },
+ day_end: {
+ name: "Day end time",
+ type: "string",
+ default: parseInt(''+egw.preference('workdayends','calendar')) || 17,
+ description: "Work day end time. If unset, this will default to the current user's preference"
+ },
+ show_weekend: {
+ name: "Weekends",
+ type: "boolean",
+ // @ts-ignore
+ default: egw.preference('days_in_weekview','calendar') != 5,
+ description: "Display weekends. The date range should still include them for proper scrolling, but they just won't be shown."
+ },
+ granularity: {
+ name: "Granularity",
+ type: "integer",
+ default: parseInt(''+egw.preference('interval','calendar')) || 30,
+ description: "How many minutes per row, or 0 to display events as a list"
+ },
+ "onchange": {
+ "name": "onchange",
+ "type": "js",
+ "default": et2_no_init,
+ "description": "JS code which is executed when the date range changes."
+ },
+ "onevent_change": {
+ "name": "onevent_change",
+ "type": "js",
+ "default": et2_no_init,
+ "description": "JS code which is executed when an event changes."
+ },
+ height: {
+ "default": '100%'
+ }
+ };
+ private gridHeader: JQuery;
+ private dayHeader: JQuery;
+ private scrolling: JQuery;
+ private days: JQuery;
+ private owner: any;
+ private gridHover: JQuery;
+
+ private day_list: any[];
+ private day_widgets: any[];
+ private resize_timer: number;
+ private _top_time: number;
+ private rowHeight: number;
+ private daily_owner: boolean = false;
+ private _drop_data: any;
+ private day_start: any;
+ private day_end: any;
+
+ /**
+ * Constructor
+ *
+ * @memberOf et2_calendar_timegrid
+ */
+ constructor(_parent, _attrs? : WidgetConfig, _child? : object)
+ {
+ // Call the inherited constructor
+ super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_timegrid._attributes, _child || {}));
+
+
+ // Main container
+ this.div = jQuery(document.createElement("div"))
+ .addClass("calendar_calTimeGrid")
+ .addClass("calendar_TimeGridNoLabel");
+
+ // Headers
+ this.gridHeader = jQuery(document.createElement("div"))
+ .addClass("calendar_calGridHeader")
+ .appendTo(this.div);
+ this.dayHeader = jQuery(document.createElement("div"))
+ .appendTo(this.gridHeader);
+
+ // Contains times / rows
+ this.scrolling = jQuery(document.createElement('div'))
+ .addClass("calendar_calTimeGridScroll")
+ .appendTo(this.div)
+ .append('');
+
+ // Contains days / columns
+ this.days = jQuery(document.createElement("div"))
+ .addClass("calendar_calDayCols")
+ .appendTo(this.scrolling);
+
+ // Used for owners
+ this.owner = et2_createWidget('description',{},this);
+
+ this._labelContainer = jQuery(document.createElement("label"))
+ .addClass("et2_label et2_link")
+ .appendTo(this.gridHeader);
+
+ this.gridHover = jQuery('');
+
+ // List of dates in Ymd
+ // The first one should be start_date, last should be end_date
+ this.day_list = [];
+ this.day_widgets = [];
+
+ // Timer to re-scale time to fit
+ this.resize_timer = null;
+
+ this.setDOMNode(this.div[0]);
+ }
+
+ destroy( )
+ {
+ // Stop listening to tab changes
+ if(typeof framework !== 'undefined' && framework.getApplicationByName('calendar').tab)
+ {
+ jQuery(framework.getApplicationByName('calendar').tab.contentDiv).off('show.' + this.id);
+ }
+
+ super.destroy();
+
+ // Delete all old objects
+ this._actionObject.clear();
+ this._actionObject.unregisterActions();
+ this._actionObject.remove();
+ this._actionObject = null;
+
+ this.div.off();
+ this.div = null;
+ this.gridHeader = null;
+ this.dayHeader = null;
+ this.days = null;
+ this.scrolling = null;
+ this._labelContainer = null;
+
+ // Stop the resize timer
+ if(this.resize_timer)
+ {
+ window.clearTimeout(this.resize_timer);
+ }
+ }
+
+ doLoadingFinished( )
+ {
+ super.doLoadingFinished();
+
+ // Listen to tab show to make sure we scroll to the day start, not top
+ if(typeof framework !== 'undefined' && framework.getApplicationByName('calendar').tab)
+ {
+ jQuery(framework.getApplicationByName('calendar').tab.contentDiv)
+ .on('show.' + this.id, jQuery.proxy(
+ function()
+ {
+ if(this.scrolling)
+ {
+ this.scrolling.scrollTop(this._top_time);
+ }
+ },this)
+ );
+ }
+
+ // Need to get the correct internal sizing
+ this.resize();
+
+ this._drawGrid();
+
+ // Actions may be set on a parent, so we need to explicitly get in here
+ // and get ours
+ this._link_actions(this.options.actions || this.getParent().options.actions || []);
+
+ // Automatically bind drag and resize for every event using jQuery directly
+ // - no action system -
+ var timegrid = this;
+
+ /**
+ * If user puts the mouse over an event, then we'll set up resizing so
+ * they can adjust the length. Should be a little better on resources
+ * than binding it for every calendar event, and we won't need exceptions
+ * for planner view to resize horizontally.
+ */
+ this.div.on('mouseover', '.calendar_calEvent:not(.ui-resizable):not(.rowNoEdit)', function() {
+ // Only resize in timegrid
+ if(timegrid.options.granularity === 0) return;
+
+ // Load the event
+ timegrid._get_event_info(this);
+ var that = this;
+
+ //Resizable event handler
+ jQuery(this).resizable
+ ({
+ distance: 10,
+ // Grid matching preference
+ grid: [10000,timegrid.rowHeight],
+ autoHide: false,
+ handles: 's,se',
+ containment:'parent',
+
+ /**
+ * Triggered when the resizable is created.
+ *
+ * @param {event} event
+ * @param {Object} ui
+ */
+ create:function(event, ui)
+ {
+ var resizeHelper = event.target.getAttribute('data-resize');
+ if (resizeHelper == 'WD' || resizeHelper == 'WDS')
+ {
+ jQuery(this).resizable('destroy');
+ }
+ },
+
+ /**
+ * If dragging to resize an event, abort drag to create
+ *
+ * @param {jQuery.Event} event
+ * @param {Object} ui
+ */
+ start: function(event, ui)
+ {
+ if(timegrid.drag_create.start)
+ {
+ // Abort drag to create, we're dragging to resize
+ timegrid._drag_create_end({});
+ }
+ },
+
+ /**
+ * Triggered at the end of resizing the calEvent.
+ *
+ * @param {event} event
+ * @param {Object} ui
+ */
+ stop:function(event, ui)
+ {
+ var e = new jQuery.Event('change');
+ e.originalEvent = event;
+ e.data = {duration: 0};
+ var event_data = timegrid._get_event_info(this);
+ var event_widget =
timegrid.getWidgetById(event_data.widget_id);
+ var sT = event_widget.options.value.start_m;
+ if (typeof this.dropEnd != 'undefined' && this.dropEnd.length == 1)
+ {
+ var eT = (parseInt(timegrid._drop_data.hour) * 60) + parseInt(timegrid._drop_data.minute);
+ e.data.duration = ((eT - sT)/60) * 3600;
+
+ if(event_widget)
+ {
+ event_widget.options.value.end_m = eT;
+ event_widget.options.value.duration = e.data.duration;
+ }
+ jQuery(this).trigger(e);
+ event_widget._update(event_widget.options.value);
+
+ // That cleared the resize handles, so remove for re-creation...
+ if(jQuery(this).resizable('instance'))
+ {
+ jQuery(this).resizable('destroy');
+ }
+ }
+ // Clear the helper, re-draw
+ if(event_widget && event_widget._parent)
+ {
+ event_widget._parent.position_event(event_widget);
+ }
+ timegrid.div.children('.drop-hover').removeClass('.drop-hover');
+ },
+
+ /**
+ * Triggered during the resize, on the drag of the resize handler
+ *
+ * @param {event} event
+ * @param {Object} ui
+ */
+ resize:function(event, ui)
+ {
+ // Add a bit for better understanding - it will show _to_ the start,
+ // covering the 'actual' target
+ timegrid._get_time_from_position(ui.helper[0].getBoundingClientRect().left, ui.helper[0].getBoundingClientRect().bottom+5);
+ timegrid.gridHover.hide();
+ var drop = timegrid._drag_helper(this,ui.element[0]);
+ if(drop && !drop.is(':visible'))
+ {
+ drop.get(0).scrollIntoView(false);
+ }
+ }
+ });
+ });
+
+ // Customize and override some draggable settings
+ this.div
+ .on('dragcreate','.calendar_calEvent', function(event, ui) {
+ jQuery(this).draggable('option','cancel','.rowNoEdit');
+ // Act like you clicked the header, makes it easier to position
+ // but put it to the side (-5) so we can still do the hover
+ jQuery(this).draggable('option','cursorAt', {top: 5, left: -5});
+ })
+ .on('dragstart', '.calendar_calEvent', function(event,ui) {
+ jQuery('.calendar_calEvent',ui.helper).width(jQuery(this).width())
+ .height(jQuery(this).outerHeight())
+ .css('top', '').css('left','')
+ .appendTo(ui.helper);
+ ui.helper.width(jQuery(this).width());
+
+ // Cancel drag to create, we're dragging an existing event
+ timegrid.drag_create.start = null;
+ timegrid._drag_create_end();
+ })
+ .on('mousemove', function(event) {
+ timegrid._get_time_from_position(event.clientX, event.clientY);
+ })
+ .on('mouseout', function(event) {
+ if(timegrid.div.has(event.relatedTarget).length === 0)
+ {
+ timegrid.gridHover.hide();
+ }
+ })
+ .on('mousedown', jQuery.proxy(this._mouse_down, this))
+ .on('mouseup', jQuery.proxy(this._mouse_up, this));
+
+ return true;
+ }
+
+ _createNamespace() {
+ return true;
+ }
+
+ /**
+ * Show the current time while dragging
+ * Used for resizing as well as drag & drop
+ *
+ * @param {type} element
+ * @param {type} helper
+ * @param {type} height
+ */
+ _drag_helper(element, helper,height)
+ {
+ if(!element) return;
+
+ element.dropEnd = this.gridHover;
+
+ if(element.dropEnd.length)
+ {
+ this._drop_data = jQuery.extend({},element.dropEnd[0].dataset || {});
+ }
+
+ if (typeof element.dropEnd != 'undefined' && element.dropEnd.length)
+ {
+ // Make sure the target is visible in the scrollable day
+ if(this.gridHover.is(':visible'))
+ {
+ if(this.scrolling.scrollTop() > 0 && this.scrolling.scrollTop() >= this.gridHover.position().top - this.rowHeight)
+ {
+ this.scrolling.scrollTop(this.gridHover.position().top-this.rowHeight);
+ }
+ else if (this.scrolling.scrollTop() + this.scrolling.height() <= this.gridHover.position().top + (2*this.rowHeight))
+ {
+ this.scrolling.scrollTop(this.scrolling.scrollTop() + this.rowHeight);
+ }
+ }
+ var time = '';
+ if(this._drop_data.whole_day)
+ {
+ time = this.egw().lang('Whole day');
+ }
+ else if (this.options.granularity === 0)
+ {
+ // No times, keep what's in the event
+ // Add class to helper to keep formatting
+ jQuery(helper).addClass('calendar_calTimeGridList');
+ }
+ else
+ {
+ // @ts-ignore
+ time = jQuery.datepicker.formatTime(
+ egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
+ {
+ hour: element.dropEnd.attr('data-hour'),
+ minute: element.dropEnd.attr('data-minute'),
+ seconds: 0,
+ timezone: 0
+ },
+ {"ampm": (egw.preference("timeformat") == "12")}
+ );
+ }
+ element.innerHTML = ''+time+'
';
+ }
+ else
+ {
+ element.innerHTML = '';
+ }
+ jQuery(element).width(jQuery(helper).width());
+ return element.dropEnd;
+ }
+
+ /**
+ * Handler for dropping an event on the timegrid
+ *
+ * @param {type} timegrid
+ * @param {type} event
+ * @param {type} ui
+ * @param {type} dropEnd
+ */
+ _event_drop( timegrid, event,ui, dropEnd)
+ {
+ var e = new jQuery.Event('change');
+ e.originalEvent = event;
+ e.data = {start: 0};
+
+ if(typeof dropEnd != 'undefined' && dropEnd)
+ {
+ var drop_date = dropEnd.date||false;
+
+ var event_data = timegrid._get_event_info(ui.draggable);
+ var event_widget = timegrid.getWidgetById(event_data.widget_id);
+ if(!event_widget)
+ {
+ // Widget was moved across weeks / owners
+ event_widget = timegrid.getParent().getWidgetById(event_data.widget_id);
+ }
+ if(event_widget)
+ {
+ // Send full string to avoid rollover between months using set_month()
+ event_widget._parent.date_helper.set_value(
+ drop_date.substring(0,4)+'-'+drop_date.substring(4,6)+'-'+drop_date.substring(6,8)+
+ 'T00:00:00Z'
+ );
+
+
+ // Make sure whole day events stay as whole day events by ignoring drop time
+ if(event_data.app == 'calendar' && event_widget.options.value.whole_day)
+ {
+ event_widget._parent.date_helper.set_hours(0);
+ event_widget._parent.date_helper.set_minutes(0);
+ }
+ else if (timegrid.options.granularity === 0)
+ {
+ // List, not time grid - keep time
+ event_widget._parent.date_helper.set_hours(event_widget.options.value.start.getUTCHours());
+ event_widget._parent.date_helper.set_minutes(event_widget.options.value.start.getUTCMinutes());
+ }
+ else
+ {
+ // Non-whole day events, and integrated apps, can change
+ event_widget._parent.date_helper.set_hours(dropEnd.whole_day ? 0 : dropEnd.hour||0);
+ event_widget._parent.date_helper.set_minutes(dropEnd.whole_day ? 0 : dropEnd.minute||0);
+ }
+
+ // Leave the helper there until the update is done
+ var loading = ui.helper.clone(true).appendTo(jQuery('body'));
+ // and add a loading icon so user knows something is happening
+ if(jQuery('.calendar_timeDemo',loading).length == 0)
+ {
+ jQuery('.calendar_calEventHeader',loading).addClass('loading');
+ }
+ else
+ {
+ jQuery('.calendar_timeDemo',loading).after('');
+ }
+
+ event_widget.recur_prompt(function(button_id) {
+ if(button_id === 'cancel' || !button_id)
+ {
+ // Need to refresh the event with original info to clean up
+ var app_id = event_widget.options.value.app_id ? event_widget.options.value.app_id : event_widget.options.value.id + (event_widget.options.value.recur_type ? ':'+event_widget.options.value.recur_date : '');
+ egw().dataStoreUID('calendar::'+app_id,egw.dataGetUIDdata('calendar::'+app_id).data);
+ loading.remove();
+ return;
+ }
+ let duration : string | number | boolean;
+ //Get infologID if in case if it's an integrated infolog event
+ if (event_data.app === 'infolog')
+ {
+ // Duration - infologs are always non-blocking
+ duration = dropEnd.whole_day ? 86400-1 : (
+ event_widget.options.value.whole_day ? (egw().preference('defaultlength','calendar')*60) : false);
+
+ // If it is an integrated infolog event we need to edit infolog entry
+ egw().json('stylite_infolog_calendar_integration::ajax_moveInfologEvent',
+ [event_data.app_id, event_widget._parent.date_helper.getValue()||false,duration],
+ function() {loading.remove();}
+ ).sendRequest(true);
+ }
+ else
+ {
+ //Edit calendar event
+
+ // Duration - check for whole day dropped on a time, change it to full days
+ duration = event_widget.options.value.whole_day && dropEnd.hour ?
+ // Make duration whole days, less 1 second
+ (Math.round((event_widget.options.value.end - event_widget.options.value.start) / (1000 * 86400)) * 86400) -1 :
+ false;
+ // Event (whole day or not) dropped on whole day section, change to whole day non blocking
+ if(dropEnd.whole_day) duration = 'whole_day';
+
+ // Send the update
+ var _send = function(series_instance)
+ {
+ var start = new Date(event_widget._parent.date_helper.getValue());
+
+ egw().json('calendar.calendar_uiforms.ajax_moveEvent', [
+ button_id==='series' ? event_data.id : event_data.app_id,event_data.owner,
+ start,
+ timegrid.options.owner||egw.user('account_id'),
+ duration,
+ series_instance
+ ],
+ function() { loading.remove();}
+ ).sendRequest(true);
+ };
+
+ // Check for modifying a series that started before today
+ if (event_widget.options.value.recur_type && button_id === 'series')
+ {
+ event_widget.series_split_prompt(function(_button_id) {
+ if (_button_id === et2_dialog.OK_BUTTON)
+ {
+ _send(event_widget.options.value.recur_date);
+ }
+ else
+ {
+ loading.remove();
+ }
+ });
+ }
+ else
+ {
+ _send(event_widget.options.value.recur_date);
+ }
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * Something changed, and the days need to be re-drawn. We wait a bit to
+ * avoid re-drawing twice if start and end date both changed, then recreate
+ * the days.
+ * The whole grid is not regenerated because times aren't expected to change,
+ * just the days.
+ *
+ * @param {boolean} [trigger=false] Trigger an event once things are done.
+ * Waiting until invalidate completes prevents 2 updates when changing the date range.
+ * @returns {undefined}
+ */
+ invalidate( trigger?)
+ {
+
+ // Reset the list of days
+ this.day_list = [];
+
+ // Wait a bit to see if anything else changes, then re-draw the days
+ if(this.update_timer)
+ {
+ window.clearTimeout(this.update_timer);
+ }
+ this.update_timer = window.setTimeout(jQuery.proxy(function() {
+ this.widget.update_timer = null;
+ window.clearTimeout(this.resize_timer);
+ this.widget.loader.hide().show();
+
+ // Update actions
+ if(this.widget._actionManager)
+ {
+ this.widget._link_actions(this.widget._actionManager.children);
+ }
+
+ this.widget._drawDays();
+ // We have to completely re-do times, as they may have changed in
+ // scale to the point where more labels are needed / need to be removed
+ this.widget._drawTimes();
+ if(this.trigger)
+ {
+ this.widget.change();
+ }
+ // Hide loader
+ window.setTimeout(jQuery.proxy(function() {this.loader.hide();},this.widget),200);
+ },{widget:this,"trigger":trigger}),et2_dataview_grid.ET2_GRID_INVALIDATE_TIMEOUT);
+ }
+
+ detachFromDOM( )
+ {
+ // Remove the binding to the change handler
+ jQuery(this.div).off(".et2_calendar_timegrid");
+
+ return super.detachFromDOM();
+ }
+
+ attachToDOM( )
+ {
+ let result = super.attachToDOM();
+
+ // Add the binding for the event change handler
+ jQuery(this.div).on("change.et2_calendar_timegrid", '.calendar_calEvent', this, function(e) {
+ // Make sure function gets a reference to the widget
+ var args = Array.prototype.slice.call(arguments);
+ if(args.indexOf(this) == -1) args.push(this);
+
+ return e.data.event_change.apply(e.data, args);
+ });
+
+ // Add the binding for the change handler
+ jQuery(this.div).on("change.et2_calendar_timegrid", '*:not(.calendar_calEvent)', this, function(e) {
+ return e.data.change.call(e.data, e, this);
+ });
+
+ // Catch resize and prevent it from bubbling further, triggering
+ // etemplate's resize
+ this.div.on('resize', this, function(e) {
+ e.stopPropagation();
+ });
+
+ return result;
+ }
+
+ getDOMNode( _sender)
+ {
+ if(_sender === this || !_sender)
+ {
+ return this.div ? this.div[0] : null;
+ }
+ else if (_sender.instanceOf(et2_calendar_daycol))
+ {
+ return this.days ? this.days[0] : null;
+ }
+ else if (_sender)
+ {
+ return this.gridHeader ? this.gridHeader[0] : null;
+ }
+ }
+
+ set_disabled( disabled)
+ {
+ var old_value = this.options.disabled;
+ super.set_disabled(disabled);
+ if(disabled)
+ {
+ this.loader.show();
+ }
+ else if (old_value !== disabled)
+ {
+ // Scroll to start of day - stops jumping in FF
+ // For some reason on Chrome & FF this doesn't quite get the day start
+ // to the top, so add 2px;
+ this.scrolling.scrollTop(this._top_time+2);
+ }
+
+ }
+
+ /**
+ * Clear everything, and redraw the whole grid
+ */
+ _drawGrid( )
+ {
+
+ this.div.css('height', this.options.height)
+ .empty();
+ this.loader.prependTo(this.div).show();
+
+ // Draw in the horizontal - the times
+ this._drawTimes();
+
+ // Draw in the vertical - the days
+ this.invalidate();
+ }
+
+ /**
+ * Creates the DOM nodes for the times in the left column, and the horizontal
+ * lines (mostly via CSS) that span the whole date range.
+ */
+ _drawTimes( )
+ {
+ jQuery('.calendar_calTimeRow',this.div).remove();
+
+ this.div.toggleClass('calendar_calTimeGridList', this.options.granularity === 0);
+
+ this.gridHeader
+ .attr('data-date', this.options.start_date)
+ .attr('data-owner', this.options.owner)
+ .append(this._labelContainer)
+ .append(this.owner.getDOMNode())
+ .append(this.dayHeader)
+ .appendTo(this.div);
+
+ // Max with 18 avoids problems when it's not shown
+ var header_height = Math.max(this.gridHeader.outerHeight(true), 18);
+
+ this.scrolling
+ .appendTo(this.div)
+ .off();
+
+ // No time grid - list
+ if(this.options.granularity === 0)
+ {
+ this.scrolling.css('height','100%');
+ this.days.css('height', '100%');
+ this.iterateOver(function(day) {
+ day.resize();
+ },this,et2_calendar_daycol);
+ return;
+ }
+
+ var wd_start = 60*this.options.day_start;
+ var wd_end = 60*this.options.day_end;
+ var granularity = this.options.granularity;
+ var totalDisplayMinutes = wd_end - wd_start;
+ var rowsToDisplay = Math.ceil((totalDisplayMinutes+60)/granularity);
+ var row_count = (1440 / this.options.granularity);
+
+
+ this.scrolling
+ .on('scroll', jQuery.proxy(this._scroll, this));
+
+ // Percent
+ var rowHeight = (100/rowsToDisplay).toFixed(1);
+ // Pixels
+ this.rowHeight = this.scrolling.height() / rowsToDisplay;
+
+ // We need a reasonable bottom limit here, but resize will handle it
+ // if we get too small
+ if(this.rowHeight < 5 && this.div.is(':visible'))
+ {
+ if(this.rowHeight === 0)
+ {
+ // Something is not right...
+ this.rowHeight = 5;
+ }
+ }
+
+ // the hour rows
+ var show = {
+ 5 : [0,15,30,45],
+ 10 : [0,30],
+ 15 : [0,30],
+ 45 : [0,15,30,45]
+ };
+ var html = '';
+ var line_height = parseInt(this.div.css('line-height'));
+ this._top_time = 0;
+ for(var t = 0,i = 0; t < 1440; t += granularity,++i)
+ {
+ if(t <= wd_start && t + granularity > wd_start)
+ {
+ this._top_time = this.rowHeight * (i+1+(wd_start - (t+granularity))/granularity);
+ }
+ var working_hours = (t >= wd_start && t < wd_end) ? ' calendar_calWorkHours' : '';
+ html += '';
+ // show time for full hours, always for 45min interval and at least on every 3 row
+ // @ts-ignore
+ var time = jQuery.datepicker.formatTime(
+ egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm",
+ {
+ hour: t / 60,
+ minute: t % 60,
+ seconds: 0,
+ timezone: 0
+ },
+ {"ampm": (egw.preference("timeformat") === "12")}
+ );
+
+ var time_label = (typeof show[granularity] === 'undefined' ? t % 60 === 0 : show[granularity].indexOf(t % 60) !== -1) ? time : '';
+ if(time_label && egw.preference("timeformat") == "12" && time_label.split(':')[0] < 10)
+ {
+ time_label =' ' + time_label;
+ }
+ html += '
'+time_label+"
\n";
+ }
+
+ // Set heights in pixels for scrolling
+ jQuery('.calendar_calTimeLabels',this.scrolling)
+ .empty()
+ .height(this.rowHeight*i)
+ .append(html);
+ this.days.css('height', (this.rowHeight*i)+'px');
+ this.gridHover.css('height', this.rowHeight);
+
+ // Scroll to start of day
+ this.scrolling.scrollTop(this._top_time);
+ }
+
+ /**
+ * As window size and number of all day non-blocking events change, we need
+ * to re-scale the time grid to make sure the full working day is shown.
+ *
+ * We use a timeout to avoid doing it multiple times if redrawing or resizing.
+ */
+ resizeTimes( )
+ {
+
+ // Hide resizing from user
+ this.loader.show();
+
+ // Wait a bit to see if anything else changes, then re-draw the times
+ if(this.resize_timer)
+ {
+ window.clearTimeout(this.resize_timer);
+ }
+ // No point if it is just going to be redone completely
+ if(this.update_timer) return;
+
+ this.resize_timer = window.setTimeout(jQuery.proxy(function() {
+ if(this._resizeTimes)
+ {
+ this.resize_timer = null;
+
+ this._resizeTimes();
+ }
+ },this),1);
+ }
+
+ /**
+ * Re-scale the time grid to make sure the full working day is shown.
+ * This is the timeout callback that does the actual re-size immediately.
+ */
+ _resizeTimes( )
+ {
+
+ if(!this.div.is(':visible'))
+ {
+ return;
+ }
+ var wd_start = 60*this.options.day_start;
+ var wd_end = 60*this.options.day_end;
+ var totalDisplayMinutes = wd_end - wd_start;
+ var rowsToDisplay = Math.ceil((totalDisplayMinutes+60)/this.options.granularity);
+ var row_count = (1440 / this.options.granularity);
+
+ var new_height = this.scrolling.height() / rowsToDisplay;
+ var old_height = this.rowHeight;
+ this.rowHeight = new_height;
+
+ jQuery('.calendar_calTimeLabels', this.scrolling).height(this.rowHeight*row_count);
+ this.days.css('height', this.options.granularity === 0 ?
+ '100%' :
+ (this.rowHeight*row_count)+'px'
+ );
+
+ // Scroll to start of day
+ this._top_time = (wd_start * this.rowHeight) / this.options.granularity;
+ // For some reason on Chrome & FF this doesn't quite get the day start
+ // to the top, so add 2px;
+ this.scrolling.scrollTop(this._top_time+2);
+
+ if(this.rowHeight != old_height)
+ {
+ this.iterateOver(function(child) {
+ if(child === this) return;
+ child.resize();
+ },this, et2_IResizeable);
+ }
+
+ this.loader.hide();
+ }
+
+ /**
+ * Set up the needed day widgets to correctly display the selected date
+ * range. First we calculate the needed dates, then we create any needed
+ * widgets. Existing widgets are recycled rather than discarded.
+ */
+ _drawDays( )
+ {
+ this.scrolling.append(this.days);
+
+ // If day list is still empty, recalculate it from start & end date
+ if(this.day_list.length === 0 && this.options.start_date && this.options.end_date)
+ {
+ this.day_list = this._calculate_day_list(this.options.start_date, this.options.end_date, this.options.show_weekend);
+ }
+ // For a single day, we show each owner in their own daycol
+ this.daily_owner = this.day_list.length === 1 &&
+ this.options.owner.length > 1 &&
+ this.options.owner.length < (parseInt(''+egw.preference('day_consolidate','calendar')) || 6);
+ var daycols_needed = this.daily_owner ? this.options.owner.length : this.day_list.length;
+ var day_width = ( Math.min( jQuery(this.getInstanceManager().DOMContainer).width(),this.days.width())/daycols_needed);
+ if(!day_width || !this.day_list)
+ {
+ // Hidden on another tab, or no days for some reason
+ var dim = egw.getHiddenDimensions(this.days, false);
+ day_width = ( dim.w /Math.max(daycols_needed,1));
+ }
+
+ // Create any needed widgets - otherwise, we'll just recycle
+ // Add any needed day widgets (now showing more days)
+ var add_index = 0;
+ var before = true;
+
+ while(daycols_needed > this.day_widgets.length)
+ {
+ var existing_index = this.day_widgets[add_index] && !this.daily_owner ?
+ this.day_list.indexOf(this.day_widgets[add_index].options.date) :
+ -1;
+ before = existing_index > add_index;
+
+ var day = et2_createWidget('calendar-daycol',{
+ owner: this.options.owner,
+ width: (before ? 0 : day_width) + "px"
+ },this);
+ if(this.isInTree())
+ {
+ day.doLoadingFinished();
+ }
+ if(existing_index != -1 && parseInt(this.day_list[add_index]) < parseInt(this.day_list[existing_index]))
+ {
+ this.day_widgets.unshift(day);
+ jQuery(this.getDOMNode(day)).prepend(day.getDOMNode(day));
+ }
+ else
+ {
+ this.day_widgets.push(day);
+ }
+ add_index++;
+ }
+ // Remove any extra day widgets (now showing less)
+ var delete_index = this.day_widgets.length - 1;
+ before = false;
+ while(this.day_widgets.length > daycols_needed)
+ {
+ // If we're going down to an existing one, just keep it for cool CSS animation
+ while(delete_index > 1 && this.day_list.indexOf(this.day_widgets[delete_index].options.date) > -1)
+ {
+ delete_index--;
+ before = true;
+ }
+ if(delete_index < 0) delete_index = 0;
+
+ // Widgets that are before our date shrink, after just get pushed out
+ if(before)
+ {
+ this.day_widgets[delete_index].set_width('0px');
+ }
+ this.day_widgets[delete_index].div.hide();
+ this.day_widgets[delete_index].header.hide();
+ this.day_widgets[delete_index].destroy();
+ this.day_widgets.splice(delete_index--,1);
+ }
+
+ this.set_header_classes();
+
+ // Create / update day widgets with dates and data
+ for(var i = 0; i < this.day_widgets.length; i++)
+ {
+ day = this.day_widgets[i];
+
+ // Position
+ day.set_left((day_width * i) + 'px');
+ day.title.removeClass('blue_title');
+ if(this.daily_owner)
+ {
+ // Each 'day' is the same date, different user
+ day.set_id(this.day_list[0]+'-'+this.options.owner[i]);
+ day.set_date(this.day_list[0], false);
+ day.set_owner(this.options.owner[i]);
+ day.set_label(this._get_owner_name(this.options.owner[i]));
+ day.title.addClass('blue_title');
+ }
+ else
+ {
+ // Show user name in day header even if only one
+ if(this.day_list.length === 1)
+ {
+ day.set_label(this._get_owner_name(this.options.owner));
+ day.title.addClass('blue_title');
+ }
+ else
+ {
+ // Go back to self-calculated date by clearing the label
+ day.set_label('');
+ }
+ day.set_id(this.day_list[i]);
+ day.set_date(this.day_list[i], this.value[this.day_list[i]] || false);
+ day.set_owner(this.options.owner);
+ }
+ day.set_width(day_width + 'px');
+ }
+
+ // Adjust and scroll to start of day
+ this.resizeTimes();
+
+ // Don't hold on to value any longer, use the data cache for best info
+ this.value = {};
+
+ if(this.daily_owner)
+ {
+ this.set_label('');
+ }
+
+ // Handle not fully visible elements
+ this._scroll();
+
+ // TODO: Figure out how to do this with detached nodes
+ /*
+ var nodes = this.day_col.getDetachedNodes();
+ var supportedAttrs = [];
+ this.day_col.getDetachedAttributes(supportedAttrs);
+ supportedAttrs.push("id");
+
+ for(var i = 0; i < day_count; i++)
+ {
+ this.day_col.setDetachedAttributes(nodes.clone(),)
+ }
+ */
+ }
+
+ /**
+ * Set header classes
+ *
+ */
+ set_header_classes()
+ {
+ var day;
+ for(var i = 0; i < this.day_widgets.length; i++)
+ {
+ day = this.day_widgets[i];
+
+ // Classes
+ if(app.calendar && app.calendar.state &&
+ this.day_list[i] && parseInt(this.day_list[i].substr(4,2)) !== new Date(app.calendar.state.date).getUTCMonth()+1)
+ {
+ day.set_class('calendar_differentMonth');
+ }
+ else
+ {
+ day.set_class('');
+ }
+ }
+ }
+
+ /**
+ * Update UI while scrolling within the selected time
+ *
+ * Toggles out of view indicators and adjusts not visible headers
+ * @param {Event} event Scroll event
+ */
+ private _scroll(event?)
+ {
+ if(!this.day_widgets) return;
+
+ // Loop through days, let them deal with it
+ for(var day = 0; day < this.day_widgets.length; day++)
+ {
+ this.day_widgets[day]._out_of_view();
+ }
+ }
+
+ /**
+ * Calculate a list of days between start and end date, skipping weekends if
+ * desired.
+ *
+ * @param {Date|string} start_date Date that et2_date widget can understand
+ * @param {Date|string} end_date Date that et2_date widget can understand
+ * @param {boolean} show_weekend If not showing weekend, Saturday and Sunday
+ * will not be in the returned list.
+ *
+ * @returns {string[]} List of days in Ymd format
+ */
+ _calculate_day_list( start_date, end_date, show_weekend)
+ {
+
+ var day_list = [];
+
+ this.date_helper.set_value(end_date);
+ var end = this.date_helper.date.getTime();
+ var i = 1;
+ this.date_helper.set_value(new Date(start_date));
+
+ do
+ {
+ if(show_weekend || !show_weekend && [0,6].indexOf(this.date_helper.date.getUTCDay()) === -1 || end_date === start_date)
+ {
+ day_list.push(''+this.date_helper.get_year() + sprintf('%02d',this.date_helper.get_month()) + sprintf('%02d',this.date_helper.get_date()));
+ }
+ this.date_helper.set_date(this.date_helper.get_date()+1);
+ }
+ // Limit it to 14 days to avoid infinite loops in case something is mis-set,
+ // though the limit is more based on how wide the screen is
+ while(end >= this.date_helper.date.getTime() && i++ <= 14);
+
+ return day_list;
+ }
+
+ /**
+ * Link the actions to the DOM nodes / widget bits.
+ *
+ * @param {object} actions {ID: {attributes..}+} map of egw action information
+ */
+ _link_actions(actions)
+ {
+ // Get the parent? Might be a grid row, might not. Either way, it is
+ // just a container with no valid actions
+ var objectManager = egw_getObjectManager(this.getInstanceManager().app,true,1);
+ objectManager = objectManager.getObjectById(this.getInstanceManager().uniqueId,2) || objectManager;
+ var parent = objectManager.getObjectById(this.id,1) || objectManager.getObjectById(this.getParent().id,1) || objectManager;
+ if(!parent)
+ {
+ debugger;
+ egw.debug('error','No parent objectManager found');
+ return;
+ }
+
+ // This binds into the egw action system. Most user interactions (drag to move, resize)
+ // are handled internally using jQuery directly.
+ var widget_object = this._actionObject || parent.getObjectById(this.id);
+ var aoi = new et2_action_object_impl(this,this.getDOMNode(this)).getAOI();
+
+ for(var i = 0; i < parent.children.length; i++)
+ {
+ var parent_finder = jQuery(parent.children[i].iface.doGetDOMNode()).find(this.div);
+ if(parent_finder.length > 0)
+ {
+ parent = parent.children[i];
+ break;
+ }
+ }
+ // Determine if we allow a dropped event to use the invite/change actions
+ let _invite_enabled = function (action, event, target)
+ {
+ var event = event.iface.getWidget();
+ var timegrid = target.iface.getWidget() || false;
+ if(event === timegrid || !event || !timegrid ||
+ !event.options || !event.options.value.participants || !timegrid.options.owner
+ )
+ {
+ return false;
+ }
+ var owner_match = false;
+ var own_timegrid = event.getParent().getParent() === timegrid && !timegrid.daily_owner;
+
+ for (var id in event.options.value.participants)
+ {
+ if(!timegrid.daily_owner)
+ {
+ if(timegrid.options.owner === id ||
+ timegrid.options.owner.indexOf &&
+ timegrid.options.owner.indexOf(id) >= 0)
+ {
+ owner_match = true;
+ }
+ }
+ else
+ {
+ timegrid.iterateOver(function (col)
+ {
+ // Check scroll section or header section
+ if(col.div.has(timegrid.gridHover).length || col.header.has(timegrid.gridHover).length)
+ {
+ owner_match = owner_match || col.options.owner.indexOf(id) !== -1;
+ own_timegrid = (col === event.getParent());
+ }
+ }, this, et2_calendar_daycol);
+ }
+ }
+ var enabled = !owner_match &&
+ // Not inside its own timegrid
+ !own_timegrid;
+
+ widget_object.getActionLink('invite').enabled = enabled;
+ widget_object.getActionLink('change_participant').enabled = enabled;
+
+ // If invite or change participant are enabled, drag is not
+ widget_object.getActionLink('egw_link_drop').enabled = !enabled;
+ };
+
+ aoi.doTriggerEvent = function(_event, _data) {
+ // Determine target node
+ var event = _data.event || false;
+ if(!event) return;
+ if(_data.ui.draggable.hasClass('rowNoEdit')) return;
+
+ /*
+ We have to handle the drop in the normal event stream instead of waiting
+ for the egwAction system so we can get the helper, and destination
+ */
+ if(event.type === 'drop')
+ {
+ var dropEnd = false;
+ var helper = jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper)[0];
+ if(helper && helper.dropEnd && helper.dropEnd.length >= 1)
+ if (typeof this.dropEnd !== 'undefined' && this.dropEnd.length >= 1)
+ {
+ dropEnd = helper.dropEnd[0].dataset || false;
+ }
+ this.getWidget()._event_drop.call(jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper)[0],this.getWidget(),event, _data.ui, dropEnd);
+ }
+ var drag_listener = function(_event, ui) {
+ aoi.getWidget()._drag_helper(jQuery('.calendar_d-n-d_timeCounter',ui.helper)[0],ui.helper[0],0);
+ if(aoi.getWidget().daily_owner)
+ {
+ _invite_enabled(
+ widget_object.getActionLink('invite').actionObj,
+ event,
+ widget_object
+ );
+ }
+ };
+ var time = jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper);
+ switch(_event)
+ {
+ // Triggered once, when something is dragged into the timegrid's div
+ case EGW_AI_DRAG_OVER:
+ // Listen to the drag and update the helper with the time
+ // This part lets us drag between different timegrids
+ _data.ui.draggable.on('drag.et2_timegrid'+widget_object.id, drag_listener);
+ _data.ui.draggable.on('dragend.et2_timegrid'+widget_object.id, function() {
+ _data.ui.draggable.off('drag.et2_timegrid' + widget_object.id);
+ });
+
+ // Remove formatting for out-of-view events (full day non-blocking)
+ jQuery('.calendar_calEventHeader',_data.ui.helper).css('top','');
+ jQuery('.calendar_calEventBody',_data.ui.helper).css('padding-top','');
+
+ // Disable invite / change actions for same calendar or already participant
+ var event = _data.ui.draggable.data('selected')[0];
+ if(!event || event.id && event.id.indexOf('calendar') !== 0)
+ {
+ event = false;
+ }
+ if(event)
+ {
+ _invite_enabled(
+ widget_object.getActionLink('invite').actionObj,
+ event,
+ widget_object
+ );
+ }
+
+ if(time.length)
+ {
+ // The out will trigger after the over, so we count
+ time.data('count',time.data('count')+1);
+ }
+ else
+ {
+ _data.ui.helper.prepend('
');
+ }
+
+ break;
+
+ // Triggered once, when something is dragged out of the timegrid
+ case EGW_AI_DRAG_OUT:
+ // Stop listening
+ _data.ui.draggable.off('drag.et2_timegrid'+widget_object.id);
+ // Remove highlighted time square
+ var timegrid = aoi.getWidget();
+ timegrid.gridHover.hide();
+ timegrid.scrolling.scrollTop(timegrid._top_time);
+
+ // Out triggers after the over, count to not accidentally remove
+ time.data('count',time.data('count')-1);
+ if(time.length && time.data('count') <= 0)
+ {
+ time.remove();
+ }
+ break;
+ }
+ };
+
+ if (widget_object == null) {
+ // Add a new container to the object manager which will hold the widget
+ // objects
+ widget_object = parent.insertObject(false, new egwActionObject(
+ this.id, parent, aoi,
+ this._actionManager|| parent.manager.getActionById(this.id) || parent.manager
+ ));
+ }
+ else
+ {
+ widget_object.setAOI(aoi);
+ }
+ this._actionObject = widget_object;
+
+ // Delete all old objects
+ widget_object.clear();
+ widget_object.unregisterActions();
+
+ // Go over the widget & add links - this is where we decide which actions are
+ // 'allowed' for this widget at this time
+ var action_links = this._get_action_links(actions);
+
+ this._init_links_dnd(widget_object.manager, action_links);
+
+ widget_object.updateActionLinks(action_links);
+ }
+
+ /**
+ * Automatically add dnd support for linking
+ *
+ * @param {type} mgr
+ * @param {type} actionLinks
+ */
+ _init_links_dnd( mgr,actionLinks)
+ {
+
+ if (this.options.readonly) return;
+
+ var self = this;
+
+ var drop_link = mgr.getActionById('egw_link_drop');
+ var drop_change_participant = mgr.getActionById('change_participant');
+ var drop_invite = mgr.getActionById('invite');
+ var drag_action = mgr.getActionById('egw_link_drag');
+
+ // Check if this app supports linking
+ if(!egw.link_get_registry(this.dataStorePrefix, 'query') ||
+ egw.link_get_registry(this.dataStorePrefix, 'title'))
+ {
+ if(drop_link)
+ {
+ drop_link.remove();
+ if(actionLinks.indexOf(drop_link.id) >= 0)
+ {
+ actionLinks.splice(actionLinks.indexOf(drop_link.id),1);
+ }
+ }
+ if(drag_action)
+ {
+ drag_action.remove();
+ if(actionLinks.indexOf(drag_action.id) >= 0)
+ {
+ actionLinks.splice(actionLinks.indexOf(drag_action.id),1);
+ }
+ }
+ return;
+ }
+ // Don't re-add
+ if(drop_link == null)
+ {
+ // Create the drop action that links entries
+ drop_link = mgr.addAction('drop', 'egw_link_drop', egw.lang('Create link'), egw.image('link'), function(action, source, target) {
+
+ // Extract link IDs
+ var links = [];
+ var id = '';
+ for(var i = 0; i < source.length; i++)
+ {
+ // Check for no ID (invalid) or same manager (dragging an event)
+ if(!source[i].id) continue;
+ if(source[i].manager === target.manager)
+ {
+ // Find the timegrid, could have dropped on an event
+ var timegrid = target.iface.getWidget();
+ while(target.parent && timegrid.instanceOf && !timegrid.instanceOf(et2_calendar_timegrid))
+ {
+ target = target.parent;
+ timegrid = target.iface.getWidget();
+ }
+
+
+ if (timegrid && timegrid._drop_data)
+ {
+ timegrid._event_drop.call(source[i].iface.getDOMNode(),timegrid,null, action.ui,timegrid._drop_data);
+ }
+ timegrid._drop_data = false;
+ // Ok, stop.
+ return false;
+ }
+
+ id = source[i].id.split('::');
+ links.push({app: id[0] == 'filemanager' ? 'link' : id[0], id: id[1]});
+ }
+ if(links.length && target && target.iface.getWidget() && target.iface.getWidget().instanceOf(et2_calendar_event))
+ {
+ // Link the entries
+ egw.json(self.egw().getAppName()+".etemplate_widget_link.ajax_link.etemplate",
+ target.id.split('::').concat([links]),
+ function(result) {
+ if(result)
+ {
+ this.egw().message('Linked');
+ }
+ },
+ self,
+ true,
+ self
+ ).sendRequest();
+ }
+ else if (links.length)
+ {
+ // Get date and time
+ var params = jQuery.extend({},jQuery('.drop-hover[data-date]',target.iface.getDOMNode())[0].dataset || {});
+
+ // Add link IDs
+ var app_registry = egw.link_get_registry('calendar');
+ params[app_registry.add_app] = [];
+ params[app_registry.add_id] = [];
+ for(var n in links)
+ {
+ params[app_registry.add_app].push( links[n].app);
+ params[app_registry.add_id].push( links[n].id);
+ }
+ app.calendar.add(params);
+ }
+
+ },true);
+
+ drop_link.acceptedTypes = ['default','link'];
+ drop_link.hideOnDisabled = true;
+
+ // Create the drop action for moving events between calendars
+ var invite_action = function(action, source, target) {
+
+ // Extract link IDs
+ var links = [];
+ var id = '';
+ for(var i = 0; i < source.length; i++)
+ {
+ // Check for no ID (invalid) or same manager (dragging an event)
+ if(!source[i].id) continue;
+ if(source[i].manager === target.manager)
+ {
+
+ // Find the timegrid, could have dropped on an event
+ var timegrid = target.iface.getWidget();
+ while(target.parent && timegrid.instanceOf && !timegrid.instanceOf(et2_calendar_timegrid))
+ {
+ target = target.parent;
+ timegrid = target.iface.getWidget();
+ }
+
+ // Leave the helper there until the update is done
+ var loading = action.ui.helper.clone(true).appendTo(jQuery('body'));
+
+ // and add a loading icon so user knows something is happening
+ if(jQuery('.calendar_timeDemo',loading).length == 0)
+ {
+ jQuery('.calendar_calEventHeader',loading).addClass('loading');
+ }
+ else
+ {
+ jQuery('.calendar_timeDemo',loading).after('');
+ }
+
+ var event_data = egw.dataGetUIDdata(source[i].id).data;
+ et2_calendar_event.recur_prompt(event_data, function(button_id) {
+ if(button_id === 'cancel' || !button_id)
+ {
+ return;
+ }
+ var add_owner = jQuery.extend([],timegrid.options.owner);
+ if(timegrid.daily_owner)
+ {
+ timegrid.iterateOver(function(col) {
+ if(col.div.has(timegrid.gridHover).length || col.header.has(timegrid.gridHover).length)
+ {
+ add_owner = col.options.owner;
+ }
+ }, this, et2_calendar_daycol);
+ }
+ egw().json('calendar.calendar_uiforms.ajax_invite', [
+ button_id==='series' ? event_data.id : event_data.app_id,
+ add_owner,
+ action.id === 'change_participant' ?
+ jQuery.extend([],source[i].iface.getWidget().getParent().options.owner) :
+ []
+ ],
+ function() { loading.remove();}
+ ).sendRequest(true);
+ });
+ // Ok, stop.
+ return false;
+ }
+ }
+ };
+
+ drop_change_participant = mgr.addAction('drop', 'change_participant', egw.lang('Move to'), egw.image('participant'), invite_action,true);
+ drop_change_participant.acceptedTypes = ['calendar'];
+ drop_change_participant.hideOnDisabled = true;
+
+ drop_invite = mgr.addAction('drop', 'invite', egw.lang('Invite'), egw.image('participant'), invite_action,true);
+ drop_invite.acceptedTypes = ['calendar'];
+ drop_invite.hideOnDisabled = true;
+ }
+ if(actionLinks.indexOf(drop_link.id) < 0)
+ {
+ actionLinks.push(drop_link.id);
+ }
+
+ actionLinks.push(drop_invite.id);
+ actionLinks.push(drop_change_participant.id);
+
+ // Don't re-add
+ if(drag_action == null)
+ {
+ // Create drag action that allows linking
+ drag_action = mgr.addAction('drag', 'egw_link_drag', egw.lang('link'), 'link', function(action, selected) {
+ // Drag helper - list titles.
+ // As we wanted to have a general defaul helper interface, we return null here and not using customize helper for links
+ // TODO: Need to decide if we need to create a customized helper interface for links anyway
+ //return helper;
+ return null;
+ },true);
+ }
+ // The timegrid itself is not draggable, so don't add a link.
+ // The action is there for the children (events) to use
+ if(false && actionLinks.indexOf(drag_action.id) < 0)
+ {
+ actionLinks.push(drag_action.id);
+ }
+ drag_action.set_dragType(['link','calendar']);
+ }
+
+ /**
+ * Get all action-links / id's of 1.-level actions from a given action object
+ *
+ * Here we are only interested in drop events.
+ *
+ * @param actions
+ * @returns {Array}
+ */
+ _get_action_links(actions)
+ {
+ var action_links = [];
+ // TODO: determine which actions are allowed without an action (empty actions)
+ for(var i in actions)
+ {
+ var action = actions[i];
+ if(action.type == 'drop')
+ {
+ action_links.push(typeof action.id != 'undefined' ? action.id : i);
+ }
+ }
+ return action_links;
+ }
+
+ /**
+ * Provide specific data to be displayed.
+ * This is a way to set start and end dates, owner and event data in one call.
+ *
+ * Events will be retrieved automatically from the egw.data cache, so there
+ * is no great need to provide them.
+ *
+ * @param {Object[]} events Array of events, indexed by date in Ymd format:
+ * {
+ * 20150501: [...],
+ * 20150502: [...]
+ * }
+ * Days should be in order.
+ * {string|number|Date} events.start_date - New start date
+ * {string|number|Date} events.end_date - New end date
+ * {number|number[]|string|string[]} event.owner - Owner ID, which can
+ * be an account ID, a resource ID (as defined in calendar_bo, not
+ * necessarily an entry from the resource app), or a list containing a
+ * combination of both.
+ */
+ set_value(events)
+ {
+ if(typeof events !== 'object') return false;
+
+ var use_days_sent = true;
+
+ if(events.start_date)
+ {
+ use_days_sent = false;
+ }
+ if(events.end_date)
+ {
+ use_days_sent = false;
+ }
+
+ super.set_value(events);
+
+ if(use_days_sent)
+ {
+ var day_list = Object.keys(events);
+ if(day_list.length)
+ {
+ this.set_start_date(day_list[0]);
+ this.set_end_date(day_list[day_list.length-1]);
+ }
+
+
+ // Sub widgets actually get their own data from egw.data, so we'll
+ // stick it there
+ var consolidated = et2_calendar_view.is_consolidated(this.options.owner, this.day_list.length == 1 ? 'day' : 'week');
+ for(var day in events)
+ {
+ let day_list = [];
+ for(var i = 0; i < events[day].length; i++)
+ {
+ day_list.push(events[day][i].row_id);
+ egw.dataStoreUID('calendar::'+events[day][i].row_id, events[day][i]);
+ }
+ // Might be split by user, so we have to check that too
+ for(var i = 0; i < this.options.owner.length; i++)
+ {
+ var owner = consolidated ? this.options.owner : this.options.owner[i];
+ var day_id = CalendarApp._daywise_cache_id(day,owner);
+ egw.dataStoreUID(day_id, day_list);
+ if(consolidated) break;
+ }
+ }
+ }
+
+ // Reset and calculate instead of just use the keys so we can get the weekend preference
+ this.day_list = [];
+
+ // None of the above changed anything, hide the loader
+ if(!this.update_timer)
+ {
+ window.setTimeout(jQuery.proxy(function() {this.loader.hide();},this),200);
+ }
+ }
+
+ /**
+ * Set which user owns this. Owner is passed along to the individual
+ * days.
+ *
+ * @param {number|number[]} _owner Account ID
+ * @returns {undefined}
+ */
+ set_owner(_owner)
+ {
+ var old = this.options.owner || 0;
+ super.set_owner(_owner);
+
+ this.owner.set_label('');
+ this.div.removeClass('calendar_TimeGridNoLabel');
+
+ // Check to see if it's our own calendar, with just us showing
+ if(typeof _owner == 'object' && _owner.length == 1)
+ {
+ var rowCount = 0;
+ this.getParent().iterateOver(function(widget) {
+ if(!widget.disabled) rowCount++;
+ },this, et2_calendar_timegrid);
+ // Just us, show week number
+ if(rowCount == 1 && _owner.length == 1 && _owner[0] == egw.user('account_id') || rowCount != 1) _owner = false;
+ }
+
+ var day_count = this.day_list.length ? this.day_list.length :
+ this._calculate_day_list(this.options.start_date, this.options.end_date, this.options.show_weekend).length;
+ // @ts-ignore
+ if(typeof _owner == 'string' && isNaN(_owner))
+ {
+ this.set_label('');
+ this.owner.set_value(this._get_owner_name(_owner));
+
+ // Label is empty, but give extra space for the owner name
+ this.div.removeClass('calendar_TimeGridNoLabel');
+ }
+ else if (!_owner || typeof _owner == 'object' && _owner.length > 1 ||
+ // Single owner, single day
+ _owner.length === 1 && day_count === 1
+ )
+ {
+ // Don't show owners if more than one, show week number
+ this.owner.set_value('');
+ if(this.options.start_date)
+ {
+ this.set_label(egw.lang('wk') + ' ' +
+ (app.calendar ? app.calendar.date.week_number(this.options.start_date) : '')
+ );
+ }
+ }
+ else
+ {
+ this.owner.options.application = 'api-accounts';
+ this.owner.set_value(this._get_owner_name(_owner));
+ this.set_label('');
+ jQuery(this.getDOMNode(this.owner)).prepend(this.owner.getDOMNode());
+ }
+
+ if(this.isAttached() && (
+ typeof old === "number" && typeof _owner === "number" && old !== this.options.owner ||
+ // Array of ids will not compare as equal
+ ((typeof old === 'object' || typeof _owner === 'object') && old.toString() !== _owner.toString()) ||
+ // Strings
+ typeof old === 'string' && ''+old !== ''+this.options.owner
+ ))
+ {
+ this.invalidate(true);
+ }
+ }
+
+ /**
+ * Set a label for this week
+ *
+ * May conflict with owner, which is displayed when there's only one owner.
+ *
+ * @param {string} label
+ */
+ set_label(label)
+ {
+ this.options.label = label;
+ this._labelContainer.html(label);
+ this.gridHeader.prepend(this._labelContainer);
+
+ // If it's a short label (eg week number), don't give it an extra line
+ // but is empty, but give extra space for a single owner name
+ this.div.toggleClass(
+ 'calendar_TimeGridNoLabel',
+ label.trim().length > 0 && label.trim().length <= 6 ||
+ this.options.owner.length > 1
+ );
+ }
+
+ /**
+ * Set how big the time divisions are
+ *
+ * Setting granularity to 0 will remove the time divisions and display
+ * each days events in a list style. This 'gridlist' is not to be confused
+ * with the list view, which uses a nextmatch.
+ *
+ * @param {number} minutes
+ */
+ set_granularity(minutes)
+ {
+ // Avoid < 0
+ minutes = Math.max(0,minutes);
+
+ if(this.options.granularity !== minutes)
+ {
+ if(this.options.granularity === 0 || minutes === 0)
+ {
+ this.options.granularity = minutes;
+ // Need to re-do a bunch to make sure this is propagated
+ this.invalidate();
+ }
+ else
+ {
+ this.options.granularity = minutes;
+ this._drawTimes();
+ }
+ }
+ else if (!this.update_timer)
+ {
+ this.resizeTimes();
+ }
+ }
+
+ /**
+ * Turn on or off the visibility of weekends
+ *
+ * @param {boolean} weekends
+ */
+ set_show_weekend(weekends)
+ {
+ weekends = weekends ? true : false;
+ if(this.options.show_weekend !== weekends)
+ {
+ this.options.show_weekend = weekends;
+ if(this.isAttached())
+ {
+ this.invalidate();
+ }
+ }
+ }
+
+ /**
+ * Call change handler, if set
+ */
+ change( )
+ {
+ if (this.onchange)
+ {
+ if(typeof this.onchange == 'function')
+ {
+ // Make sure function gets a reference to the widget
+ var args = Array.prototype.slice.call(arguments);
+ if(args.indexOf(this) == -1) args.push(this);
+
+ return this.onchange.apply(this, args);
+ } else {
+ return (et2_compileLegacyJS(this.options.onchange, this, _node))();
+ }
+ }
+ }
+
+ /**
+ * Call event change handler, if set
+ *
+ * @param {type} event
+ * @param {type} dom_node
+ */
+ event_change( event, dom_node)
+ {
+ if (this.onevent_change)
+ {
+ var event_data = this._get_event_info(dom_node);
+ var event_widget = this.getWidgetById(event_data.widget_id);
+ et2_calendar_event.recur_prompt(event_data, jQuery.proxy(function(button_id, event_data) {
+ // No need to continue
+ if(button_id === 'cancel') return false;
+
+ if(typeof this.onevent_change == 'function')
+ {
+ // Make sure function gets a reference to the widget
+ var args = Array.prototype.slice.call(arguments);
+
+ if(args.indexOf(event_widget) == -1) args.push(event_widget);
+
+ // Put button ID in event
+ event.button_id = button_id;
+
+ return this.onevent_change.apply(this, [event, event_widget, button_id]);
+ } else {
+ return (et2_compileLegacyJS(this.options.onevent_change, event_widget, dom_node))();
+ }
+ },this));
+ }
+ return false;
+ }
+
+ get_granularity()
+ {
+ // get option, or user's preference
+ if(typeof this.options.granularity === 'undefined')
+ {
+ this.options.granularity = egw.preference('interval','calendar') || 30;
+ }
+ return parseInt(this.options.granularity);
+ }
+
+ /**
+ * Click handler calling custom handler set via onclick attribute to this.onclick
+ *
+ * This also handles all its own actions, including navigation. If there is
+ * an event associated with the click, it will be found and passed to the
+ * onclick function.
+ *
+ * @param {Event} _ev
+ * @returns {boolean} Continue processing event (true) or stop (false)
+ */
+ click(_ev)
+ {
+ var result = true;
+ if(this.options.readonly ) return;
+
+ // Drag to create in progress
+ if(this.drag_create.start !== null) return;
+
+ // Is this click in the event stuff, or in the header?
+ if(_ev.target.dataset.id || jQuery(_ev.target).parents('.calendar_calEvent').length)
+ {
+ // Event came from inside, maybe a calendar event
+ var event = this._get_event_info(_ev.originalEvent.target);
+ if(typeof this.onclick == 'function')
+ {
+ // Make sure function gets a reference to the widget, splice it in as 2. argument if not
+ var args = Array.prototype.slice.call(arguments);
+ if(args.indexOf(this) == -1) args.splice(1, 0, this);
+
+ result = this.onclick.apply(this, args);
+ }
+
+ var event_node = jQuery(event.event_node);
+ if(event.id && result && !this.disabled && !this.options.readonly &&
+ // Permissions - opening will fail if we try
+ event_node && !(event_node.hasClass('rowNoView'))
+ )
+ {
+ if(event.widget_id && this.getWidgetById(event.widget_id))
+ {
+ this.getWidgetById(event.widget_id).recur_prompt();
+ }
+ else
+ {
+ et2_calendar_event.recur_prompt(event);
+ }
+
+ return false;
+ }
+ return result;
+ }
+ else if (this.gridHeader.is(_ev.target) && _ev.target.dataset ||
+ this._labelContainer.is(_ev.target) && this.gridHeader[0].dataset)
+ {
+ app.calendar.update_state(jQuery.extend(
+ {view: 'week'},
+ this._labelContainer.is(_ev.target) ?
+ this.gridHeader[0].dataset :
+ _ev.target.dataset
+ ));
+ }
+ else if (this.options.owner.length === 1 && jQuery(this.owner.getDOMNode()).is(_ev.target))
+ {
+ // Click on the owner in header, show just that owner
+ app.calendar.update_state({owner: this.options.owner});
+ }
+ else if (this.dayHeader.has(_ev.target).length)
+ {
+ // Click on a day header - let day deal with it
+ // First child is a selectAccount
+ for(var i = 1; i < this._children.length; i++)
+ {
+ if(this._children[i].header && (
+ this._children[i].header.has(_ev.target).length || this._children[i].header.is(_ev.target))
+ )
+ {
+ return this._children[i].click(_ev);
+ }
+ }
+ }
+ // No time grid, click on a day
+ else if (this.options.granularity === 0 &&
+ (jQuery(_ev.target).hasClass('event_wrapper') || jQuery(_ev.target).hasClass('.calendar_calDayCol'))
+ )
+ {
+ // Default handler to open a new event at the selected time
+ var target = jQuery(_ev.target).hasClass('event_wrapper') ? _ev.target.parentNode : _ev.target;
+ var options = {
+ date: target.dataset.date || this.options.date,
+ hour: target.dataset.hour || this._parent.options.day_start,
+ minute: target.dataset.minute || 0,
+ owner: this.options.owner
+ };
+ app.calendar.add(options);
+ return false;
+ }
+ }
+
+ /**
+ * Mousedown handler to support drag to create
+ *
+ * @param {jQuery.Event} event
+ */
+ _mouse_down(event)
+ {
+ if(event.which !== 1) return;
+
+ if (this.options.readonly) return;
+
+ var start = jQuery.extend({},this.gridHover[0].dataset);
+ if(start.date)
+ {
+ // Set parent for event
+ if(this.daily_owner)
+ {
+ // Each 'day' is the same date, different user
+ // Find the correct row so we know the parent
+ var col = event.target.closest('.calendar_calDayCol');
+ for(var i = 0; i < this._children.length && col; i++)
+ {
+ if(this._children[i].node === col)
+ {
+ this.drag_create.parent = this._children[i];
+ break;
+ }
+ }
+ }
+ else
+ {
+ this.drag_create.parent = this.getWidgetById(start.date);
+ }
+
+ // Format date
+ this.date_helper.set_year(start.date.substring(0,4));
+ this.date_helper.set_month(start.date.substring(4,6));
+ this.date_helper.set_date(start.date.substring(6,8));
+ if(start.hour)
+ {
+ this.date_helper.set_hours(start.hour);
+ }
+ if(start.minute)
+ {
+ this.date_helper.set_minutes(start.minute);
+ }
+ start.date = this.date_helper.get_value();
+
+ this.gridHover.css('cursor', 'ns-resize');
+
+ // Start update
+ var timegrid = this;
+ this.div.on('mousemove.dragcreate', function()
+ {
+ if(timegrid.drag_create.event && timegrid.drag_create.parent && timegrid.drag_create.end)
+ {
+ var end = jQuery.extend({}, timegrid.gridHover[0].dataset);
+ if(end.date)
+ {
+ timegrid.date_helper.set_year(end.date.substring(0,4));
+ timegrid.date_helper.set_month(end.date.substring(4,6));
+ timegrid.date_helper.set_date(end.date.substring(6,8));
+ if(end.hour)
+ {
+ timegrid.date_helper.set_hours(end.hour);
+ }
+ if(end.minute)
+ {
+ timegrid.date_helper.set_minutes(end.minute);
+ }
+ timegrid.drag_create.end.date = timegrid.date_helper.get_value();
+ }
+ try
+ {
+ timegrid._drag_update_event();
+ }
+ catch (e)
+ {
+ timegrid._drag_create_end();
+ }
+ }
+ });
+ }
+ return this._drag_create_start(start);
+ }
+
+ /**
+ * Mouseup handler to support drag to create
+ *
+ * @param {jQuery.Event} event
+ */
+ _mouse_up(event)
+ {
+ if (this.options.readonly) return;
+ var end = jQuery.extend({}, this.gridHover[0].dataset);
+ if(end.date)
+ {
+ this.date_helper.set_year(end.date.substring(0,4));
+ this.date_helper.set_month(end.date.substring(4,6));
+ this.date_helper.set_date(end.date.substring(6,8));
+ if(end.hour)
+ {
+ this.date_helper.set_hours(end.hour);
+ }
+ if(end.minute)
+ {
+ this.date_helper.set_minutes(end.minute);
+ }
+ end.date = this.date_helper.get_value();
+ }
+ this.div.off('mousemove.dragcreate');
+ this.gridHover.css('cursor', '');
+
+ return this._drag_create_end(end);
+ }
+
+ /**
+ * Get time from position for drag and drop
+ *
+ * This does not return an actual time on a clock, but finds the closest
+ * time node (.calendar_calAddEvent or day column) to the given position.
+ *
+ * @param {number} x
+ * @param {number} y
+ * @returns {DOMNode[]} time node(s) for the given position
+ */
+ _get_time_from_position( x,y)
+ {
+
+ x = Math.round(x);
+ y = Math.round(y);
+
+ var path = [];
+ var day = null;
+ var time = null;
+
+ var node = document.elementFromPoint(x,y);
+ var $node = jQuery(node);
+
+ // Ignore high level & non-time (grid itself, header parent & week label)
+ if([this.node, this.gridHeader[0], this._labelContainer[0]].indexOf(node) !== -1 ||
+ // Day labels
+ this.gridHeader.has(node).length && !$node.hasClass("calendar_calDayColAllDay") && !$node.hasClass('calendar_calDayColHeader'))
+ {
+ return [];
+ }
+ for(var id in this.gridHover[0].dataset) {
+ delete this.gridHover[0].dataset[id];
+ }
+ if(this.options.granularity == 0)
+ {
+ this.gridHover.css('height','');
+ }
+ while(node && node != this.node && node.tagName != 'BODY' && path.length < 10)
+ {
+ path.push(node);
+ node.style.display = 'none';
+ $node = jQuery(node);
+ if($node.hasClass('calendar_calDayColHeader')) {
+ for(var id in node.dataset) {
+ this.gridHover[0].dataset[id] = node.dataset[id];
+ }
+ this.gridHover.css({
+ position: 'absolute',
+ top: '',
+ bottom: '0px',
+ // Use 100% height if we're hiding the day labels to avoid
+ // any remaining space from the hidden labels
+ height: $node.height() > parseInt($node.css('line-height')) ?
+ $node.css('padding-bottom') : '100%'
+ });
+ day = node;
+ this.gridHover
+ .attr('data-non_blocking','true');
+ break;
+ }
+ if($node.hasClass('calendar_calDayCol'))
+ {
+ day = node;
+ this.gridHover
+ .attr('data-date',day.dataset.date);
+ }
+ if($node.hasClass('calendar_calTimeRowTime'))
+ {
+ time = node;
+ this.gridHover
+ .attr('data-hour',time.dataset.hour)
+ .attr('data-minute',time.dataset.minute);
+ break;
+ }
+ node = document.elementFromPoint(x,y);
+ }
+ for(var i = 0; i < path.length; i++)
+ {
+ path[i].style.display = '';
+ }
+
+ if(!day)
+ {
+ return [];
+ }
+ this.gridHover
+ .show()
+ .appendTo(day);
+ if(time)
+ {
+ this.gridHover
+ .height(this.rowHeight)
+ .position({my:'left top', at: 'left top', of: time});
+ }
+ this.gridHover.css('left','');
+ return this.gridHover;
+ }
+
+ /**
+ * Code for implementing et2_IDetachedDOM
+ *
+ * @param {array} _attrs array to add further attributes to
+ */
+ getDetachedAttributes( _attrs)
+ {
+ _attrs.push('start_date','end_date');
+ }
+
+ getDetachedNodes( )
+ {
+ return [this.getDOMNode(this)];
+ }
+
+ setDetachedAttributes( _nodes, _values)
+ {
+ this.div = jQuery(_nodes[0]);
+
+ if(_values.start_date)
+ {
+ this.set_start_date(_values.start_date);
+ }
+ if(_values.end_date)
+ {
+ this.set_end_date(_values.end_date);
+ }
+ }
+
+ // Resizable interface
+ /**
+ * @param {boolean} [_too_small=null] Force the widget to act as if it was too small
+ */
+ resize (_too_small?)
+ {
+ if(this.disabled || !this.div.is(':visible'))
+ {
+ return;
+ }
+
+ /*
+ We expect the timegrid to be in a table with 0 or more other timegrids,
+ 1 per row. We want each timegrid to be as large as possible, but space
+ shared equally. Height can't be set to a percentage on the rows, because
+ that doesn't work. However, if any timegrid is too small (1/2 hour < 1 line
+ height), we change to showing only the working hours with no vertical
+ scrollbar. Each week gets as much space as it needs, and all scroll together.
+ */
+ // How many rows?
+ var rowCount = 0;
+ this.getParent().iterateOver(function(widget) {
+ if(!widget.disabled) rowCount++;
+ },this, et2_calendar_timegrid);
+
+ // Take the whole tab height, or home portlet
+ if(this.getInstanceManager().app === 'home')
+ {
+ var height = jQuery(this.getParent().getDOMNode(this)).parentsUntil('.et2_portlet').last().parent().innerHeight();
+
+ // Allow for portlet header
+ height -= jQuery('.ui-widget-header',this.div.parents('.egw_fw_ui_tab_content')).outerHeight(true);
+ }
+ else
+ {
+ var height = jQuery(this.getInstanceManager().DOMContainer).parent().innerHeight();
+
+ // Allow for toolbar
+ height -= jQuery('#calendar-toolbar',this.div.parents('.egw_fw_ui_tab_content')).outerHeight(true);
+ }
+
+ this.options.height = Math.floor(height / rowCount);
+
+ // Allow for borders & padding
+ this.options.height -= 2*((this.div.outerWidth(true) - this.div.innerWidth()) + parseInt(this.div.parent().css('padding-top')));
+
+ // Calculate how much space is needed, and
+ // if too small be bigger
+ var needed = ((this.day_end - this.day_start) /
+ (this.options.granularity / 60) * parseInt(this.div.css('line-height'))) +
+ this.gridHeader.outerHeight();
+ var too_small = needed > this.options.height && this.options.granularity != 0;
+
+
+ if(this.getInstanceManager().app === 'home')
+ {
+ var modify_node = jQuery(this.getParent().getDOMNode(this)).parentsUntil('.et2_portlet').last();
+ }
+ else
+ {
+ var modify_node = jQuery(this.getInstanceManager().DOMContainer);
+ }
+ modify_node
+ .css({
+ 'overflow-y': too_small || _too_small ? 'auto' : 'hidden',
+ 'overflow-x': 'hidden',
+ 'height': too_small || _too_small ? height : '100%'
+ });
+ if(too_small || _too_small)
+ {
+ this.options.height = Math.max(this.options.height, needed);
+ // Set all others to match
+ if(!_too_small && rowCount > 1 && this.getParent())
+ {
+ window.setTimeout(jQuery.proxy(function() {
+ if(!this._parent) return;
+ this._parent.iterateOver(function(widget) {
+ if(!widget.disabled) widget.resize(true);
+ },this, et2_calendar_timegrid);
+ },this),1);
+ return;
+ }
+ this.div.addClass('calendar_calTimeGridFixed');
+ }
+ else
+ {
+ this.div.removeClass('calendar_calTimeGridFixed');
+ }
+ this.div.css('height', this.options.height);
+
+ // Re-do time grid
+ if(!this.update_timer)
+ {
+ this.resizeTimes();
+ }
+
+ // Try to resize width, though animations cause problems
+ var total_width = modify_node.parent().innerWidth() - this.days.position().left;
+ // Space for todos, if there
+ total_width -= jQuery(this.getInstanceManager().DOMContainer).siblings().has(':visible').not('#calendar-toolbar').outerWidth();
+
+ var day_width = (total_width > 0 ? total_width : modify_node.width())/this.day_widgets.length;
+ // update day widgets
+ for(var i = 0; i < this.day_widgets.length; i++)
+ {
+ var day = this.day_widgets[i];
+
+ // Position
+ day.set_left((day_width * i) + 'px');
+ day.set_width(day_width + 'px');
+ }
+ }
+
+ /**
+ * Set up for printing
+ *
+ * @return {undefined|Deferred} Return a jQuery Deferred object if not done setting up
+ * (waiting for data)
+ */
+ beforePrint( )
+ {
+
+ if(this.disabled || !this.div.is(':visible'))
+ {
+ return;
+ }
+
+ var height_check = this.div.height();
+ this.div.css('max-height','17cm');
+ if(this.div.height() != height_check)
+ {
+ this.div.height('17cm');
+ this._resizeTimes();
+ }
+
+ // update day widgets, if not on single day view
+ //
+ // TODO: Find out why don't we update single day view
+ // Let the single day view participate in print calculation.
+ if(this.day_widgets.length > 0)
+ {
+ var day_width = (100 / this.day_widgets.length);
+ for(var i = 0; i < this.day_widgets.length; i++)
+ {
+ var day = this.day_widgets[i];
+
+ // Position
+ day.set_left((i*day_width) + '%');
+ day.set_width(day_width + '%');
+ // For some reason the column's method does not set it correctly in Chrome
+ day.header[0].style.width = day_width + '%';
+ }
+ }
+
+ // Stop Firefox from scrolling the day to the top - this would break printing in Chrome
+ if (navigator.userAgent.match(/(firefox|safari|iceweasel)/i) && !navigator.userAgent.match(/chrome/i))
+ {
+ var height = this.scrolling.scrollTop() + this.scrolling.height();
+ this.scrolling
+ // Disable scroll event, or it will recalculate out of view events
+ .off('scroll')
+ // Explicitly transform to the correct place
+ .css({
+ 'transform': 'translateY(-'+this.scrolling.scrollTop()+'px)',
+ 'margin-bottom': '-'+this.scrolling.scrollTop()+'px',
+ 'height': height+'px'
+ });
+ this.div.css({'height':'','max-height':''});
+ }
+ }
+
+ /**
+ * Reset after printing
+ */
+ afterPrint( )
+ {
+ this.div.css('maxHeight','');
+ this.scrolling.children().css({'transform':'', 'overflow':''});
+ this.div.height(this.options.height);
+ if (navigator.userAgent.match(/(firefox|safari|iceweasel)/i) && !navigator.userAgent.match(/chrome/i))
+ {
+ this._resizeTimes();
+ this.scrolling
+ // Re-enable out-of-view formatting on scroll
+ .on('scroll', jQuery.proxy(this._scroll, this))
+ // Remove translation
+ .css({'transform':'', 'margin-bottom':''});
+ }
+ }
+}
+et2_register_widget(et2_calendar_timegrid, ["calendar-timegrid"]);
\ No newline at end of file
diff --git a/calendar/js/et2_widget_view.ts b/calendar/js/et2_widget_view.ts
new file mode 100644
index 0000000000..edb31ea42d
--- /dev/null
+++ b/calendar/js/et2_widget_view.ts
@@ -0,0 +1,700 @@
+/*
+ * Egroupware
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package
+ * @subpackage
+ * @link http://www.egroupware.org
+ * @author Nathan Gray
+ * @version $Id$
+ */
+
+/*egw:uses
+ /etemplate/js/et2_core_valueWidget;
+*/
+
+import {et2_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget";
+import {et2_valueWidget} from "../../api/js/etemplate/et2_core_valueWidget";
+import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance";
+import {et2_date} from "../../api/js/etemplate/et2_widget_date";
+
+/**
+ * Parent class for the various calendar views to reduce copied code
+ *
+ *
+ * et2_calendar_view is responsible for its own loader div, which is displayed while
+ * the times & days are redrawn.
+ *
+ * @augments et2_valueWidget
+ */
+export class et2_calendar_view extends et2_valueWidget
+{
+ static readonly _attributes : any = {
+ owner: {
+ name: "Owner",
+ type: "any", // Integer, or array of integers, or string like r13 (resources, addressbook)
+ default: [egw.user('account_id')],
+ description: "Account ID number of the calendar owner, if not the current user"
+ },
+ start_date: {
+ name: "Start date",
+ type: "any"
+ },
+ end_date: {
+ name: "End date",
+ type: "any"
+ }
+ };
+ protected dataStorePrefix: string = 'calendar';
+ protected _date_helper: et2_date;
+ protected loader: JQuery;
+ protected div: JQuery;
+
+ protected update_timer: number = null;
+ protected drag_create: { parent: et2_widget; start: any; end: any; event: et2_calendar_event };
+ protected value: any;
+
+ protected _actionObject: egwActionObject;
+
+ /**
+ * Constructor
+ *
+ */
+ constructor(_parent, _attrs? : WidgetConfig, _child? : object)
+ {
+ // Call the inherited constructor
+ super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_view._attributes, _child || {}));
+
+
+ // Used for its date calculations
+ this._date_helper = et2_createWidget('date-time', {}, null);
+ this._date_helper.loadingFinished();
+
+ this.loader = jQuery('');
+ this.update_timer = null;
+
+ // Used to support dragging on empty space to create an event
+ this.drag_create = {
+ start: null,
+ end: null,
+ parent: null,
+ event: null
+ };
+ }
+
+ destroy() {
+ super.destroy();
+
+ // date_helper has no parent, so we must explicitly remove it
+ this._date_helper.destroy();
+ this._date_helper = null;
+
+ // Stop the invalidate timer
+ if(this.update_timer)
+ {
+ window.clearTimeout(this.update_timer);
+ }
+ }
+
+ doLoadingFinished( )
+ {
+ super.doLoadingFinished();
+ this.loader.hide(0).prependTo(this.div);
+ if(this.options.owner) this.set_owner(this.options.owner);
+
+ return true;
+ }
+
+ /**
+ * Something changed, and the view need to be re-drawn. We wait a bit to
+ * avoid re-drawing twice if start and end date both changed, then recreate
+ * as needed.
+ *
+ * @param {boolean} [trigger_event=false] Trigger an event once things are done.
+ * Waiting until invalidate completes prevents 2 updates when changing the date range.
+ * @returns {undefined}
+ *
+ * @memberOf et2_calendar_view
+ */
+ invalidate(trigger_event) {
+ // If this wasn't a stub, we'd set this.update_timer
+ }
+
+ /**
+ * Returns the current start date
+ *
+ * @returns {Date}
+ *
+ * @memberOf et2_calendar_view
+ */
+ get_start_date() {
+ return new Date(this.options.start_date);
+ }
+
+ /**
+ * Returns the current start date
+ *
+ * @returns {Date}
+ *
+ * @memberOf et2_calendar_view
+ */
+ get_end_date() {
+ return new Date(this.options.end_date);
+ }
+
+ /**
+ * Change the start date
+ *
+ * Changing the start date will invalidate the display, and it will be redrawn
+ * after a timeout.
+ *
+ * @param {string|number|Date} new_date New starting date. Strings can be in
+ * any format understood by et2_widget_date, or Ymd (eg: 20160101).
+ * @returns {undefined}
+ *
+ * @memberOf et2_calendar_view
+ */
+ set_start_date(new_date)
+ {
+ if(!new_date || new_date === null)
+ {
+ new_date = new Date();
+ }
+
+ // Use date widget's existing functions to deal
+ if(typeof new_date === "object" || typeof new_date === "string" && new_date.length > 8)
+ {
+ this._date_helper.set_value(new_date);
+ }
+ else if(typeof new_date === "string")
+ {
+ this._date_helper.set_year(new_date.substring(0, 4));
+ // Avoid overflow into next month, since we re-use date_helper
+ this._date_helper.set_date(1);
+ this._date_helper.set_month(new_date.substring(4, 6));
+ this._date_helper.set_date(new_date.substring(6, 8));
+ }
+
+ var old_date = this.options.start_date;
+ this.options.start_date = new Date(this._date_helper.getValue());
+
+ if(old_date !== this.options.start_date && this.isAttached())
+ {
+ this.invalidate(true);
+ }
+ }
+
+ /**
+ * Change the end date
+ *
+ * Changing the end date will invalidate the display, and it will be redrawn
+ * after a timeout.
+ *
+ * @param {string|number|Date} new_date - New end date. Strings can be in
+ * any format understood by et2_widget_date, or Ymd (eg: 20160101).
+ * @returns {undefined}
+ *
+ * @memberOf et2_calendar_view
+ */
+ set_end_date(new_date)
+ {
+ if(!new_date || new_date === null)
+ {
+ new_date = new Date();
+ }
+ // Use date widget's existing functions to deal
+ if(typeof new_date === "object" || typeof new_date === "string" && new_date.length > 8)
+ {
+ this._date_helper.set_value(new_date);
+ }
+ else if(typeof new_date === "string")
+ {
+ this._date_helper.set_year(new_date.substring(0, 4));
+ // Avoid overflow into next month, since we re-use date_helper
+ this._date_helper.set_date(1);
+ this._date_helper.set_month(new_date.substring(4, 6));
+ this._date_helper.set_date(new_date.substring(6, 8));
+ }
+
+ var old_date = this.options.end_date;
+ this.options.end_date = new Date(this._date_helper.getValue());
+
+ if(old_date !== this.options.end_date && this.isAttached())
+ {
+ this.invalidate(true);
+ }
+ }
+
+ /**
+ * Set which users to display
+ *
+ * Changing the owner will invalidate the display, and it will be redrawn
+ * after a timeout.
+ *
+ * @param {number|number[]|string|string[]} _owner - Owner ID, which can
+ * be an account ID, a resource ID (as defined in calendar_bo, not
+ * necessarily an entry from the resource app), or a list containing a
+ * combination of both.
+ *
+ * @memberOf et2_calendar_view
+ */
+ set_owner(_owner)
+ {
+ var old = this.options.owner;
+
+ // 0 means current user, but that causes problems for comparison,
+ // so we'll just switch to the actual ID
+ if(_owner == '0')
+ {
+ _owner = [egw.user('account_id')];
+ }
+ if(!jQuery.isArray(_owner))
+ {
+ if(typeof _owner === "string")
+ {
+ _owner = _owner.split(',');
+ }
+ else
+ {
+ _owner = [_owner];
+ }
+ }
+ else
+ {
+ _owner = jQuery.extend([],_owner);
+ }
+ this.options.owner = _owner;
+ if(this.isAttached() && (
+ typeof old === "number" && typeof _owner === "number" && old !== this.options.owner ||
+ // Array of ids will not compare as equal
+ ((typeof old === 'object' || typeof _owner === 'object') && old.toString() !== _owner.toString()) ||
+ // Strings
+ typeof old === 'string' && ''+old !== ''+this.options.owner
+ ))
+ {
+ this.invalidate(true);
+ }
+ }
+
+ /**
+ * Provide specific data to be displayed.
+ * This is a way to set start and end dates, owner and event data in one call.
+ *
+ * If events are not provided in the array,
+ * @param {Object[]} events Array of events, indexed by date in Ymd format:
+ * {
+ * 20150501: [...],
+ * 20150502: [...]
+ * }
+ * Days should be in order.
+ * {string|number|Date} events.start_date - New start date
+ * {string|number|Date} events.end_date - New end date
+ * {number|number[]|string|string[]} event.owner - Owner ID, which can
+ * be an account ID, a resource ID (as defined in calendar_bo, not
+ * necessarily an entry from the resource app), or a list containing a
+ * combination of both.
+ */
+ set_value(events)
+ {
+ if(typeof events !== 'object') return false;
+
+ if(events.length && events.length > 0 || !jQuery.isEmptyObject(events))
+ {
+ this.set_disabled(false);
+ }
+ if(events.id)
+ {
+ this.set_id(events.id);
+ delete events.id;
+ }
+ if(events.start_date)
+ {
+ this.set_start_date(events.start_date);
+ delete events.start_date;
+ }
+ if(events.end_date)
+ {
+ this.set_end_date(events.end_date);
+ delete events.end_date;
+ }
+ // set_owner() wants start_date set to get the correct week number
+ // for the corner label
+ if(events.owner)
+ {
+ this.set_owner(events.owner);
+ delete events.owner;
+ }
+
+ this.value = events || {};
+
+ // None of the above changed anything, hide the loader
+ if(!this.update_timer)
+ {
+ window.setTimeout(jQuery.proxy(function() {this.loader.hide();},this),200);
+ }
+ }
+
+ get date_helper(): et2_date
+ {
+ return this._date_helper;
+ }
+ _createNamespace() {
+ return true;
+ }
+
+ /**
+ * Calendar supports many different owner types, including users & resources.
+ * This translates an ID to a user-friendly name.
+ *
+ * @param {string} user
+ * @returns {string}
+ *
+ * @memberOf et2_calendar_view
+ */
+ _get_owner_name(user) {
+ var label = undefined;
+ if(parseInt(user) === 0)
+ {
+ // 0 means current user
+ user = egw.user('account_id');
+ }
+ if(et2_calendar_view.owner_name_cache[user])
+ {
+ return et2_calendar_view.owner_name_cache[user];
+ }
+ if (!isNaN(user))
+ {
+ user = parseInt(user);
+ var accounts = egw.accounts('both');
+ for(var j = 0; j < accounts.length; j++)
+ {
+ if(accounts[j].value === user)
+ {
+ label = accounts[j].label;
+ break;
+ }
+ }
+ }
+ if(typeof label === 'undefined')
+ {
+ // Not found? Ask the sidebox owner widget (it gets updated) or the original arrayMgr
+ let options = false;
+ if(app.calendar && app.calendar.sidebox_et2 && app.calendar.sidebox_et2.getWidgetById('owner'))
+ {
+ options = app.calendar.sidebox_et2.getWidgetById('owner').taglist.getSelection();
+ }
+ else
+ {
+ options = this.getArrayMgr("sel_options").getRoot().getEntry('owner');
+ }
+ if(options && options.find)
+ {
+ var found = options.find(function(element) {return element.id == user;}) || {};
+ if(found && found.label && found.label !== user)
+ {
+ label = found.label;
+ }
+ }
+ if(!label)
+ {
+ // No sidebox? Must be in home or sitemgr (no caching) - ask directly
+ label = '?';
+ egw.jsonq('calendar_owner_etemplate_widget::ajax_owner',user,function(data) {
+ et2_calendar_view.owner_name_cache[user] = data;
+ this.invalidate(true);
+
+ // Set owner to make sure labels get set
+ if(this.owner && typeof this.owner.set_value === 'function')
+ {
+ this.owner.set_value(data);
+ }
+ }.bind(this), this);
+ }
+ }
+ if(label)
+ {
+ et2_calendar_view.owner_name_cache[user] = label;
+ }
+ return label;
+ }
+
+ /**
+ * Find the event information linked to a given DOM node
+ *
+ * @param {HTMLElement} dom_node - It should have something to do with an event
+ * @returns {Object}
+ */
+ _get_event_info(dom_node)
+ {
+ // Determine as much relevant info as can be found
+ var event_node = jQuery(dom_node).closest('[data-id]',this.div)[0];
+ var day_node = jQuery(event_node).closest('[data-date]',this.div)[0];
+
+ var result = jQuery.extend({
+ event_node: event_node,
+ day_node: day_node
+ },
+ event_node ? event_node.dataset : {},
+ day_node ? day_node.dataset : {}
+ );
+
+ // Widget ID should be the DOM node ID without the event_ prefix
+ if(event_node && event_node.id)
+ {
+ var widget_id = event_node.id || '';
+ widget_id = widget_id.split('event_');
+ widget_id.shift();
+ result.widget_id = 'event_' + widget_id.join('');
+ }
+ return result;
+ }
+
+ /**
+ * Starting (mousedown) handler to support drag to create
+ *
+ * Extending classes need to set this.drag_create.parent, which is the
+ * parent container (child of extending class) that will directly hold the
+ * event.
+ *
+ * @param {String} start Date string (JSON format)
+ */
+ _drag_create_start(start)
+ {
+ this.drag_create.start = jQuery.extend({},start);
+ if(!this.drag_create.start.date)
+ {
+ this.drag_create.start = null;
+ }
+ this.drag_create.end = start;
+
+ // Clear some stuff, if last time did not complete
+ if(this.drag_create.event)
+ {
+ if(this.drag_create.event.destroy)
+ {
+ this.drag_create.event.destroy();
+ }
+ this.drag_create.event = null;
+ }
+ // Wait a bit before adding an "event", it may be just a click
+ window.setTimeout(jQuery.proxy(function() {
+ // Create event
+ this._drag_create_event();
+ }, this), 250);
+ }
+
+ /**
+ * Create or update an event used for feedback while dragging on empty space,
+ * so user can see something is happening
+ */
+ _drag_create_event()
+ {
+ if(!this.drag_create.parent || !this.drag_create.start)
+ {
+ return;
+ }
+ if(!this.drag_create.event)
+ {
+ this._date_helper.set_value(this.drag_create.start.date);
+ var value = jQuery.extend({},
+ this.drag_create.start,
+ this.drag_create.end,
+ {
+ start: this.drag_create.start.date,
+ end: this.drag_create.end && this.drag_create.end.date || this.drag_create.start.date,
+ date: ""+this._date_helper.get_year()+
+ sprintf("%02d",this._date_helper.get_month())+
+ sprintf("%02d",this._date_helper.get_date()),
+ title: '',
+ description: '',
+ owner: this.options.owner,
+ participants: this.options.owner,
+ app: 'calendar',
+ whole_day_on_top: this.drag_create.start.whole_day
+ }
+ );
+ this.drag_create.event = et2_createWidget('calendar-event',{
+ id:'event_drag',
+ value: value
+ },this.drag_create.parent);
+ this.drag_create.event._values_check(value);
+ this.drag_create.event.doLoadingFinished();
+ }
+
+ }
+
+ _drag_update_event()
+ {
+ if(!this.drag_create.event || !this.drag_create.start || !this.drag_create.end
+ || !this.drag_create.parent || !this.drag_create.event._type)
+ {
+ return;
+ }
+ else if (this.drag_create.end)
+ {
+ this.drag_create.event.options.value.end = this.drag_create.end.date;
+ this.drag_create.event._values_check(this.drag_create.event.options.value);
+ }
+ this.drag_create.event._update();
+ this.drag_create.parent.position_event(this.drag_create.event);
+ }
+
+ /**
+ * Ending (mouseup) handler to support drag to create
+ *
+ * @param {String} end Date string (JSON format)
+ */
+ _drag_create_end(end?)
+ {
+ this.div.css('cursor','');
+ if(typeof end === 'undefined')
+ {
+ end = {};
+ }
+
+ if(this.drag_create.start && end.date &&
+ JSON.stringify(this.drag_create.start.date) !== JSON.stringify(end.date))
+ {
+ // Drag from start to end, open dialog
+ var options = {
+ start: this.drag_create.start.date < end.date ? this.drag_create.start.date : end.date,
+ end: this.drag_create.start.date < end.date ? end.date : this.drag_create.start.date
+ };
+
+ // Whole day needs to go from 00:00 to 23:59
+ if(end.whole_day || this.drag_create.start.whole_day)
+ {
+ var start = new Date(options.start);
+ start.setUTCHours(0);
+ start.setUTCMinutes(0);
+ options.start = start.toJSON();
+
+ var end = new Date(options.end);
+ end.setUTCHours(23);
+ end.setUTCMinutes(59);
+ options.end = end.toJSON();
+ }
+
+ // Add anything else that was set, but not date
+ jQuery.extend(options,this.drag_create.start, end);
+ delete(options.date);
+
+ // Make sure parent is set, if needed
+ if (this.drag_create.parent && this.drag_create.parent.options.owner !== app.calendar.state.owner && !options.owner)
+ {
+ options.owner = this.drag_create.parent.options.owner;
+ }
+
+ // Remove empties
+ for(var key in options)
+ {
+ if(!options[key]) delete options[key];
+ }
+ app.calendar.add(options, this.drag_create.event);
+
+ // Wait a bit, having these stops the click
+ window.setTimeout(jQuery.proxy(function() {
+ this.drag_create.start = null;
+ this.drag_create.end = null;
+ this.drag_create.parent = null;
+ if(this.drag_create.event)
+ {
+ this.drag_create.event = null;
+ }
+ },this),100);
+
+ return false;
+ }
+
+ this.drag_create.start = null;
+ this.drag_create.end = null;
+ this.drag_create.parent = null;
+ if(this.drag_create.event)
+ {
+ try
+ {
+ if(this.drag_create.event.destroy)
+ {
+ this.drag_create.event.destroy();
+ }
+ } catch(e) {}
+ this.drag_create.event = null;
+ }
+ return true;
+ }
+
+ /**
+ * Check if the view should be consolidated into one, or listed seperately
+ * based on the user's preferences
+ *
+ * @param {string[]} owners List of owners
+ * @param {string} view Name of current view (day, week)
+ * @returns {boolean} True of only one is needed, false if each owner needs
+ * to be listed seperately.
+ */
+ static is_consolidated(owners, view)
+ {
+ // Seperate owners, or consolidated?
+ return !(
+ owners.length > 1 &&
+ (view === 'day' && owners.length < parseInt(''+egw.preference('day_consolidate','calendar')) ||
+ view === 'week' && owners.length < parseInt(''+egw.preference('week_consolidate','calendar')))
+ );
+ }
+
+ /**
+ * Cache to map owner & resource IDs to names, helps cut down on server requests
+ */
+ static owner_name_cache = {};
+
+ public static holiday_cache = {};
+ /**
+ * Fetch and cache a list of the year's holidays
+ *
+ * @param {et2_calendar_timegrid} widget
+ * @param {string|numeric} year
+ * @returns {Array}
+ */
+ static get_holidays(widget,year)
+ {
+ // Loaded in an iframe or something
+ var view = egw.window.et2_calendar_view ? egw.window.et2_calendar_view : this;
+
+ // No country selected causes error, so skip if it's missing
+ if(!view || !egw.preference('country','common')) return {};
+
+ var cache = view.holiday_cache[year];
+ if (typeof cache == 'undefined')
+ {
+ // Fetch with json instead of jsonq because there may be more than
+ // one widget listening for the response by the time it gets back,
+ // and we can't do that when it's queued.
+ view.holiday_cache[year] = jQuery.getJSON(
+ egw.link('/calendar/holidays.php', {year: year})
+ );
+ }
+ cache = view.holiday_cache[year];
+ if(typeof cache.done == 'function')
+ {
+ // pending, wait for it
+ cache.done(jQuery.proxy(function(response) {
+ view.holiday_cache[this.year] = response||undefined;
+
+ egw.window.setTimeout(jQuery.proxy(function() {
+ // Make sure widget hasn't been destroyed while we wait
+ if(typeof this.widget.free == 'undefined')
+ {
+ this.widget.day_class_holiday();
+ }
+ },this),1);
+ },{widget:widget,year:year}))
+ .fail(jQuery.proxy(function() {
+ view.holiday_cache[this.year] = undefined;
+ }, {widget: widget, year: year}));
+ return {};
+ }
+ else
+ {
+ return cache;
+ }
+ }
+}