diff --git a/src/NzbDrone.Api/Calendar/CalendarFeedModule.cs b/src/NzbDrone.Api/Calendar/CalendarFeedModule.cs new file mode 100644 index 000000000..3f05eb36c --- /dev/null +++ b/src/NzbDrone.Api/Calendar/CalendarFeedModule.cs @@ -0,0 +1,66 @@ +using Nancy; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using DDay.iCal; +using NzbDrone.Core.Tv; +using Nancy.Responses; + +namespace NzbDrone.Api.Calendar +{ + public class CalendarFeedModule : NzbDroneFeedModule + { + private readonly IEpisodeService _episodeService; + + public CalendarFeedModule(IEpisodeService episodeService) + : base("calendar") + { + _episodeService = episodeService; + + Get["/NzbDrone.ics"] = options => GetCalendarFeed(); + } + + private Response GetCalendarFeed() + { + var start = DateTime.Today.AddDays(-7); + var end = DateTime.Today.AddDays(28); + + var queryStart = Request.Query.Start; + var queryEnd = Request.Query.End; + + if (queryStart.HasValue) start = DateTime.Parse(queryStart.Value); + if (queryEnd.HasValue) end = DateTime.Parse(queryEnd.Value); + + var episodes = _episodeService.EpisodesBetweenDates(start, end); + var icalCalendar = new iCalendar(); + + foreach (var episode in episodes.OrderBy(v => v.AirDateUtc.Value)) + { + var occurrence = icalCalendar.Create(); + occurrence.UID = "NzbDrone_episode_" + episode.Id.ToString(); + occurrence.Status = episode.HasFile ? EventStatus.Confirmed : EventStatus.Tentative; + occurrence.Start = new iCalDateTime(episode.AirDateUtc.Value); + occurrence.End = new iCalDateTime(episode.AirDateUtc.Value.AddMinutes(episode.Series.Runtime)); + occurrence.Description = episode.Overview; + occurrence.Categories = new List() { episode.Series.Network }; + + switch (episode.Series.SeriesType) + { + case SeriesTypes.Daily: + occurrence.Summary = string.Format("{0} - {1}", episode.Series.Title, episode.Title); + break; + + default: + occurrence.Summary = string.Format("{0} - {1}x{2:00} - {3}", episode.Series.Title, episode.SeasonNumber, episode.EpisodeNumber, episode.Title); + break; + } + } + + var serializer = new DDay.iCal.Serialization.iCalendar.SerializerFactory().Build(icalCalendar.GetType(), new DDay.iCal.Serialization.SerializationContext()) as DDay.iCal.Serialization.IStringSerializer; + var icalendar = serializer.SerializeToString(icalCalendar); + + return new TextResponse(icalendar, "text/calendar"); + } + } +} diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index 177d606df..ba75c4bfa 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -40,6 +40,9 @@ 4 + + ..\packages\DDay.iCal.1.0.2.575\lib\DDay.iCal.dll + False ..\packages\Microsoft.AspNet.SignalR.Core.1.1.3\lib\net40\Microsoft.AspNet.SignalR.Core.dll @@ -88,6 +91,7 @@ + @@ -139,6 +143,7 @@ + @@ -199,7 +204,9 @@ - + + Designer + diff --git a/src/NzbDrone.Api/NzbDroneFeedModule.cs b/src/NzbDrone.Api/NzbDroneFeedModule.cs new file mode 100644 index 000000000..d79307bef --- /dev/null +++ b/src/NzbDrone.Api/NzbDroneFeedModule.cs @@ -0,0 +1,12 @@ +using Nancy; + +namespace NzbDrone.Api +{ + public abstract class NzbDroneFeedModule : NancyModule + { + protected NzbDroneFeedModule(string resource) + : base("/feed/" + resource.Trim('/')) + { + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/packages.config b/src/NzbDrone.Api/packages.config index bc189533c..d7ee10e3a 100644 --- a/src/NzbDrone.Api/packages.config +++ b/src/NzbDrone.Api/packages.config @@ -1,5 +1,6 @@  + diff --git a/src/UI/Calendar/CalendarFeedView.js b/src/UI/Calendar/CalendarFeedView.js new file mode 100644 index 000000000..e69895af9 --- /dev/null +++ b/src/UI/Calendar/CalendarFeedView.js @@ -0,0 +1,25 @@ +'use strict'; +define( + [ + 'marionette', + 'System/StatusModel', + 'Mixins/CopyToClipboard' + ], function (Marionette, StatusModel) { + return Marionette.Layout.extend({ + template: 'Calendar/CalendarFeedViewTemplate', + + ui: { + icalUrl : '.x-ical-url', + icalCopy : '.x-ical-copy' + }, + + templateHelpers: { + icalHttpUrl : window.location.protocol + '//' + window.location.host + StatusModel.get('urlBase') + '/feed/calendar/NzbDrone.ics', + icalWebCalUrl : 'webcal://' + window.location.host + StatusModel.get('urlBase') + '/feed/calendar/NzbDrone.ics' + }, + + onShow: function () { + this.ui.icalCopy.copyToClipboard(this.ui.icalUrl); + } + }); + }); diff --git a/src/UI/Calendar/CalendarFeedViewTemplate.html b/src/UI/Calendar/CalendarFeedViewTemplate.html new file mode 100644 index 000000000..4c4d8c0d9 --- /dev/null +++ b/src/UI/Calendar/CalendarFeedViewTemplate.html @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/src/UI/Calendar/CalendarLayout.js b/src/UI/Calendar/CalendarLayout.js index 31d9e4a7f..e2772c3f5 100644 --- a/src/UI/Calendar/CalendarLayout.js +++ b/src/UI/Calendar/CalendarLayout.js @@ -1,10 +1,12 @@ 'use strict'; define( [ + 'AppLayout', 'marionette', 'Calendar/UpcomingCollectionView', - 'Calendar/CalendarView' - ], function (Marionette, UpcomingCollectionView, CalendarView) { + 'Calendar/CalendarView', + 'Calendar/CalendarFeedView' + ], function (AppLayout, Marionette, UpcomingCollectionView, CalendarView, CalendarFeedView) { return Marionette.Layout.extend({ template: 'Calendar/CalendarLayoutTemplate', @@ -12,6 +14,10 @@ define( upcoming: '#x-upcoming', calendar: '#x-calendar' }, + + events: { + 'click .x-ical': '_showiCal' + }, onShow: function () { this._showUpcoming(); @@ -24,6 +30,11 @@ define( _showCalendar: function () { this.calendar.show(new CalendarView()); + }, + + _showiCal: function () { + var view = new CalendarFeedView(); + AppLayout.modalRegion.show(view); } }); }); diff --git a/src/UI/Calendar/CalendarLayoutTemplate.html b/src/UI/Calendar/CalendarLayoutTemplate.html index 37ea4276e..0df20bd10 100644 --- a/src/UI/Calendar/CalendarLayoutTemplate.html +++ b/src/UI/Calendar/CalendarLayoutTemplate.html @@ -1,6 +1,13 @@ 
-

Upcoming

+
+

Upcoming

+
+
+

+ +

+
diff --git a/src/UI/Calendar/CalendarView.js b/src/UI/Calendar/CalendarView.js index c21e0d488..d0ed7902b 100644 --- a/src/UI/Calendar/CalendarView.js +++ b/src/UI/Calendar/CalendarView.js @@ -8,25 +8,24 @@ define( 'Calendar/Collection', 'System/StatusModel', 'History/Queue/QueueCollection', + 'Config', 'Mixins/backbone.signalr.mixin', 'fullcalendar', 'jquery.easypiechart' - ], function (vent, Marionette, moment, CalendarCollection, StatusModel, QueueCollection) { - - var _instance; + ], function (vent, Marionette, moment, CalendarCollection, StatusModel, QueueCollection, Config) { return Marionette.ItemView.extend({ + storageKey: 'calendar.view', + initialize: function () { this.collection = new CalendarCollection().bindSignalR({ updateOnly: true }); this.listenTo(this.collection, 'change', this._reloadCalendarEvents); this.listenTo(QueueCollection, 'sync', this._reloadCalendarEvents); }, - render : function () { - - var self = this; + render : function () { this.$el.empty().fullCalendar({ - defaultView : 'basicWeek', + defaultView : Config.getValue(this.storageKey, 'basicWeek'), allDayDefault : false, ignoreTimezone: false, weekMode : 'variable', @@ -41,54 +40,62 @@ define( prev: '', next: '' }, - viewRender : this._getEvents, - eventRender : function (event, element) { - self.$(element).addClass(event.statusLevel); - self.$(element).children('.fc-event-inner').addClass(event.statusLevel); - - if (event.progress > 0) { - self.$(element).find('.fc-event-time') - .after(''.format(event.progress)); - - self.$(element).find('.chart').easyPieChart({ - barColor : '#ffffff', - trackColor: false, - scaleColor: false, - lineWidth : 2, - size : 14, - animate : false - }); - } - }, + viewRender : this._viewRender.bind(this), + eventRender : this._eventRender.bind(this), eventClick : function (event) { vent.trigger(vent.Commands.ShowEpisodeDetails, {episode: event.model}); } }); - - _instance = this; }, onShow: function () { this.$('.fc-button-today').click(); }, + _viewRender: function (view) { + if (Config.getValue(this.storageKey) !== view.name) { + Config.setValue(this.storageKey, view.name); + } + + this._getEvents(view); + }, + + _eventRender: function (event, element) { + 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 + }); + } + }, + _getEvents: function (view) { var start = moment(view.visStart).toISOString(); var end = moment(view.visEnd).toISOString(); - _instance.$el.fullCalendar('removeEvents'); + this.$el.fullCalendar('removeEvents'); - _instance.collection.fetch({ + this.collection.fetch({ data : { start: start, end: end }, - success: function (collection) { - _instance._setEventData(collection); - } + success: this._setEventData.bind(this) }); }, _setEventData: function (collection) { var events = []; + var self = this; + collection.each(function (model) { var seriesTitle = model.get('series').title; var start = model.get('airDateUtc'); @@ -100,15 +107,15 @@ define( start : start, end : end, allDay : false, - statusLevel : _instance._getStatusLevel(model, end), - progress : _instance._getDownloadProgress(model), + statusLevel : self._getStatusLevel(model, end), + progress : self._getDownloadProgress(model), model : model }; events.push(event); }); - _instance.$el.fullCalendar('addEventSource', events); + this.$el.fullCalendar('addEventSource', events); }, _getStatusLevel: function (element, endTime) { diff --git a/src/UI/Calendar/calendar.less b/src/UI/Calendar/calendar.less index 39a5e284a..430de3e0a 100644 --- a/src/UI/Calendar/calendar.less +++ b/src/UI/Calendar/calendar.less @@ -158,3 +158,17 @@ margin-right: 2px; } } + +.ical +{ + color: @btnInverseBackground; + cursor: pointer; +} + +.ical-url { + + input { + width : 440px; + cursor : text; + } +} diff --git a/src/UI/index.html b/src/UI/index.html index c5ab40f60..7fe301c61 100644 --- a/src/UI/index.html +++ b/src/UI/index.html @@ -23,6 +23,8 @@ + + diff --git a/src/UI/jQuery/RouteBinder.js b/src/UI/jQuery/RouteBinder.js index a67077a07..f4b541102 100644 --- a/src/UI/jQuery/RouteBinder.js +++ b/src/UI/jQuery/RouteBinder.js @@ -29,17 +29,21 @@ define( return; } - event.preventDefault(); - var href = event.target.getAttribute('href'); if (!href && $target.closest('a') && $target.closest('a')[0]) { var linkElement = $target.closest('a')[0]; + if ($(linkElement).hasClass('no-router')) { + return; + } + href = linkElement.getAttribute('href'); } + event.preventDefault(); + if (!href) { throw 'couldn\'t find route target'; }