diff --git a/src/NzbDrone.Api/Config/UiConfigModule.cs b/src/NzbDrone.Api/Config/UiConfigModule.cs new file mode 100644 index 000000000..782e4ebf7 --- /dev/null +++ b/src/NzbDrone.Api/Config/UiConfigModule.cs @@ -0,0 +1,45 @@ +using System.Linq; +using System.Reflection; +using NzbDrone.Core.Configuration; +using Omu.ValueInjecter; + +namespace NzbDrone.Api.Config +{ + public class UiConfigModule : NzbDroneRestModule + { + private readonly IConfigService _configService; + + public UiConfigModule(IConfigService configService) + : base("/config/ui") + { + _configService = configService; + + GetResourceSingle = GetUiConfig; + GetResourceById = GetUiConfig; + UpdateResource = SaveUiConfig; + } + + private UiConfigResource GetUiConfig() + { + var resource = new UiConfigResource(); + resource.InjectFrom(_configService); + resource.Id = 1; + + return resource; + } + + private UiConfigResource GetUiConfig(int id) + { + return GetUiConfig(); + } + + private void SaveUiConfig(UiConfigResource resource) + { + var dictionary = resource.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); + + _configService.SaveConfigDictionary(dictionary); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/UiConfigResource.cs b/src/NzbDrone.Api/Config/UiConfigResource.cs new file mode 100644 index 000000000..6912cd668 --- /dev/null +++ b/src/NzbDrone.Api/Config/UiConfigResource.cs @@ -0,0 +1,18 @@ +using System; +using NzbDrone.Api.REST; + +namespace NzbDrone.Api.Config +{ + public class UiConfigResource : RestResource + { + //Calendar + public Int32 FirstDayOfWeek { get; set; } + public String CalendarWeekColumnHeader { get; set; } + + //Dates + public String ShortDateFormat { get; set; } + public String LongDateFormat { get; set; } + public String TimeFormat { get; set; } + public Boolean ShowRelativeDates { get; set; } + } +} diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index a10896797..deacaca17 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -92,6 +92,8 @@ + + diff --git a/src/NzbDrone.Api/System/SystemModule.cs b/src/NzbDrone.Api/System/SystemModule.cs index ca1bab855..702a60ee4 100644 --- a/src/NzbDrone.Api/System/SystemModule.cs +++ b/src/NzbDrone.Api/System/SystemModule.cs @@ -3,11 +3,9 @@ using Nancy.Routing; using NzbDrone.Common; using NzbDrone.Api.Extensions; using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Processes; using NzbDrone.Core.Configuration; using NzbDrone.Core.Datastore; using NzbDrone.Core.Lifecycle; -using NzbDrone.Core.Lifecycle.Commands; namespace NzbDrone.Api.System { @@ -60,7 +58,6 @@ namespace NzbDrone.Api.System IsWindows = OsInfo.IsWindows, Branch = _configFileProvider.Branch, Authentication = _configFileProvider.AuthenticationEnabled, - StartOfWeek = (int)OsInfo.FirstDayOfWeek, SqliteVersion = _database.Version, UrlBase = _configFileProvider.UrlBase, RuntimeVersion = OsInfo.IsMono ? _runtimeInfo.RuntimeVersion : null diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index e1462a561..06068e5db 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Events; @@ -254,6 +255,48 @@ namespace NzbDrone.Core.Configuration set { SetValue("ChownGroup", value); } } + public Int32 FirstDayOfWeek + { + get { return GetValueInt("FirstDayOfWeek", (int)OsInfo.FirstDayOfWeek); } + + set { SetValue("FirstDayOfWeek", value); } + } + + public String CalendarWeekColumnHeader + { + get { return GetValue("CalendarWeekColumnHeader", "ddd M/D"); } + + set { SetValue("CalendarWeekColumnHeader", value); } + } + + public String ShortDateFormat + { + get { return GetValue("ShortDateFormat", "MMM D YYYY"); } + + set { SetValue("ShortDateFormat", value); } + } + + public String LongDateFormat + { + get { return GetValue("LongDateFormat", "dddd, MMMM D YYYY"); } + + set { SetValue("LongDateFormat", value); } + } + + public String TimeFormat + { + get { return GetValue("TimeFormat", "h(:mm)a"); } + + set { SetValue("TimeFormat", value); } + } + + public Boolean ShowRelativeDates + { + get { return GetValueBoolean("ShowRelativeDates", true); } + + set { SetValue("ShowRelativeDates", value); } + } + private string GetValue(string key) { return GetValue(key, String.Empty); diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index f471762c5..e21185bb7 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Update; namespace NzbDrone.Core.Configuration { @@ -49,5 +48,14 @@ namespace NzbDrone.Core.Configuration Int32 Retention { get; set; } Int32 RssSyncInterval { get; set; } String ReleaseRestrictions { get; set; } + + //UI + Int32 FirstDayOfWeek { get; set; } + String CalendarWeekColumnHeader { get; set; } + + String ShortDateFormat { get; set; } + String LongDateFormat { get; set; } + String TimeFormat { get; set; } + Boolean ShowRelativeDates { get; set; } } } diff --git a/src/UI/Calendar/CalendarView.js b/src/UI/Calendar/CalendarView.js index 14dc6f3f9..1c26d1908 100644 --- a/src/UI/Calendar/CalendarView.js +++ b/src/UI/Calendar/CalendarView.js @@ -7,13 +7,13 @@ define( 'marionette', 'moment', 'Calendar/Collection', - 'System/StatusModel', + 'Shared/UiSettingsModel', 'History/Queue/QueueCollection', 'Config', 'Mixins/backbone.signalr.mixin', 'fullcalendar', 'jquery.easypiechart' - ], function ($, vent, Marionette, moment, CalendarCollection, StatusModel, QueueCollection, Config) { + ], function ($, vent, Marionette, moment, CalendarCollection, UiSettings, QueueCollection, Config) { return Marionette.ItemView.extend({ storageKey: 'calendar.view', @@ -44,39 +44,45 @@ define( this.$(element).addClass(event.statusLevel); this.$(element).children('.fc-event-inner').addClass(event.statusLevel); - if (event.progress > 0) { - this.$(element).find('.fc-event-time') - .after(''.format(event.progress)); - - this.$(element).find('.chart').easyPieChart({ - barColor : '#ffffff', - trackColor: false, - scaleColor: false, - lineWidth : 2, - size : 14, - animate : false - }); - - this.$(element).find('.chart').tooltip({ - title: 'Episode is downloading - {0}% {1}'.format(event.progress.toFixed(1), event.releaseTitle), - container: 'body' - }); - } + if (event.downloading) { + var progress = 100 - (event.downloading.get('sizeleft') / event.downloading.get('size') * 100); + var releaseTitle = event.downloading.get('title'); + var estimatedCompletionTime = moment(event.downloading.get('estimatedCompletionTime')).fromNow(); + + if (event.downloading.get('status').toLocaleLowerCase() === 'pending') { + this.$(element).find('.fc-event-time') + .after(''); - if (event.pending) { - this.$(element).find('.fc-event-time') - .after(''); + this.$(element).find('.pending').tooltip({ + title: 'Release will be processed {0}'.format(estimatedCompletionTime), + container: 'body' + }); + } - this.$(element).find('.pending').tooltip({ - title: 'Release will be processed {0}'.format(event.pending), - container: 'body' - }); + else { + this.$(element).find('.fc-event-time') + .after(''.format(progress)); + + this.$(element).find('.chart').easyPieChart({ + barColor : '#ffffff', + trackColor: false, + scaleColor: false, + lineWidth : 2, + size : 14, + animate : false + }); + + this.$(element).find('.chart').tooltip({ + title: 'Episode is downloading - {0}% {1}'.format(progress.toFixed(1), releaseTitle), + container: 'body' + }); + } } }, _getEvents: function (view) { - var start = moment(view.visStart).toISOString(); - var end = moment(view.visEnd).toISOString(); + var start = view.start.toISOString(); + var end = view.end.toISOString(); this.$el.fullCalendar('removeEvents'); @@ -99,13 +105,10 @@ define( var event = { title : seriesTitle, - start : start, - end : end, + start : moment(start), + end : moment(end), allDay : false, statusLevel : self._getStatusLevel(model, end), - progress : self._getDownloadProgress(model), - pending : self._getPendingInfo(model), - releaseTitle: self._getReleaseTitle(model), downloading : QueueCollection.findEpisode(model.get('id')), model : model }; @@ -153,47 +156,12 @@ define( this._setEventData(this.collection); }, - _getDownloadProgress: function (element) { - var downloading = QueueCollection.findEpisode(element.get('id')); - - if (!downloading) { - return 0; - } - - return 100 - (downloading.get('sizeleft') / downloading.get('size') * 100); - }, - - _getPendingInfo: function (element) { - var pending = QueueCollection.findEpisode(element.get('id')); - - if (!pending || pending.get('status').toLocaleLowerCase() !== 'pending') { - return undefined; - } - - return moment(pending.get('estimatedCompletionTime')).fromNow(); - }, - - _getReleaseTitle: function (element) { - var downloading = QueueCollection.findEpisode(element.get('id')); - - if (!downloading) { - return ''; - } - - return downloading.get('title'); - }, - _getOptions: function () { var options = { allDayDefault : false, - ignoreTimezone: false, weekMode : 'variable', - firstDay : StatusModel.get('startOfWeek'), - timeFormat : 'h(:mm)tt', - buttonText : { - prev: '', - next: '' - }, + firstDay : UiSettings.get('firstDayOfWeek'), + timeFormat : 'h(:mm)a', viewRender : this._viewRender.bind(this), eventRender : this._eventRender.bind(this), eventClick : function (event) { @@ -204,12 +172,6 @@ define( if ($(window).width() < 768) { options.defaultView = Config.getValue(this.storageKey, 'basicDay'); - options.titleFormat = { - month: 'MMM yyyy', // September 2009 - week: 'MMM d[ yyyy]{ \'—\'[ MMM] d yyyy}', // Sep 7 - 13 2009 - day: 'ddd, MMM d, yyyy' // Tuesday, Sep 8, 2009 - }; - options.header = { left : 'prev,next today', center: 'title', @@ -220,12 +182,6 @@ define( else { options.defaultView = Config.getValue(this.storageKey, 'basicWeek'); - options.titleFormat = { - month: 'MMM yyyy', // September 2009 - week: 'MMM d[ yyyy]{ \'—\'[ MMM] d yyyy}', // Sep 7 - 13 2009 - day: 'dddd, MMM d, yyyy' // Tues, Sep 8, 2009 - }; - options.header = { left : 'prev,next today', center: 'title', @@ -233,6 +189,22 @@ define( }; } + options.titleFormat = { + month : 'MMMM YYYY', + week : UiSettings.get('shortDateFormat'), + day : UiSettings.get('longDateFormat') + }; + + options.columnFormat = { + month : 'ddd', // Mon + week : UiSettings.get('calendarWeekColumnHeader'), + day : 'dddd' // Monday + }; + + options.timeFormat = { + 'default': UiSettings.get('timeFormat') + }; + return options; } }); diff --git a/src/UI/Calendar/UpcomingCollection.js b/src/UI/Calendar/UpcomingCollection.js index 2429e3744..7ccafbbd5 100644 --- a/src/UI/Calendar/UpcomingCollection.js +++ b/src/UI/Calendar/UpcomingCollection.js @@ -4,18 +4,18 @@ define( 'backbone', 'moment', 'Series/EpisodeModel' - ], function (Backbone, Moment, EpisodeModel) { + ], function (Backbone, moment, EpisodeModel) { return Backbone.Collection.extend({ url : window.NzbDrone.ApiRoot + '/calendar', model: EpisodeModel, comparator: function (model1, model2) { var airDate1 = model1.get('airDateUtc'); - var date1 = Moment(airDate1); + var date1 = moment(airDate1); var time1 = date1.unix(); var airDate2 = model2.get('airDateUtc'); - var date2 = Moment(airDate2); + var date2 = moment(airDate2); var time2 = date2.unix(); if (time1 < time2){ diff --git a/src/UI/Calendar/UpcomingItemView.js b/src/UI/Calendar/UpcomingItemView.js index dcfc64562..feddd958d 100644 --- a/src/UI/Calendar/UpcomingItemView.js +++ b/src/UI/Calendar/UpcomingItemView.js @@ -5,7 +5,7 @@ define( 'vent', 'marionette', 'moment' - ], function (vent, Marionette, Moment) { + ], function (vent, Marionette, moment) { return Marionette.ItemView.extend({ template: 'Calendar/UpcomingItemViewTemplate', tagName : 'div', @@ -17,7 +17,7 @@ define( initialize: function () { var start = this.model.get('airDateUtc'); var runtime = this.model.get('series').runtime; - var end = Moment(start).add('minutes', runtime); + var end = moment(start).add('minutes', runtime); this.model.set({ end: end.toISOString() diff --git a/src/UI/Cells/EpisodeStatusCell.js b/src/UI/Cells/EpisodeStatusCell.js index c8cd57510..34a492b5e 100644 --- a/src/UI/Cells/EpisodeStatusCell.js +++ b/src/UI/Cells/EpisodeStatusCell.js @@ -8,7 +8,7 @@ define( 'History/Queue/QueueCollection', 'moment', 'Shared/FormatHelpers' - ], function (reqres, Backbone, NzbDroneCell, QueueCollection, Moment, FormatHelpers) { + ], function (reqres, Backbone, NzbDroneCell, QueueCollection, moment, FormatHelpers) { return NzbDroneCell.extend({ className: 'episode-status-cell', @@ -29,7 +29,7 @@ define( var icon; var tooltip; - var hasAired = Moment(this.model.get('airDateUtc')).isBefore(Moment()); + var hasAired = moment(this.model.get('airDateUtc')).isBefore(moment()); var hasFile = this.model.get('hasFile'); if (hasFile) { diff --git a/src/UI/Cells/RelativeDateCell.js b/src/UI/Cells/RelativeDateCell.js index 0ffb11d76..8174e6b09 100644 --- a/src/UI/Cells/RelativeDateCell.js +++ b/src/UI/Cells/RelativeDateCell.js @@ -3,18 +3,32 @@ define( [ 'Cells/NzbDroneCell', 'moment', - 'Shared/FormatHelpers' - ], function (NzbDroneCell, Moment, FormatHelpers) { + 'Shared/FormatHelpers', + 'Shared/UiSettingsModel' + ], function (NzbDroneCell, moment, FormatHelpers, UiSettings) { return NzbDroneCell.extend({ className: 'relative-date-cell', render: function () { - var date = this.model.get(this.column.get('name')); + var dateStr = this.model.get(this.column.get('name')); - if (date) { - this.$el.html('' + FormatHelpers.dateHelper(date) + ''); + if (dateStr) { + var date = moment(dateStr); + var result = '{1}'; + var tooltip = date.format(UiSettings.longDateTime()); + var text; + + if (UiSettings.get('showRelativeDates')) { + text = FormatHelpers.relativeDate(dateStr); + } + + else { + text = date.format(UiSettings.get('shortDateFormat')); + } + + this.$el.html(result.format(tooltip, text)); } return this; diff --git a/src/UI/Content/fullcalendar.css b/src/UI/Content/fullcalendar.css index 92fe47f20..a31ce83da 100644 --- a/src/UI/Content/fullcalendar.css +++ b/src/UI/Content/fullcalendar.css @@ -1,5 +1,5 @@ /*! - * FullCalendar v1.6.4 Stylesheet + * FullCalendar v2.0.2 Stylesheet * Docs & License: http://arshaw.com/fullcalendar/ * (c) 2013 Adam Shaw */ @@ -101,11 +101,14 @@ html .fc, ------------------------------------------------------------------------*/ .fc-content { + position: relative; + z-index: 1; /* scopes all other z-index's to be inside this container */ clear: both; zoom: 1; /* for IE7, gives accurate coordinates for [un]freezeContentHeight */ } .fc-view { + position: relative; width: 100%; overflow: hidden; } @@ -165,32 +168,38 @@ html .fc, and we'll try to make them look good cross-browser. */ -.fc-text-arrow { +.fc-button .fc-icon { margin: 0 .1em; font-size: 2em; font-family: "Courier New", Courier, monospace; vertical-align: baseline; /* for IE7 */ } -.fc-button-prev .fc-text-arrow, -.fc-button-next .fc-text-arrow { /* for ‹ › */ +.fc-icon-left-single-arrow:after { + content: "\02039"; font-weight: bold; } - -/* icon (for jquery ui) */ - -.fc-button .fc-icon-wrap { - position: relative; - float: left; - top: 50%; + +.fc-icon-right-single-arrow:after { + content: "\0203A"; + font-weight: bold; + } + +.fc-icon-left-double-arrow:after { + content: "\000AB"; + } + +.fc-icon-right-double-arrow:after { + content: "\000BB"; } +/* icon (for jquery ui) */ + .fc-button .ui-icon { position: relative; + top: 50%; float: left; - margin-top: -50%; - *margin-top: 0; - *top: -50%; + margin-top: -8px; /* we know jqui icons are always 16px tall */ } /* @@ -447,10 +456,13 @@ table.fc-border-separate { padding: 0 4px; vertical-align: middle; text-align: right; - white-space: nowrap; font-weight: normal; } +.fc-agenda-slots .fc-agenda-axis { + white-space: nowrap; + } + .fc-agenda .fc-week-number { font-weight: bold; } diff --git a/src/UI/Episode/Summary/EpisodeSummaryLayoutTemplate.html b/src/UI/Episode/Summary/EpisodeSummaryLayoutTemplate.html index 87e4b838b..28367800e 100644 --- a/src/UI/Episode/Summary/EpisodeSummaryLayoutTemplate.html +++ b/src/UI/Episode/Summary/EpisodeSummaryLayoutTemplate.html @@ -4,7 +4,7 @@ {{network}} {{/with}} {{StartTime airDateUtc}} - {{NextAiring airDateUtc}} + {{RelativeDate airDateUtc}}
diff --git a/src/UI/Handlebars/Helpers/DateTime.js b/src/UI/Handlebars/Helpers/DateTime.js index 0f8082ccb..28b8fdf49 100644 --- a/src/UI/Handlebars/Helpers/DateTime.js +++ b/src/UI/Handlebars/Helpers/DateTime.js @@ -3,26 +3,39 @@ define( [ 'handlebars', 'moment', - 'Shared/FormatHelpers' - ], function (Handlebars, Moment, FormatHelpers) { + 'Shared/FormatHelpers', + 'Shared/UiSettingsModel' + ], function (Handlebars, moment, FormatHelpers, UiSettings) { Handlebars.registerHelper('ShortDate', function (input) { if (!input) { return ''; } - var date = Moment(input); - var result = '' + date.format('LL') + ''; + var date = moment(input); + var result = '' + date.format(UiSettings.get('shortDateFormat')) + ''; return new Handlebars.SafeString(result); }); - Handlebars.registerHelper('NextAiring', function (input) { + Handlebars.registerHelper('RelativeDate', function (input) { if (!input) { return ''; } - var date = Moment(input); - var result = '' + FormatHelpers.dateHelper(input) + ''; + var date = moment(input); + var result = '{1}'; + var tooltip = date.format(UiSettings.longDateTime()); + var text; + + if (UiSettings.get('showRelativeDates')) { + text = FormatHelpers.relativeDate(input); + } + + else { + text = date.format(UiSettings.get('shortDateFormat')); + } + + result = result.format(tooltip, text); return new Handlebars.SafeString(result); }); @@ -32,7 +45,7 @@ define( return ''; } - return Moment(input).format('DD'); + return moment(input).format('DD'); }); Handlebars.registerHelper('Month', function (input) { @@ -40,7 +53,7 @@ define( return ''; } - return Moment(input).format('MMM'); + return moment(input).format('MMM'); }); Handlebars.registerHelper('StartTime', function (input) { @@ -48,11 +61,11 @@ define( return ''; } - var date = Moment(input); + var date = moment(input); if (date.format('mm') === '00') { return date.format('ha'); } - return date.format('h.mma'); + return date.format('h:mma'); }); }); diff --git a/src/UI/Handlebars/Helpers/Episode.js b/src/UI/Handlebars/Helpers/Episode.js index dbe5ac499..9e277a50d 100644 --- a/src/UI/Handlebars/Helpers/Episode.js +++ b/src/UI/Handlebars/Helpers/Episode.js @@ -4,11 +4,11 @@ define( 'handlebars', 'Shared/FormatHelpers', 'moment' - ], function (Handlebars, FormatHelpers, Moment) { + ], function (Handlebars, FormatHelpers, moment) { Handlebars.registerHelper('EpisodeNumber', function () { if (this.series.seriesType === 'daily') { - return Moment(this.airDate).format('L'); + return moment(this.airDate).format('L'); } else { @@ -21,9 +21,9 @@ define( var hasFile = this.hasFile; var downloading = require('History/Queue/QueueCollection').findEpisode(this.id) || this.downloading; - var currentTime = Moment(); - var start = Moment(this.airDateUtc); - var end = Moment(this.end); + var currentTime = moment(); + var start = moment(this.airDateUtc); + var end = moment(this.end); if (hasFile) { return 'success'; diff --git a/src/UI/JsLibraries/fullcalendar.js b/src/UI/JsLibraries/fullcalendar.js index 28f2a04c9..57b2a0362 100644 --- a/src/UI/JsLibraries/fullcalendar.js +++ b/src/UI/JsLibraries/fullcalendar.js @@ -1,22 +1,29 @@ /*! - * FullCalendar v1.6.4 + * FullCalendar v2.0.2 * Docs & License: http://arshaw.com/fullcalendar/ * (c) 2013 Adam Shaw */ -/* - * Use fullcalendar.css for basic styling. - * For event drag & drop, requires jQuery UI draggable. - * For event resizing, requires jQuery UI resizable. - */ - -(function($, undefined) { - +(function(factory) { + if (typeof define === 'function' && define.amd) { + define([ 'jquery', 'moment' ], factory); + } + else { + factory(jQuery, moment); + } +})(function($, moment) { ;; var defaults = { + lang: 'en', + + defaultTimedEventDuration: '02:00:00', + defaultAllDayEventDuration: { days: 1 }, + forceEventDuration: false, + nextDayThreshold: '09:00:00', // 9am + // display defaultView: 'month', aspectRatio: 1.35, @@ -27,60 +34,70 @@ var defaults = { }, weekends: true, weekNumbers: false, - weekNumberCalculation: 'iso', + weekNumberTitle: 'W', + weekNumberCalculation: 'local', - // editing //editable: false, - //disableDragging: false, - //disableResizing: false, - - allDayDefault: true, - ignoreTimezone: true, // event ajax lazyFetching: true, startParam: 'start', endParam: 'end', + timezoneParam: 'timezone', + + timezone: false, + + //allDayDefault: undefined, // time formats titleFormat: { - month: 'MMMM yyyy', - week: "MMM d[ yyyy]{ '—'[ MMM] d yyyy}", - day: 'dddd, MMM d, yyyy' + month: 'MMMM YYYY', // like "September 1986". each language will override this + week: 'll', // like "Sep 4 1986" + day: 'LL' // like "September 4 1986" }, columnFormat: { - month: 'ddd', - week: 'ddd M/d', - day: 'dddd M/d' + month: 'ddd', // like "Sat" + week: generateWeekColumnFormat, + day: 'dddd' // like "Saturday" }, timeFormat: { // for event elements - '': 'h(:mm)t' // default + 'default': generateShortTimeFormat + }, + + displayEventEnd: { + month: false, + basicWeek: false, + 'default': true }, // locale isRTL: false, - firstDay: 0, - monthNames: ['January','February','March','April','May','June','July','August','September','October','November','December'], - monthNamesShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'], - dayNames: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'], - dayNamesShort: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'], - buttonText: { - prev: "", - next: "", - prevYear: "«", - nextYear: "»", + defaultButtonText: { + prev: "prev", + next: "next", + prevYear: "prev year", + nextYear: "next year", today: 'today', month: 'month', week: 'week', day: 'day' }, + + buttonIcons: { + prev: 'left-single-arrow', + next: 'right-single-arrow', + prevYear: 'left-double-arrow', + nextYear: 'right-double-arrow' + }, // jquery-ui theming theme: false, - buttonIcons: { + themeButtonIcons: { prev: 'circle-triangle-w', - next: 'circle-triangle-e' + next: 'circle-triangle-e', + prevYear: 'seek-prev', + nextYear: 'seek-next' }, //selectable: false, @@ -88,10 +105,42 @@ var defaults = { dropAccept: '*', - handleWindowResize: true + handleWindowResize: true, + windowResizeDelay: 200 // milliseconds before a rerender happens }; + +function generateShortTimeFormat(options, langData) { + return langData.longDateFormat('LT') + .replace(':mm', '(:mm)') + .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs + .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand +} + + +function generateWeekColumnFormat(options, langData) { + var format = langData.longDateFormat('L'); // for the format like "MM/DD/YYYY" + format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); // strip the year off the edge, as well as other misc non-whitespace chars + if (options.isRTL) { + format += ' ddd'; // for RTL, add day-of-week to end + } + else { + format = 'ddd ' + format; // for LTR, add day-of-week to beginning + } + return format; +} + + +var langOptionHash = { + en: { + columnFormat: { + week: 'ddd M/D' // override for english. different from the generated default, which is MM/DD + } + } +}; + + // right-to-left defaults var rtlDefaults = { header: { @@ -99,15 +148,17 @@ var rtlDefaults = { center: '', right: 'title' }, - buttonText: { - prev: "", - next: "", - prevYear: "»", - nextYear: "«" - }, buttonIcons: { + prev: 'right-single-arrow', + next: 'left-single-arrow', + prevYear: 'right-double-arrow', + nextYear: 'left-double-arrow' + }, + themeButtonIcons: { prev: 'circle-triangle-e', - next: 'circle-triangle-w' + next: 'circle-triangle-w', + nextYear: 'seek-prev', + prevYear: 'seek-next' } }; @@ -115,81 +166,195 @@ var rtlDefaults = { ;; -var fc = $.fullCalendar = { version: "1.6.4" }; +var fc = $.fullCalendar = { version: "2.0.2" }; var fcViews = fc.views = {}; $.fn.fullCalendar = function(options) { + var args = Array.prototype.slice.call(arguments, 1); // for a possible method call + var res = this; // what this function will return (this jQuery object by default) + this.each(function(i, _element) { // loop each DOM element involved + var element = $(_element); + var calendar = element.data('fullCalendar'); // get the existing calendar object (if any) + var singleRes; // the returned value of this single method call - // method calling - if (typeof options == 'string') { - var args = Array.prototype.slice.call(arguments, 1); - var res; - this.each(function() { - var calendar = $.data(this, 'fullCalendar'); + // a method call + if (typeof options === 'string') { if (calendar && $.isFunction(calendar[options])) { - var r = calendar[options].apply(calendar, args); - if (res === undefined) { - res = r; + singleRes = calendar[options].apply(calendar, args); + if (!i) { + res = singleRes; // record the first method call result } - if (options == 'destroy') { - $.removeData(this, 'fullCalendar'); + if (options === 'destroy') { // for the destroy method, must remove Calendar object data + element.removeData('fullCalendar'); } } - }); - if (res !== undefined) { - return res; } - return this; - } - - options = options || {}; - - // would like to have this logic in EventManager, but needs to happen before options are recursively extended - var eventSources = options.eventSources || []; - delete options.eventSources; - if (options.events) { - eventSources.push(options.events); - delete options.events; - } - - - options = $.extend(true, {}, - defaults, - (options.isRTL || options.isRTL===undefined && defaults.isRTL) ? rtlDefaults : {}, - options - ); - - - this.each(function(i, _element) { - var element = $(_element); - var calendar = new Calendar(element, options, eventSources); - element.data('fullCalendar', calendar); // TODO: look into memory leak implications - calendar.render(); + // a new calendar initialization + else if (!calendar) { // don't initialize twice + calendar = new Calendar(element, options); + element.data('fullCalendar', calendar); + calendar.render(); + } }); - - return this; - + return res; }; // function for adding/overriding defaults function setDefaults(d) { - $.extend(true, defaults, d); + mergeOptions(defaults, d); } +// Recursively combines option hash-objects. +// Better than `$.extend(true, ...)` because arrays are not traversed/copied. +// +// called like: +// mergeOptions(target, obj1, obj2, ...) +// +function mergeOptions(target) { + + function mergeIntoTarget(name, value) { + if ($.isPlainObject(value) && $.isPlainObject(target[name]) && !isForcedAtomicOption(name)) { + // merge into a new object to avoid destruction + target[name] = mergeOptions({}, target[name], value); // combine. `value` object takes precedence + } + else if (value !== undefined) { // only use values that are set and not undefined + target[name] = value; + } + } + + for (var i=1; i") + content = $("
") .prependTo(element); header = new Header(t, options); @@ -312,9 +653,19 @@ function Calendar(element, options, eventSources) { $(window).unbind('resize', windowResize); + if (options.droppable) { + $(document) + .off('dragstart', droppableDragStart) + .off('dragstop', droppableDragStop); + } + + if (currentView.selectionManagerDestroy) { + currentView.selectionManagerDestroy(); + } + header.destroy(); content.remove(); - element.removeClass('fc fc-rtl ui-widget'); + element.removeClass('fc fc-ltr fc-rtl ui-widget'); } @@ -328,9 +679,9 @@ function Calendar(element, options, eventSources) { } - - /* View Rendering - -----------------------------------------------------------------------------*/ + + // View Rendering + // ----------------------------------------------------------------------------------- function changeView(newViewName) { @@ -355,7 +706,7 @@ function Calendar(element, options, eventSources) { header.activateButton(newViewName); currentView = new fcViews[newViewName]( - $("
") + $("
") .appendTo(content), t // the calendar object ); @@ -370,7 +721,8 @@ function Calendar(element, options, eventSources) { function renderView(inc) { if ( !currentView.start || // never rendered before - inc || date < currentView.start || date >= currentView.end // or new date range + inc || // explicit date window change + !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change ) { if (elementVisible()) { _renderView(inc); @@ -389,7 +741,10 @@ function Calendar(element, options, eventSources) { } freezeContentHeight(); - currentView.render(date, inc || 0); // the view's render method ONLY renders the skeleton, nothing else + if (inc) { + date = currentView.incrementDate(date, inc); + } + currentView.render(date.clone()); // the view's render method ONLY renders the skeleton, nothing else setSize(); unfreezeContentHeight(); (currentView.afterRender || noop)(); @@ -398,7 +753,6 @@ function Calendar(element, options, eventSources) { updateTodayButton(); trigger('viewRender', currentView, currentView, currentView.element); - currentView.trigger('viewDisplay', _element); // deprecated ignoreWindowResize--; @@ -407,8 +761,8 @@ function Calendar(element, options, eventSources) { - /* Resizing - -----------------------------------------------------------------------------*/ + // Resizing + // ----------------------------------------------------------------------------------- function updateSize() { @@ -452,8 +806,11 @@ function Calendar(element, options, eventSources) { } - function windowResize() { - if (!ignoreWindowResize) { + function windowResize(ev) { + if ( + !ignoreWindowResize && + ev.target === window // so we don't process jqui "resize" events that have bubbled up + ) { if (currentView.start) { // view has already been rendered var uid = ++resizeUID; setTimeout(function() { // add a delay @@ -465,7 +822,7 @@ function Calendar(element, options, eventSources) { ignoreWindowResize--; } } - }, 200); + }, options.windowResizeDelay); }else{ // calendar must have been initialized in a 0x0 iframe that has just been resized lateRender(); @@ -494,7 +851,6 @@ function Calendar(element, options, eventSources) { function renderEvents(modifiedEventID) { // TODO: remove modifiedEventID hack if (elementVisible()) { - currentView.setEventData(events); // for View.js, TODO: unify with renderEvents currentView.renderEvents(events, modifiedEventID); // actually render the DOM elements currentView.trigger('eventAfterAllRender'); } @@ -509,7 +865,7 @@ function Calendar(element, options, eventSources) { function getAndRenderEvents() { - if (!options.lazyFetching || isFetchNeeded(currentView.visStart, currentView.visEnd)) { + if (!options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) { fetchAndRenderEvents(); } else { @@ -519,7 +875,7 @@ function Calendar(element, options, eventSources) { function fetchAndRenderEvents() { - fetchEvents(currentView.visStart, currentView.visEnd); + fetchEvents(currentView.start, currentView.end); // ... will call reportEvents // ... which will call renderEvents } @@ -549,8 +905,8 @@ function Calendar(element, options, eventSources) { function updateTodayButton() { - var today = new Date(); - if (today >= currentView.start && today < currentView.end) { + var now = t.getNow(); + if (now.isWithin(currentView.intervalStart, currentView.intervalEnd)) { header.disableButton('today'); } else { @@ -564,8 +920,8 @@ function Calendar(element, options, eventSources) { -----------------------------------------------------------------------------*/ - function select(start, end, allDay) { - currentView.select(start, end, allDay===undefined ? true : allDay); + function select(start, end) { + currentView.select(start, end); } @@ -592,49 +948,37 @@ function Calendar(element, options, eventSources) { function prevYear() { - addYears(date, -1); + date.add('years', -1); renderView(); } function nextYear() { - addYears(date, 1); + date.add('years', 1); renderView(); } function today() { - date = new Date(); + date = t.getNow(); renderView(); } - function gotoDate(year, month, dateOfMonth) { - if (year instanceof Date) { - date = cloneDate(year); // provided 1 argument, a Date - }else{ - setYMD(date, year, month, dateOfMonth); - } + function gotoDate(dateInput) { + date = t.moment(dateInput); renderView(); } - function incrementDate(years, months, days) { - if (years !== undefined) { - addYears(date, years); - } - if (months !== undefined) { - addMonths(date, months); - } - if (days !== undefined) { - addDays(date, days); - } + function incrementDate(delta) { + date.add(moment.duration(delta)); renderView(); } function getDate() { - return cloneDate(date); + return date.clone(); } @@ -665,6 +1009,11 @@ function Calendar(element, options, eventSources) { /* Misc -----------------------------------------------------------------------------*/ + + function getCalendar() { + return t; + } + function getView() { return currentView; @@ -697,24 +1046,30 @@ function Calendar(element, options, eventSources) { ------------------------------------------------------------------------*/ if (options.droppable) { + // TODO: unbind on destroy $(document) - .bind('dragstart', function(ev, ui) { - var _e = ev.target; - var e = $(_e); - if (!e.parents('.fc').length) { // not already inside a calendar - var accept = options.dropAccept; - if ($.isFunction(accept) ? accept.call(_e, e) : e.is(accept)) { - _dragElement = _e; - currentView.dragStart(_dragElement, ev, ui); - } - } - }) - .bind('dragstop', function(ev, ui) { - if (_dragElement) { - currentView.dragStop(_dragElement, ev, ui); - _dragElement = null; - } - }); + .on('dragstart', droppableDragStart) + .on('dragstop', droppableDragStop); + // this is undone in destroy + } + + function droppableDragStart(ev, ui) { + var _e = ev.target; + var e = $(_e); + if (!e.parents('.fc').length) { // not already inside a calendar + var accept = options.dropAccept; + if ($.isFunction(accept) ? accept.call(_e, e) : e.is(accept)) { + _dragElement = _e; + currentView.dragStart(_dragElement, ev, ui); + } + } + } + + function droppableDragStop(ev, ui) { + if (_dragElement) { + currentView.dragStop(_dragElement, ev, ui); + _dragElement = null; + } } @@ -791,16 +1146,30 @@ function Header(calendar, options) { }; } if (buttonClick) { - var icon = options.theme ? smartProperty(options.buttonIcons, buttonName) : null; // why are we using smartProperty here? - var text = smartProperty(options.buttonText, buttonName); // why are we using smartProperty here? + + // smartProperty allows different text per view button (ex: "Agenda Week" vs "Basic Week") + var themeIcon = smartProperty(options.themeButtonIcons, buttonName); + var normalIcon = smartProperty(options.buttonIcons, buttonName); + var defaultText = smartProperty(options.defaultButtonText, buttonName); + var customText = smartProperty(options.buttonText, buttonName); + var html; + + if (customText) { + html = htmlEscape(customText); + } + else if (themeIcon && options.theme) { + html = ""; + } + else if (normalIcon && !options.theme) { + html = ""; + } + else { + html = htmlEscape(defaultText || buttonName); + } + var button = $( "" + - (icon ? - "" + - "" + - "" : - text - ) + + html + "" ) .click(function() { @@ -893,7 +1262,7 @@ var ajaxDefaults = { var eventGUID = 1; -function EventManager(options, _sources) { +function EventManager(options) { // assumed to be a calendar var t = this; @@ -906,13 +1275,14 @@ function EventManager(options, _sources) { t.renderEvent = renderEvent; t.removeEvents = removeEvents; t.clientEvents = clientEvents; - t.normalizeEvent = normalizeEvent; + t.mutateEvent = mutateEvent; // imports var trigger = t.trigger; var getView = t.getView; var reportEvents = t.reportEvents; + var getEventEnd = t.getEventEnd; // locals @@ -923,11 +1293,17 @@ function EventManager(options, _sources) { var pendingSourceCnt = 0; var loadingLevel = 0; var cache = []; - - - for (var i=0; i<_sources.length; i++) { - _addEventSource(_sources[i]); - } + + + $.each( + (options.events ? [ options.events ] : []).concat(options.eventSources || []), + function(i, sourceInput) { + var source = buildEventSource(sourceInput); + if (source) { + sources.push(source); + } + } + ); @@ -936,7 +1312,10 @@ function EventManager(options, _sources) { function isFetchNeeded(start, end) { - return !rangeStart || start < rangeStart || end > rangeEnd; + return !rangeStart || // nothing has been fetched yet? + // or, a part of the new range is outside of the old range? (after normalizing) + start.clone().stripZone() < rangeStart.clone().stripZone() || + end.clone().stripZone() > rangeEnd.clone().stripZone(); } @@ -955,24 +1334,27 @@ function EventManager(options, _sources) { function fetchEventSource(source, fetchID) { _fetchEventSource(source, function(events) { + var isArraySource = $.isArray(source.events); + var i; + var event; + if (fetchID == currentFetchID) { + if (events) { + for (i=0; i)), return null instead - return null; -} + // NOTE: throughout this function, the initial values of `newStart` and `newEnd` are + // preserved. These values may be undefined. -function parseISO8601(s, ignoreTimezone) { // ignoreTimezone defaults to false - // derived from http://delete.me.uk/2005/03/iso8601.html - // TODO: for a know glitch/feature, read tests/issue_206_parseDate_dst.html - var m = s.match(/^([0-9]{4})(-([0-9]{2})(-([0-9]{2})([T ]([0-9]{2}):([0-9]{2})(:([0-9]{2})(\.([0-9]+))?)?(Z|(([-+])([0-9]{2})(:?([0-9]{2}))?))?)?)?)?$/); - if (!m) { - return null; - } - var date = new Date(m[1], 0, 1); - if (ignoreTimezone || !m[13]) { - var check = new Date(m[1], 0, 1, 9, 0); - if (m[3]) { - date.setMonth(m[3] - 1); - check.setMonth(m[3] - 1); - } - if (m[5]) { - date.setDate(m[5]); - check.setDate(m[5]); - } - fixDate(date, check); - if (m[7]) { - date.setHours(m[7]); - } - if (m[8]) { - date.setMinutes(m[8]); + // detect new allDay + if (event.allDay != oldAllDay) { // if value has changed, use it + newAllDay = event.allDay; } - if (m[10]) { - date.setSeconds(m[10]); + else { // otherwise, see if any of the new dates are allDay + newAllDay = !(newStart || newEnd).hasTime(); } - if (m[12]) { - date.setMilliseconds(Number("0." + m[12]) * 1000); - } - fixDate(date, check); - }else{ - date.setUTCFullYear( - m[1], - m[3] ? m[3] - 1 : 0, - m[5] || 1 - ); - date.setUTCHours( - m[7] || 0, - m[8] || 0, - m[10] || 0, - m[12] ? Number("0." + m[12]) * 1000 : 0 - ); - if (m[14]) { - var offset = Number(m[16]) * 60 + (m[18] ? Number(m[18]) : 0); - offset *= m[15] == '-' ? 1 : -1; - date = new Date(+date + (offset * 60 * 1000)); - } - } - return date; -} - -function parseTime(s) { // returns minutes since start of day - if (typeof s == 'number') { // an hour - return s * 60; - } - if (typeof s == 'object') { // a Date object - return s.getHours() * 60 + s.getMinutes(); - } - var m = s.match(/(\d+)(?::(\d+))?\s*(\w+)?/); - if (m) { - var h = parseInt(m[1], 10); - if (m[3]) { - h %= 12; - if (m[3].toLowerCase().charAt(0) == 'p') { - h += 12; + // normalize the new dates based on allDay + if (newAllDay) { + if (newStart) { + newStart = newStart.clone().stripTime(); + } + if (newEnd) { + newEnd = newEnd.clone().stripTime(); } } - return h * 60 + (m[2] ? parseInt(m[2], 10) : 0); - } -} + // compute dateDelta + if (newStart) { + if (newAllDay) { + dateDelta = dayishDiff(newStart, oldStart.clone().stripTime()); // treat oldStart as allDay + } + else { + dateDelta = dayishDiff(newStart, oldStart); + } + } + if (newAllDay != oldAllDay) { + // if allDay has changed, always throw away the end + clearEnd = true; + } + else if (newEnd) { + durationDelta = dayishDiff( + // new duration + newEnd || t.getDefaultEventEnd(newAllDay, newStart || oldStart), + newStart || oldStart + ).subtract(dayishDiff( + // subtract old duration + oldEnd || t.getDefaultEventEnd(oldAllDay, oldStart), + oldStart + )); + } + + undoFunc = mutateEvents( + clientEvents(event._id), // get events with this ID + clearEnd, + newAllDay, + dateDelta, + durationDelta + ); -/* Date Formatting ------------------------------------------------------------------------------*/ -// TODO: use same function formatDate(date, [date2], format, [options]) - - -function formatDate(date, format, options) { - return formatDates(date, null, format, options); -} + return { + dateDelta: dateDelta, + durationDelta: durationDelta, + undo: undoFunc + }; + } -function formatDates(date1, date2, format, options) { - options = options || defaults; - var date = date1, - otherDate = date2, - i, len = format.length, c, - i2, formatter, - res = ''; - for (i=0; ii; i2--) { - if (formatter = dateFormatters[format.substring(i, i2)]) { - if (date) { - res += formatter(date, options); + + // ensure we have an end date if necessary + if (!newEnd && (options.forceEventDuration || +durationDelta)) { + newEnd = t.getDefaultEventEnd(newAllDay, newStart); + } + + // translate the dates + newStart.add(dateDelta); + if (newEnd) { + newEnd.add(dateDelta).add(durationDelta); + } + + // if the dates have changed, and we know it is impossible to recompute the + // timezone offsets, strip the zone. + if (isAmbigTimezone) { + if (+dateDelta || +durationDelta) { + newStart.stripZone(); + if (newEnd) { + newEnd.stripZone(); } - i = i2 - 1; - break; } } - if (i2 == i) { - if (date) { - res += c; - } + + event.allDay = newAllDay; + event.start = newStart; + event.end = newEnd; + backupEventDates(event); + + undoFunctions.push(function() { + event.allDay = oldAllDay; + event.start = oldStart; + event.end = oldEnd; + backupEventDates(event); + }); + }); + + return function() { + for (var i=0; i 10 && date < 20) { - return 'th'; - } - return ['st', 'nd', 'rd'][date%10-1] || 'th'; - }, - w : function(d, o) { // local - return o.weekNumberCalculation(d); - }, - W : function(d) { // ISO - return iso8601Week(d); - } -}; -fc.dateFormatters = dateFormatters; +// updates the "backup" properties, which are preserved in order to compute diffs later on. +function backupEventDates(event) { + event._allDay = event.allDay; + event._start = event.start.clone(); + event._end = event.end ? event.end.clone() : null; +} + +;; + +fc.applyAll = applyAll; -/* thanks jQuery UI (https://github.com/jquery/jquery-ui/blob/master/ui/jquery.ui.datepicker.js) - * - * Set as calculateWeek to determine the week of the year based on the ISO 8601 definition. - * `date` - the date to get the week for - * `number` - the number of the week within the year that contains this date - */ -function iso8601Week(date) { - var time; - var checkDate = new Date(date.getTime()); - // Find Thursday of this week starting on Monday - checkDate.setDate(checkDate.getDate() + 4 - (checkDate.getDay() || 7)); - time = checkDate.getTime(); - checkDate.setMonth(0); // Compare with Jan 1 - checkDate.setDate(1); - return Math.floor(Math.round((time - checkDate) / 86400000) / 7) + 1; +// Create an object that has the given prototype. +// Just like Object.create +function createObject(proto) { + var f = function() {}; + f.prototype = proto; + return new f(); +} + +// Copies specifically-owned (non-protoype) properties of `b` onto `a`. +// FYI, $.extend would copy *all* properties of `b` onto `a`. +function extend(a, b) { + for (var i in b) { + if (b.hasOwnProperty(i)) { + a[i] = b[i]; + } + } } -;; -fc.applyAll = applyAll; +/* Date +-----------------------------------------------------------------------------*/ -/* Event Date Math ------------------------------------------------------------------------------*/ +var dayIDs = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; -function exclEndDay(event) { - if (event.end) { - return _exclEndDay(event.end, event.allDay); - }else{ - return addDays(cloneDate(event.start), 1); - } +// diffs the two moments into a Duration where full-days are recorded first, +// then the remaining time. +function dayishDiff(d1, d0) { + return moment.duration({ + days: d1.clone().stripTime().diff(d0.clone().stripTime(), 'days'), + ms: d1.time() - d0.time() + }); } -function _exclEndDay(end, allDay) { - end = cloneDate(end); - return allDay || end.getHours() || end.getMinutes() ? addDays(end, 1) : clearTime(end); - // why don't we check for seconds/ms too? +function isNativeDate(input) { + return Object.prototype.toString.call(input) === '[object Date]' || + input instanceof Date; } @@ -1802,7 +2116,7 @@ function vborders(element) { function noop() { } -function dateCompare(a, b) { +function dateCompare(a, b) { // works with moments too return a - b; } @@ -1812,12 +2126,8 @@ function arrayMax(a) { } -function zeroPad(n) { - return (n < 10 ? '0' : '') + n; -} - - function smartProperty(obj, name) { // get a camel-cased/namespaced property of an object + obj = obj || {}; if (obj[name] !== undefined) { return obj[name]; } @@ -1829,12 +2139,12 @@ function smartProperty(obj, name) { // get a camel-cased/namespaced property of return res; } } - return obj['']; + return obj['default']; } function htmlEscape(s) { - return s.replace(/&/g, '&') + return (s + '').replace(/&/g, '&') .replace(//g, '>') .replace(/'/g, ''') @@ -1843,6 +2153,11 @@ function htmlEscape(s) { } +function stripHTMLEntities(text) { + return text.replace(/&.*?;/g, ''); +} + + function disableTextSelection(element) { element .attr('unselectable', 'on') @@ -1861,7 +2176,7 @@ function enableTextSelection(element) { */ -function markFirstLast(e) { +function markFirstLast(e) { // TODO: use CSS selectors instead e.children() .removeClass('fc-first fc-last') .filter(':first-child') @@ -1872,14 +2187,6 @@ function markFirstLast(e) { } -function setDayID(cell, date) { - cell.each(function(i, _cell) { - _cell.className = _cell.className.replace(/^fc-\w*/, 'fc-' + dayIDs[date.getDay()]); - // TODO: make a way that doesn't rely on order of classes - }); -} - - function getSkinCss(event, opt) { var source = event.source || {}; var eventColor = event.color; @@ -1941,6 +2248,599 @@ function firstDefined() { } +;; + +var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/; +var ambigTimeOrZoneRegex = /^\s*\d{4}-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?)?$/; + + +// Creating +// ------------------------------------------------------------------------------------------------- + +// Creates a new moment, similar to the vanilla moment(...) constructor, but with +// extra features (ambiguous time, enhanced formatting). When gived an existing moment, +// it will function as a clone (and retain the zone of the moment). Anything else will +// result in a moment in the local zone. +fc.moment = function() { + return makeMoment(arguments); +}; + +// Sames as fc.moment, but forces the resulting moment to be in the UTC timezone. +fc.moment.utc = function() { + var mom = makeMoment(arguments, true); + + // Force it into UTC because makeMoment doesn't guarantee it. + if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone + mom.utc(); + } + + return mom; +}; + +// Same as fc.moment, but when given an ISO8601 string, the timezone offset is preserved. +// ISO8601 strings with no timezone offset will become ambiguously zoned. +fc.moment.parseZone = function() { + return makeMoment(arguments, true, true); +}; + +// Builds an FCMoment from args. When given an existing moment, it clones. When given a native +// Date, or called with no arguments (the current time), the resulting moment will be local. +// Anything else needs to be "parsed" (a string or an array), and will be affected by: +// parseAsUTC - if there is no zone information, should we parse the input in UTC? +// parseZone - if there is zone information, should we force the zone of the moment? +function makeMoment(args, parseAsUTC, parseZone) { + var input = args[0]; + var isSingleString = args.length == 1 && typeof input === 'string'; + var isAmbigTime; + var isAmbigZone; + var ambigMatch; + var output; // an object with fields for the new FCMoment object + + if (moment.isMoment(input)) { + output = moment.apply(null, args); // clone it + + // the ambig properties have not been preserved in the clone, so reassign them + if (input._ambigTime) { + output._ambigTime = true; + } + if (input._ambigZone) { + output._ambigZone = true; + } + } + else if (isNativeDate(input) || input === undefined) { + output = moment.apply(null, args); // will be local + } + else { // "parsing" is required + isAmbigTime = false; + isAmbigZone = false; + + if (isSingleString) { + if (ambigDateOfMonthRegex.test(input)) { + // accept strings like '2014-05', but convert to the first of the month + input += '-01'; + args = [ input ]; // for when we pass it on to moment's constructor + isAmbigTime = true; + isAmbigZone = true; + } + else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) { + isAmbigTime = !ambigMatch[5]; // no time part? + isAmbigZone = true; + } + } + else if ($.isArray(input)) { + // arrays have no timezone information, so assume ambiguous zone + isAmbigZone = true; + } + // otherwise, probably a string with a format + + if (parseAsUTC) { + output = moment.utc.apply(moment, args); + } + else { + output = moment.apply(null, args); + } + + if (isAmbigTime) { + output._ambigTime = true; + output._ambigZone = true; // ambiguous time always means ambiguous zone + } + else if (parseZone) { // let's record the inputted zone somehow + if (isAmbigZone) { + output._ambigZone = true; + } + else if (isSingleString) { + output.zone(input); // if not a valid zone, will assign UTC + } + } + } + + return new FCMoment(output); +} + +// Our subclass of Moment. +// Accepts an object with the internal Moment properties that should be copied over to +// `this` object (most likely another Moment object). The values in this data must not +// be referenced by anything else (two moments sharing a Date object for example). +function FCMoment(internalData) { + extend(this, internalData); +} + +// Chain the prototype to Moment's +FCMoment.prototype = createObject(moment.fn); + +// We need this because Moment's implementation won't create an FCMoment, +// nor will it copy over the ambig flags. +FCMoment.prototype.clone = function() { + return makeMoment([ this ]); +}; + + +// Time-of-day +// ------------------------------------------------------------------------------------------------- + +// GETTER +// Returns a Duration with the hours/minutes/seconds/ms values of the moment. +// If the moment has an ambiguous time, a duration of 00:00 will be returned. +// +// SETTER +// You can supply a Duration, a Moment, or a Duration-like argument. +// When setting the time, and the moment has an ambiguous time, it then becomes unambiguous. +FCMoment.prototype.time = function(time) { + if (time == null) { // getter + return moment.duration({ + hours: this.hours(), + minutes: this.minutes(), + seconds: this.seconds(), + milliseconds: this.milliseconds() + }); + } + else { // setter + + delete this._ambigTime; // mark that the moment now has a time + + if (!moment.isDuration(time) && !moment.isMoment(time)) { + time = moment.duration(time); + } + + // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day). + // Only for Duration times, not Moment times. + var dayHours = 0; + if (moment.isDuration(time)) { + dayHours = Math.floor(time.asDays()) * 24; + } + + // We need to set the individual fields. + // Can't use startOf('day') then add duration. In case of DST at start of day. + return this.hours(dayHours + time.hours()) + .minutes(time.minutes()) + .seconds(time.seconds()) + .milliseconds(time.milliseconds()); + } +}; + +// Converts the moment to UTC, stripping out its time-of-day and timezone offset, +// but preserving its YMD. A moment with a stripped time will display no time +// nor timezone offset when .format() is called. +FCMoment.prototype.stripTime = function() { + var a = this.toArray(); // year,month,date,hours,minutes,seconds as an array + + // set the internal UTC flag + moment.fn.utc.call(this); // call the original method, because we don't want to affect _ambigZone + + this.year(a[0]) // TODO: find a way to do this in one shot + .month(a[1]) + .date(a[2]) + .hours(0) + .minutes(0) + .seconds(0) + .milliseconds(0); + + // Mark the time as ambiguous. This needs to happen after the .utc() call, which calls .zone(), which + // clears all ambig flags. Same concept with the .year/month/date calls in the case of moment-timezone. + this._ambigTime = true; + this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset + + return this; // for chaining +}; + +// Returns if the moment has a non-ambiguous time (boolean) +FCMoment.prototype.hasTime = function() { + return !this._ambigTime; +}; + + +// Timezone +// ------------------------------------------------------------------------------------------------- + +// Converts the moment to UTC, stripping out its timezone offset, but preserving its +// YMD and time-of-day. A moment with a stripped timezone offset will display no +// timezone offset when .format() is called. +FCMoment.prototype.stripZone = function() { + var a = this.toArray(); // year,month,date,hours,minutes,seconds as an array + var wasAmbigTime = this._ambigTime; + + moment.fn.utc.call(this); // set the internal UTC flag + + this.year(a[0]) // TODO: find a way to do this in one shot + .month(a[1]) + .date(a[2]) + .hours(a[3]) + .minutes(a[4]) + .seconds(a[5]) + .milliseconds(a[6]); + + if (wasAmbigTime) { + // the above call to .utc()/.zone() unfortunately clears the ambig flags, so reassign + this._ambigTime = true; + } + + // Mark the zone as ambiguous. This needs to happen after the .utc() call, which calls .zone(), which + // clears all ambig flags. Same concept with the .year/month/date calls in the case of moment-timezone. + this._ambigZone = true; + + return this; // for chaining +}; + +// Returns of the moment has a non-ambiguous timezone offset (boolean) +FCMoment.prototype.hasZone = function() { + return !this._ambigZone; +}; + +// this method implicitly marks a zone +FCMoment.prototype.zone = function(tzo) { + + if (tzo != null) { + // FYI, the delete statements need to be before the .zone() call or else chaos ensues + // for reasons I don't understand. + delete this._ambigTime; + delete this._ambigZone; + } + + return moment.fn.zone.apply(this, arguments); +}; + +// this method implicitly marks a zone +FCMoment.prototype.local = function() { + var a = this.toArray(); // year,month,date,hours,minutes,seconds as an array + var wasAmbigZone = this._ambigZone; + + // will happen anyway via .local()/.zone(), but don't want to rely on internal implementation + delete this._ambigTime; + delete this._ambigZone; + + moment.fn.local.apply(this, arguments); + + if (wasAmbigZone) { + // If the moment was ambiguously zoned, the date fields were stored as UTC. + // We want to preserve these, but in local time. + this.year(a[0]) // TODO: find a way to do this in one shot + .month(a[1]) + .date(a[2]) + .hours(a[3]) + .minutes(a[4]) + .seconds(a[5]) + .milliseconds(a[6]); + } + + return this; // for chaining +}; + +// this method implicitly marks a zone +FCMoment.prototype.utc = function() { + + // will happen anyway via .local()/.zone(), but don't want to rely on internal implementation + delete this._ambigTime; + delete this._ambigZone; + + return moment.fn.utc.apply(this, arguments); +}; + + +// Formatting +// ------------------------------------------------------------------------------------------------- + +FCMoment.prototype.format = function() { + if (arguments[0]) { + return formatDate(this, arguments[0]); // our extended formatting + } + if (this._ambigTime) { + return momentFormat(this, 'YYYY-MM-DD'); + } + if (this._ambigZone) { + return momentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); + } + return momentFormat(this); // default moment original formatting +}; + +FCMoment.prototype.toISOString = function() { + if (this._ambigTime) { + return momentFormat(this, 'YYYY-MM-DD'); + } + if (this._ambigZone) { + return momentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); + } + return moment.fn.toISOString.apply(this, arguments); +}; + + +// Querying +// ------------------------------------------------------------------------------------------------- + +// Is the moment within the specified range? `end` is exclusive. +FCMoment.prototype.isWithin = function(start, end) { + var a = commonlyAmbiguate([ this, start, end ]); + return a[0] >= a[1] && a[0] < a[2]; +}; + +// Make these query methods work with ambiguous moments +$.each([ + 'isBefore', + 'isAfter', + 'isSame' +], function(i, methodName) { + FCMoment.prototype[methodName] = function(input, units) { + var a = commonlyAmbiguate([ this, input ]); + return moment.fn[methodName].call(a[0], a[1], units); + }; +}); + + +// Misc Internals +// ------------------------------------------------------------------------------------------------- + +// given an array of moment-like inputs, return a parallel array w/ moments similarly ambiguated. +// for example, of one moment has ambig time, but not others, all moments will have their time stripped. +function commonlyAmbiguate(inputs) { + var outputs = []; + var anyAmbigTime = false; + var anyAmbigZone = false; + var i; + + for (i=0; i "MMMM D YYYY" + formatStr = date1.lang().longDateFormat(formatStr) || formatStr; + // BTW, this is not important for `formatDate` because it is impossible to put custom tokens + // or non-zero areas in Moment's localized format strings. + + separator = separator || ' - '; + + return formatRangeWithChunks( + date1, + date2, + getFormatStringChunks(formatStr), + separator, + isRTL + ); +} +fc.formatRange = formatRange; // expose + + +function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { + var chunkStr; // the rendering of the chunk + var leftI; + var leftStr = ''; + var rightI; + var rightStr = ''; + var middleI; + var middleStr1 = ''; + var middleStr2 = ''; + var middleStr = ''; + + // Start at the leftmost side of the formatting string and continue until you hit a token + // that is not the same between dates. + for (leftI=0; leftIleftI; rightI--) { + chunkStr = formatSimilarChunk(date1, date2, chunks[rightI]); + if (chunkStr === false) { + break; + } + rightStr = chunkStr + rightStr; + } + + // The area in the middle is different for both of the dates. + // Collect them distinctly so we can jam them together later. + for (middleI=leftI; middleI<=rightI; middleI++) { + middleStr1 += formatDateWithChunk(date1, chunks[middleI]); + middleStr2 += formatDateWithChunk(date2, chunks[middleI]); + } + + if (middleStr1 || middleStr2) { + if (isRTL) { + middleStr = middleStr2 + separator + middleStr1; + } + else { + middleStr = middleStr1 + separator + middleStr2; + } + } + + return leftStr + middleStr + rightStr; +} + + +var similarUnitMap = { + Y: 'year', + M: 'month', + D: 'day', // day of month + d: 'day', // day of week + // prevents a separator between anything time-related... + A: 'second', // AM/PM + a: 'second', // am/pm + T: 'second', // A/P + t: 'second', // a/p + H: 'second', // hour (24) + h: 'second', // hour (12) + m: 'second', // minute + s: 'second' // second +}; +// TODO: week maybe? + + +// Given a formatting chunk, and given that both dates are similar in the regard the +// formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`. +function formatSimilarChunk(date1, date2, chunk) { + var token; + var unit; + + if (typeof chunk === 'string') { // a literal string + return chunk; + } + else if ((token = chunk.token)) { + unit = similarUnitMap[token.charAt(0)]; + // are the dates the same for this unit of measurement? + if (unit && date1.isSame(date2, unit)) { + return momentFormat(date1, token); // would be the same if we used `date2` + // BTW, don't support custom tokens + } + } + + return false; // the chunk is NOT the same for the two dates + // BTW, don't support splitting on non-zero areas +} + + +// Chunking Utils +// ------------------------------------------------------------------------------------------------- + + +var formatStringChunkCache = {}; + + +function getFormatStringChunks(formatStr) { + if (formatStr in formatStringChunkCache) { + return formatStringChunkCache[formatStr]; + } + return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr)); +} + + +// Break the formatting string into an array of chunks +function chunkFormatString(formatStr) { + var chunks = []; + var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination + var match; + + while ((match = chunker.exec(formatStr))) { + if (match[1]) { // a literal string inside [ ... ] + chunks.push(match[1]); + } + else if (match[2]) { // non-zero formatting inside ( ... ) + chunks.push({ maybe: chunkFormatString(match[2]) }); + } + else if (match[3]) { // a formatting token + chunks.push({ token: match[3] }); + } + else if (match[5]) { // an unenclosed literal string + chunks.push(match[5]); + } + } + + return chunks; +} + ;; fcViews.month = MonthView; @@ -1950,56 +2850,45 @@ function MonthView(element, calendar) { // exports + t.incrementDate = incrementDate; t.render = render; // imports BasicView.call(t, element, calendar, 'month'); - var opt = t.opt; - var renderBasic = t.renderBasic; - var skipHiddenDays = t.skipHiddenDays; - var getCellsPerWeek = t.getCellsPerWeek; - var formatDate = calendar.formatDate; - - - function render(date, delta) { - if (delta) { - addMonths(date, delta); - date.setDate(1); - } - var firstDay = opt('firstDay'); + function incrementDate(date, delta) { + return date.clone().stripTime().add('months', delta).startOf('month'); + } - var start = cloneDate(date, true); - start.setDate(1); - var end = addMonths(cloneDate(start), 1); + function render(date) { - var visStart = cloneDate(start); - addDays(visStart, -((visStart.getDay() - firstDay + 7) % 7)); - skipHiddenDays(visStart); + t.intervalStart = date.clone().stripTime().startOf('month'); + t.intervalEnd = t.intervalStart.clone().add('months', 1); - var visEnd = cloneDate(end); - addDays(visEnd, (7 - visEnd.getDay() + firstDay) % 7); - skipHiddenDays(visEnd, -1, true); + t.start = t.intervalStart.clone(); + t.start = t.skipHiddenDays(t.start); // move past the first week if no visible days + t.start.startOf('week'); + t.start = t.skipHiddenDays(t.start); // move past the first invisible days of the week - var colCnt = getCellsPerWeek(); - var rowCnt = Math.round(dayDiff(visEnd, visStart) / 7); // should be no need for Math.round + t.end = t.intervalEnd.clone(); + t.end = t.skipHiddenDays(t.end, -1, true); // move in from the last week if no visible days + t.end.add('days', (7 - t.end.weekday()) % 7); // move to end of week if not already + t.end = t.skipHiddenDays(t.end, -1, true); // move in from the last invisible days of the week - if (opt('weekMode') == 'fixed') { - addDays(visEnd, (6 - rowCnt) * 7); // add weeks to make up for it + var rowCnt = Math.ceil( // need to ceil in case there are hidden days + t.end.diff(t.start, 'weeks', true) // returnfloat=true + ); + if (t.opt('weekMode') == 'fixed') { + t.end.add('weeks', 6 - rowCnt); rowCnt = 6; } - t.title = formatDate(start, opt('titleFormat')); - - t.start = start; - t.end = end; - t.visStart = visStart; - t.visEnd = visEnd; + t.title = calendar.formatDate(t.intervalStart, t.opt('titleFormat')); - renderBasic(rowCnt, colCnt, true); + t.renderBasic(rowCnt, t.getCellsPerWeek(), true); } @@ -2009,52 +2898,40 @@ function MonthView(element, calendar) { fcViews.basicWeek = BasicWeekView; -function BasicWeekView(element, calendar) { +function BasicWeekView(element, calendar) { // TODO: do a WeekView mixin var t = this; // exports + t.incrementDate = incrementDate; t.render = render; // imports BasicView.call(t, element, calendar, 'basicWeek'); - var opt = t.opt; - var renderBasic = t.renderBasic; - var skipHiddenDays = t.skipHiddenDays; - var getCellsPerWeek = t.getCellsPerWeek; - var formatDates = calendar.formatDates; - - - function render(date, delta) { - if (delta) { - addDays(date, delta * 7); - } - var start = addDays(cloneDate(date), -((date.getDay() - opt('firstDay') + 7) % 7)); - var end = addDays(cloneDate(start), 7); + function incrementDate(date, delta) { + return date.clone().stripTime().add('weeks', delta).startOf('week'); + } - var visStart = cloneDate(start); - skipHiddenDays(visStart); - var visEnd = cloneDate(end); - skipHiddenDays(visEnd, -1, true); + function render(date) { - var colCnt = getCellsPerWeek(); + t.intervalStart = date.clone().stripTime().startOf('week'); + t.intervalEnd = t.intervalStart.clone().add('weeks', 1); - t.start = start; - t.end = end; - t.visStart = visStart; - t.visEnd = visEnd; + t.start = t.skipHiddenDays(t.intervalStart); + t.end = t.skipHiddenDays(t.intervalEnd, -1, true); - t.title = formatDates( - visStart, - addDays(cloneDate(visEnd), -1), - opt('titleFormat') + t.title = calendar.formatRange( + t.start, + t.end.clone().subtract(1), // make inclusive by subtracting 1 ms + t.opt('titleFormat'), + ' \u2014 ' // emphasized dash ); - renderBasic(1, colCnt, false); + t.renderBasic(1, t.getCellsPerWeek(), false); } @@ -2064,39 +2941,34 @@ function BasicWeekView(element, calendar) { fcViews.basicDay = BasicDayView; - -function BasicDayView(element, calendar) { +function BasicDayView(element, calendar) { // TODO: make a DayView mixin var t = this; // exports + t.incrementDate = incrementDate; t.render = render; // imports BasicView.call(t, element, calendar, 'basicDay'); - var opt = t.opt; - var renderBasic = t.renderBasic; - var skipHiddenDays = t.skipHiddenDays; - var formatDate = calendar.formatDate; - - - function render(date, delta) { - if (delta) { - addDays(date, delta); - } - skipHiddenDays(date, delta < 0 ? -1 : 1); - var start = cloneDate(date, true); - var end = addDays(cloneDate(start), 1); + function incrementDate(date, delta) { + var out = date.clone().stripTime().add('days', delta); + out = t.skipHiddenDays(out, delta < 0 ? -1 : 1); + return out; + } + + + function render(date) { - t.title = formatDate(date, opt('titleFormat')); + t.start = t.intervalStart = date.clone().stripTime(); + t.end = t.intervalEnd = t.start.clone().add('days', 1); - t.start = t.visStart = start; - t.end = t.visEnd = end; + t.title = calendar.formatDate(t.start, t.opt('titleFormat')); - renderBasic(1, 1, false); + t.renderBasic(1, 1, false); } @@ -2124,18 +2996,17 @@ function BasicView(element, calendar, viewName) { t.reportDayClick = reportDayClick; // for selection (kinda hacky) t.dragStart = dragStart; t.dragStop = dragStop; - t.defaultEventEnd = defaultEventEnd; - t.getHoverListener = function() { return hoverListener }; + t.getHoverListener = function() { return hoverListener; }; t.colLeft = colLeft; t.colRight = colRight; t.colContentLeft = colContentLeft; t.colContentRight = colContentRight; - t.getIsCellAllDay = function() { return true }; + t.getIsCellAllDay = function() { return true; }; t.allDayRow = allDayRow; - t.getRowCnt = function() { return rowCnt }; - t.getColCnt = function() { return colCnt }; - t.getColWidth = function() { return colWidth }; - t.getDaySegmentContainer = function() { return daySegmentContainer }; + t.getRowCnt = function() { return rowCnt; }; + t.getColCnt = function() { return colCnt; }; + t.getColWidth = function() { return colWidth; }; + t.getDaySegmentContainer = function() { return daySegmentContainer; }; // imports @@ -2152,6 +3023,7 @@ function BasicView(element, calendar, viewName) { var dateToCell = t.dateToCell; var rangeToSegments = t.rangeToSegments; var formatDate = calendar.formatDate; + var calculateWeekNumber = calendar.calculateWeekNumber; // locals @@ -2182,8 +3054,6 @@ function BasicView(element, calendar, viewName) { var tm; var colFormat; var showWeekNumbers; - var weekNumberTitle; - var weekNumberFormat; @@ -2211,16 +3081,7 @@ function BasicView(element, calendar, viewName) { function updateOptions() { tm = opt('theme') ? 'ui' : 'fc'; colFormat = opt('columnFormat'); - - // week # options. (TODO: bad, logic also in other views) showWeekNumbers = opt('weekNumbers'); - weekNumberTitle = opt('weekNumberTitle'); - if (opt('weekNumberCalculation') != 'iso') { - weekNumberFormat = "w"; - } - else { - weekNumberFormat = "W"; - } } @@ -2293,14 +3154,14 @@ function BasicView(element, calendar, viewName) { if (showWeekNumbers) { html += "" + - htmlEscape(weekNumberTitle) + + htmlEscape(opt('weekNumberTitle')) + ""; } for (col=0; col" + + "" + htmlEscape(formatDate(date, colFormat)) + ""; } @@ -2329,7 +3190,7 @@ function BasicView(element, calendar, viewName) { html += "" + "
" + - htmlEscape(formatDate(date, weekNumberFormat)) + + htmlEscape(calculateWeekNumber(date)) + "
" + ""; } @@ -2348,21 +3209,21 @@ function BasicView(element, calendar, viewName) { } - function buildCellHTML(date) { - var contentClass = tm + "-widget-content"; - var month = t.start.getMonth(); - var today = clearTime(new Date()); + function buildCellHTML(date) { // date assumed to have stripped time + var month = t.intervalStart.month(); + var today = calendar.getNow().stripTime(); var html = ''; + var contentClass = tm + "-widget-content"; var classNames = [ 'fc-day', - 'fc-' + dayIDs[date.getDay()], + 'fc-' + dayIDs[date.day()], contentClass ]; - if (date.getMonth() != month) { + if (date.month() != month) { classNames.push('fc-other-month'); } - if (+date == +today) { + if (date.isSame(today, 'day')) { classNames.push( 'fc-today', tm + '-state-highlight' @@ -2378,12 +3239,12 @@ function BasicView(element, calendar, viewName) { html += "" + "
"; if (showNumbers) { - html += "
" + date.getDate() + "
"; + html += "
" + date.date() + "
"; } html += @@ -2405,7 +3266,7 @@ function BasicView(element, calendar, viewName) { function setHeight(height) { viewHeight = height; - var bodyHeight = viewHeight - head.height(); + var bodyHeight = Math.max(viewHeight - head.height(), 0); var rowHeight; var rowHeightLast; var cell; @@ -2458,8 +3319,8 @@ function BasicView(element, calendar, viewName) { function dayClick(ev) { if (!opt('selectable')) { // if selectable, SelectionManager will worry about dayClick - var date = parseISO8601($(this).data('date')); - trigger('dayClick', this, date, true, ev); + var date = calendar.moment($(this).data('date')); + trigger('dayClick', this, date, ev); } } @@ -2503,13 +3364,13 @@ function BasicView(element, calendar, viewName) { -----------------------------------------------------------------------*/ - function defaultSelectionEnd(startDate, allDay) { - return cloneDate(startDate); + function defaultSelectionEnd(start) { + return start.clone().stripTime().add('days', 1); } - function renderSelection(startDate, endDate, allDay) { - renderDayOverlay(startDate, addDays(cloneDate(endDate), 1), true); // rebuild every time??? + function renderSelection(start, end) { // end is exclusive + renderDayOverlay(start, end, true); // true = rebuild every time } @@ -2518,10 +3379,10 @@ function BasicView(element, calendar, viewName) { } - function reportDayClick(date, allDay, ev) { + function reportDayClick(date, ev) { var cell = dateToCell(date); var _element = bodyCells[cell.row*colCnt + cell.col]; - trigger('dayClick', _element, date, allDay, ev); + trigger('dayClick', _element, date, ev); } @@ -2534,7 +3395,9 @@ function BasicView(element, calendar, viewName) { hoverListener.start(function(cell) { clearOverlays(); if (cell) { - renderCellOverlay(cell.row, cell.col, cell.row, cell.col); + var d1 = cellToDate(cell); + var d2 = d1.clone().add(calendar.defaultAllDayEventDuration); + renderDayOverlay(d1, d2); } }, ev); } @@ -2544,8 +3407,13 @@ function BasicView(element, calendar, viewName) { var cell = hoverListener.stop(); clearOverlays(); if (cell) { - var d = cellToDate(cell); - trigger('drop', _dragElement, d, true, ev, ui); + trigger( + 'drop', + _dragElement, + cellToDate(cell), + ev, + ui + ); } } @@ -2555,11 +3423,6 @@ function BasicView(element, calendar, viewName) { --------------------------------------------------------*/ - function defaultEventEnd(event) { - return cloneDate(event.start); - } - - coordinateGrid = new CoordinateGrid(function(rows, cols) { var e, n, p; headCells.each(function(i, _e) { @@ -2657,93 +3520,77 @@ function BasicEventRenderer() { fcViews.agendaWeek = AgendaWeekView; -function AgendaWeekView(element, calendar) { +function AgendaWeekView(element, calendar) { // TODO: do a WeekView mixin var t = this; // exports + t.incrementDate = incrementDate; t.render = render; // imports AgendaView.call(t, element, calendar, 'agendaWeek'); - var opt = t.opt; - var renderAgenda = t.renderAgenda; - var skipHiddenDays = t.skipHiddenDays; - var getCellsPerWeek = t.getCellsPerWeek; - var formatDates = calendar.formatDates; - - function render(date, delta) { - if (delta) { - addDays(date, delta * 7); - } + function incrementDate(date, delta) { + return date.clone().stripTime().add('weeks', delta).startOf('week'); + } - var start = addDays(cloneDate(date), -((date.getDay() - opt('firstDay') + 7) % 7)); - var end = addDays(cloneDate(start), 7); - var visStart = cloneDate(start); - skipHiddenDays(visStart); + function render(date) { - var visEnd = cloneDate(end); - skipHiddenDays(visEnd, -1, true); + t.intervalStart = date.clone().stripTime().startOf('week'); + t.intervalEnd = t.intervalStart.clone().add('weeks', 1); - var colCnt = getCellsPerWeek(); + t.start = t.skipHiddenDays(t.intervalStart); + t.end = t.skipHiddenDays(t.intervalEnd, -1, true); - t.title = formatDates( - visStart, - addDays(cloneDate(visEnd), -1), - opt('titleFormat') + t.title = calendar.formatRange( + t.start, + t.end.clone().subtract(1), // make inclusive by subtracting 1 ms + t.opt('titleFormat'), + ' \u2014 ' // emphasized dash ); - t.start = start; - t.end = end; - t.visStart = visStart; - t.visEnd = visEnd; - - renderAgenda(colCnt); + t.renderAgenda(t.getCellsPerWeek()); } + } ;; fcViews.agendaDay = AgendaDayView; - -function AgendaDayView(element, calendar) { +function AgendaDayView(element, calendar) { // TODO: make a DayView mixin var t = this; // exports + t.incrementDate = incrementDate; t.render = render; // imports AgendaView.call(t, element, calendar, 'agendaDay'); - var opt = t.opt; - var renderAgenda = t.renderAgenda; - var skipHiddenDays = t.skipHiddenDays; - var formatDate = calendar.formatDate; - - - function render(date, delta) { - if (delta) { - addDays(date, delta); - } - skipHiddenDays(date, delta < 0 ? -1 : 1); - var start = cloneDate(date, true); - var end = addDays(cloneDate(start), 1); + function incrementDate(date, delta) { + var out = date.clone().stripTime().add('days', delta); + out = t.skipHiddenDays(out, delta < 0 ? -1 : 1); + return out; + } + - t.title = formatDate(date, opt('titleFormat')); + function render(date) { - t.start = t.visStart = start; - t.end = t.visEnd = end; + t.start = t.intervalStart = date.clone().stripTime(); + t.end = t.intervalEnd = t.start.clone().add('days', 1); - renderAgenda(1); + t.title = calendar.formatDate(t.start, t.opt('titleFormat')); + + t.renderAgenda(1); } @@ -2754,22 +3601,39 @@ function AgendaDayView(element, calendar) { setDefaults({ allDaySlot: true, allDayText: 'all-day', - firstHour: 6, - slotMinutes: 30, - defaultEventMinutes: 120, - axisFormat: 'h(:mm)tt', + + scrollTime: '06:00:00', + + slotDuration: '00:30:00', + + axisFormat: generateAgendaAxisFormat, timeFormat: { - agenda: 'h:mm{ - h:mm}' + agenda: generateAgendaTimeFormat }, + dragOpacity: { agenda: .5 }, - minTime: 0, - maxTime: 24, + minTime: '00:00:00', + maxTime: '24:00:00', slotEventOverlap: true }); +function generateAgendaAxisFormat(options, langData) { + return langData.longDateFormat('LT') + .replace(':mm', '(:mm)') + .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs + .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand +} + + +function generateAgendaTimeFormat(options, langData) { + return langData.longDateFormat('LT') + .replace(/\s*a$/i, ''); // remove trailing AM/PM +} + + // TODO: make it work in quirks mode (event corners, all-day height) // TODO: test liquid width, especially in IE6 @@ -2783,26 +3647,27 @@ function AgendaView(element, calendar, viewName) { t.setWidth = setWidth; t.setHeight = setHeight; t.afterRender = afterRender; - t.defaultEventEnd = defaultEventEnd; - t.timePosition = timePosition; + t.computeDateTop = computeDateTop; t.getIsCellAllDay = getIsCellAllDay; - t.allDayRow = getAllDayRow; - t.getCoordinateGrid = function() { return coordinateGrid }; // specifically for AgendaEventRenderer - t.getHoverListener = function() { return hoverListener }; + t.allDayRow = function() { return allDayRow; }; // badly named + t.getCoordinateGrid = function() { return coordinateGrid; }; // specifically for AgendaEventRenderer + t.getHoverListener = function() { return hoverListener; }; t.colLeft = colLeft; t.colRight = colRight; t.colContentLeft = colContentLeft; t.colContentRight = colContentRight; - t.getDaySegmentContainer = function() { return daySegmentContainer }; - t.getSlotSegmentContainer = function() { return slotSegmentContainer }; - t.getMinMinute = function() { return minMinute }; - t.getMaxMinute = function() { return maxMinute }; - t.getSlotContainer = function() { return slotContainer }; - t.getRowCnt = function() { return 1 }; - t.getColCnt = function() { return colCnt }; - t.getColWidth = function() { return colWidth }; - t.getSnapHeight = function() { return snapHeight }; - t.getSnapMinutes = function() { return snapMinutes }; + t.getDaySegmentContainer = function() { return daySegmentContainer; }; + t.getSlotSegmentContainer = function() { return slotSegmentContainer; }; + t.getSlotContainer = function() { return slotContainer; }; + t.getRowCnt = function() { return 1; }; + t.getColCnt = function() { return colCnt; }; + t.getColWidth = function() { return colWidth; }; + t.getSnapHeight = function() { return snapHeight; }; + t.getSnapDuration = function() { return snapDuration; }; + t.getSlotHeight = function() { return slotHeight; }; + t.getSlotDuration = function() { return slotDuration; }; + t.getMinTime = function() { return minTime; }; + t.getMaxTime = function() { return maxTime; }; t.defaultSelectionEnd = defaultSelectionEnd; t.renderDayOverlay = renderDayOverlay; t.renderSelection = renderSelection; @@ -2829,6 +3694,7 @@ function AgendaView(element, calendar, viewName) { var dateToCell = t.dateToCell; var rangeToSegments = t.rangeToSegments; var formatDate = calendar.formatDate; + var calculateWeekNumber = calendar.calculateWeekNumber; // locals @@ -2857,9 +3723,11 @@ function AgendaView(element, calendar, viewName) { var axisWidth; var colWidth; var gutterWidth; + + var slotDuration; var slotHeight; // TODO: what if slotHeight changes? (see issue 650) - var snapMinutes; + var snapDuration; var snapRatio; // ratio of number of "selection" slots to normal slots. (ex: 1, 2, 4) var snapHeight; // holds the pixel hight of a "selection" slot @@ -2873,11 +3741,9 @@ function AgendaView(element, calendar, viewName) { var tm; var rtl; - var minMinute, maxMinute; + var minTime; + var maxTime; var colFormat; - var showWeekNumbers; - var weekNumberTitle; - var weekNumberFormat; @@ -2904,22 +3770,15 @@ function AgendaView(element, calendar, viewName) { function updateOptions() { tm = opt('theme') ? 'ui' : 'fc'; - rtl = opt('isRTL') - minMinute = parseTime(opt('minTime')); - maxMinute = parseTime(opt('maxTime')); + rtl = opt('isRTL'); colFormat = opt('columnFormat'); - // week # options. (TODO: bad, logic also in other views) - showWeekNumbers = opt('weekNumbers'); - weekNumberTitle = opt('weekNumberTitle'); - if (opt('weekNumberCalculation') != 'iso') { - weekNumberFormat = "w"; - } - else { - weekNumberFormat = "W"; - } + minTime = moment.duration(opt('minTime')); + maxTime = moment.duration(opt('maxTime')); - snapMinutes = opt('snapMinutes') || opt('slotMinutes'); + slotDuration = moment.duration(opt('slotDuration')); + snapDuration = opt('snapDuration'); + snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration; } @@ -2929,14 +3788,13 @@ function AgendaView(element, calendar, viewName) { function buildSkeleton() { + var s; var headerClass = tm + "-widget-header"; var contentClass = tm + "-widget-content"; - var s; - var d; - var i; - var maxd; + var slotTime; + var slotDate; var minutes; - var slotNormal = opt('slotMinutes') % 15 == 0; + var slotNormal = slotDuration.asMinutes() % 15 === 0; buildDayTable(); @@ -2953,7 +3811,12 @@ function AgendaView(element, calendar, viewName) { s = "" + "" + - "" + + "" + "" + @@ -2992,27 +3855,32 @@ function AgendaView(element, calendar, viewName) { s = "
" + opt('allDayText') + "" + + ( + opt('allDayHTML') || + htmlEscape(opt('allDayText')) + ) + + "" + "
" + "
" + ""; - d = zeroDate(); - maxd = addMinutes(cloneDate(d), maxMinute); - addMinutes(d, minMinute); + + slotTime = moment.duration(+minTime); // i wish there was .clone() for durations slotCnt = 0; - for (i=0; d < maxd; i++) { - minutes = d.getMinutes(); + while (slotTime < maxTime) { + slotDate = t.start.clone().time(slotTime); // will be in UTC but that's good. to avoid DST issues + minutes = slotDate.minutes(); s += - "" + + "" + "" + "" + ""; - addMinutes(d, opt('slotMinutes')); + slotTime.add(slotDuration); slotCnt++; } + s += "" + "
" + - ((!slotNormal || !minutes) ? formatDate(d, opt('axisFormat')) : ' ') + + ((!slotNormal || !minutes) ? + htmlEscape(formatDate(slotDate, opt('axisFormat'))) : + ' ' + ) + "" + "
 
" + "
"; + slotTable = $(s).appendTo(slotContainer); slotBind(slotTable.find('td')); @@ -3071,14 +3939,14 @@ function AgendaView(element, calendar, viewName) { "" + ""; - if (showWeekNumbers) { + if (opt('weekNumbers')) { date = cellToDate(0, 0); - weekText = formatDate(date, weekNumberFormat); + weekText = calculateWeekNumber(date); if (rtl) { - weekText += weekNumberTitle; + weekText += opt('weekNumberTitle'); } else { - weekText = weekNumberTitle + weekText; + weekText = opt('weekNumberTitle') + weekText; } html += "" + @@ -3092,7 +3960,7 @@ function AgendaView(element, calendar, viewName) { for (col=0; col" + + "" + htmlEscape(formatDate(date, colFormat)) + ""; } @@ -3110,7 +3978,7 @@ function AgendaView(element, calendar, viewName) { var headerClass = tm + "-widget-header"; // TODO: make these when updateOptions() called var contentClass = tm + "-widget-content"; var date; - var today = clearTime(new Date()); + var today = calendar.getNow().stripTime(); var col; var cellsHTML; var cellHTML; @@ -3130,10 +3998,10 @@ function AgendaView(element, calendar, viewName) { classNames = [ 'fc-col' + col, - 'fc-' + dayIDs[date.getDay()], + 'fc-' + dayIDs[date.day()], contentClass ]; - if (+date == +today) { + if (date.isSame(today, 'day')) { classNames.push( tm + '-state-highlight', 'fc-today' @@ -3199,9 +4067,12 @@ function AgendaView(element, calendar, viewName) { // the stylesheet guarantees that the first row has no border. // this allows .height() to work well cross-browser. - slotHeight = slotTable.find('tr:first').height() + 1; // +1 for bottom border + var slotHeight0 = slotTable.find('tr:first').height() + 1; // +1 for bottom border + var slotHeight1 = slotTable.find('tr:eq(1)').height(); + // HACK: i forget why we do this, but i think a cross-browser issue + slotHeight = (slotHeight0 + slotHeight1) / 2; - snapRatio = opt('slotMinutes') / snapMinutes; + snapRatio = slotDuration / snapDuration; snapHeight = slotHeight / snapRatio; } @@ -3259,13 +4130,14 @@ function AgendaView(element, calendar, viewName) { function resetScroll() { - var d0 = zeroDate(); - var scrollDate = cloneDate(d0); - scrollDate.setHours(opt('firstHour')); - var top = timePosition(d0, scrollDate) + 1; // +1 for the border + var top = computeTimeTop( + moment.duration(opt('scrollTime')) + ) + 1; // +1 for the border + function scroll() { slotScroller.scrollTop(top); } + scroll(); setTimeout(scroll, 0); // overrides any previous scroll state made by the browser } @@ -3297,15 +4169,24 @@ function AgendaView(element, calendar, viewName) { if (!opt('selectable')) { // if selectable, SelectionManager will worry about dayClick var col = Math.min(colCnt-1, Math.floor((ev.pageX - dayTable.offset().left - axisWidth) / colWidth)); var date = cellToDate(0, col); - var rowMatch = this.parentNode.className.match(/fc-slot(\d+)/); // TODO: maybe use data - if (rowMatch) { - var mins = parseInt(rowMatch[1]) * opt('slotMinutes'); - var hours = Math.floor(mins/60); - date.setHours(hours); - date.setMinutes(mins%60 + minMinute); - trigger('dayClick', dayBodyCells[col], date, false, ev); + var match = this.parentNode.className.match(/fc-slot(\d+)/); // TODO: maybe use data + if (match) { + var slotIndex = parseInt(match[1], 10); + date.add(minTime + slotIndex * slotDuration); + date = calendar.rezoneDate(date); + trigger( + 'dayClick', + dayBodyCells[col], + date, + ev + ); }else{ - trigger('dayClick', dayBodyCells[col], date, true, ev); + trigger( + 'dayClick', + dayBodyCells[col], + date, + ev + ); } } } @@ -3346,15 +4227,24 @@ function AgendaView(element, calendar, viewName) { function renderSlotOverlay(overlayStart, overlayEnd) { - for (var i=0; i= 0) { - addMinutes(d, minMinute + slotIndex * snapMinutes); + + if (snapIndex >= 0) { + date.time(moment.duration(minTime + snapIndex * snapDuration)); + date = calendar.rezoneDate(date); } - return d; + + return date; } - - - // get the Y coordinate of the given time on the given day (both Date objects) - function timePosition(day, time) { // both date objects. day holds 00:00 of current day - day = cloneDate(day, true); - if (time < addMinutes(cloneDate(day), minMinute)) { + + + function computeDateTop(date, startOfDayDate) { + return computeTimeTop( + moment.duration( + date.clone().stripZone() - startOfDayDate.clone().stripTime() + ) + ); + } + + + function computeTimeTop(time) { // time is a duration + + if (time < minTime) { return 0; } - if (time >= addMinutes(cloneDate(day), maxMinute)) { + if (time >= maxTime) { return slotTable.height(); } - var slotMinutes = opt('slotMinutes'), - minutes = time.getHours()*60 + time.getMinutes() - minMinute, - slotI = Math.floor(minutes / slotMinutes), - slotTop = slotTopCache[slotI]; + + var slots = (time - minTime) / slotDuration; + var slotIndex = Math.floor(slots); + var slotPartial = slots - slotIndex; + var slotTop = slotTopCache[slotIndex]; + + // find the position of the corresponding + // need to use this tecnhique because not all rows are rendered at same height sometimes. if (slotTop === undefined) { - slotTop = slotTopCache[slotI] = - slotTable.find('tr').eq(slotI).find('td div')[0].offsetTop; + slotTop = slotTopCache[slotIndex] = + slotTable.find('tr').eq(slotIndex).find('td div')[0].offsetTop; // .eq() is faster than ":eq()" selector // [0].offsetTop is faster than .position().top (do we really need this optimization?) // a better optimization would be to cache all these divs } - return Math.max(0, Math.round( - slotTop - 1 + slotHeight * ((minutes % slotMinutes) / slotMinutes) - )); - } - - - function getAllDayRow(index) { - return allDayRow; - } - - - function defaultEventEnd(event) { - var start = cloneDate(event.start); - if (event.allDay) { - return start; - } - return addMinutes(start, opt('defaultEventMinutes')); + + var top = + slotTop - 1 + // because first row doesn't have a top border + slotPartial * slotHeight; // part-way through the row + + top = Math.max(top, 0); + + return top; } /* Selection ---------------------------------------------------------------------------------*/ + - - function defaultSelectionEnd(startDate, allDay) { - if (allDay) { - return cloneDate(startDate); + function defaultSelectionEnd(start) { + if (start.hasTime()) { + return start.clone().add(slotDuration); + } + else { + return start.clone().add('days', 1); } - return addMinutes(cloneDate(startDate), opt('slotMinutes')); } - function renderSelection(startDate, endDate, allDay) { // only for all-day - if (allDay) { - if (opt('allDaySlot')) { - renderDayOverlay(startDate, addDays(cloneDate(endDate), 1), true); - } - }else{ - renderSlotSelection(startDate, endDate); + function renderSelection(start, end) { + if (start.hasTime() || end.hasTime()) { + renderSlotSelection(start, end); + } + else if (opt('allDaySlot')) { + renderDayOverlay(start, end, true); // true for refreshing coordinate grid } } @@ -3522,8 +4423,8 @@ function AgendaView(element, calendar, viewName) { var col = dateToCell(startDate).col; if (col >= 0 && col < colCnt) { // only works when times are on same day var rect = coordinateGrid.rect(0, col, 0, col, slotContainer); // only for horizontal coords - var top = timePosition(startDate, startDate); - var bottom = timePosition(startDate, endDate); + var top = computeDateTop(startDate, startDate); + var bottom = computeDateTop(endDate, startDate); if (bottom > top) { // protect against selections that are entirely before or after visible range rect.top = top; rect.height = bottom - top; @@ -3586,9 +4487,9 @@ function AgendaView(element, calendar, viewName) { var d2 = realCellToDate(cell); dates = [ d1, - addMinutes(cloneDate(d1), snapMinutes), // calculate minutes depending on selection slot minutes + d1.clone().add(snapDuration), // calculate minutes depending on selection slot minutes d2, - addMinutes(cloneDate(d2), snapMinutes) + d2.clone().add(snapDuration) ].sort(dateCompare); renderSlotSelection(dates[0], dates[3]); }else{ @@ -3599,17 +4500,17 @@ function AgendaView(element, calendar, viewName) { hoverListener.stop(); if (dates) { if (+dates[0] == +dates[1]) { - reportDayClick(dates[0], false, ev); + reportDayClick(dates[0], ev); } - reportSelection(dates[0], dates[3], false, ev); + reportSelection(dates[0], dates[3], ev); } }); } } - function reportDayClick(date, allDay, ev) { - trigger('dayClick', dayBodyCells[dateToCell(date).col], date, allDay, ev); + function reportDayClick(date, ev) { + trigger('dayClick', dayBodyCells[dateToCell(date).col], date, ev); } @@ -3622,13 +4523,16 @@ function AgendaView(element, calendar, viewName) { hoverListener.start(function(cell) { clearOverlays(); if (cell) { - if (getIsCellAllDay(cell)) { - renderCellOverlay(cell.row, cell.col, cell.row, cell.col); - }else{ - var d1 = realCellToDate(cell); - var d2 = addMinutes(cloneDate(d1), opt('defaultEventMinutes')); + var d1 = realCellToDate(cell); + var d2 = d1.clone(); + if (d1.hasTime()) { + d2.add(calendar.defaultTimedEventDuration); renderSlotOverlay(d1, d2); } + else { + d2.add(calendar.defaultAllDayEventDuration); + renderDayOverlay(d1, d2); + } } }, ev); } @@ -3638,7 +4542,13 @@ function AgendaView(element, calendar, viewName) { var cell = hoverListener.stop(); clearOverlays(); if (cell) { - trigger('drop', _dragElement, realCellToDate(cell), getIsCellAllDay(cell), ev, ui); + trigger( + 'drop', + _dragElement, + realCellToDate(cell), + ev, + ui + ); } } @@ -3663,15 +4573,12 @@ function AgendaEventRenderer() { var trigger = t.trigger; var isEventDraggable = t.isEventDraggable; var isEventResizable = t.isEventResizable; - var eventEnd = t.eventEnd; var eventElementHandlers = t.eventElementHandlers; var setHeight = t.setHeight; var getDaySegmentContainer = t.getDaySegmentContainer; var getSlotSegmentContainer = t.getSlotSegmentContainer; var getHoverListener = t.getHoverListener; - var getMaxMinute = t.getMaxMinute; - var getMinMinute = t.getMinMinute; - var timePosition = t.timePosition; + var computeDateTop = t.computeDateTop; var getIsCellAllDay = t.getIsCellAllDay; var colContentLeft = t.colContentLeft; var colContentRight = t.colContentRight; @@ -3679,7 +4586,9 @@ function AgendaEventRenderer() { var getColCnt = t.getColCnt; var getColWidth = t.getColWidth; var getSnapHeight = t.getSnapHeight; - var getSnapMinutes = t.getSnapMinutes; + var getSnapDuration = t.getSnapDuration; + var getSlotHeight = t.getSlotHeight; + var getSlotDuration = t.getSlotDuration; var getSlotContainer = t.getSlotContainer; var reportEventElement = t.reportEventElement; var showEvents = t.showEvents; @@ -3689,9 +4598,11 @@ function AgendaEventRenderer() { var renderDayOverlay = t.renderDayOverlay; var clearOverlays = t.clearOverlays; var renderDayEvents = t.renderDayEvents; + var getMinTime = t.getMinTime; + var getMaxTime = t.getMaxTime; var calendar = t.calendar; var formatDate = calendar.formatDate; - var formatDates = calendar.formatDates; + var getEventEnd = calendar.getEventEnd; // overrides @@ -3732,25 +4643,21 @@ function AgendaEventRenderer() { function compileSlotSegs(events) { var colCnt = getColCnt(), - minMinute = getMinMinute(), - maxMinute = getMaxMinute(), - d, - visEventEnds = $.map(events, slotEventEnd), + minTime = getMinTime(), + maxTime = getMaxTime(), + cellDate, i, j, seg, colSegs, segs = []; for (i=0; i start && eventStart < end) { - if (eventStart < start) { - segStart = cloneDate(start); + + // get dates, make copies, then strip zone to normalize + eventStart = event.start.clone().stripZone(); + eventEnd = getEventEnd(event).stripZone(); + + if (eventEnd > rangeStart && eventStart < rangeEnd) { + + if (eventStart < rangeStart) { + segStart = rangeStart.clone(); isStart = false; - }else{ + } + else { segStart = eventStart; isStart = true; } - if (eventEnd > end) { - segEnd = cloneDate(end); + + if (eventEnd > rangeEnd) { + segEnd = rangeEnd.clone(); isEnd = false; - }else{ + } + else { segEnd = eventEnd; isEnd = true; } + segs.push({ event: event, start: segStart, @@ -3800,16 +4721,8 @@ function AgendaEventRenderer() { }); } } - return segs.sort(compareSlotSegs); - } - - function slotEventEnd(event) { - if (event.end) { - return cloneDate(event.end); - }else{ - return addMinutes(cloneDate(event.start), opt('defaultEventMinutes')); - } + return segs.sort(compareSlotSegs); } @@ -3842,8 +4755,8 @@ function AgendaEventRenderer() { for (i=0; i" + "
" + "
" + - htmlEscape(formatDates(event.start, event.end, opt('timeFormat'))) + + htmlEscape(t.getEventTimeText(event)) + "
" + "
" + htmlEscape(event.title || '') + "
" + "
" + "
"; + if (seg.isEnd && isEventResizable(event)) { html += "
=
"; @@ -4034,79 +4951,98 @@ function AgendaEventRenderer() { var revert; var allDay = true; var dayDelta; + var hoverListener = getHoverListener(); var colWidth = getColWidth(); + var minTime = getMinTime(); + var slotDuration = getSlotDuration(); + var slotHeight = getSlotHeight(); + var snapDuration = getSnapDuration(); var snapHeight = getSnapHeight(); - var snapMinutes = getSnapMinutes(); - var minMinute = getMinMinute(); + eventElement.draggable({ opacity: opt('dragOpacity', 'month'), // use whatever the month view was using revertDuration: opt('dragRevertDuration'), start: function(ev, ui) { - trigger('eventDragStart', eventElement, event, ev, ui); + + trigger('eventDragStart', eventElement[0], event, ev, ui); hideEvents(event, eventElement); origWidth = eventElement.width(); + hoverListener.start(function(cell, origCell) { clearOverlays(); if (cell) { revert = false; + var origDate = cellToDate(0, origCell.col); var date = cellToDate(0, cell.col); - dayDelta = dayDiff(date, origDate); - if (!cell.row) { - // on full-days + dayDelta = date.diff(origDate, 'days'); + + if (!cell.row) { // on full-days + renderDayOverlay( - addDays(cloneDate(event.start), dayDelta), - addDays(exclEndDay(event), dayDelta) + event.start.clone().add('days', dayDelta), + getEventEnd(event).add('days', dayDelta) ); + resetElement(); - }else{ - // mouse is over bottom slots + } + else { // mouse is over bottom slots + if (isStart) { if (allDay) { // convert event to temporary slot-event eventElement.width(colWidth - 10); // don't use entire width - setOuterHeight( - eventElement, - snapHeight * Math.round( - (event.end ? ((event.end - event.start) / MINUTE_MS) : opt('defaultEventMinutes')) / - snapMinutes - ) - ); - eventElement.draggable('option', 'grid', [colWidth, 1]); + setOuterHeight(eventElement, calendar.defaultTimedEventDuration / slotDuration * slotHeight); // the default height + eventElement.draggable('option', 'grid', [ colWidth, 1 ]); allDay = false; } - }else{ + } + else { revert = true; } } + revert = revert || (allDay && !dayDelta); - }else{ + } + else { resetElement(); revert = true; } + eventElement.draggable('option', 'revert', revert); + }, ev, 'drag'); }, stop: function(ev, ui) { hoverListener.stop(); clearOverlays(); - trigger('eventDragStop', eventElement, event, ev, ui); - if (revert) { - // hasn't moved or is out of bounds (draggable has already reverted) + trigger('eventDragStop', eventElement[0], event, ev, ui); + + if (revert) { // hasn't moved or is out of bounds (draggable has already reverted) + resetElement(); eventElement.css('filter', ''); // clear IE opacity side-effects showEvents(event, eventElement); - }else{ - // changed! - var minuteDelta = 0; + } + else { // changed! + + var eventStart = event.start.clone().add('days', dayDelta); // already assumed to have a stripped time + var snapTime; + var snapIndex; if (!allDay) { - minuteDelta = Math.round((eventElement.offset().top - getSlotContainer().offset().top) / snapHeight) - * snapMinutes - + minMinute - - (event.start.getHours() * 60 + event.start.getMinutes()); + snapIndex = Math.round((eventElement.offset().top - getSlotContainer().offset().top) / snapHeight); // why not use ui.offset.top? + snapTime = moment.duration(minTime + snapIndex * snapDuration); + eventStart = calendar.rezoneDate(eventStart.clone().time(snapTime)); } - eventDrop(this, event, dayDelta, minuteDelta, allDay, ev, ui); + + eventDrop( + eventElement[0], + event, + eventStart, + ev, + ui + ); } } }); @@ -4129,7 +5065,7 @@ function AgendaEventRenderer() { var colCnt = getColCnt(); var colWidth = getColWidth(); var snapHeight = getSnapHeight(); - var snapMinutes = getSnapMinutes(); + var snapDuration = getSnapDuration(); // states var origPosition; // original position of the element, not the mouse @@ -4138,7 +5074,10 @@ function AgendaEventRenderer() { var isAllDay, prevIsAllDay; var colDelta, prevColDelta; var dayDelta; // derived from colDelta - var minuteDelta, prevMinuteDelta; + var snapDelta, prevSnapDelta; // the number of snaps away from the original position + + // newly computed + var eventStart, eventEnd; eventElement.draggable({ scroll: false, @@ -4148,7 +5087,7 @@ function AgendaEventRenderer() { revertDuration: opt('dragRevertDuration'), start: function(ev, ui) { - trigger('eventDragStart', eventElement, event, ev, ui); + trigger('eventDragStart', eventElement[0], event, ev, ui); hideEvents(event, eventElement); coordinateGrid.build(); @@ -4160,8 +5099,10 @@ function AgendaEventRenderer() { isAllDay = prevIsAllDay = getIsCellAllDay(origCell); colDelta = prevColDelta = 0; dayDelta = 0; - minuteDelta = prevMinuteDelta = 0; + snapDelta = prevSnapDelta = 0; + eventStart = null; + eventEnd = null; }, drag: function(ev, ui) { @@ -4187,12 +5128,12 @@ function AgendaEventRenderer() { col = Math.max(0, col); col = Math.min(colCnt-1, col); var date = cellToDate(0, col); - dayDelta = dayDiff(date, origDate); + dayDelta = date.diff(origDate, 'days'); } // calculate minute delta (only if over slots) if (!isAllDay) { - minuteDelta = Math.round((ui.position.top - origPosition.top) / snapHeight) * snapMinutes; + snapDelta = Math.round((ui.position.top - origPosition.top) / snapHeight); } } @@ -4201,16 +5142,26 @@ function AgendaEventRenderer() { isInBounds != prevIsInBounds || isAllDay != prevIsAllDay || colDelta != prevColDelta || - minuteDelta != prevMinuteDelta + snapDelta != prevSnapDelta ) { + // compute new dates + if (isAllDay) { + eventStart = event.start.clone().stripTime().add('days', dayDelta); + eventEnd = eventStart.clone().add(calendar.defaultAllDayEventDuration); + } + else { + eventStart = event.start.clone().add(snapDelta * snapDuration).add('days', dayDelta); + eventEnd = getEventEnd(event).add(snapDelta * snapDuration).add('days', dayDelta); + } + updateUI(); // update previous states for next time prevIsInBounds = isInBounds; prevIsAllDay = isAllDay; prevColDelta = colDelta; - prevMinuteDelta = minuteDelta; + prevSnapDelta = snapDelta; } // if out-of-bounds, revert when done, and vice versa. @@ -4220,10 +5171,16 @@ function AgendaEventRenderer() { stop: function(ev, ui) { clearOverlays(); - trigger('eventDragStop', eventElement, event, ev, ui); - - if (isInBounds && (isAllDay || dayDelta || minuteDelta)) { // changed! - eventDrop(this, event, dayDelta, isAllDay ? 0 : minuteDelta, isAllDay, ev, ui); + trigger('eventDragStop', eventElement[0], event, ev, ui); + + if (isInBounds && (isAllDay || dayDelta || snapDelta)) { // changed! + eventDrop( + eventElement[0], + event, + eventStart, + ev, + ui + ); } else { // either no change or out-of-bounds (draggable has already reverted) @@ -4232,7 +5189,7 @@ function AgendaEventRenderer() { isAllDay = false; colDelta = 0; dayDelta = 0; - minuteDelta = 0; + snapDelta = 0; updateUI(); eventElement.css('filter', ''); // clear IE opacity side-effects @@ -4253,26 +5210,24 @@ function AgendaEventRenderer() { if (isAllDay) { timeElement.hide(); eventElement.draggable('option', 'grid', null); // disable grid snapping - renderDayOverlay( - addDays(cloneDate(event.start), dayDelta), - addDays(exclEndDay(event), dayDelta) - ); + renderDayOverlay(eventStart, eventEnd); } else { - updateTimeText(minuteDelta); + updateTimeText(); timeElement.css('display', ''); // show() was causing display=inline eventElement.draggable('option', 'grid', [colWidth, snapHeight]); // re-enable grid snapping } } } - function updateTimeText(minuteDelta) { - var newStart = addMinutes(cloneDate(event.start), minuteDelta); - var newEnd; - if (event.end) { - newEnd = addMinutes(cloneDate(event.end), minuteDelta); + function updateTimeText() { + if (eventStart) { // must of had a state change + timeElement.text( + t.getEventTimeText(eventStart, event.end ? eventEnd : null) + // ^ + // only display the new end if there was an old end + ); } - timeElement.text(formatDates(newStart, newEnd, opt('timeFormat'))); } } @@ -4286,7 +5241,9 @@ function AgendaEventRenderer() { function resizableSlotEvent(event, eventElement, timeElement) { var snapDelta, prevSnapDelta; var snapHeight = getSnapHeight(); - var snapMinutes = getSnapMinutes(); + var snapDuration = getSnapDuration(); + var eventEnd; + eventElement.resizable({ handles: { s: '.ui-resizable-handle' @@ -4295,28 +5252,36 @@ function AgendaEventRenderer() { start: function(ev, ui) { snapDelta = prevSnapDelta = 0; hideEvents(event, eventElement); - trigger('eventResizeStart', this, event, ev, ui); + trigger('eventResizeStart', eventElement[0], event, ev, ui); }, resize: function(ev, ui) { // don't rely on ui.size.height, doesn't take grid into account snapDelta = Math.round((Math.max(snapHeight, eventElement.height()) - ui.originalSize.height) / snapHeight); if (snapDelta != prevSnapDelta) { - timeElement.text( - formatDates( - event.start, - (!snapDelta && !event.end) ? null : // no change, so don't display time range - addMinutes(eventEnd(event), snapMinutes*snapDelta), - opt('timeFormat') - ) - ); + eventEnd = getEventEnd(event).add(snapDuration * snapDelta); + var text; + if (snapDelta) { // has there been a change? + text = t.getEventTimeText(event.start, eventEnd); + } + else { + text = t.getEventTimeText(event); // the original time text + } + timeElement.text(text); prevSnapDelta = snapDelta; } }, stop: function(ev, ui) { - trigger('eventResizeStop', this, event, ev, ui); + trigger('eventResizeStop', eventElement[0], event, ev, ui); if (snapDelta) { - eventResize(this, event, 0, snapMinutes*snapDelta, ev, ui); - }else{ + eventResize( + eventElement[0], + event, + eventEnd, + ev, + ui + ); + } + else { showEvents(event, eventElement); // BUG: if event was really short, need to put title back in span } @@ -4549,9 +5514,7 @@ function View(element, calendar, viewName) { t.trigger = trigger; t.isEventDraggable = isEventDraggable; t.isEventResizable = isEventResizable; - t.setEventData = setEventData; t.clearEventData = clearEventData; - t.eventEnd = eventEnd; t.reportEventElement = reportEventElement; t.triggerEventDestroy = triggerEventDestroy; t.eventElementHandlers = eventElementHandlers; @@ -4559,28 +5522,26 @@ function View(element, calendar, viewName) { t.hideEvents = hideEvents; t.eventDrop = eventDrop; t.eventResize = eventResize; - // t.title - // t.start, t.end - // t.visStart, t.visEnd + // t.start, t.end // moments with ambiguous-time + // t.intervalStart, t.intervalEnd // moments with ambiguous-time // imports - var defaultEventEnd = t.defaultEventEnd; - var normalizeEvent = calendar.normalizeEvent; // in EventManager var reportEventChange = calendar.reportEventChange; // locals - var eventsByID = {}; // eventID mapped to array of events (there can be multiple b/c of repeating events) var eventElementsByID = {}; // eventID mapped to array of jQuery elements var eventElementCouples = []; // array of objects, { event, element } // TODO: unify with segment system var options = calendar.options; + var nextDayThreshold = moment.duration(options.nextDayThreshold); + function opt(name, viewNameOverride) { var v = options[name]; - if ($.isPlainObject(v)) { + if ($.isPlainObject(v) && !isForcedAtomicOption(name)) { return smartProperty(v, viewNameOverride || viewName); } return v; @@ -4609,8 +5570,7 @@ function View(element, calendar, viewName) { event.editable, source.editable, opt('editable') - ) - && !opt('disableDragging'); // deprecated + ); } @@ -4623,43 +5583,21 @@ function View(element, calendar, viewName) { event.editable, source.editable, opt('editable') - ) - && !opt('disableResizing'); // deprecated + ); } /* Event Data ------------------------------------------------------------------------------*/ - - - function setEventData(events) { // events are already normalized at this point - eventsByID = {}; - var i, len=events.length, event; - for (i=0; i
"; - if (segment.isEnd && isEventResizable(event)) { + if (event.allDay && segment.isEnd && isEventResizable(event)) { html += "
" + "   " + // makes hit area a lot better for IE6/7 @@ -5431,17 +6358,18 @@ function DayEventRenderer() { var rowContentHeights = calculateVerticals(segments); // also sets segment.top var rowContentElements = getRowContentElements(); // returns 1 inner div per row var rowContentTops = []; + var i; // Set each row's height by setting height of first inner div if (doRowHeights) { - for (var i=0; i=0 && c>=0) ? { row:r, col:c } : null; + return (r>=0 && c>=0) ? { row: r, col: c } : null; }; @@ -6041,7 +7011,10 @@ function HoverListener(coordinateGrid) { function mouse(ev) { _fixUIEvent(ev); // see below var newCell = coordinateGrid.cell(ev.pageX, ev.pageY); - if (!newCell != !cell || newCell && (newCell.row != cell.row || newCell.col != cell.col)) { + if ( + Boolean(newCell) !== Boolean(cell) || + newCell && (newCell.row != cell.row || newCell.col != cell.col) + ) { if (newCell) { if (!firstCell) { firstCell = newCell; @@ -6086,15 +7059,15 @@ function HorizontalPositionCache(getElement) { rights = {}; function e(i) { - return elements[i] = elements[i] || getElement(i); + return (elements[i] = (elements[i] || getElement(i))); } t.left = function(i) { - return lefts[i] = lefts[i] === undefined ? e(i).position().left : lefts[i]; + return (lefts[i] = (lefts[i] === undefined ? e(i).position().left : lefts[i])); }; t.right = function(i) { - return rights[i] = rights[i] === undefined ? t.left(i) + e(i).width() : rights[i]; + return (rights[i] = (rights[i] === undefined ? t.left(i) + e(i).width() : rights[i])); }; t.clear = function() { @@ -6107,4 +7080,4 @@ function HorizontalPositionCache(getElement) { ;; -})(jQuery); \ No newline at end of file +}); \ No newline at end of file diff --git a/src/UI/JsLibraries/moment.js b/src/UI/JsLibraries/moment.js index c8a870e8c..83282c6fd 100644 --- a/src/UI/JsLibraries/moment.js +++ b/src/UI/JsLibraries/moment.js @@ -1,8 +1,8 @@ -// moment.js -// version : 2.1.0 -// author : Tim Wood -// license : MIT -// momentjs.com +//! moment.js +//! version : 2.7.0 +//! authors : Tim Wood, Iskren Chernev, Moment.js contributors +//! license : MIT +//! momentjs.com (function (undefined) { @@ -11,41 +11,90 @@ ************************************/ var moment, - VERSION = "2.1.0", - round = Math.round, i, + VERSION = "2.7.0", + // the global-scope this is NOT the global object in Node.js + globalScope = typeof global !== 'undefined' ? global : this, + oldGlobalMoment, + round = Math.round, + i, + + YEAR = 0, + MONTH = 1, + DATE = 2, + HOUR = 3, + MINUTE = 4, + SECOND = 5, + MILLISECOND = 6, + // internal storage for language config files languages = {}, + // moment internal properties + momentProperties = { + _isAMomentObject: null, + _i : null, + _f : null, + _l : null, + _strict : null, + _tzm : null, + _isUTC : null, + _offset : null, // optional. Combine with _isUTC + _pf : null, + _lang : null // optional + }, + // check for nodeJS hasModule = (typeof module !== 'undefined' && module.exports), // ASP.NET json date format regex aspNetJsonRegex = /^\/?Date\((\-?\d+)/i, - aspNetTimeSpanJsonRegex = /(\-)?(\d*)?\.?(\d+)\:(\d+)\:(\d+)\.?(\d{3})?/, + aspNetTimeSpanJsonRegex = /(\-)?(?:(\d*)\.)?(\d+)\:(\d+)(?:\:(\d+)\.?(\d{3})?)?/, + + // from http://docs.closure-library.googlecode.com/git/closure_goog_date_date.js.source.html + // somewhat more in line with 4.4.3.2 2004 spec, but allows decimal anywhere + isoDurationRegex = /^(-)?P(?:(?:([0-9,.]*)Y)?(?:([0-9,.]*)M)?(?:([0-9,.]*)D)?(?:T(?:([0-9,.]*)H)?(?:([0-9,.]*)M)?(?:([0-9,.]*)S)?)?|([0-9,.]*)W)$/, // format tokens - formattingTokens = /(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|SS?S?|X|zz?|ZZ?|.)/g, + formattingTokens = /(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Q|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|S{1,4}|X|zz?|ZZ?|.)/g, localFormattingTokens = /(\[[^\[]*\])|(\\)?(LT|LL?L?L?|l{1,4})/g, // parsing token regexes parseTokenOneOrTwoDigits = /\d\d?/, // 0 - 99 parseTokenOneToThreeDigits = /\d{1,3}/, // 0 - 999 - parseTokenThreeDigits = /\d{3}/, // 000 - 999 - parseTokenFourDigits = /\d{1,4}/, // 0 - 9999 - parseTokenSixDigits = /[+\-]?\d{1,6}/, // -999,999 - 999,999 + parseTokenOneToFourDigits = /\d{1,4}/, // 0 - 9999 + parseTokenOneToSixDigits = /[+\-]?\d{1,6}/, // -999,999 - 999,999 + parseTokenDigits = /\d+/, // nonzero number of digits parseTokenWord = /[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i, // any word (or two) characters or numbers including two/three word month in arabic. - parseTokenTimezone = /Z|[\+\-]\d\d:?\d\d/i, // +00:00 -00:00 +0000 -0000 or Z - parseTokenT = /T/i, // T (ISO seperator) + parseTokenTimezone = /Z|[\+\-]\d\d:?\d\d/gi, // +00:00 -00:00 +0000 -0000 or Z + parseTokenT = /T/i, // T (ISO separator) parseTokenTimestampMs = /[\+\-]?\d+(\.\d{1,3})?/, // 123456789 123456789.123 + parseTokenOrdinal = /\d{1,2}/, + + //strict parsing regexes + parseTokenOneDigit = /\d/, // 0 - 9 + parseTokenTwoDigits = /\d\d/, // 00 - 99 + parseTokenThreeDigits = /\d{3}/, // 000 - 999 + parseTokenFourDigits = /\d{4}/, // 0000 - 9999 + parseTokenSixDigits = /[+-]?\d{6}/, // -999,999 - 999,999 + parseTokenSignedNumber = /[+-]?\d+/, // -inf - inf + + // iso 8601 regex + // 0000-00-00 0000-W00 or 0000-W00-0 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 or +00) + isoRegex = /^\s*(?:[+-]\d{6}|\d{4})-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/, - // preliminary iso regex - // 0000-00-00 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 - isoRegex = /^\s*\d{4}-\d\d-\d\d((T| )(\d\d(:\d\d(:\d\d(\.\d\d?\d?)?)?)?)?([\+\-]\d\d:?\d\d)?)?/, isoFormat = 'YYYY-MM-DDTHH:mm:ssZ', + isoDates = [ + ['YYYYYY-MM-DD', /[+-]\d{6}-\d{2}-\d{2}/], + ['YYYY-MM-DD', /\d{4}-\d{2}-\d{2}/], + ['GGGG-[W]WW-E', /\d{4}-W\d{2}-\d/], + ['GGGG-[W]WW', /\d{4}-W\d{2}/], + ['YYYY-DDD', /\d{4}-\d{3}/] + ], + // iso time formats and regexes isoTimes = [ - ['HH:mm:ss.S', /(T| )\d\d:\d\d:\d\d\.\d{1,3}/], + ['HH:mm:ss.SSSS', /(T| )\d\d:\d\d:\d\d\.\d+/], ['HH:mm:ss', /(T| )\d\d:\d\d:\d\d/], ['HH:mm', /(T| )\d\d:\d\d/], ['HH', /(T| )\d\d/] @@ -72,14 +121,40 @@ m : 'minute', h : 'hour', d : 'day', + D : 'date', w : 'week', + W : 'isoWeek', M : 'month', - y : 'year' + Q : 'quarter', + y : 'year', + DDD : 'dayOfYear', + e : 'weekday', + E : 'isoWeekday', + gg: 'weekYear', + GG: 'isoWeekYear' + }, + + camelFunctions = { + dayofyear : 'dayOfYear', + isoweekday : 'isoWeekday', + isoweek : 'isoWeek', + weekyear : 'weekYear', + isoweekyear : 'isoWeekYear' }, // format function strings formatFunctions = {}, + // default relative time thresholds + relativeTimeThresholds = { + s: 45, //seconds to minutes + m: 45, //minutes to hours + h: 22, //hours to days + dd: 25, //days to month (month == 1) + dm: 45, //days to months (months > 1) + dy: 345 //days to year + }, + // tokens to ordinalize and pad ordinalizeTokens = 'DDD w W M D d'.split(' '), paddedTokens = 'M D H h m s w W'.split(' '), @@ -127,11 +202,15 @@ YYYYY : function () { return leftZeroFill(this.year(), 5); }, + YYYYYY : function () { + var y = this.year(), sign = y >= 0 ? '+' : '-'; + return sign + leftZeroFill(Math.abs(y), 6); + }, gg : function () { return leftZeroFill(this.weekYear() % 100, 2); }, gggg : function () { - return this.weekYear(); + return leftZeroFill(this.weekYear(), 4); }, ggggg : function () { return leftZeroFill(this.weekYear(), 5); @@ -140,7 +219,7 @@ return leftZeroFill(this.isoWeekYear() % 100, 2); }, GGGG : function () { - return this.isoWeekYear(); + return leftZeroFill(this.isoWeekYear(), 4); }, GGGGG : function () { return leftZeroFill(this.isoWeekYear(), 5); @@ -170,14 +249,17 @@ return this.seconds(); }, S : function () { - return ~~(this.milliseconds() / 100); + return toInt(this.milliseconds() / 100); }, SS : function () { - return leftZeroFill(~~(this.milliseconds() / 10), 2); + return leftZeroFill(toInt(this.milliseconds() / 10), 2); }, SSS : function () { return leftZeroFill(this.milliseconds(), 3); }, + SSSS : function () { + return leftZeroFill(this.milliseconds(), 3); + }, Z : function () { var a = -this.zone(), b = "+"; @@ -185,7 +267,7 @@ a = -a; b = "-"; } - return b + leftZeroFill(~~(a / 60), 2) + ":" + leftZeroFill(~~a % 60, 2); + return b + leftZeroFill(toInt(a / 60), 2) + ":" + leftZeroFill(toInt(a) % 60, 2); }, ZZ : function () { var a = -this.zone(), @@ -194,7 +276,7 @@ a = -a; b = "-"; } - return b + leftZeroFill(~~(10 * a / 6), 4); + return b + leftZeroFill(toInt(a / 60), 2) + leftZeroFill(toInt(a) % 60, 2); }, z : function () { return this.zoneAbbr(); @@ -204,8 +286,57 @@ }, X : function () { return this.unix(); + }, + Q : function () { + return this.quarter(); } + }, + + lists = ['months', 'monthsShort', 'weekdays', 'weekdaysShort', 'weekdaysMin']; + + // Pick the first defined of two or three arguments. dfl comes from + // default. + function dfl(a, b, c) { + switch (arguments.length) { + case 2: return a != null ? a : b; + case 3: return a != null ? a : b != null ? b : c; + default: throw new Error("Implement me"); + } + } + + function defaultParsingFlags() { + // We need to deep clone this object, and es5 standard is not very + // helpful. + return { + empty : false, + unusedTokens : [], + unusedInput : [], + overflow : -2, + charsLeftOver : 0, + nullInput : false, + invalidMonth : null, + invalidFormat : false, + userInvalidated : false, + iso: false }; + } + + function deprecate(msg, fn) { + var firstTime = true; + function printMsg() { + if (moment.suppressDeprecationWarnings === false && + typeof console !== 'undefined' && console.warn) { + console.warn("Deprecation warning: " + msg); + } + } + return extend(function () { + if (firstTime) { + printMsg(); + firstTime = false; + } + return fn.apply(this, arguments); + }, fn); + } function padToken(func, count) { return function (a) { @@ -239,36 +370,37 @@ // Moment prototype object function Moment(config) { + checkOverflow(config); extend(this, config); } // Duration Constructor function Duration(duration) { - var years = duration.years || duration.year || duration.y || 0, - months = duration.months || duration.month || duration.M || 0, - weeks = duration.weeks || duration.week || duration.w || 0, - days = duration.days || duration.day || duration.d || 0, - hours = duration.hours || duration.hour || duration.h || 0, - minutes = duration.minutes || duration.minute || duration.m || 0, - seconds = duration.seconds || duration.second || duration.s || 0, - milliseconds = duration.milliseconds || duration.millisecond || duration.ms || 0; - - // store reference to input for deterministic cloning - this._input = duration; + var normalizedInput = normalizeObjectUnits(duration), + years = normalizedInput.year || 0, + quarters = normalizedInput.quarter || 0, + months = normalizedInput.month || 0, + weeks = normalizedInput.week || 0, + days = normalizedInput.day || 0, + hours = normalizedInput.hour || 0, + minutes = normalizedInput.minute || 0, + seconds = normalizedInput.second || 0, + milliseconds = normalizedInput.millisecond || 0; // representation for dateAddRemove - this._milliseconds = milliseconds + + this._milliseconds = +milliseconds + seconds * 1e3 + // 1000 minutes * 6e4 + // 1000 * 60 hours * 36e5; // 1000 * 60 * 60 // Because of dateAddRemove treats 24 hours as different from a // day when working around DST, we need to store them separately - this._days = days + + this._days = +days + weeks * 7; // It is impossible translate months into days without knowing // which months you are are talking about, so we have to store // it separately. - this._months = months + + this._months = +months + + quarters * 3 + years * 12; this._data = {}; @@ -276,7 +408,6 @@ this._bubble(); } - /************************************ Helpers ************************************/ @@ -288,9 +419,29 @@ a[i] = b[i]; } } + + if (b.hasOwnProperty("toString")) { + a.toString = b.toString; + } + + if (b.hasOwnProperty("valueOf")) { + a.valueOf = b.valueOf; + } + return a; } + function cloneMoment(m) { + var result = {}, i; + for (i in m) { + if (m.hasOwnProperty(i) && momentProperties.hasOwnProperty(i)) { + result[i] = m[i]; + } + } + + return result; + } + function absRound(number) { if (number < 0) { return Math.ceil(number); @@ -301,44 +452,34 @@ // left zero fill a number // see http://jsperf.com/left-zero-filling for performance comparison - function leftZeroFill(number, targetLength) { - var output = number + ''; + function leftZeroFill(number, targetLength, forceSign) { + var output = '' + Math.abs(number), + sign = number >= 0; + while (output.length < targetLength) { output = '0' + output; } - return output; + return (sign ? (forceSign ? '+' : '') : '-') + output; } // helper function for _.addTime and _.subtractTime - function addOrSubtractDurationFromMoment(mom, duration, isAdding, ignoreUpdateOffset) { + function addOrSubtractDurationFromMoment(mom, duration, isAdding, updateOffset) { var milliseconds = duration._milliseconds, days = duration._days, - months = duration._months, - minutes, - hours, - currentDate; + months = duration._months; + updateOffset = updateOffset == null ? true : updateOffset; if (milliseconds) { mom._d.setTime(+mom._d + milliseconds * isAdding); } - // store the minutes and hours so we can restore them - if (days || months) { - minutes = mom.minute(); - hours = mom.hour(); - } if (days) { - mom.date(mom.date() + days * isAdding); + rawSetter(mom, 'Date', rawGetter(mom, 'Date') + days * isAdding); } if (months) { - mom.month(mom.month() + months * isAdding); - } - if (milliseconds && !ignoreUpdateOffset) { - moment.updateOffset(mom); + rawMonthSetter(mom, rawGetter(mom, 'Month') + months * isAdding); } - // restore the minutes and hours after possibly changing dst - if (days || months) { - mom.minute(minutes); - mom.hour(hours); + if (updateOffset) { + moment.updateOffset(mom, days || months); } } @@ -347,14 +488,20 @@ return Object.prototype.toString.call(input) === '[object Array]'; } + function isDate(input) { + return Object.prototype.toString.call(input) === '[object Date]' || + input instanceof Date; + } + // compare two arrays, return the number of differences - function compareArrays(array1, array2) { + function compareArrays(array1, array2, dontConvert) { var len = Math.min(array1.length, array2.length), lengthDiff = Math.abs(array1.length - array2.length), diffs = 0, i; for (i = 0; i < len; i++) { - if (~~array1[i] !== ~~array2[i]) { + if ((dontConvert && array1[i] !== array2[i]) || + (!dontConvert && toInt(array1[i]) !== toInt(array2[i]))) { diffs++; } } @@ -362,16 +509,159 @@ } function normalizeUnits(units) { - return units ? unitAliases[units] || units.toLowerCase().replace(/(.)s$/, '$1') : units; + if (units) { + var lowered = units.toLowerCase().replace(/(.)s$/, '$1'); + units = unitAliases[units] || camelFunctions[lowered] || lowered; + } + return units; + } + + function normalizeObjectUnits(inputObject) { + var normalizedInput = {}, + normalizedProp, + prop; + + for (prop in inputObject) { + if (inputObject.hasOwnProperty(prop)) { + normalizedProp = normalizeUnits(prop); + if (normalizedProp) { + normalizedInput[normalizedProp] = inputObject[prop]; + } + } + } + + return normalizedInput; + } + + function makeList(field) { + var count, setter; + + if (field.indexOf('week') === 0) { + count = 7; + setter = 'day'; + } + else if (field.indexOf('month') === 0) { + count = 12; + setter = 'month'; + } + else { + return; + } + + moment[field] = function (format, index) { + var i, getter, + method = moment.fn._lang[field], + results = []; + + if (typeof format === 'number') { + index = format; + format = undefined; + } + + getter = function (i) { + var m = moment().utc().set(setter, i); + return method.call(moment.fn._lang, m, format || ''); + }; + + if (index != null) { + return getter(index); + } + else { + for (i = 0; i < count; i++) { + results.push(getter(i)); + } + return results; + } + }; + } + + function toInt(argumentForCoercion) { + var coercedNumber = +argumentForCoercion, + value = 0; + + if (coercedNumber !== 0 && isFinite(coercedNumber)) { + if (coercedNumber >= 0) { + value = Math.floor(coercedNumber); + } else { + value = Math.ceil(coercedNumber); + } + } + + return value; + } + + function daysInMonth(year, month) { + return new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + } + + function weeksInYear(year, dow, doy) { + return weekOfYear(moment([year, 11, 31 + dow - doy]), dow, doy).week; + } + + function daysInYear(year) { + return isLeapYear(year) ? 366 : 365; + } + + function isLeapYear(year) { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + } + + function checkOverflow(m) { + var overflow; + if (m._a && m._pf.overflow === -2) { + overflow = + m._a[MONTH] < 0 || m._a[MONTH] > 11 ? MONTH : + m._a[DATE] < 1 || m._a[DATE] > daysInMonth(m._a[YEAR], m._a[MONTH]) ? DATE : + m._a[HOUR] < 0 || m._a[HOUR] > 23 ? HOUR : + m._a[MINUTE] < 0 || m._a[MINUTE] > 59 ? MINUTE : + m._a[SECOND] < 0 || m._a[SECOND] > 59 ? SECOND : + m._a[MILLISECOND] < 0 || m._a[MILLISECOND] > 999 ? MILLISECOND : + -1; + + if (m._pf._overflowDayOfYear && (overflow < YEAR || overflow > DATE)) { + overflow = DATE; + } + + m._pf.overflow = overflow; + } + } + + function isValid(m) { + if (m._isValid == null) { + m._isValid = !isNaN(m._d.getTime()) && + m._pf.overflow < 0 && + !m._pf.empty && + !m._pf.invalidMonth && + !m._pf.nullInput && + !m._pf.invalidFormat && + !m._pf.userInvalidated; + + if (m._strict) { + m._isValid = m._isValid && + m._pf.charsLeftOver === 0 && + m._pf.unusedTokens.length === 0; + } + } + return m._isValid; + } + + function normalizeLanguage(key) { + return key ? key.toLowerCase().replace('_', '-') : key; } + // Return a moment from input, that is local/utc/zone equivalent to model. + function makeAs(input, model) { + return model._isUTC ? moment(input).zone(model._offset || 0) : + moment(input).local(); + } /************************************ Languages ************************************/ - Language.prototype = { + extend(Language.prototype, { + set : function (config) { var prop, i; for (i in config) { @@ -404,7 +694,7 @@ for (i = 0; i < 12; i++) { // make the regex if we don't have it already if (!this._monthsParse[i]) { - mom = moment([2000, i]); + mom = moment.utc([2000, i]); regex = '^' + this.months(mom, '') + '|^' + this.monthsShort(mom, ''); this._monthsParse[i] = new RegExp(regex.replace('.', ''), 'i'); } @@ -470,7 +760,9 @@ }, isPM : function (input) { - return ((input + '').toLowerCase()[0] === 'p'); + // IE8 Quirks Mode & IE7 Standards Mode do not allow accessing strings like arrays + // Using charAt should be more compatible. + return ((input + '').toLowerCase().charAt(0) === 'p'); }, _meridiemParse : /[ap]\.?m?\.?/i, @@ -537,11 +829,17 @@ week : function (mom) { return weekOfYear(mom, this._week.dow, this._week.doy).week; }, + _week : { dow : 0, // Sunday is the first day of the week. doy : 6 // The week that contains Jan 1st is the first week of the year. + }, + + _invalidDate: 'Invalid date', + invalidDate: function () { + return this._invalidDate; } - }; + }); // Loads a language definition into the `languages` cache. The function // takes a key and optionally values. If not in the browser and no values @@ -556,6 +854,11 @@ return languages[key]; } + // Remove a language from the `languages` cache. Mostly useful in tests. + function unloadLang(key) { + delete languages[key]; + } + // Determines which language definition to use and returns it. // // With no parameters, it will return the global language. If you @@ -563,20 +866,52 @@ // definition for 'en', so long as 'en' has already been loaded using // moment.lang. function getLangDefinition(key) { + var i = 0, j, lang, next, split, + get = function (k) { + if (!languages[k] && hasModule) { + try { + require('./lang/' + k); + } catch (e) { } + } + return languages[k]; + }; + if (!key) { return moment.fn._lang; } - if (!languages[key] && hasModule) { - try { - require('./lang/' + key); - } catch (e) { - // call with no params to set to default - return moment.fn._lang; + + if (!isArray(key)) { + //short-circuit everything else + lang = get(key); + if (lang) { + return lang; } + key = [key]; } - return languages[key]; - } + //pick the language from the array + //try ['en-au', 'en-gb'] as 'en-au', 'en-gb', 'en', as in move through the list trying each + //substring from most specific to least, but move to the next array item if it's a more specific variant than the current root + while (i < key.length) { + split = normalizeLanguage(key[i]).split('-'); + j = split.length; + next = normalizeLanguage(key[i + 1]); + next = next ? next.split('-') : null; + while (j > 0) { + lang = get(split.slice(0, j).join('-')); + if (lang) { + return lang; + } + if (next && next.length >= j && compareArrays(split, next, true) >= j - 1) { + //the next array item is better than a shallower substring of this one + break; + } + j--; + } + i++; + } + return moment.fn._lang; + } /************************************ Formatting @@ -584,7 +919,7 @@ function removeFormattingTokens(input) { - if (input.match(/\[.*\]/)) { + if (input.match(/\[[\s\S]/)) { return input.replace(/^\[|\]$/g, ""); } return input.replace(/\\/g, ""); @@ -612,15 +947,12 @@ // format date using native date object function formatMoment(m, format) { - var i = 5; - function replaceLongDateFormatTokens(input) { - return m.lang().longDateFormat(input) || input; + if (!m.isValid()) { + return m.lang().invalidDate(); } - while (i-- && localFormattingTokens.test(format)) { - format = format.replace(localFormattingTokens, replaceLongDateFormatTokens); - } + format = expandFormat(format, m.lang()); if (!formatFunctions[format]) { formatFunctions[format] = makeFormatFunction(format); @@ -629,6 +961,23 @@ return formatFunctions[format](m); } + function expandFormat(format, lang) { + var i = 5; + + function replaceLongDateFormatTokens(input) { + return lang.longDateFormat(input) || input; + } + + localFormattingTokens.lastIndex = 0; + while (i >= 0 && localFormattingTokens.test(format)) { + format = format.replace(localFormattingTokens, replaceLongDateFormatTokens); + localFormattingTokens.lastIndex = 0; + i -= 1; + } + + return format; + } + /************************************ Parsing @@ -637,16 +986,34 @@ // get the regex to find the next token function getParseRegexForToken(token, config) { + var a, strict = config._strict; switch (token) { + case 'Q': + return parseTokenOneDigit; case 'DDDD': return parseTokenThreeDigits; case 'YYYY': - return parseTokenFourDigits; + case 'GGGG': + case 'gggg': + return strict ? parseTokenFourDigits : parseTokenOneToFourDigits; + case 'Y': + case 'G': + case 'g': + return parseTokenSignedNumber; + case 'YYYYYY': case 'YYYYY': - return parseTokenSixDigits; + case 'GGGGG': + case 'ggggg': + return strict ? parseTokenSixDigits : parseTokenOneToSixDigits; case 'S': + if (strict) { return parseTokenOneDigit; } + /* falls through */ case 'SS': + if (strict) { return parseTokenTwoDigits; } + /* falls through */ case 'SSS': + if (strict) { return parseTokenThreeDigits; } + /* falls through */ case 'DDD': return parseTokenOneToThreeDigits; case 'MMM': @@ -665,13 +1032,20 @@ return parseTokenTimezone; case 'T': return parseTokenT; + case 'SSSS': + return parseTokenDigits; case 'MM': case 'DD': case 'YY': + case 'GG': + case 'gg': case 'HH': case 'hh': case 'mm': case 'ss': + case 'ww': + case 'WW': + return strict ? parseTokenTwoDigits : parseTokenOneOrTwoDigits; case 'M': case 'D': case 'd': @@ -679,16 +1053,25 @@ case 'h': case 'm': case 's': + case 'w': + case 'W': + case 'e': + case 'E': return parseTokenOneOrTwoDigits; + case 'Do': + return parseTokenOrdinal; default : - return new RegExp(token.replace('\\', '')); + a = new RegExp(regexpEscape(unescapeFormat(token.replace('\\', '')), "i")); + return a; } } function timezoneMinutesFromString(string) { - var tzchunk = (parseTokenTimezone.exec(string) || [])[0], - parts = (tzchunk + '').match(parseTimezoneChunker) || ['-', 0, 0], - minutes = +(parts[1] * 60) + ~~parts[2]; + string = string || ""; + var possibleTzMatches = (string.match(parseTokenTimezone) || []), + tzChunk = possibleTzMatches[possibleTzMatches.length - 1] || [], + parts = (tzChunk + '').match(parseTimezoneChunker) || ['-', 0, 0], + minutes = +(parts[1] * 60) + toInt(parts[2]); return parts[0] === '+' ? -minutes : minutes; } @@ -698,37 +1081,57 @@ var a, datePartArray = config._a; switch (token) { + // QUARTER + case 'Q': + if (input != null) { + datePartArray[MONTH] = (toInt(input) - 1) * 3; + } + break; // MONTH case 'M' : // fall through to MM case 'MM' : - datePartArray[1] = (input == null) ? 0 : ~~input - 1; + if (input != null) { + datePartArray[MONTH] = toInt(input) - 1; + } break; case 'MMM' : // fall through to MMMM case 'MMMM' : a = getLangDefinition(config._l).monthsParse(input); // if we didn't find a month name, mark the date as invalid. if (a != null) { - datePartArray[1] = a; + datePartArray[MONTH] = a; } else { - config._isValid = false; + config._pf.invalidMonth = input; } break; // DAY OF MONTH - case 'D' : // fall through to DDDD - case 'DD' : // fall through to DDDD + case 'D' : // fall through to DD + case 'DD' : + if (input != null) { + datePartArray[DATE] = toInt(input); + } + break; + case 'Do' : + if (input != null) { + datePartArray[DATE] = toInt(parseInt(input, 10)); + } + break; + // DAY OF YEAR case 'DDD' : // fall through to DDDD case 'DDDD' : if (input != null) { - datePartArray[2] = ~~input; + config._dayOfYear = toInt(input); } + break; // YEAR case 'YY' : - datePartArray[0] = ~~input + (~~input > 68 ? 1900 : 2000); + datePartArray[YEAR] = moment.parseTwoDigitYear(input); break; case 'YYYY' : case 'YYYYY' : - datePartArray[0] = ~~input; + case 'YYYYYY' : + datePartArray[YEAR] = toInt(input); break; // AM / PM case 'a' : // fall through to A @@ -740,23 +1143,24 @@ case 'HH' : // fall through to hh case 'h' : // fall through to hh case 'hh' : - datePartArray[3] = ~~input; + datePartArray[HOUR] = toInt(input); break; // MINUTE case 'm' : // fall through to mm case 'mm' : - datePartArray[4] = ~~input; + datePartArray[MINUTE] = toInt(input); break; // SECOND case 's' : // fall through to ss case 'ss' : - datePartArray[5] = ~~input; + datePartArray[SECOND] = toInt(input); break; // MILLISECOND case 'S' : case 'SS' : case 'SSS' : - datePartArray[6] = ~~ (('0.' + input) * 1000); + case 'SSSS' : + datePartArray[MILLISECOND] = toInt(('0.' + input) * 1000); break; // UNIX TIMESTAMP WITH MS case 'X': @@ -768,137 +1172,330 @@ config._useUTC = true; config._tzm = timezoneMinutesFromString(input); break; + // WEEKDAY - human + case 'dd': + case 'ddd': + case 'dddd': + a = getLangDefinition(config._l).weekdaysParse(input); + // if we didn't get a weekday name, mark the date as invalid + if (a != null) { + config._w = config._w || {}; + config._w['d'] = a; + } else { + config._pf.invalidWeekday = input; + } + break; + // WEEK, WEEK DAY - numeric + case 'w': + case 'ww': + case 'W': + case 'WW': + case 'd': + case 'e': + case 'E': + token = token.substr(0, 1); + /* falls through */ + case 'gggg': + case 'GGGG': + case 'GGGGG': + token = token.substr(0, 2); + if (input) { + config._w = config._w || {}; + config._w[token] = toInt(input); + } + break; + case 'gg': + case 'GG': + config._w = config._w || {}; + config._w[token] = moment.parseTwoDigitYear(input); } + } - // if the input is null, the date is not valid - if (input == null) { - config._isValid = false; + function dayOfYearFromWeekInfo(config) { + var w, weekYear, week, weekday, dow, doy, temp, lang; + + w = config._w; + if (w.GG != null || w.W != null || w.E != null) { + dow = 1; + doy = 4; + + // TODO: We need to take the current isoWeekYear, but that depends on + // how we interpret now (local, utc, fixed offset). So create + // a now version of current config (take local/utc/offset flags, and + // create now). + weekYear = dfl(w.GG, config._a[YEAR], weekOfYear(moment(), 1, 4).year); + week = dfl(w.W, 1); + weekday = dfl(w.E, 1); + } else { + lang = getLangDefinition(config._l); + dow = lang._week.dow; + doy = lang._week.doy; + + weekYear = dfl(w.gg, config._a[YEAR], weekOfYear(moment(), dow, doy).year); + week = dfl(w.w, 1); + + if (w.d != null) { + // weekday -- low day numbers are considered next week + weekday = w.d; + if (weekday < dow) { + ++week; + } + } else if (w.e != null) { + // local weekday -- counting starts from begining of week + weekday = w.e + dow; + } else { + // default to begining of week + weekday = dow; + } } + temp = dayOfYearFromWeeks(weekYear, week, weekday, doy, dow); + + config._a[YEAR] = temp.year; + config._dayOfYear = temp.dayOfYear; } // convert an array to a date. // the array should mirror the parameters below // note: all values past the year are optional and will default to the lowest possible value. // [year, month, day , hour, minute, second, millisecond] - function dateFromArray(config) { - var i, date, input = []; + function dateFromConfig(config) { + var i, date, input = [], currentDate, yearToUse; if (config._d) { return; } - for (i = 0; i < 7; i++) { + currentDate = currentDateArray(config); + + //compute day of the year from weeks and weekdays + if (config._w && config._a[DATE] == null && config._a[MONTH] == null) { + dayOfYearFromWeekInfo(config); + } + + //if the day of the year is set, figure out what it is + if (config._dayOfYear) { + yearToUse = dfl(config._a[YEAR], currentDate[YEAR]); + + if (config._dayOfYear > daysInYear(yearToUse)) { + config._pf._overflowDayOfYear = true; + } + + date = makeUTCDate(yearToUse, 0, config._dayOfYear); + config._a[MONTH] = date.getUTCMonth(); + config._a[DATE] = date.getUTCDate(); + } + + // Default to current date. + // * if no year, month, day of month are given, default to today + // * if day of month is given, default month and year + // * if month is given, default only year + // * if year is given, don't default anything + for (i = 0; i < 3 && config._a[i] == null; ++i) { + config._a[i] = input[i] = currentDate[i]; + } + + // Zero out whatever was not defaulted, including time + for (; i < 7; i++) { config._a[i] = input[i] = (config._a[i] == null) ? (i === 2 ? 1 : 0) : config._a[i]; } - // add the offsets to the time to be parsed so that we can have a clean array for checking isValid - input[3] += ~~((config._tzm || 0) / 60); - input[4] += ~~((config._tzm || 0) % 60); + config._d = (config._useUTC ? makeUTCDate : makeDate).apply(null, input); + // Apply timezone offset from input. The actual zone can be changed + // with parseZone. + if (config._tzm != null) { + config._d.setUTCMinutes(config._d.getUTCMinutes() + config._tzm); + } + } - date = new Date(0); + function dateFromObject(config) { + var normalizedInput; + if (config._d) { + return; + } + + normalizedInput = normalizeObjectUnits(config._i); + config._a = [ + normalizedInput.year, + normalizedInput.month, + normalizedInput.day, + normalizedInput.hour, + normalizedInput.minute, + normalizedInput.second, + normalizedInput.millisecond + ]; + + dateFromConfig(config); + } + + function currentDateArray(config) { + var now = new Date(); if (config._useUTC) { - date.setUTCFullYear(input[0], input[1], input[2]); - date.setUTCHours(input[3], input[4], input[5], input[6]); + return [ + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() + ]; } else { - date.setFullYear(input[0], input[1], input[2]); - date.setHours(input[3], input[4], input[5], input[6]); + return [now.getFullYear(), now.getMonth(), now.getDate()]; } - - config._d = date; } // date from string and format string function makeDateFromStringAndFormat(config) { - // This array is used to make a Date, either with `new Date` or `Date.UTC` - var tokens = config._f.match(formattingTokens), - string = config._i, - i, parsedInput; + + if (config._f === moment.ISO_8601) { + parseISO(config); + return; + } config._a = []; + config._pf.empty = true; + + // This array is used to make a Date, either with `new Date` or `Date.UTC` + var lang = getLangDefinition(config._l), + string = '' + config._i, + i, parsedInput, tokens, token, skipped, + stringLength = string.length, + totalParsedInputLength = 0; + + tokens = expandFormat(config._f, lang).match(formattingTokens) || []; for (i = 0; i < tokens.length; i++) { - parsedInput = (getParseRegexForToken(tokens[i], config).exec(string) || [])[0]; + token = tokens[i]; + parsedInput = (string.match(getParseRegexForToken(token, config)) || [])[0]; if (parsedInput) { + skipped = string.substr(0, string.indexOf(parsedInput)); + if (skipped.length > 0) { + config._pf.unusedInput.push(skipped); + } string = string.slice(string.indexOf(parsedInput) + parsedInput.length); + totalParsedInputLength += parsedInput.length; + } + // don't parse if it's not a known token + if (formatTokenFunctions[token]) { + if (parsedInput) { + config._pf.empty = false; + } + else { + config._pf.unusedTokens.push(token); + } + addTimeToArrayFromToken(token, parsedInput, config); } - // don't parse if its not a known token - if (formatTokenFunctions[tokens[i]]) { - addTimeToArrayFromToken(tokens[i], parsedInput, config); + else if (config._strict && !parsedInput) { + config._pf.unusedTokens.push(token); } } - // add remaining unparsed input to the string - if (string) { - config._il = string; + // add remaining unparsed input length to the string + config._pf.charsLeftOver = stringLength - totalParsedInputLength; + if (string.length > 0) { + config._pf.unusedInput.push(string); } // handle am pm - if (config._isPm && config._a[3] < 12) { - config._a[3] += 12; + if (config._isPm && config._a[HOUR] < 12) { + config._a[HOUR] += 12; } // if is 12 am, change hours to 0 - if (config._isPm === false && config._a[3] === 12) { - config._a[3] = 0; + if (config._isPm === false && config._a[HOUR] === 12) { + config._a[HOUR] = 0; } - // return - dateFromArray(config); + + dateFromConfig(config); + checkOverflow(config); + } + + function unescapeFormat(s) { + return s.replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, function (matched, p1, p2, p3, p4) { + return p1 || p2 || p3 || p4; + }); + } + + // Code from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript + function regexpEscape(s) { + return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); } // date from string and array of format strings function makeDateFromStringAndArray(config) { var tempConfig, - tempMoment, bestMoment, - scoreToBeat = 99, + scoreToBeat, i, currentScore; + if (config._f.length === 0) { + config._pf.invalidFormat = true; + config._d = new Date(NaN); + return; + } + for (i = 0; i < config._f.length; i++) { + currentScore = 0; tempConfig = extend({}, config); + tempConfig._pf = defaultParsingFlags(); tempConfig._f = config._f[i]; makeDateFromStringAndFormat(tempConfig); - tempMoment = new Moment(tempConfig); - - currentScore = compareArrays(tempConfig._a, tempMoment.toArray()); - // if there is any input that was not parsed - // add a penalty for that format - if (tempMoment._il) { - currentScore += tempMoment._il.length; + if (!isValid(tempConfig)) { + continue; } - if (currentScore < scoreToBeat) { + // if there is any input that was not parsed add a penalty for that format + currentScore += tempConfig._pf.charsLeftOver; + + //or tokens + currentScore += tempConfig._pf.unusedTokens.length * 10; + + tempConfig._pf.score = currentScore; + + if (scoreToBeat == null || currentScore < scoreToBeat) { scoreToBeat = currentScore; - bestMoment = tempMoment; + bestMoment = tempConfig; } } - extend(config, bestMoment); + extend(config, bestMoment || tempConfig); } // date from iso format - function makeDateFromString(config) { - var i, + function parseISO(config) { + var i, l, string = config._i, match = isoRegex.exec(string); if (match) { - // match[2] should be "T" or undefined - config._f = 'YYYY-MM-DD' + (match[2] || " "); - for (i = 0; i < 4; i++) { + config._pf.iso = true; + for (i = 0, l = isoDates.length; i < l; i++) { + if (isoDates[i][1].exec(string)) { + // match[5] should be "T" or undefined + config._f = isoDates[i][0] + (match[6] || " "); + break; + } + } + for (i = 0, l = isoTimes.length; i < l; i++) { if (isoTimes[i][1].exec(string)) { config._f += isoTimes[i][0]; break; } } - if (parseTokenTimezone.exec(string)) { - config._f += " Z"; + if (string.match(parseTokenTimezone)) { + config._f += "Z"; } makeDateFromStringAndFormat(config); } else { - config._d = new Date(string); + config._isValid = false; + } + } + + // date from iso format or fallback + function makeDateFromString(config) { + parseISO(config); + if (config._isValid === false) { + delete config._isValid; + moment.createFromInputFallback(config); } } @@ -914,12 +1511,53 @@ makeDateFromString(config); } else if (isArray(input)) { config._a = input.slice(0); - dateFromArray(config); + dateFromConfig(config); + } else if (isDate(input)) { + config._d = new Date(+input); + } else if (typeof(input) === 'object') { + dateFromObject(config); + } else if (typeof(input) === 'number') { + // from milliseconds + config._d = new Date(input); } else { - config._d = input instanceof Date ? new Date(+input) : new Date(input); + moment.createFromInputFallback(config); + } + } + + function makeDate(y, m, d, h, M, s, ms) { + //can't just apply() to create a date: + //http://stackoverflow.com/questions/181348/instantiating-a-javascript-object-by-calling-prototype-constructor-apply + var date = new Date(y, m, d, h, M, s, ms); + + //the date constructor doesn't accept years < 1970 + if (y < 1970) { + date.setFullYear(y); } + return date; } + function makeUTCDate(y) { + var date = new Date(Date.UTC.apply(null, arguments)); + if (y < 1970) { + date.setUTCFullYear(y); + } + return date; + } + + function parseWeekday(input, language) { + if (typeof input === 'string') { + if (!isNaN(input)) { + input = parseInt(input, 10); + } + else { + input = language.weekdaysParse(input); + if (typeof input !== 'number') { + return null; + } + } + } + return input; + } /************************************ Relative Time @@ -937,15 +1575,15 @@ hours = round(minutes / 60), days = round(hours / 24), years = round(days / 365), - args = seconds < 45 && ['s', seconds] || + args = seconds < relativeTimeThresholds.s && ['s', seconds] || minutes === 1 && ['m'] || - minutes < 45 && ['mm', minutes] || + minutes < relativeTimeThresholds.m && ['mm', minutes] || hours === 1 && ['h'] || - hours < 22 && ['hh', hours] || + hours < relativeTimeThresholds.h && ['hh', hours] || days === 1 && ['d'] || - days <= 25 && ['dd', days] || - days <= 45 && ['M'] || - days < 345 && ['MM', round(days / 30)] || + days <= relativeTimeThresholds.dd && ['dd', days] || + days <= relativeTimeThresholds.dm && ['M'] || + days < relativeTimeThresholds.dy && ['MM', round(days / 30)] || years === 1 && ['y'] || ['yy', years]; args[2] = withoutSuffix; args[3] = milliseconds > 0; @@ -987,6 +1625,20 @@ }; } + //http://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday + function dayOfYearFromWeeks(year, week, weekday, firstDayOfWeekOfYear, firstDayOfWeek) { + var d = makeUTCDate(year, 0, 1).getUTCDay(), daysToAdd, dayOfYear; + + d = d === 0 ? 7 : d; + weekday = weekday != null ? weekday : firstDayOfWeek; + daysToAdd = firstDayOfWeek - d + (d > firstDayOfWeekOfYear ? 7 : 0) - (d < firstDayOfWeek ? 7 : 0); + dayOfYear = 7 * (week - 1) + (weekday - firstDayOfWeek) + daysToAdd + 1; + + return { + year: dayOfYear > 0 ? year : year - 1, + dayOfYear: dayOfYear > 0 ? dayOfYear : daysInYear(year - 1) + dayOfYear + }; + } /************************************ Top Level Functions @@ -996,8 +1648,8 @@ var input = config._i, format = config._f; - if (input === null || input === '') { - return null; + if (input === null || (format === undefined && input === '')) { + return moment.invalid({nullInput: true}); } if (typeof input === 'string') { @@ -1005,7 +1657,8 @@ } if (moment.isMoment(input)) { - config = extend({}, input); + config = cloneMoment(input); + config._d = new Date(+input._d); } else if (format) { if (isArray(format)) { @@ -1020,24 +1673,93 @@ return new Moment(config); } - moment = function (input, format, lang) { - return makeMoment({ - _i : input, - _f : format, - _l : lang, - _isUTC : false - }); + moment = function (input, format, lang, strict) { + var c; + + if (typeof(lang) === "boolean") { + strict = lang; + lang = undefined; + } + // object construction must be done this way. + // https://github.com/moment/moment/issues/1423 + c = {}; + c._isAMomentObject = true; + c._i = input; + c._f = format; + c._l = lang; + c._strict = strict; + c._isUTC = false; + c._pf = defaultParsingFlags(); + + return makeMoment(c); + }; + + moment.suppressDeprecationWarnings = false; + + moment.createFromInputFallback = deprecate( + "moment construction falls back to js Date. This is " + + "discouraged and will be removed in upcoming major " + + "release. Please refer to " + + "https://github.com/moment/moment/issues/1407 for more info.", + function (config) { + config._d = new Date(config._i); + }); + + // Pick a moment m from moments so that m[fn](other) is true for all + // other. This relies on the function fn to be transitive. + // + // moments should either be an array of moment objects or an array, whose + // first element is an array of moment objects. + function pickBy(fn, moments) { + var res, i; + if (moments.length === 1 && isArray(moments[0])) { + moments = moments[0]; + } + if (!moments.length) { + return moment(); + } + res = moments[0]; + for (i = 1; i < moments.length; ++i) { + if (moments[i][fn](res)) { + res = moments[i]; + } + } + return res; + } + + moment.min = function () { + var args = [].slice.call(arguments, 0); + + return pickBy('isBefore', args); + }; + + moment.max = function () { + var args = [].slice.call(arguments, 0); + + return pickBy('isAfter', args); }; // creating with utc - moment.utc = function (input, format, lang) { - return makeMoment({ - _useUTC : true, - _isUTC : true, - _l : lang, - _i : input, - _f : format - }); + moment.utc = function (input, format, lang, strict) { + var c; + + if (typeof(lang) === "boolean") { + strict = lang; + lang = undefined; + } + // object construction must be done this way. + // https://github.com/moment/moment/issues/1423 + c = {}; + c._isAMomentObject = true; + c._useUTC = true; + c._isUTC = true; + c._l = lang; + c._i = input; + c._f = format; + c._strict = strict; + c._pf = defaultParsingFlags(); + + return makeMoment(c).utc(); }; // creating with unix timestamp (in seconds) @@ -1047,34 +1769,60 @@ // duration moment.duration = function (input, key) { - var isDuration = moment.isDuration(input), - isNumber = (typeof input === 'number'), - duration = (isDuration ? input._input : (isNumber ? {} : input)), - matched = aspNetTimeSpanJsonRegex.exec(input), + var duration = input, + // matching against regexp is expensive, do it on demand + match = null, sign, - ret; + ret, + parseIso; - if (isNumber) { + if (moment.isDuration(input)) { + duration = { + ms: input._milliseconds, + d: input._days, + M: input._months + }; + } else if (typeof input === 'number') { + duration = {}; if (key) { duration[key] = input; } else { duration.milliseconds = input; } - } else if (matched) { - sign = (matched[1] === "-") ? -1 : 1; + } else if (!!(match = aspNetTimeSpanJsonRegex.exec(input))) { + sign = (match[1] === "-") ? -1 : 1; duration = { y: 0, - d: ~~matched[2] * sign, - h: ~~matched[3] * sign, - m: ~~matched[4] * sign, - s: ~~matched[5] * sign, - ms: ~~matched[6] * sign + d: toInt(match[DATE]) * sign, + h: toInt(match[HOUR]) * sign, + m: toInt(match[MINUTE]) * sign, + s: toInt(match[SECOND]) * sign, + ms: toInt(match[MILLISECOND]) * sign + }; + } else if (!!(match = isoDurationRegex.exec(input))) { + sign = (match[1] === "-") ? -1 : 1; + parseIso = function (inp) { + // We'd normally use ~~inp for this, but unfortunately it also + // converts floats to ints. + // inp may be undefined, so careful calling replace on it. + var res = inp && parseFloat(inp.replace(',', '.')); + // apply sign while we're at it + return (isNaN(res) ? 0 : res) * sign; + }; + duration = { + y: parseIso(match[2]), + M: parseIso(match[3]), + d: parseIso(match[4]), + h: parseIso(match[5]), + m: parseIso(match[6]), + s: parseIso(match[7]), + w: parseIso(match[8]) }; } ret = new Duration(duration); - if (isDuration && input.hasOwnProperty('_lang')) { + if (moment.isDuration(input) && input.hasOwnProperty('_lang')) { ret._lang = input._lang; } @@ -1087,23 +1835,44 @@ // default format moment.defaultFormat = isoFormat; + // constant that refers to the ISO standard + moment.ISO_8601 = function () {}; + + // Plugins that add properties should also add the key here (null value), + // so we can properly clone ourselves. + moment.momentProperties = momentProperties; + // This function will be called whenever a moment is mutated. // It is intended to keep the offset in sync with the timezone. moment.updateOffset = function () {}; + // This function allows you to set a threshold for relative time strings + moment.relativeTimeThreshold = function(threshold, limit) { + if (relativeTimeThresholds[threshold] === undefined) { + return false; + } + relativeTimeThresholds[threshold] = limit; + return true; + }; + // This function will load languages and then set the global language. If // no arguments are passed in, it will simply return the current global // language key. moment.lang = function (key, values) { + var r; if (!key) { return moment.fn._lang._abbr; } if (values) { - loadLang(key, values); + loadLang(normalizeLanguage(key), values); + } else if (values === null) { + unloadLang(key); + key = 'en'; } else if (!languages[key]) { getLangDefinition(key); } - moment.duration.fn._lang = moment.fn._lang = getLangDefinition(key); + r = moment.duration.fn._lang = moment.fn._lang = getLangDefinition(key); + return r._abbr; }; // returns language data @@ -1116,7 +1885,8 @@ // compare moment object moment.isMoment = function (obj) { - return obj instanceof Moment; + return obj instanceof Moment || + (obj != null && obj.hasOwnProperty('_isAMomentObject')); }; // for typechecking Duration objects @@ -1124,13 +1894,40 @@ return obj instanceof Duration; }; + for (i = lists.length - 1; i >= 0; --i) { + makeList(lists[i]); + } + + moment.normalizeUnits = function (units) { + return normalizeUnits(units); + }; + + moment.invalid = function (flags) { + var m = moment.utc(NaN); + if (flags != null) { + extend(m._pf, flags); + } + else { + m._pf.userInvalidated = true; + } + + return m; + }; + + moment.parseZone = function () { + return moment.apply(null, arguments).parseZone(); + }; + + moment.parseTwoDigitYear = function (input) { + return toInt(input) + (toInt(input) > 68 ? 1900 : 2000); + }; /************************************ Moment Prototype ************************************/ - moment.fn = Moment.prototype = { + extend(moment.fn = Moment.prototype, { clone : function () { return moment(this); @@ -1145,7 +1942,7 @@ }, toString : function () { - return this.format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ"); + return this.clone().lang('en').format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ"); }, toDate : function () { @@ -1153,7 +1950,12 @@ }, toISOString : function () { - return formatMoment(moment(this).utc(), 'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]'); + var m = moment(this).utc(); + if (0 < m.year() && m.year() <= 9999) { + return formatMoment(m, 'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]'); + } else { + return formatMoment(m, 'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]'); + } }, toArray : function () { @@ -1170,14 +1972,24 @@ }, isValid : function () { - if (this._isValid == null) { - if (this._a) { - this._isValid = !compareArrays(this._a, (this._isUTC ? moment.utc(this._a) : moment(this._a)).toArray()); - } else { - this._isValid = !isNaN(this._d.getTime()); - } + return isValid(this); + }, + + isDSTShifted : function () { + + if (this._a) { + return this.isValid() && compareArrays(this._a, (this._isUTC ? moment.utc(this._a) : moment(this._a)).toArray()) > 0; } - return !!this._isValid; + + return false; + }, + + parsingFlags : function () { + return extend({}, this._pf); + }, + + invalidAt: function () { + return this._pf.overflow; }, utc : function () { @@ -1198,7 +2010,9 @@ add : function (input, val) { var dur; // switch args to support add('s', 1) and add(1, 's') - if (typeof input === 'string') { + if (typeof input === 'string' && typeof val === 'string') { + dur = moment.duration(isNaN(+val) ? +input : +val, isNaN(+val) ? val : input); + } else if (typeof input === 'string') { dur = moment.duration(+val, input); } else { dur = moment.duration(input, val); @@ -1210,7 +2024,9 @@ subtract : function (input, val) { var dur; // switch args to support subtract('s', 1) and subtract(1, 's') - if (typeof input === 'string') { + if (typeof input === 'string' && typeof val === 'string') { + dur = moment.duration(isNaN(+val) ? +input : +val, isNaN(+val) ? val : input); + } else if (typeof input === 'string') { dur = moment.duration(+val, input); } else { dur = moment.duration(input, val); @@ -1220,7 +2036,7 @@ }, diff : function (input, units, asFloat) { - var that = this._isUTC ? moment(input).zone(this._offset || 0) : moment(input).local(), + var that = makeAs(input, this), zoneDiff = (this.zone() - that.zone()) * 6e4, diff, output; @@ -1261,20 +2077,23 @@ return this.from(moment(), withoutSuffix); }, - calendar : function () { - var diff = this.diff(moment().startOf('day'), 'days', true), + calendar : function (time) { + // We want to compare the start of today, vs this. + // Getting start-of-today depends on whether we're zone'd or not. + var now = time || moment(), + sod = makeAs(now, this).startOf('day'), + diff = this.diff(sod, 'days', true), format = diff < -6 ? 'sameElse' : - diff < -1 ? 'lastWeek' : - diff < 0 ? 'lastDay' : - diff < 1 ? 'sameDay' : - diff < 2 ? 'nextDay' : - diff < 7 ? 'nextWeek' : 'sameElse'; + diff < -1 ? 'lastWeek' : + diff < 0 ? 'lastDay' : + diff < 1 ? 'sameDay' : + diff < 2 ? 'nextDay' : + diff < 7 ? 'nextWeek' : 'sameElse'; return this.format(this.lang().calendar(format, this)); }, isLeapYear : function () { - var year = this.year(); - return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + return isLeapYear(this.year()); }, isDST : function () { @@ -1285,42 +2104,14 @@ day : function (input) { var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay(); if (input != null) { - if (typeof input === 'string') { - input = this.lang().weekdaysParse(input); - if (typeof input !== 'number') { - return this; - } - } + input = parseWeekday(input, this.lang()); return this.add({ d : input - day }); } else { return day; } }, - month : function (input) { - var utc = this._isUTC ? 'UTC' : '', - dayOfMonth, - daysInMonth; - - if (input != null) { - if (typeof input === 'string') { - input = this.lang().monthsParse(input); - if (typeof input !== 'number') { - return this; - } - } - - dayOfMonth = this.date(); - this.date(1); - this._d['set' + utc + 'Month'](input); - this.date(Math.min(dayOfMonth, this.daysInMonth())); - - moment.updateOffset(this); - return this; - } else { - return this._d['get' + utc + 'Month'](); - } - }, + month : makeAccessor('Month', true), startOf: function (units) { units = normalizeUnits(units); @@ -1330,10 +2121,12 @@ case 'year': this.month(0); /* falls through */ + case 'quarter': case 'month': this.date(1); /* falls through */ case 'week': + case 'isoWeek': case 'day': this.hours(0); /* falls through */ @@ -1351,13 +2144,21 @@ // weeks are a special case if (units === 'week') { this.weekday(0); + } else if (units === 'isoWeek') { + this.isoWeekday(1); + } + + // quarters are also special + if (units === 'quarter') { + this.month(Math.floor(this.month() / 3) * 3); } return this; }, endOf: function (units) { - return this.startOf(units).add(units, 1).subtract('ms', 1); + units = normalizeUnits(units); + return this.startOf(units).add((units === 'isoWeek' ? 'week' : units), 1).subtract('ms', 1); }, isAfter: function (input, units) { @@ -1371,21 +2172,37 @@ }, isSame: function (input, units) { - units = typeof units !== 'undefined' ? units : 'millisecond'; - return +this.clone().startOf(units) === +moment(input).startOf(units); - }, - - min: function (other) { - other = moment.apply(null, arguments); - return other < this ? this : other; - }, - - max: function (other) { - other = moment.apply(null, arguments); - return other > this ? this : other; - }, - - zone : function (input) { + units = units || 'ms'; + return +this.clone().startOf(units) === +makeAs(input, this).startOf(units); + }, + + min: deprecate( + "moment().min is deprecated, use moment.min instead. https://github.com/moment/moment/issues/1548", + function (other) { + other = moment.apply(null, arguments); + return other < this ? this : other; + } + ), + + max: deprecate( + "moment().max is deprecated, use moment.max instead. https://github.com/moment/moment/issues/1548", + function (other) { + other = moment.apply(null, arguments); + return other > this ? this : other; + } + ), + + // keepTime = true means only change the timezone, without affecting + // the local hour. So 5:31:26 +0300 --[zone(2, true)]--> 5:31:26 +0200 + // It is possible that 5:31:26 doesn't exist int zone +0200, so we + // adjust the time as needed, to be valid. + // + // Keeping the time actually adds/subtracts (one hour) + // from the actual represented time. That is why we call updateOffset + // a second time. In case it wants us to change the offset again + // _changeInProgress == true case, then we have to adjust, because + // there is no such time in the given timezone. + zone : function (input, keepTime) { var offset = this._offset || 0; if (input != null) { if (typeof input === "string") { @@ -1397,7 +2214,14 @@ this._offset = input; this._isUTC = true; if (offset !== input) { - addOrSubtractDurationFromMoment(this, moment.duration(offset - input, 'm'), 1, true); + if (!keepTime || this._changeInProgress) { + addOrSubtractDurationFromMoment(this, + moment.duration(offset - input, 'm'), 1, false); + } else if (!this._changeInProgress) { + this._changeInProgress = true; + moment.updateOffset(this, true); + this._changeInProgress = null; + } } } else { return this._isUTC ? offset : this._d.getTimezoneOffset(); @@ -1413,8 +2237,28 @@ return this._isUTC ? "Coordinated Universal Time" : ""; }, + parseZone : function () { + if (this._tzm) { + this.zone(this._tzm); + } else if (typeof this._i === 'string') { + this.zone(this._i); + } + return this; + }, + + hasAlignedHourOffset : function (input) { + if (!input) { + input = 0; + } + else { + input = moment(input).zone(); + } + + return (this.zone() - input) % 60 === 0; + }, + daysInMonth : function () { - return moment.utc([this.year(), this.month() + 1, 0]).date(); + return daysInMonth(this.year(), this.month()); }, dayOfYear : function (input) { @@ -1422,6 +2266,10 @@ return input == null ? dayOfYear : this.add("d", (input - dayOfYear)); }, + quarter : function (input) { + return input == null ? Math.ceil((this.month() + 1) / 3) : this.month((input - 1) * 3 + this.month() % 3); + }, + weekYear : function (input) { var year = weekOfYear(this, this.lang()._week.dow, this.lang()._week.doy).year; return input == null ? year : this.add("y", (input - year)); @@ -1443,7 +2291,7 @@ }, weekday : function (input) { - var weekday = (this._d.getDay() + 7 - this.lang()._week.dow) % 7; + var weekday = (this.day() + 7 - this.lang()._week.dow) % 7; return input == null ? weekday : this.add("d", input - weekday); }, @@ -1454,6 +2302,28 @@ return input == null ? this.day() || 7 : this.day(this.day() % 7 ? input : input - 7); }, + isoWeeksInYear : function () { + return weeksInYear(this.year(), 1, 4); + }, + + weeksInYear : function () { + var weekInfo = this._lang._week; + return weeksInYear(this.year(), weekInfo.dow, weekInfo.doy); + }, + + get : function (units) { + units = normalizeUnits(units); + return this[units](); + }, + + set : function (units, value) { + units = normalizeUnits(units); + if (typeof this[units] === 'function') { + this[units](value); + } + return this; + }, + // If passed a language key, it will set the language for this // instance. Otherwise, it will return the language configuration // variables for this instance. @@ -1465,35 +2335,70 @@ return this; } } - }; + }); - // helper for adding shortcuts - function makeGetterAndSetter(name, key) { - moment.fn[name] = moment.fn[name + 's'] = function (input) { - var utc = this._isUTC ? 'UTC' : ''; - if (input != null) { - this._d['set' + utc + key](input); - moment.updateOffset(this); + function rawMonthSetter(mom, value) { + var dayOfMonth; + + // TODO: Move this out of here! + if (typeof value === 'string') { + value = mom.lang().monthsParse(value); + // TODO: Another silent failure? + if (typeof value !== 'number') { + return mom; + } + } + + dayOfMonth = Math.min(mom.date(), + daysInMonth(mom.year(), value)); + mom._d['set' + (mom._isUTC ? 'UTC' : '') + 'Month'](value, dayOfMonth); + return mom; + } + + function rawGetter(mom, unit) { + return mom._d['get' + (mom._isUTC ? 'UTC' : '') + unit](); + } + + function rawSetter(mom, unit, value) { + if (unit === 'Month') { + return rawMonthSetter(mom, value); + } else { + return mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](value); + } + } + + function makeAccessor(unit, keepTime) { + return function (value) { + if (value != null) { + rawSetter(this, unit, value); + moment.updateOffset(this, keepTime); return this; } else { - return this._d['get' + utc + key](); + return rawGetter(this, unit); } }; } - // loop through and add shortcuts (Month, Date, Hours, Minutes, Seconds, Milliseconds) - for (i = 0; i < proxyGettersAndSetters.length; i ++) { - makeGetterAndSetter(proxyGettersAndSetters[i].toLowerCase().replace(/s$/, ''), proxyGettersAndSetters[i]); - } - - // add shortcut for year (uses different syntax than the getter/setter 'year' == 'FullYear') - makeGetterAndSetter('year', 'FullYear'); + moment.fn.millisecond = moment.fn.milliseconds = makeAccessor('Milliseconds', false); + moment.fn.second = moment.fn.seconds = makeAccessor('Seconds', false); + moment.fn.minute = moment.fn.minutes = makeAccessor('Minutes', false); + // Setting the hour should keep the time, because the user explicitly + // specified which hour he wants. So trying to maintain the same hour (in + // a new timezone) makes sense. Adding/subtracting hours does not follow + // this rule. + moment.fn.hour = moment.fn.hours = makeAccessor('Hours', true); + // moment.fn.month is defined separately + moment.fn.date = makeAccessor('Date', true); + moment.fn.dates = deprecate("dates accessor is deprecated. Use date instead.", makeAccessor('Date', true)); + moment.fn.year = makeAccessor('FullYear', true); + moment.fn.years = deprecate("years accessor is deprecated. Use year instead.", makeAccessor('FullYear', true)); // add plural methods moment.fn.days = moment.fn.day; moment.fn.months = moment.fn.month; moment.fn.weeks = moment.fn.week; moment.fn.isoWeeks = moment.fn.isoWeek; + moment.fn.quarters = moment.fn.quarter; // add aliased format methods moment.fn.toJSON = moment.fn.toISOString; @@ -1503,7 +2408,8 @@ ************************************/ - moment.duration.fn = Duration.prototype = { + extend(moment.duration.fn = Duration.prototype, { + _bubble : function () { var milliseconds = this._milliseconds, days = this._days, @@ -1542,7 +2448,7 @@ return this._milliseconds + this._days * 864e5 + (this._months % 12) * 2592e6 + - ~~(this._months / 12) * 31536e6; + toInt(this._months / 12) * 31536e6; }, humanize : function (withSuffix) { @@ -1591,8 +2497,34 @@ return this['as' + units.charAt(0).toUpperCase() + units.slice(1) + 's'](); }, - lang : moment.fn.lang - }; + lang : moment.fn.lang, + + toIsoString : function () { + // inspired by https://github.com/dordille/moment-isoduration/blob/master/moment.isoduration.js + var years = Math.abs(this.years()), + months = Math.abs(this.months()), + days = Math.abs(this.days()), + hours = Math.abs(this.hours()), + minutes = Math.abs(this.minutes()), + seconds = Math.abs(this.seconds() + this.milliseconds() / 1000); + + if (!this.asSeconds()) { + // this is the same as C#'s (Noda) and python (isodate)... + // but not other JS (goog.date) + return 'P0D'; + } + + return (this.asSeconds() < 0 ? '-' : '') + + 'P' + + (years ? years + 'Y' : '') + + (months ? months + 'M' : '') + + (days ? days + 'D' : '') + + ((hours || minutes || seconds) ? 'T' : '') + + (hours ? hours + 'H' : '') + + (minutes ? minutes + 'M' : '') + + (seconds ? seconds + 'S' : ''); + } + }); function makeDurationGetter(name) { moment.duration.fn[name] = function () { @@ -1628,7 +2560,7 @@ moment.lang('en', { ordinal : function (number) { var b = number % 10, - output = (~~ (number % 100 / 10) === 1) ? 'th' : + output = (toInt(number % 100 / 10) === 1) ? 'th' : (b === 1) ? 'st' : (b === 2) ? 'nd' : (b === 3) ? 'rd' : 'th'; @@ -1636,27 +2568,43 @@ } }); + /* EMBED_LANGUAGES */ /************************************ Exposing Moment ************************************/ + function makeGlobal(shouldDeprecate) { + /*global ender:false */ + if (typeof ender !== 'undefined') { + return; + } + oldGlobalMoment = globalScope.moment; + if (shouldDeprecate) { + globalScope.moment = deprecate( + "Accessing Moment through the global scope is " + + "deprecated, and will be removed in an upcoming " + + "release.", + moment); + } else { + globalScope.moment = moment; + } + } // CommonJS module is defined if (hasModule) { module.exports = moment; - } - /*global ender:false */ - if (typeof ender === 'undefined') { - // here, `this` means `window` in the browser, or `global` on the server - // add `moment` as a global object via a string identifier, - // for Closure Compiler "advanced" mode - this['moment'] = moment; - } - /*global define:false */ - if (typeof define === "function" && define.amd) { - define("moment", [], function () { + } else if (typeof define === "function" && define.amd) { + define("moment", function (require, exports, module) { + if (module.config && module.config() && module.config().noGlobal === true) { + // release the global variable + globalScope.moment = oldGlobalMoment; + } + return moment; }); + makeGlobal(true); + } else { + makeGlobal(); } }).call(this); diff --git a/src/UI/Series/Details/SeasonLayout.js b/src/UI/Series/Details/SeasonLayout.js index 0a11225fb..cafec8d23 100644 --- a/src/UI/Series/Details/SeasonLayout.js +++ b/src/UI/Series/Details/SeasonLayout.js @@ -26,7 +26,7 @@ define( EpisodeNumberCell, EpisodeWarningCell, CommandController, - Moment, + moment, _, Messenger) { return Marionette.Layout.extend({ @@ -213,15 +213,15 @@ define( }, _shouldShowEpisodes: function () { - var startDate = Moment().add('month', -1); - var endDate = Moment().add('year', 1); + var startDate = moment().add('month', -1); + var endDate = moment().add('year', 1); return this.episodeCollection.some(function (episode) { var airDate = episode.get('airDateUtc'); if (airDate) { - var airDateMoment = Moment(airDate); + var airDateMoment = moment(airDate); if (airDateMoment.isAfter(startDate) && airDateMoment.isBefore(endDate)) { return true; @@ -235,7 +235,7 @@ define( templateHelpers: function () { var episodeCount = this.episodeCollection.filter(function (episode) { - return episode.get('hasFile') || (episode.get('monitored') && Moment(episode.get('airDateUtc')).isBefore(Moment())); + return episode.get('hasFile') || (episode.get('monitored') && moment(episode.get('airDateUtc')).isBefore(moment())); }).length; var episodeFileCount = this.episodeCollection.where({ hasFile: true }).length; diff --git a/src/UI/Series/Index/Overview/SeriesOverviewItemViewTemplate.html b/src/UI/Series/Index/Overview/SeriesOverviewItemViewTemplate.html index 1eb317ace..809d63c8f 100644 --- a/src/UI/Series/Index/Overview/SeriesOverviewItemViewTemplate.html +++ b/src/UI/Series/Index/Overview/SeriesOverviewItemViewTemplate.html @@ -37,7 +37,7 @@
{{#if_eq status compare="continuing"}} {{#if nextAiring}} - {{NextAiring nextAiring}} + {{RelativeDate nextAiring}} {{/if}} {{else}} Ended diff --git a/src/UI/Series/Index/Posters/SeriesPostersItemViewTemplate.html b/src/UI/Series/Index/Posters/SeriesPostersItemViewTemplate.html index f1b82fc52..ce8253e48 100644 --- a/src/UI/Series/Index/Posters/SeriesPostersItemViewTemplate.html +++ b/src/UI/Series/Index/Posters/SeriesPostersItemViewTemplate.html @@ -22,7 +22,7 @@
{{#if_eq status compare="continuing"}} {{#if nextAiring}} - {{NextAiring nextAiring}} + {{RelativeDate nextAiring}} {{/if}} {{/if_eq}} {{> EpisodeProgressPartial }} diff --git a/src/UI/Series/Index/SeriesIndexLayout.js b/src/UI/Series/Index/SeriesIndexLayout.js index d66c79bdf..ab6c1cd55 100644 --- a/src/UI/Series/Index/SeriesIndexLayout.js +++ b/src/UI/Series/Index/SeriesIndexLayout.js @@ -65,9 +65,9 @@ define( cell : 'integer' }, { - name : 'profileId', - label: 'Profile', - cell : ProfileCell + name : 'profileId', + label : 'Profile', + cell : ProfileCell }, { name : 'network', diff --git a/src/UI/Series/SeriesCollection.js b/src/UI/Series/SeriesCollection.js index bc4183eb7..eb0b3f269 100644 --- a/src/UI/Series/SeriesCollection.js +++ b/src/UI/Series/SeriesCollection.js @@ -10,7 +10,7 @@ define( 'Mixins/AsSortedCollection', 'Mixins/AsPersistedStateCollection', 'moment' - ], function (_, Backbone, PageableCollection, SeriesModel, SeriesData, AsFilteredCollection, AsSortedCollection, AsPersistedStateCollection, Moment) { + ], function (_, Backbone, PageableCollection, SeriesModel, SeriesData, AsFilteredCollection, AsSortedCollection, AsPersistedStateCollection, moment) { var Collection = PageableCollection.extend({ url : window.NzbDrone.ApiRoot + '/series', model: SeriesModel, @@ -63,13 +63,13 @@ define( var nextAiring = model.get(attr); if (nextAiring) { - return Moment(nextAiring).unix(); + return moment(nextAiring).unix(); } var previousAiring = model.get(attr.replace('nextAiring', 'previousAiring')); if (previousAiring) { - return 10000000000 - Moment(previousAiring).unix(); + return 10000000000 - moment(previousAiring).unix(); } return Number.MAX_VALUE; diff --git a/src/UI/Settings/SettingsLayout.js b/src/UI/Settings/SettingsLayout.js index a346e915a..8d39eaaa1 100644 --- a/src/UI/Settings/SettingsLayout.js +++ b/src/UI/Settings/SettingsLayout.js @@ -21,6 +21,8 @@ define( 'Settings/Notifications/NotificationCollection', 'Settings/Metadata/MetadataLayout', 'Settings/General/GeneralView', + 'Settings/UI/UiView', + 'Settings/UI/UiSettingsModel', 'Shared/LoadingView', 'Config' ], function ($, @@ -43,6 +45,8 @@ define( NotificationCollection, MetadataLayout, GeneralView, + UiView, + UiSettingsModel, LoadingView, Config) { return Marionette.Layout.extend({ @@ -57,6 +61,7 @@ define( notifications : '#notifications', metadata : '#metadata', general : '#general', + uiRegion : '#ui', loading : '#loading-region' }, @@ -69,6 +74,7 @@ define( notificationsTab : '.x-notifications-tab', metadataTab : '.x-metadata-tab', generalTab : '.x-general-tab', + uiTab : '.x-ui-tab', advancedSettings : '.x-advanced-settings' }, @@ -81,6 +87,7 @@ define( 'click .x-notifications-tab' : '_showNotifications', 'click .x-metadata-tab' : '_showMetadata', 'click .x-general-tab' : '_showGeneral', + 'click .x-ui-tab' : '_showUi', 'click .x-save-settings' : '_save', 'change .x-advanced-settings' : '_toggleAdvancedSettings' }, @@ -103,6 +110,7 @@ define( this.downloadClientSettings = new DownloadClientSettingsModel(); this.notificationCollection = new NotificationCollection(); this.generalSettings = new GeneralSettingsModel(); + this.uiSettings = new UiSettingsModel(); Backbone.$.when( this.mediaManagementSettings.fetch(), @@ -110,7 +118,8 @@ define( this.indexerSettings.fetch(), this.downloadClientSettings.fetch(), this.notificationCollection.fetch(), - this.generalSettings.fetch() + this.generalSettings.fetch(), + this.uiSettings.fetch() ).done(function () { if(!self.isClosed) { @@ -123,6 +132,7 @@ define( self.notifications.show(new NotificationCollectionView({ collection: self.notificationCollection })); self.metadata.show(new MetadataLayout()); self.general.show(new GeneralView({ model: self.generalSettings })); + self.uiRegion.show(new UiView({ model: self.uiSettings })); } }); @@ -155,6 +165,9 @@ define( case 'general': this._showGeneral(); break; + case 'ui': + this._showUi(); + break; default: this._showMediaManagement(); } @@ -232,6 +245,15 @@ define( this._navigate('settings/general'); }, + _showUi: function (e) { + if (e) { + e.preventDefault(); + } + + this.ui.uiTab.tab('show'); + this._navigate('settings/ui'); + }, + _navigate:function(route){ Backbone.history.navigate(route, { trigger: false, replace: true }); }, diff --git a/src/UI/Settings/SettingsLayoutTemplate.html b/src/UI/Settings/SettingsLayoutTemplate.html index a1deab4da..7d37ff78c 100644 --- a/src/UI/Settings/SettingsLayoutTemplate.html +++ b/src/UI/Settings/SettingsLayoutTemplate.html @@ -7,6 +7,7 @@
  • Connect
  • General
  • +
  • UI
  • @@ -42,6 +43,7 @@
    +
    \ No newline at end of file diff --git a/src/UI/Settings/UI/UiSettingsModel.js b/src/UI/Settings/UI/UiSettingsModel.js new file mode 100644 index 000000000..4d40b3dae --- /dev/null +++ b/src/UI/Settings/UI/UiSettingsModel.js @@ -0,0 +1,12 @@ +'use strict'; +define( + [ + 'Settings/SettingsModelBase' + ], function (SettingsModelBase) { + return SettingsModelBase.extend({ + + url : window.NzbDrone.ApiRoot + '/config/ui', + successMessage: 'UI settings saved', + errorMessage : 'Failed to save UI settings' + }); + }); diff --git a/src/UI/Settings/UI/UiView.js b/src/UI/Settings/UI/UiView.js new file mode 100644 index 000000000..b251d0cb1 --- /dev/null +++ b/src/UI/Settings/UI/UiView.js @@ -0,0 +1,27 @@ +'use strict'; +define( + [ + 'vent', + 'marionette', + 'Shared/UiSettingsModel', + 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView' + ], function (vent, Marionette, UiSettingsModel, AsModelBoundView, AsValidatedView) { + var view = Marionette.ItemView.extend({ + template: 'Settings/UI/UiViewTemplate', + + initialize: function () { + this.listenTo(this.model, 'sync', this._reloadUiSettings); + }, + + _reloadUiSettings: function() { + UiSettingsModel.fetch(); + } + }); + + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; + }); + diff --git a/src/UI/Settings/UI/UiViewTemplate.html b/src/UI/Settings/UI/UiViewTemplate.html new file mode 100644 index 000000000..291e8610f --- /dev/null +++ b/src/UI/Settings/UI/UiViewTemplate.html @@ -0,0 +1,95 @@ +
    +
    + Calendar + +
    + + +
    + +
    +
    + +
    + + +
    + +
    + +
    + +
    +
    +
    + +
    + Dates + +
    + + +
    + +
    +
    + +
    + + +
    + +
    +
    + +
    + + +
    + +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    diff --git a/src/UI/Settings/settings.less b/src/UI/Settings/settings.less index fdaab3e3d..34f503e7a 100644 --- a/src/UI/Settings/settings.less +++ b/src/UI/Settings/settings.less @@ -109,6 +109,10 @@ li.save-and-add:hover { } .settings-tabs { + li>a { + padding : 10px; + } + @media (min-width: @screen-sm-min) and (max-width: @screen-md-max) { li { a { diff --git a/src/UI/Shared/FormatHelpers.js b/src/UI/Shared/FormatHelpers.js index aaf51b1e1..7b1e35417 100644 --- a/src/UI/Shared/FormatHelpers.js +++ b/src/UI/Shared/FormatHelpers.js @@ -3,8 +3,9 @@ define( [ 'moment', - 'filesize' - ], function (Moment, Filesize) { + 'filesize', + 'Shared/UiSettingsModel' + ], function (moment, filesize, UiSettings) { return { @@ -15,16 +16,15 @@ define( return ''; } - return Filesize(size, { base: 2, round: 1 }); + return filesize(size, { base: 2, round: 1 }); }, - dateHelper: function (sourceDate) { + relativeDate: function (sourceDate) { if (!sourceDate) { return ''; } - var date = Moment(sourceDate); - + var date = moment(sourceDate); var calendarDate = date.calendar(); //TODO: It would be nice to not have to hack this... @@ -34,12 +34,12 @@ define( return strippedCalendarDate; } - if (date.isAfter(Moment())) { + if (date.isAfter(moment())) { return date.fromNow(true); } - if (date.isBefore(Moment().add('years', -1))) { - return date.format('ll'); + if (date.isBefore(moment().add('years', -1))) { + return date.format(UiSettings.get('shortDateFormat')); } return date.fromNow(); diff --git a/src/UI/Shared/UiSettingsModel.js b/src/UI/Shared/UiSettingsModel.js new file mode 100644 index 000000000..5d9cda590 --- /dev/null +++ b/src/UI/Shared/UiSettingsModel.js @@ -0,0 +1,22 @@ +'use strict'; +define( + [ + 'backbone', + 'api!config/ui' + ], function (Backbone, uiSettings) { + var UiSettings = Backbone.Model.extend({ + + url : window.NzbDrone.ApiRoot + '/config/ui', + + shortDateTime : function () { + return this.get('shortDateFormat') + ' ' + this.get('timeFormat').replace('(', '').replace(')', ''); + }, + + longDateTime : function () { + return this.get('longDateFormat') + ' ' + this.get('timeFormat').replace('(', '').replace(')', ''); + } + }); + + var instance = new UiSettings(uiSettings); + return instance; + }); diff --git a/src/UI/System/Logs/Table/LogTimeCell.js b/src/UI/System/Logs/Table/LogTimeCell.js index dbabc1246..49f082c27 100644 --- a/src/UI/System/Logs/Table/LogTimeCell.js +++ b/src/UI/System/Logs/Table/LogTimeCell.js @@ -2,16 +2,17 @@ define( [ 'Cells/NzbDroneCell', - 'moment' - ], function (NzbDroneCell, Moment) { + 'moment', + 'Shared/UiSettingsModel' + ], function (NzbDroneCell, moment, UiSettings) { return NzbDroneCell.extend({ className: 'log-time-cell', render: function () { - var date = Moment(this._getValue()); - this.$el.html('{0}'.format(date.format('LT'), date.format('LLLL'))); + var date = moment(this._getValue()); + this.$el.html('{0}'.format(date.format(UiSettings.get('timeFormat')), date.format(UiSettings.longDateFormat()))); return this; } diff --git a/src/UI/app.js b/src/UI/app.js index a0878855c..cdec34b7c 100644 --- a/src/UI/app.js +++ b/src/UI/app.js @@ -199,7 +199,6 @@ require.config({ headerCell: 'NzbDrone', sortType : 'toggle' }; - }); } }, @@ -247,7 +246,19 @@ define( 'Instrumentation/StringFormat', 'LifeCycle', 'Hotkeys/Hotkeys' - ], function ($, Backbone, Marionette, RouteBinder, SignalRBroadcaster, NavbarView, AppLayout, SeriesController, Router, ModalController, ControlPanelController, serverStatusModel, Tooltip) { + ], function ($, + Backbone, + Marionette, + RouteBinder, + SignalRBroadcaster, + NavbarView, + AppLayout, + SeriesController, + Router, + ModalController, + ControlPanelController, + serverStatusModel, + Tooltip) { new SeriesController(); new ModalController();