/** * jQuery calendar plug-in 1.1.1 * Copyright 2017, Custom D * Licensed under the MIT license. * https://customd.github.io/jquery-calendar/LICENSE.txt * * @author Sam Sehnert | sam@customd.com * @docs https://customd.github.io/jquery-calendar * * Implement an extremely flexible calendar interface with minimal up front development. */ (function($){ "use strict"; // The name of your plugin. This is used to namespace your // plugin methods, object data, and registerd events. var plugin_name = 'cal'; var plugin_version = '1.1.1'; var const_month = 'month'; var const_week = 'week'; // Set up the plugin defaults. // These will be stored in $this.data(plugin_name).settings, // and can be overwritten by having 'options' passed through // as the parameter to the init method. var defaults = { // Start date and days to display. startdate : null, // Defaults to new Date() if none of start month/year/monthstodisplay are defined. daystodisplay : null, // Defaults to 7 if none of start month/year/monthstodisplay are defined. // Start month,year and months to display. startweek : null, // CUSTOM startmonth : null, // Defaults to (new Date()).getMonth()+1 if monthstodisplay or startyear are defined (we use 1-12, not 0-11). startyear : null, // Defaults to (new Date()).getFullYear() if monthstodisplay or startmonth are defined. monthstodisplay : null, // Defaults to 1 if either of startmonth or startyear are defined. TODO: Support more than one month??? // Default colors defaultcolor : '#255BA1', invalidcolor : '#888888', // Date Masks maskmonthlabel : 'l', maskeventlabel : 'g:i A', maskeventlabeldelimiter : '', // – maskeventlabelend : '', // g:i A maskdatelabel : 'D, jS', masktimelabel : { '00' : 'g:i <\\sp\\a\\n>A<\/\\sp\\a\\n>', 'noon' : '\\N\\O\\O\\N' }, // Either false, or an array of resources. resources : false, // Default height and widths. minwidth : 130, minheight : null, overlapoffset : 15, // Start and end times for the days daytimestart : '00:00:00', daytimeend : '24:00:00', // Which day the week starts on 1-7, 1 being Sunday, 7 being Saturday. weekstart : 1, // Other options... dragincrement : '15 mins', gridincrement : '15 mins', creationsize : '15 mins', // Global appointment permissions allowcreation : 'both', // Options, 'both', 'click', 'drag', 'none', false. allowmove : false, // Enable or disable appointment dragging/moving. allowresize : false, // Enable or disable appointment resizing. allowselect : false, // Enable or disable appointment selection. allowremove : false, // Enable or disable appointment deletion. allowoverlap : false, // Enable or disable appointment overlaps. allownotesedit : false, // Enable or disable inline notes editing. allowhtml : false, // Whether or not to allow users to embed html to appointment data // Easing effects easing : { eventupdate : 'linear', eventremove : 'linear', eventeditin : 'linear', eventeditout : 'linear', datechange : 'linear' }, // appointment events. eventcreate : $.noop, eventnotesedit : $.noop, eventremove : $.noop, eventselect : $.noop, eventmove : $.noop, eventresize : $.noop, eventdraw : $.noop, // day events dayclick : $.noop, daydblclick : $.noop, // Other events. onload : $.noop }; var _private = { /** * Attached to several elements to prevent default actions. * * @param object e : An object representing the event that triggered this method. * * @return void */ prevent : function(e){ e.preventDefault(); }, /** * Get the scrollbar width so we know how much space to allocate. * * @return int : Returns the width of the scrollbars in pixels. */ scrollbarSize : function() { // Use the cached version if exists. if (!_private._scrollbarSize) { var $doc = $(document.body), // Set the overflow to hidden, scroll and measure difference. w =$doc.css({overflow:'hidden'}).width(); w-=$doc.css({overflow:'scroll'}).width(); // Add support for IE in Standards mode. if(!w) w=$doc.width()-$doc[0].clientWidth; // Restore the overflow setting. $doc.css({overflow:''}); // Cache the scrollbar width. _private._scrollbarSize = w; } // Return the width. return _private._scrollbarSize; }, // Cache the scrollbar size here. _scrollbarSize : false, /** * Called with a jquery collection of textareas and/or input boxes as 'this'. * Allows setting a selecting text easily cross browser. * * @param int start : The start index in the string that we should select from. * @param int end : The end index in the string that we should select to. * * @return object : Returns the jquery collection that was passed as 'this'. */ selectrange : function(start, end) { return this.each(function() { if(this.setSelectionRange) { this.focus(); this.setSelectionRange(start, end); } else if(this.createTextRange) { var range = this.createTextRange(); range.collapse(true); range.moveEnd('character', end); range.moveStart('character', start); range.select(); } }); }, /** * Called when the scroll container elment is scrolled. Attached to the onscroll event. * * @param object e : An object representing the event that triggered this method. * * @return void */ onscroll : function(e){ // Called on scroll on the container element. // Init the variables we'll need. var $this = $(this), $parent = $this.parent('.ui-'+plugin_name+'-container'), scrollX = $this.scrollLeft(), scrollY = $this.scrollTop(), data = $parent.data(plugin_name); if( data ){ // Position the date and timeline at the correct scroll position. $parent.find('.ui-'+plugin_name+'-timeline').scrollTop(scrollY); $parent.find('.ui-'+plugin_name+'-dateline').scrollLeft(scrollX); $parent.find('.ui-'+plugin_name+'-resourceline').scrollLeft(scrollX); } }, /** * Check to see if a number or date is between start and end. * * @param mixed item : The item we want to compare with start and end. * @param mixed start : The beginning of the range we want to check item against. * @param mixed end : The end of the range we want to check item against. * * @return bool : Returns true if item falls between the range start - end. */ between : function( item, start, end ){ // If we're dealing with a date. if( item instanceof Date ){ var timestamp = item.getTime(); return ( timestamp >= start.getTime() && timestamp <= end.getTime() ); // If we're dealing with a number. } else if( !isNaN( Number( item ) ) ){ return ( item >= start && item <= end ); } return false; }, /** * Check to see if a date range overlaps any part of a second range. * * @param date partStart : * @param date partEnd : * @param date inStart : * @param date inEnd : * * @return bool : in range, or not. */ inrange : function( partStart, partEnd, inStart, inEnd ) { return !( partEnd.getTime() < inStart.getTime() || partStart.getTime() > inEnd.getTime() ); }, /** * Called to get a resource at a given index from the settings object. * * @param int index : A number representing the index position of the resouce data we want. * @param object data : Am object containing the plugin data (which we'll use to get the resources). * * @return object : The resource object at the given index. */ resource : function( index, data ){ var iterator = 0; if( data.settings.resources !== false ){ for( var i in data.settings.resources ){ if( data.settings.resources.hasOwnProperty( i ) ){ if( iterator == index ){ return { 'id' : i, 'name' : data.settings.resources[i] }; } iterator++; } } } return { 'id' : null, 'name' : null }; }, /** * Called to get the index of a given resource item in the settings object. * * @param mixed id : A string or number type which represents the resource key of the resource index we want returned. * @param object data : An object containing the plugin data (which we'll use to get the resources). * * @return int : Returns the index position of the passed resource in the settings object. */ resourceIndex : function( id, data ){ var index = 0; if( data.settings.resources !== false ){ // If we've been given a straight array of keys, then // we only check against the value. if( $.isArray( data.settings.resources ) ){ for( var i in data.settings.resources ){ if( data.settings.resources[i] == id ){ return index; } index++; } } else { for( var i in data.settings.resources ){ if( data.settings.resources.hasOwnProperty( i ) ){ if( i == id ){ return index; } index++; } } } } return false; }, /** * Get dates for given repetition rules, limited by begin and end dates. * * TODO: This is incomplete, and will only handle basic 'daily', 'weekly', 'monthly', 'yearly' * repetitions, 'until' dates, and repetition intervals, e.g., every 2 weeks, every 3 months, etc. * More advanced repetitions, like every week on tuesdays and thursdays. * * @param object data : The calendar data object. * @param object event : The event we're generating repetitions for. * * @return array : An array of dates which the repetitions fall on. Only the dates, not the times. */ repetitions : function( data, event ) { // Set up variables used in rule_loop below. var repeat = [], rule, from, until, interval, _increment_by, _date, _possible_increments; // // TODO: Extend this method to parse out 'dates' object, and add those to the repeat rules. // This also includes adding exception dates, and we'll need to do a similar thing to // support exception rules. // // Loop over each repetition rule. rule_loop : for( var i in event.repeat.rules.include ) { // Shortcut variables. rule = event.repeat.rules.include[i]; from = event.begins > data.settings.startdate ? event.begins : data.settings.startdate ; // TODO: set this to the 'next' repetition date for this range. until = 'until' in rule ? $[plugin_name].date(rule.until) : data.cache.enddate , interval = 'interval' in rule ? +rule.interval : 1 ; // // Skip rule parsing where we don't need to do it... // if( // If the event begins after the end date. ( event.begins > data.cache.enddate ) || // If we've got an 'until' date, and it's before the start date. ( until <= data.settings.startdate ) ) continue; // Get the (required) frequency rule. if( 'freq' in rule ) { // Check which rule we've got. switch( rule.freq ) { case 'daily' : _increment_by = interval+' Day'; break; case 'weekly' : _increment_by = interval+' Week'; break; case 'monthly': _increment_by = interval+' Month'; break; case 'yearly' : _increment_by = interval+' Year'; break; // We can't handle any other frequency types at present, so if this is // the case, break out and continue to the next iteration of rule_loop. default: continue rule_loop; } // Copy the begins date. _date = event.begins.copy(); // We need to make sure the begins date is the first in the iteration // of repetitions to generate. This means bringing it forward by the difference // between the event.begins date, and the data.settings.startdate if( event.begins < data.settings.startdate ) { // Increment the date so we've got the first repetition position. _date = _date.incrementBy( _increment_by, Math.ceil( event.begins.getIncrementBetween( data.settings.startdate, _increment_by ) ) ); } else { // Move to the first repetition position (i.e., NOT the actual event itself). _date = _date.incrementBy( _increment_by ); } // Get the number of increments we can fit in current display range. _possible_increments = Math.ceil( _date.getIncrementBetween( data.cache.enddate, _increment_by ) ); // Loop over and create the basic array of repetitions. // This won't work for irregular repetitions, like every Tuesday & Thursday. for( var i=0; i<_possible_increments; i++ ) { repeat[_date.getTime()] = _date; _date = _date.incrementBy( _increment_by ); } } } return repeat; }, /** * Parse event overlaps for the given event. * * @param date begin : The beginning of the range that we want to check for overlaps. * @param date end : The end of the range that we want to check for overlaps. * @param object resource : A resource id / label object if we want to check for overlaps on resources too. * * @return void; */ overlaps : function( begin, end, resource ){ // Get variables that we'll use later. var $this = $(this), data = $this.data(plugin_name), check = []; // If the calendar has been implemented on this object. if( data ){ // Store shortcut to events array. var events = data.cache.events; // Loop through the cached event data. for( var uid in events ){ if( // Part of the event falls into the date range that we're checking. events.hasOwnProperty(uid) && ( events[uid].begins < end && events[uid].ends > begin && events[uid].resource === resource ) ){ // Initialise the overlap object. events[uid].overlap = { partial : true, inset : 0, zindex : 0, count : 0, index : 0, items : {}, uid : uid }; check.push(events[uid]); } } // We only need to check if there is more than one appointment in this time span. if( check.length > 1 ){ var index = 0; // Sort by start date. check.sort(function(a,b){ return a.begins.getTime()-b.begins.getTime(); }); // Loop through each of the events that in the date range, // and build up the overlap settings. for( var uid1 in check ){ // Make sure this property exists on the object (not a prototyped property). if( check.hasOwnProperty(uid1) ){ // Increment the index. check[uid1].overlap.index = index++; // Loop through each of the events and compare. for( var uid2 in check ){ // Skip this... we don't need to compare the same object. if( uid1 === uid2 ) continue; if( // These object overlap AND they haven't already been flagged as overlapping. check.hasOwnProperty(uid2) && !( check[uid1].overlap.uid in check[uid2].overlap.items ) && !( check[uid2].overlap.uid in check[uid1].overlap.items ) && ( check[uid1].begins < check[uid2].ends && check[uid1].ends > check[uid2].begins && check[uid1].resource === check[uid2].resource ) ){ // Store a reference to the overlapped object. check[uid1].overlap.items[check[uid2].overlap.uid] = check[uid2]; check[uid2].overlap.items[check[uid1].overlap.uid] = check[uid1]; check[uid1].overlap.count++; check[uid2].overlap.count++; if( // The begin times are exactly the same... check[uid1].begins.getTime() == check[uid2].begins.getTime() ){ // Set these up as non-partial overlaps. check[uid1].overlap.partial = false; check[uid2].overlap.partial = false; // Set the new inset for non-partial overlaps. check[uid2].overlap.inset = check[uid1].overlap.inset+1; check[uid2].overlap.zindex = check[uid1].overlap.zindex+1; } else if( // The begins time is less than the ends time. check[uid1].begins.getTime() < check[uid2].begins.getTime() ){ // Increment the inset if this is a partial overlap. if( check[uid1].overlap.partial ) check[uid2].overlap.inset++; check[uid2].overlap.zindex = check[uid1].overlap.zindex+1; } else { // Increment the first appointments inset if this is a partial overlap. if( check[uid2].overlap.partial ) check[uid1].overlap.inset++; check[uid1].overlap.zindex = check[uid2].overlap.zindex+1; } // Update the cache. data.cache.events[check[uid1].overlap.uid] = check[uid1]; data.cache.events[check[uid2].overlap.uid] = check[uid2]; } } } } // Update each of the overlap items data. for( var uid in check ) check[uid].elems.data(plugin_name,check[uid]); } // Update the plugin data. $this.data(plugin_name,data); } }, // Error objects used by the calendar errors : { eventParse : function( message, event ){ // Create a new error object var error = new Error( message ); error.type = 'EventParse'; error.event = event; // Return the error object (usually to throw). return error; }, icsParse : function( message, line, value ){ // Create a new error object var error = new Error( message ); error.type = 'ICSParse'; error.line = line; error.value = value; // Return the error object (usually to throw). return error; } }, // Holds the event parser methods. parse : { // Patterns for parsing ICS files. _icalendar : { // Folded lines: start with a whitespace character */ folds : /^\s(.*)$/, // Individual entry: name:value */ entry : /^([A-Za-z0-9-]+)((?:;[A-Za-z0-9-]+=(?:"[^"]+"|[^";:,]+)(?:,(?:"[^"]+"|[^";:,]+))*)*):(.*)$/, // Individual parameter: name=value[,value] */ param : /;([A-Za-z0-9-]+)=((?:"[^"]+"|[^";:,]+)(?:,(?:"[^"]+"|[^";:,]+))*)/g, // Individual parameter value: value | "value" */ value : /,?("[^"]+"|[^";:,]+)/g, // Date only field: yyyymmdd */ date : /^(\d{4})(\d\d)(\d\d)$/, // Date/time field: yyyymmddThhmmss[Z] */ time : /^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)(Z?)$/, // Date/time range field: yyyymmddThhmmss[Z]/yyyymmddThhmmss[Z] */ range : /^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)(Z?)\/(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)(Z?)$/, // Timezone offset field: +hhmm */ offset : /^([+-])(\d\d)(\d\d)$/, // Duration: [+-]PnnW or [+-]PnnDTnnHnnMnnS */ duration : /^([+-])?P(\d+W)?(\d+D)?(T)?(\d+H)?(\d+M)?(\d+S)?$/, /** * Parses iCalendar (RFC 2445) RRULE property. * * @param string rule : The rule to parse. * * @return object : An object describing the repetition rules. */ _rrule : function( rule ) { var parts = rule.split(';'), props, rules = {}; // Loop over each rule set. for( var i in parts ) { props = parts[i].split('='); rules[props[0].toLowerCase()] = props[1].toLowerCase(); } // Return this rule set. return rules; } }, /** * Parses iCalendar (RFC 2445) into a javascript object. * @docs http://www.ietf.org/rfc/rfc2445.txt * * @param string ics : An iCalendar formatted string. * * @return object : Returns a javascript object representing the passed iCalendar format. */ icalendar : function( ics ) { var err = _private.errors.icsParse, parse = _private.parse._icalendar, parsing = 'cal_begin', calendars = [], lines = ics.replace(/\r\n/g,'\n').split('\n'), event = null, calendar = null, // Event prototype. _event = { repeat : { rules : { include : [], exclude : [] }, dates : { include : [], exclude : [] } } }; for( var i=lines.length-1; i>0; i-- ){ var matches = parse.folds.exec(lines[i]); if( matches ){ lines[i-1] += matches[1]; lines[i] = ''; } } $.each(lines,function(i,line){ // Skip blank lines. if( !line ) return; switch( parsing ){ //case 'done' : If we decide to support more than one calendar in the same ics. case 'cal_begin' : // Check for calendar begin. if( line.indexOf('BEGIN:VCALENDAR')==-1 ) throw new err( 'Expecting BEGIN:VCALENDAR but found \''+line+'\' instead.', i, line ); // Initialise the calendar object. calendar = {events:[]}; parsing = 'cal_info'; break; case 'cal_info' : // Check for a change in parsing mode. if( line.indexOf('BEGIN:VEVENT')==0 ){ event = $.extend(true,{},_event); parsing = 'cal_event' }; if( line.indexOf('END:VCALENDAR')==0 ){ calendars.push(calendar); parsing = 'done' }; // If parsing mode has changed, continue with next line. if( parsing !== 'cal_info' ) return; break; case 'cal_event' : // Check for a change in parsing mode. if( line.indexOf('END:VEVENT')==0 ){ calendar.events.push(event); parsing = 'cal_info' }; // If parsing mode has changed, continue with next line. if( parsing !== 'cal_event' ) return; // Match an entry line. var matches = parse.entry.exec(line); if (!matches) { throw new err( 'Missing entry name.', i, line ); } // Parse the different date values. switch( matches[1].toLowerCase() ){ // // Standard / Basic values. // case 'uid' : event.uid = matches[3]; break; case 'dtstart' : event.begins = $[plugin_name].date(matches[3]); break; case 'dtend' : event.ends = $[plugin_name].date(matches[3]); break; case 'summary' : event.title = matches[3].replace(/\\([;,])/g,'$1').replace(/\\n/g,'\n'); break; case 'description' : event.notes = matches[3].replace(/\\([;,])/g,'$1').replace(/\\n/g,'\n'); break; // // Repetition rules. // case 'rrule' : event.repeat.rules.include.push(parse._rrule(matches[3])); break; case 'exrule' : event.repeat.rules.exclude.push(parse._rrule(matches[3])); break; // // Repetition dates. // case 'rdate' : event.repeat.dates.include.push($[plugin_name].date(matches[3])); break; case 'exdate' : event.repeat.dates.exclude.push($[plugin_name].date(matches[3])); break; } break; } }); // Throw an error if we didn't find group end. if( parsing !== 'done' ) throw new err( 'Unexpected end of file. Expecting END:VCALENDAR.', lines.length, '' ); // Return the parsed calendars. return calendars.length > 0 ? calendars.pop() : false; } }, // Pseudo events used by the calendar. event : { /** * Returns the number of elements required to draw the event. * * @param obj values : Should be a fully validated values object, with minimum of begins, ends timestamps. * * @return int : The number of elements that should be drawn (the number of days that this element spans, visually). */ calculateElementCount : function( values ){ // Return the number of elements that should be drawn for this object. return Math.ceil( values.cache.begins.getDaysBetween( values.cache.ends, true ) ); }, /** * Positions an event object on the screen according to its data object. * * @param mixed speed : (opt) If int is number of milleseconds for animation, or string 'fast', 'slow'. If undefined, no animation. * @param string ease : (opt) The easing method to use. See jQuery easing documentation for details. * * @return void */ update : function( bData, speed, ease ){ // Clone the event element, and set up the values. var $event = $(this), values = $event.data(plugin_name), data = values && values.calendar ? values.calendar.data(plugin_name) : false ; // Make sure we've got values. if( data && values ){ // Get each of the event elements. var $events = values.elems; // Set the new values. if( 'begins' in bData ) values.begins = $[plugin_name].date( bData.begins ); if( 'ends' in bData ) values.ends = $[plugin_name].date( bData.ends ); if( 'color' in bData ) values.colors = bData.color ? $[plugin_name].colors.generate( bData.color ) : data.settings.defaultcolor; if( 'title' in bData ) values.title = bData.title || null; if( 'notes' in bData ) values.notes = bData.notes || ''; // Exit if there's no need to draw anything. if( values.ends < data.settings.startdate || values.begins > data.cache.enddate ) return false; // Work out the cached end date. values.cache.ends = ( data.cache.enddate < values.ends ? data.cache.enddate.addSeconds(-1) : values.ends ); values.cache.begins = ( data.settings.startdate > values.begins ? data.settings.startdate.copy() : values.begins ); var content_setter = data.settings.allowhtml ? 'html' : 'text' ; // Set the new value into the event data. $events.find('pre.details')[content_setter]( values.notes ); $events.find('p.title')[content_setter]( values.title || ( values.begins.format(data.settings.maskeventlabel) + ( data.settings.maskeventlabelend !== '' ? data.settings.maskeventlabeldelimiter + values.ends.format( data.settings.maskeventlabelend ) : '' ) ) ); // Save the new values to the element. $events.data(plugin_name,values); data.cache.events[values.uid] = values; values.calendar.data(plugin_name,data); // Call the positioning code. _private.draw[data.type].position.apply($events,[speed,ease]); return true; } return false; }, /** * Creates an inline edit area for an appointment's description. * * @param object e : An object representing the event that triggered this method. * * @return void */ edit : function( e ){ var $event = e && !$(this).is('div.ui-'+plugin_name+'-event') ? $(this).parents('div.ui-'+plugin_name+'-event') : $(this), values = $event.data(plugin_name), data = values && values.calendar ? values.calendar.data(plugin_name) : false ; if( data && values ){ // Exit now if we don't allow selection. if( !data.settings.allownotesedit ) return; // Set the notes base height. var $notes = $event.find('pre.details'), noteHeight = $notes.height(); var content_setter = data.settings.allowhtml ? 'html' : 'text' ; // Now append the textarea. var $textarea = $('