diff --git a/PlexRequests.Api.Interfaces/IPushbulletApi.cs b/PlexRequests.Api.Interfaces/IPushbulletApi.cs index 647280aaf..5df902d4c 100644 --- a/PlexRequests.Api.Interfaces/IPushbulletApi.cs +++ b/PlexRequests.Api.Interfaces/IPushbulletApi.cs @@ -24,6 +24,8 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion +using System.Threading.Tasks; + using PlexRequests.Api.Models.Notifications; namespace PlexRequests.Api.Interfaces @@ -38,6 +40,6 @@ namespace PlexRequests.Api.Interfaces /// The message. /// The device identifier. /// - PushbulletResponse Push(string accessToken, string title, string message, string deviceIdentifier = default(string)); + Task PushAsync(string accessToken, string title, string message, string deviceIdentifier = default(string)); } } \ No newline at end of file diff --git a/PlexRequests.Api.Interfaces/IPushoverApi.cs b/PlexRequests.Api.Interfaces/IPushoverApi.cs new file mode 100644 index 000000000..15f93f596 --- /dev/null +++ b/PlexRequests.Api.Interfaces/IPushoverApi.cs @@ -0,0 +1,37 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: IPushoverApi.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System.Threading.Tasks; + +using PlexRequests.Api.Models.Notifications; + +namespace PlexRequests.Api.Interfaces +{ + public interface IPushoverApi + { + Task PushAsync(string accessToken, string message, string userToken); + } +} \ No newline at end of file diff --git a/PlexRequests.Api.Interfaces/ISickRageApi.cs b/PlexRequests.Api.Interfaces/ISickRageApi.cs index 516b8784f..4953b16f6 100644 --- a/PlexRequests.Api.Interfaces/ISickRageApi.cs +++ b/PlexRequests.Api.Interfaces/ISickRageApi.cs @@ -26,15 +26,18 @@ #endregion using System; +using System.Threading.Tasks; using PlexRequests.Api.Models.SickRage; namespace PlexRequests.Api.Interfaces { public interface ISickRageApi { - SickRageTvAdd AddSeries(int tvdbId, bool latest, string quality, string apiKey, + Task AddSeries(int tvdbId, int seasoncount, int[] seasons, string quality, string apiKey, Uri baseUrl); SickRagePing Ping(string apiKey, Uri baseUrl); + + Task AddSeason(int tvdbId, int season, string apiKey, Uri baseUrl); } } \ No newline at end of file diff --git a/PlexRequests.Api.Interfaces/ISonarrApi.cs b/PlexRequests.Api.Interfaces/ISonarrApi.cs index d9e7c61db..e4dce0c3a 100644 --- a/PlexRequests.Api.Interfaces/ISonarrApi.cs +++ b/PlexRequests.Api.Interfaces/ISonarrApi.cs @@ -36,7 +36,7 @@ namespace PlexRequests.Api.Interfaces List GetProfiles(string apiKey, Uri baseUrl); SonarrAddSeries AddSeries(int tvdbId, string title, int qualityId, bool seasonFolders, string rootPath, - bool episodes, string apiKey, Uri baseUrl); + int seasonCount, int[] seasons, string apiKey, Uri baseUrl); SystemStatus SystemStatus(string apiKey, Uri baseUrl); } diff --git a/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj b/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj index 3ea7be621..7522c8156 100644 --- a/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj +++ b/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj @@ -49,6 +49,7 @@ + diff --git a/PlexRequests.Api.Models/Notifications/PushoverResponse.cs b/PlexRequests.Api.Models/Notifications/PushoverResponse.cs new file mode 100644 index 000000000..94849fba1 --- /dev/null +++ b/PlexRequests.Api.Models/Notifications/PushoverResponse.cs @@ -0,0 +1,34 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: PushoverResponse.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +namespace PlexRequests.Api.Models.Notifications +{ + public class PushoverResponse + { + public int status { get; set; } + public string request { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj b/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj index bd3151c55..1ec1a67ae 100644 --- a/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj +++ b/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj @@ -50,6 +50,7 @@ + @@ -59,6 +60,7 @@ + @@ -66,6 +68,7 @@ + diff --git a/PlexRequests.Api.Models/SickRage/SickRageShowInformation.cs b/PlexRequests.Api.Models/SickRage/SickRageShowInformation.cs new file mode 100644 index 000000000..8076008a5 --- /dev/null +++ b/PlexRequests.Api.Models/SickRage/SickRageShowInformation.cs @@ -0,0 +1,85 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: SickRageShowInformation.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion + +using System.Collections.Generic; + +namespace PlexRequests.Api.Models.SickRage +{ + public class Cache + { + public int banner { get; set; } + public int poster { get; set; } + } + + public class QualityDetails + { + public List archive { get; set; } + public List initial { get; set; } + } + + public class SeasonList + { + } + + public class Data + { + public int air_by_date { get; set; } + public string airs { get; set; } + public int anime { get; set; } + public int archive_firstmatch { get; set; } + public Cache cache { get; set; } + public int dvdorder { get; set; } + public int flatten_folders { get; set; } + public List genre { get; set; } + public string imdbid { get; set; } + public int indexerid { get; set; } + public string language { get; set; } + public string location { get; set; } + public string network { get; set; } + public string next_ep_airdate { get; set; } + public int paused { get; set; } + public string quality { get; set; } + public QualityDetails quality_details { get; set; } + public List rls_ignore_words { get; set; } + public List rls_require_words { get; set; } + public int scene { get; set; } + public SeasonList season_list { get; set; } + public string show_name { get; set; } + public int sports { get; set; } + public string status { get; set; } + public int subtitles { get; set; } + public int tvdbid { get; set; } + } + + public class SickRageShowInformation + { + public Data data { get; set; } + public string message { get; set; } + public string result { get; set; } + } + +} \ No newline at end of file diff --git a/PlexRequests.Api.Models/Tv/TVMazeShow.cs b/PlexRequests.Api.Models/Tv/TVMazeShow.cs index 165658602..faebd6d84 100644 --- a/PlexRequests.Api.Models/Tv/TVMazeShow.cs +++ b/PlexRequests.Api.Models/Tv/TVMazeShow.cs @@ -23,5 +23,6 @@ namespace PlexRequests.Api.Models.Tv public string summary { get; set; } public int updated { get; set; } public Links _links { get; set; } + public int seasonCount { get; set; } } } \ No newline at end of file diff --git a/PlexRequests.Api.Models/Tv/TvMazeSeasons.cs b/PlexRequests.Api.Models/Tv/TvMazeSeasons.cs new file mode 100644 index 000000000..bce31cea5 --- /dev/null +++ b/PlexRequests.Api.Models/Tv/TvMazeSeasons.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PlexRequests.Api.Models.Tv +{ + public class TvMazeSeasons : TvMazeShow + { + public int number { get; set; } + } +} diff --git a/PlexRequests.Api/Mocks/MockSonarrApi.cs b/PlexRequests.Api/Mocks/MockSonarrApi.cs index 8508d5a13..dfb816f8a 100644 --- a/PlexRequests.Api/Mocks/MockSonarrApi.cs +++ b/PlexRequests.Api/Mocks/MockSonarrApi.cs @@ -43,7 +43,7 @@ namespace PlexRequests.Api.Mocks return obj; } - public SonarrAddSeries AddSeries(int tvdbId, string title, int qualityId, bool seasonFolders, string rootPath, bool episodes, + public SonarrAddSeries AddSeries(int tvdbId, string title, int qualityId, bool seasonFolders, string rootPath, int seasonCount, int[] seasons, string apiKey, Uri baseUrl) { var json = MockApiData.Sonarr_AddSeriesResult; diff --git a/PlexRequests.Api/PlexRequests.Api.csproj b/PlexRequests.Api/PlexRequests.Api.csproj index 2f204c975..6422dfd6f 100644 --- a/PlexRequests.Api/PlexRequests.Api.csproj +++ b/PlexRequests.Api/PlexRequests.Api.csproj @@ -72,6 +72,7 @@ MockApiData.resx + diff --git a/PlexRequests.Api/PushbulletApi.cs b/PlexRequests.Api/PushbulletApi.cs index 2c542348d..1e399e048 100644 --- a/PlexRequests.Api/PushbulletApi.cs +++ b/PlexRequests.Api/PushbulletApi.cs @@ -25,6 +25,7 @@ // ************************************************************************/ #endregion using System; +using System.Threading.Tasks; using PlexRequests.Api.Interfaces; using PlexRequests.Api.Models.Notifications; @@ -35,7 +36,7 @@ namespace PlexRequests.Api { public class PushbulletApi : IPushbulletApi { - public PushbulletResponse Push(string accessToken, string title, string message, string deviceIdentifier = default(string)) + public async Task PushAsync(string accessToken, string title, string message, string deviceIdentifier = default(string)) { var request = new RestRequest { @@ -56,7 +57,7 @@ namespace PlexRequests.Api request.AddJsonBody(push); var api = new ApiRequest(); - return api.ExecuteJson(request, new Uri("https://api.pushbullet.com/v2/pushes")); + return await Task.Run(() => api.ExecuteJson(request, new Uri("https://api.pushbullet.com/v2/pushes"))); } } } diff --git a/PlexRequests.Api/PushoverApi.cs b/PlexRequests.Api/PushoverApi.cs new file mode 100644 index 000000000..6d109ca9b --- /dev/null +++ b/PlexRequests.Api/PushoverApi.cs @@ -0,0 +1,57 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: PlexApi.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System; +using System.Threading.Tasks; + +using PlexRequests.Api.Interfaces; +using PlexRequests.Api.Models.Notifications; + +using RestSharp; + +namespace PlexRequests.Api +{ + public class PushoverApi : IPushoverApi + { + public async Task PushAsync(string accessToken, string message, string userToken) + { + var request = new RestRequest + { + Method = Method.POST, + Resource = "messages.json?token={token}&user={user}&message={message}" + }; + + request.AddUrlSegment("token", accessToken); + request.AddUrlSegment("message", message); + request.AddUrlSegment("user", userToken); + + + var api = new ApiRequest(); + return await Task.Run(() => api.ExecuteJson(request, new Uri("https://api.pushover.net/1"))); + } + } +} + diff --git a/PlexRequests.Api/SickrageApi.cs b/PlexRequests.Api/SickrageApi.cs index f81693f5d..fc4714279 100644 --- a/PlexRequests.Api/SickrageApi.cs +++ b/PlexRequests.Api/SickrageApi.cs @@ -28,9 +28,14 @@ #endregion using System; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using NLog; using PlexRequests.Api.Interfaces; using PlexRequests.Api.Models.SickRage; +using PlexRequests.Helpers; using RestSharp; namespace PlexRequests.Api @@ -47,13 +52,11 @@ namespace PlexRequests.Api private ApiRequest Api { get; } - public SickRageTvAdd AddSeries(int tvdbId, bool latest, string quality, string apiKey, + public async Task AddSeries(int tvdbId, int seasonCount, int[] seasons, string quality, string apiKey, Uri baseUrl) { - string status; - var futureStatus = SickRageStatus.Wanted; - - status = latest ? SickRageStatus.Skipped : SickRageStatus.Wanted; + var futureStatus = seasons.Length > 0 && !seasons.Any(x => x == seasonCount) ? SickRageStatus.Skipped : SickRageStatus.Wanted; + var status = seasons.Length > 0 ? SickRageStatus.Skipped : SickRageStatus.Wanted; var request = new RestRequest { @@ -71,6 +74,38 @@ namespace PlexRequests.Api var obj = Api.Execute(request, baseUrl); + + if (obj.result != "failure") + { + var sw = new Stopwatch(); + sw.Start(); + + // Check to see if it's been added yet. + var showInfo = new SickRageShowInformation { message = "Show not found" }; + while (showInfo.message.Equals("Show not found", StringComparison.CurrentCultureIgnoreCase)) + { + showInfo = CheckShowHasBeenAdded(tvdbId, apiKey, baseUrl); + if (sw.ElapsedMilliseconds > 30000) // Break out after 30 seconds, it's not going to get added + { + Log.Warn("Couldn't find out if the show had been added after 10 seconds. I doubt we can change the status to wanted."); + break; + } + } + sw.Stop(); + } + + + if (seasons.Length > 0) + { + //handle the seasons requested + foreach (var s in seasons) + { + var result = await AddSeason(tvdbId, s, apiKey, baseUrl); + Log.Trace("SickRage adding season results: "); + Log.Trace(result.DumpJson()); + } + } + return obj; } @@ -87,5 +122,37 @@ namespace PlexRequests.Api return obj; } + + public async Task AddSeason(int tvdbId, int season, string apiKey, Uri baseUrl) + { + var request = new RestRequest + { + Resource = "/api/{apiKey}/?cmd=episode.setstatus", + Method = Method.GET + }; + request.AddUrlSegment("apiKey", apiKey); + request.AddQueryParameter("tvdbid", tvdbId.ToString()); + request.AddQueryParameter("season", season.ToString()); + request.AddQueryParameter("status", SickRageStatus.Wanted); + + await Task.Run(() => Thread.Sleep(2000)); + return await Task.Run(() => Api.Execute(request, baseUrl)).ConfigureAwait(false); + } + + + public SickRageShowInformation CheckShowHasBeenAdded(int tvdbId, string apiKey, Uri baseUrl) + { + var request = new RestRequest + { + Resource = "/api/{apiKey}/?cmd=show", + Method = Method.GET + }; + request.AddUrlSegment("apiKey", apiKey); + request.AddQueryParameter("tvdbid", tvdbId.ToString()); + + var obj = Api.Execute(request, baseUrl); + + return obj; + } } } \ No newline at end of file diff --git a/PlexRequests.Api/SonarrApi.cs b/PlexRequests.Api/SonarrApi.cs index 4ff51e2d7..1d1a57f34 100644 --- a/PlexRequests.Api/SonarrApi.cs +++ b/PlexRequests.Api/SonarrApi.cs @@ -26,7 +26,7 @@ #endregion using System; using System.Collections.Generic; - +using System.Linq; using NLog; using PlexRequests.Api.Interfaces; using PlexRequests.Api.Models.Sonarr; @@ -54,7 +54,7 @@ namespace PlexRequests.Api return obj; } - public SonarrAddSeries AddSeries(int tvdbId, string title, int qualityId, bool seasonFolders, string rootPath, bool episodes, string apiKey, Uri baseUrl) + public SonarrAddSeries AddSeries(int tvdbId, string title, int qualityId, bool seasonFolders, string rootPath, int seasonCount, int[] seasons, string apiKey, Uri baseUrl) { var request = new RestRequest @@ -63,33 +63,27 @@ namespace PlexRequests.Api Method = Method.POST }; - var options = new SonarrAddSeries(); - if (episodes == true) + var options = new SonarrAddSeries { - options.addOptions = new AddOptions - { - ignoreEpisodesWithFiles = true, - ignoreEpisodesWithoutFiles = true, - searchForMissingEpisodes = false - }; - } - else + seasonFolder = seasonFolders, + title = title, + qualityProfileId = qualityId, + tvdbId = tvdbId, + titleSlug = title, + seasons = new List(), + rootFolderPath = rootPath + }; + + + for (var i = 1; i <= seasonCount; i++) { - options.addOptions = new AddOptions + var season = new Season { - ignoreEpisodesWithFiles = false, - searchForMissingEpisodes = true, - ignoreEpisodesWithoutFiles = false + seasonNumber = i, + monitored = seasons.Length == 0 || seasons.Any(x => x == i) }; + options.seasons.Add(season); } - options.seasonFolder = seasonFolders; - options.title = title; - options.qualityProfileId = qualityId; - options.tvdbId = tvdbId; - options.titleSlug = title; - options.seasons = new List(); - options.rootFolderPath = rootPath; - request.AddHeader("X-Api-Key", apiKey); request.AddJsonBody(options); diff --git a/PlexRequests.Api/TvMazeApi.cs b/PlexRequests.Api/TvMazeApi.cs index 8b6cb9f37..6043710d7 100644 --- a/PlexRequests.Api/TvMazeApi.cs +++ b/PlexRequests.Api/TvMazeApi.cs @@ -26,7 +26,7 @@ #endregion using System; using System.Collections.Generic; - +using System.Linq; using NLog; using PlexRequests.Api.Models.Tv; @@ -79,7 +79,25 @@ namespace PlexRequests.Api request.AddUrlSegment("id", theTvDbId.ToString()); request.AddHeader("Content-Type", "application/json"); - return Api.Execute(request, new Uri(Uri)); + var obj = Api.Execute(request, new Uri(Uri)); + obj.seasonCount = GetSeasonCount(obj.id); + + return obj; + } + + public int GetSeasonCount(int id) + { + var request = new RestRequest + { + Method = Method.GET, + Resource = "shows/{id}/seasons" + }; + request.AddUrlSegment("id", id.ToString()); + request.AddHeader("Content-Type", "application/json"); + + var obj = Api.Execute>(request, new Uri(Uri)); + var seasons = obj.Select(x => x.number > 0); + return seasons.Count(); } } diff --git a/PlexRequests.Core/JsonRequestService.cs b/PlexRequests.Core/JsonRequestService.cs index 1a99569d3..1504faed6 100644 --- a/PlexRequests.Core/JsonRequestService.cs +++ b/PlexRequests.Core/JsonRequestService.cs @@ -30,8 +30,10 @@ using System.Text; using Newtonsoft.Json; +using PlexRequests.Helpers; using PlexRequests.Store; using PlexRequests.Store.Models; +using PlexRequests.Store.Repository; namespace PlexRequests.Core { @@ -44,13 +46,13 @@ namespace PlexRequests.Core private IRequestRepository Repo { get; } public long AddRequest(RequestedModel model) { - var entity = new RequestBlobs { Type = model.Type, Content = ReturnBytes(model), ProviderId = model.ProviderId }; + var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId }; var id = Repo.Insert(entity); // TODO Keep an eye on this, since we are now doing 2 DB update for 1 single request, inserting and then updating model.Id = (int)id; - entity = new RequestBlobs { Type = model.Type, Content = ReturnBytes(model), ProviderId = model.ProviderId, Id = (int)id }; + entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = (int)id }; var result = Repo.Update(entity); return result ? id : -1; @@ -70,15 +72,14 @@ namespace PlexRequests.Core public bool UpdateRequest(RequestedModel model) { - var entity = new RequestBlobs { Type = model.Type, Content = ReturnBytes(model), ProviderId = model.ProviderId, Id = model.Id }; + var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = model.Id }; return Repo.Update(entity); } public RequestedModel Get(int id) { var blob = Repo.Get(id); - var json = Encoding.UTF8.GetString(blob.Content); - var model = JsonConvert.DeserializeObject(json); + var model = ByteConverterHelper.ReturnObject(blob.Content); return model; } @@ -92,16 +93,8 @@ namespace PlexRequests.Core public bool BatchUpdate(List model) { - var entities = model.Select(m => new RequestBlobs { Type = m.Type, Content = ReturnBytes(m), ProviderId = m.ProviderId, Id = m.Id }).ToList(); + var entities = model.Select(m => new RequestBlobs { Type = m.Type, Content = ByteConverterHelper.ReturnBytes(m), ProviderId = m.ProviderId, Id = m.Id }).ToList(); return Repo.UpdateAll(entities); } - - public byte[] ReturnBytes(object obj) - { - var json = JsonConvert.SerializeObject(obj); - var bytes = Encoding.UTF8.GetBytes(json); - - return bytes; - } } } \ No newline at end of file diff --git a/PlexRequests.Core/Models/StatusModel.cs b/PlexRequests.Core/Models/StatusModel.cs index 33d8f0fe1..bea494dfe 100644 --- a/PlexRequests.Core/Models/StatusModel.cs +++ b/PlexRequests.Core/Models/StatusModel.cs @@ -31,7 +31,9 @@ namespace PlexRequests.Core.Models { public string Version { get; set; } public bool UpdateAvailable { get; set; } - public int ReleasesBehind { get; set; } public string UpdateUri { get; set; } + public string DownloadUri { get; set; } + public string ReleaseNotes { get; set; } + public string ReleaseTitle { get; set; } } } \ No newline at end of file diff --git a/PlexRequests.Core/Models/UserProperties.cs b/PlexRequests.Core/Models/UserProperties.cs new file mode 100644 index 000000000..8cd210d57 --- /dev/null +++ b/PlexRequests.Core/Models/UserProperties.cs @@ -0,0 +1,35 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: UserProperties.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +namespace PlexRequests.Core.Models +{ + public class UserProperties + { + public string EmailAddress { get; set; } + public bool NotifyOnRelease { get; set; } + public bool NotifyOnApprove { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Core/PlexRequests.Core.csproj b/PlexRequests.Core/PlexRequests.Core.csproj index e1c4ee1f3..a1e0538f7 100644 --- a/PlexRequests.Core/PlexRequests.Core.csproj +++ b/PlexRequests.Core/PlexRequests.Core.csproj @@ -72,7 +72,9 @@ + + @@ -81,7 +83,6 @@ - diff --git a/PlexRequests.Core/SettingModels/CouchPotatoSettings.cs b/PlexRequests.Core/SettingModels/CouchPotatoSettings.cs index 7c18ca5d7..030ff16b8 100644 --- a/PlexRequests.Core/SettingModels/CouchPotatoSettings.cs +++ b/PlexRequests.Core/SettingModels/CouchPotatoSettings.cs @@ -33,17 +33,24 @@ namespace PlexRequests.Core.SettingModels { public class CouchPotatoSettings : Settings { + public bool Enabled { get; set; } public string Ip { get; set; } public int Port { get; set; } public string ApiKey { get; set; } public bool Ssl { get; set; } public string ProfileId { get; set; } + public string SubDir { get; set; } [JsonIgnore] public Uri FullUri { get { + if (!string.IsNullOrEmpty(SubDir)) + { + var formattedSubDir = Ip.ReturnUriWithSubDir(Port, Ssl, SubDir); + return formattedSubDir; + } var formatted = Ip.ReturnUri(Port, Ssl); return formatted; } diff --git a/PlexRequests.Core/SettingModels/EmailNotificationSettings.cs b/PlexRequests.Core/SettingModels/EmailNotificationSettings.cs index e725b0f0c..6a151e861 100644 --- a/PlexRequests.Core/SettingModels/EmailNotificationSettings.cs +++ b/PlexRequests.Core/SettingModels/EmailNotificationSettings.cs @@ -6,6 +6,7 @@ public int EmailPort { get; set; } public bool Ssl { get; set; } public string RecipientEmail { get; set; } + public string EmailSender { get; set; } public string EmailUsername { get; set; } public string EmailPassword { get; set; } public bool Enabled { get; set; } diff --git a/PlexRequests.Core/SettingModels/PlexSettings.cs b/PlexRequests.Core/SettingModels/PlexSettings.cs index 89bc01931..fa39af857 100644 --- a/PlexRequests.Core/SettingModels/PlexSettings.cs +++ b/PlexRequests.Core/SettingModels/PlexSettings.cs @@ -36,12 +36,18 @@ namespace PlexRequests.Core.SettingModels public string Ip { get; set; } public int Port { get; set; } public bool Ssl { get; set; } + public string SubDir { get; set; } [JsonIgnore] public Uri FullUri { get { + if (!string.IsNullOrEmpty(SubDir)) + { + var formattedSubDir = Ip.ReturnUriWithSubDir(Port, Ssl, SubDir); + return formattedSubDir; + } var formatted = Ip.ReturnUri(Port, Ssl); return formatted; } diff --git a/PlexRequests.Core/SettingModels/PushoverNotificationSettings.cs b/PlexRequests.Core/SettingModels/PushoverNotificationSettings.cs new file mode 100644 index 000000000..ac6c4c435 --- /dev/null +++ b/PlexRequests.Core/SettingModels/PushoverNotificationSettings.cs @@ -0,0 +1,9 @@ +namespace PlexRequests.Core.SettingModels +{ + public class PushoverNotificationSettings : Settings + { + public bool Enabled { get; set; } + public string AccessToken { get; set; } + public string UserToken { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Core/SettingModels/SickRageSettings.cs b/PlexRequests.Core/SettingModels/SickRageSettings.cs index 344e41434..657de3b50 100644 --- a/PlexRequests.Core/SettingModels/SickRageSettings.cs +++ b/PlexRequests.Core/SettingModels/SickRageSettings.cs @@ -39,12 +39,18 @@ namespace PlexRequests.Core.SettingModels public string ApiKey { get; set; } public string QualityProfile { get; set; } public bool Ssl { get; set; } + public string SubDir { get; set; } [JsonIgnore] public Uri FullUri { get { + if (!string.IsNullOrEmpty(SubDir)) + { + var formattedSubDir = Ip.ReturnUriWithSubDir(Port, Ssl, SubDir); + return formattedSubDir; + } var formatted = Ip.ReturnUri(Port, Ssl); return formatted; } diff --git a/PlexRequests.Core/SettingModels/SonarrSettings.cs b/PlexRequests.Core/SettingModels/SonarrSettings.cs index 2c128f993..3c680e0e5 100644 --- a/PlexRequests.Core/SettingModels/SonarrSettings.cs +++ b/PlexRequests.Core/SettingModels/SonarrSettings.cs @@ -41,12 +41,18 @@ namespace PlexRequests.Core.SettingModels public bool SeasonFolders { get; set; } public string RootPath { get; set; } public bool Ssl { get; set; } + public string SubDir { get; set; } [JsonIgnore] public Uri FullUri { get { + if (!string.IsNullOrEmpty(SubDir)) + { + var formattedSubDir = Ip.ReturnUriWithSubDir(Port, Ssl, SubDir); + return formattedSubDir; + } var formatted = Ip.ReturnUri(Port, Ssl); return formatted; } diff --git a/PlexRequests.Core/SettingsServiceV2.cs b/PlexRequests.Core/SettingsServiceV2.cs index 067b3b44b..3fb53cd35 100644 --- a/PlexRequests.Core/SettingsServiceV2.cs +++ b/PlexRequests.Core/SettingsServiceV2.cs @@ -30,6 +30,7 @@ using PlexRequests.Core.SettingModels; using PlexRequests.Helpers; using PlexRequests.Store; using PlexRequests.Store.Models; +using PlexRequests.Store.Repository; namespace PlexRequests.Core { diff --git a/PlexRequests.Core/Setup.cs b/PlexRequests.Core/Setup.cs index 04ce5b9db..3163f8c56 100644 --- a/PlexRequests.Core/Setup.cs +++ b/PlexRequests.Core/Setup.cs @@ -75,7 +75,7 @@ namespace PlexRequests.Core { var result = new List(); RequestedModel[] requestedModels; - var repo = new GenericRepository(Db); + var repo = new GenericRepository(Db, new MemoryCacheProvider()); try { var records = repo.GetAll(); @@ -110,7 +110,6 @@ namespace PlexRequests.Core Available = r.Available, ImdbId = show.externals.imdb, Issues = r.Issues, - LatestTv = r.LatestTv, OtherMessage = r.OtherMessage, Overview = show.summary.RemoveHtml(), RequestedBy = r.RequestedBy, diff --git a/PlexRequests.Core/StatusChecker.cs b/PlexRequests.Core/StatusChecker.cs index 52b135312..94a745535 100644 --- a/PlexRequests.Core/StatusChecker.cs +++ b/PlexRequests.Core/StatusChecker.cs @@ -70,7 +70,11 @@ namespace PlexRequests.Core { model.UpdateAvailable = true; model.UpdateUri = latestRelease.Result.HtmlUrl; - } + } + + model.ReleaseNotes = latestRelease.Result.Body; + model.DownloadUri = latestRelease.Result.Assets[0].BrowserDownloadUrl; + model.ReleaseTitle = latestRelease.Result.Name; return model; } diff --git a/PlexRequests.Core/UserMapper.cs b/PlexRequests.Core/UserMapper.cs index 35c5c3781..a846652c5 100644 --- a/PlexRequests.Core/UserMapper.cs +++ b/PlexRequests.Core/UserMapper.cs @@ -25,12 +25,16 @@ // ************************************************************************/ #endregion using System; +using System.Collections.Generic; using System.Linq; +using System.Security; using Nancy; using Nancy.Authentication.Forms; using Nancy.Security; +using PlexRequests.Core.Models; +using PlexRequests.Helpers; using PlexRequests.Store; namespace PlexRequests.Core @@ -44,7 +48,7 @@ namespace PlexRequests.Core private static ISqliteConfiguration Db { get; set; } public IUserIdentity GetUserFromIdentifier(Guid identifier, NancyContext context) { - var repo = new UserRepository(Db); + var repo = new UserRepository(Db); var user = repo.Get(identifier.ToString()); @@ -56,40 +60,85 @@ namespace PlexRequests.Core return new UserIdentity { UserName = user.UserName, + Claims = ByteConverterHelper.ReturnObject(user.Claims) }; } public static Guid? ValidateUser(string username, string password) { - var repo = new UserRepository(Db); + var repo = new UserRepository(Db); var users = repo.GetAll(); - var userRecord = users.FirstOrDefault(u => u.UserName.Equals(username, StringComparison.InvariantCultureIgnoreCase) && u.Password.Equals(password)); // TODO hashing - if (userRecord == null) + foreach (var u in users) { - return null; + if (username == u.UserName) + { + var passwordMatch = PasswordHasher.VerifyPassword(password, u.Salt, u.Hash); + if (passwordMatch) + { + return new Guid(u.UserGuid); + } + } } - - return new Guid(userRecord.User); + return null; } public static bool DoUsersExist() { - var repo = new UserRepository(Db); + var repo = new UserRepository(Db); var users = repo.GetAll(); + return users.Any(); } - public static Guid? CreateUser(string username, string password) + public static Guid? CreateUser(string username, string password, string[] claims = default(string[])) { - var repo = new UserRepository(Db); + var repo = new UserRepository(Db); + var salt = PasswordHasher.GenerateSalt(); - var userModel = new UserModel { UserName = username, User = Guid.NewGuid().ToString(), Password = password }; + var userModel = new UsersModel + { + UserName = username, + UserGuid = Guid.NewGuid().ToString(), + Salt = salt, + Hash = PasswordHasher.ComputeHash(password, salt), + Claims = ByteConverterHelper.ReturnBytes(claims), + UserProperties = ByteConverterHelper.ReturnBytes(new UserProperties()) + }; repo.Insert(userModel); - var userRecord = repo.Get(userModel.User); - - return new Guid(userRecord.User); + var userRecord = repo.Get(userModel.UserGuid); + + return new Guid(userRecord.UserGuid); + } + + public static bool UpdatePassword(string username, string oldPassword, string newPassword) + { + var repo = new UserRepository(Db); + var users = repo.GetAll(); + var userToChange = users.FirstOrDefault(x => x.UserName == username); + if (userToChange == null) + return false; + + var passwordMatch = PasswordHasher.VerifyPassword(oldPassword, userToChange.Salt, userToChange.Hash); + if (!passwordMatch) + { + throw new SecurityException("Password does not match"); + } + + var newSalt = PasswordHasher.GenerateSalt(); + var newHash = PasswordHasher.ComputeHash(newPassword, newSalt); + + userToChange.Hash = newHash; + userToChange.Salt = newSalt; + + return repo.Update(userToChange); + } + + public static IEnumerable GetUsers() + { + var repo = new UserRepository(Db); + return repo.GetAll(); } } } diff --git a/PlexRequests.Helpers.Tests/PasswordHasherTests.cs b/PlexRequests.Helpers.Tests/PasswordHasherTests.cs new file mode 100644 index 000000000..500d07534 --- /dev/null +++ b/PlexRequests.Helpers.Tests/PasswordHasherTests.cs @@ -0,0 +1,50 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: AssemblyHelperTests.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System.Diagnostics; + +using NUnit.Framework; + +namespace PlexRequests.Helpers.Tests +{ + [TestFixture] + public class PasswordHasherTests + { + [Test] + public void TestHash() + { + var password = "abcdef"; + var salt = PasswordHasher.GenerateSalt(); + var hash = PasswordHasher.ComputeHash(password, salt); + + Assert.That(hash, Is.Not.EqualTo(password)); + + var match = PasswordHasher.VerifyPassword(password, salt, hash); + + Assert.That(match, Is.True); + } + } +} \ No newline at end of file diff --git a/PlexRequests.Helpers.Tests/PlexRequests.Helpers.Tests.csproj b/PlexRequests.Helpers.Tests/PlexRequests.Helpers.Tests.csproj index 4bacd1b68..bb9e7143b 100644 --- a/PlexRequests.Helpers.Tests/PlexRequests.Helpers.Tests.csproj +++ b/PlexRequests.Helpers.Tests/PlexRequests.Helpers.Tests.csproj @@ -70,6 +70,7 @@ + diff --git a/PlexRequests.Helpers.Tests/UriHelperTests.cs b/PlexRequests.Helpers.Tests/UriHelperTests.cs index 7c767dfd0..93fb32997 100644 --- a/PlexRequests.Helpers.Tests/UriHelperTests.cs +++ b/PlexRequests.Helpers.Tests/UriHelperTests.cs @@ -26,6 +26,7 @@ #endregion using System; +using System.Linq.Expressions; using NUnit.Framework; namespace PlexRequests.Helpers.Tests @@ -35,7 +36,7 @@ namespace PlexRequests.Helpers.Tests { [TestCaseSource(nameof(UriData))] public void CreateUri1(string uri, Uri expected) - { + { var result = uri.ReturnUri(); Assert.That(result, Is.EqualTo(expected)); @@ -58,6 +59,14 @@ namespace PlexRequests.Helpers.Tests Assert.That(result, Is.EqualTo(expected)); } + [TestCaseSource(nameof(UriDataWithSubDir))] + public void CreateUriWithSubDir(string uri, int port, bool ssl, string subDir, Uri expected) + { + var result = uri.ReturnUriWithSubDir(port, ssl, subDir); + + Assert.That(result, Is.EqualTo(expected)); + } + static readonly object[] UriData = { new object[] { "google.com", new Uri("http://google.com/"), }, @@ -84,5 +93,13 @@ namespace PlexRequests.Helpers.Tests new object[] {"http://www.google.com/id=2", 443, new Uri("http://www.google.com:443/id=2") }, new object[] {"https://www.google.com/id=2", 443, new Uri("https://www.google.com:443/id=2") }, }; + + static readonly object[] UriDataWithSubDir = +{ + new object[] {"www.google.com", 80, false,"test", new Uri("http://www.google.com:80/test"), }, + new object[] {"www.google.com", 443, false,"test", new Uri("http://www.google.com:443/test") }, + new object[] {"http://www.google.com", 443, true,"test", new Uri("https://www.google.com:443/test") }, + new object[] {"https://www.google.com", 443,true,"test", new Uri("https://www.google.com:443/test") }, + }; } } \ No newline at end of file diff --git a/PlexRequests.Core/RequestService.cs b/PlexRequests.Helpers/ByteConverterHelper.cs similarity index 50% rename from PlexRequests.Core/RequestService.cs rename to PlexRequests.Helpers/ByteConverterHelper.cs index 7788b6436..87d569592 100644 --- a/PlexRequests.Core/RequestService.cs +++ b/PlexRequests.Helpers/ByteConverterHelper.cs @@ -1,7 +1,7 @@ #region Copyright // /************************************************************************ // Copyright (c) 2016 Jamie Rees -// File: RequestService.cs +// File: ByteConverterHelper.cs // Created By: Jamie Rees // // Permission is hereby granted, free of charge, to any person obtaining @@ -24,61 +24,31 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion +using System.Text; -using System.Collections.Generic; -using System.Linq; -using PlexRequests.Store; +using Newtonsoft.Json; -namespace PlexRequests.Core +namespace PlexRequests.Helpers { - public class RequestService : IRequestService + public class ByteConverterHelper { - public RequestService(IRepository db) + public static byte[] ReturnBytes(object obj) { - Repo = db; - } - - private IRepository Repo { get; set; } - - public long AddRequest(RequestedModel model) - { - return Repo.Insert(model); - } + var json = JsonConvert.SerializeObject(obj); + var bytes = Encoding.UTF8.GetBytes(json); - public bool CheckRequest(int providerId) - { - return Repo.GetAll().Any(x => x.ProviderId == providerId); - } - - public void DeleteRequest(RequestedModel model) - { - var entity = Repo.Get(model.Id); - Repo.Delete(entity); + return bytes; } - public bool UpdateRequest(RequestedModel model) + public static T ReturnObject(byte[] bytes) { - return Repo.Update(model); + var json = Encoding.UTF8.GetString(bytes); + var model = JsonConvert.DeserializeObject(json); + return model; } - - /// - /// Updates all the entities. NOTE: we need to Id to be the original entity - /// - /// The model. - /// - public bool BatchUpdate(List model) - { - return Repo.UpdateAll(model); - } - - public RequestedModel Get(int id) - { - return Repo.Get(id); - } - - public IEnumerable GetAll() + public static string ReturnFromBytes(byte[] bytes) { - return Repo.GetAll(); + return Encoding.UTF8.GetString(bytes); } } -} +} \ No newline at end of file diff --git a/PlexRequests.Helpers/LoggingHelper.cs b/PlexRequests.Helpers/LoggingHelper.cs index b3ee3dc1b..db7270db3 100644 --- a/PlexRequests.Helpers/LoggingHelper.cs +++ b/PlexRequests.Helpers/LoggingHelper.cs @@ -25,9 +25,14 @@ // ************************************************************************/ #endregion using System; +using System.Data; using Newtonsoft.Json; +using NLog; +using NLog.Config; +using NLog.Targets; + namespace PlexRequests.Helpers { public static class LoggingHelper @@ -55,5 +60,107 @@ namespace PlexRequests.Helpers } return dumpTarget.ToString(); } + + public static void ConfigureLogging(string connectionString) + { + LogManager.ThrowExceptions = true; + // Step 1. Create configuration object + var config = new LoggingConfiguration(); + + // Step 2. Create targets and add them to the configuration + var databaseTarget = new DatabaseTarget + { + CommandType = CommandType.Text, + ConnectionString = connectionString, + DBProvider = "Mono.Data.Sqlite.SqliteConnection, Mono.Data.Sqlite, Version=4.0.0.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756", + Name = "database" + }; + + var messageParam = new DatabaseParameterInfo { Name = "@Message", Layout = "${message}" }; + var callsiteParam = new DatabaseParameterInfo { Name = "@Callsite", Layout = "${callsite}" }; + var levelParam = new DatabaseParameterInfo { Name = "@Level", Layout = "${level}" }; + var dateParam = new DatabaseParameterInfo { Name = "@Date", Layout = "${date}" }; + var loggerParam = new DatabaseParameterInfo { Name = "@Logger", Layout = "${logger}" }; + var exceptionParam = new DatabaseParameterInfo { Name = "@Exception", Layout = "${exception:tostring}" }; + + databaseTarget.Parameters.Add(messageParam); + databaseTarget.Parameters.Add(callsiteParam); + databaseTarget.Parameters.Add(levelParam); + databaseTarget.Parameters.Add(dateParam); + databaseTarget.Parameters.Add(loggerParam); + databaseTarget.Parameters.Add(exceptionParam); + + databaseTarget.CommandText = "INSERT INTO Logs (Date,Level,Logger, Message, Callsite, Exception) VALUES(@Date,@Level,@Logger, @Message, @Callsite, @Exception);"; + config.AddTarget("database", databaseTarget); + + // Step 4. Define rules + var rule1 = new LoggingRule("*", LogLevel.Info, databaseTarget); + config.LoggingRules.Add(rule1); + + // Step 5. Activate the configuration + LogManager.Configuration = config; + } + + public static void ReconfigureLogLevel(LogLevel level) + { + + foreach (var rule in LogManager.Configuration.LoggingRules) + { + // Remove all levels + rule.DisableLoggingForLevel(LogLevel.Trace); + rule.DisableLoggingForLevel(LogLevel.Info); + rule.DisableLoggingForLevel(LogLevel.Debug); + rule.DisableLoggingForLevel(LogLevel.Warn); + rule.DisableLoggingForLevel(LogLevel.Error); + rule.DisableLoggingForLevel(LogLevel.Fatal); + + + if (level == LogLevel.Trace) + { + rule.EnableLoggingForLevel(LogLevel.Trace); + rule.EnableLoggingForLevel(LogLevel.Info); + rule.EnableLoggingForLevel(LogLevel.Debug); + rule.EnableLoggingForLevel(LogLevel.Warn); + rule.EnableLoggingForLevel(LogLevel.Error); + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + if (level == LogLevel.Info) + { + rule.EnableLoggingForLevel(LogLevel.Info); + rule.EnableLoggingForLevel(LogLevel.Debug); + rule.EnableLoggingForLevel(LogLevel.Warn); + rule.EnableLoggingForLevel(LogLevel.Error); + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + if (level == LogLevel.Debug) + { + rule.EnableLoggingForLevel(LogLevel.Debug); + rule.EnableLoggingForLevel(LogLevel.Warn); + rule.EnableLoggingForLevel(LogLevel.Error); + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + if (level == LogLevel.Warn) + { + rule.EnableLoggingForLevel(LogLevel.Warn); + rule.EnableLoggingForLevel(LogLevel.Error); + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + if (level == LogLevel.Error) + { + rule.EnableLoggingForLevel(LogLevel.Error); + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + if (level == LogLevel.Fatal) + { + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + } + + + //Call to update existing Loggers created with GetLogger() or + //GetCurrentClassLogger() + LogManager.ReconfigExistingLoggers(); + } } } + diff --git a/PlexRequests.Helpers/PasswordHasher.cs b/PlexRequests.Helpers/PasswordHasher.cs new file mode 100644 index 000000000..68d797ae8 --- /dev/null +++ b/PlexRequests.Helpers/PasswordHasher.cs @@ -0,0 +1,69 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: PasswordHasher.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System.Collections.Generic; +using System.Security.Cryptography; +namespace PlexRequests.Helpers +{ + + public static class PasswordHasher + { + // 24 = 192 bits + private const int SaltByteSize = 24; + private const int HashByteSize = 24; + private const int HasingIterationsCount = 10101; + + public static byte[] ComputeHash(string password, byte[] salt, int iterations = HasingIterationsCount, int hashByteSize = HashByteSize) + { + var hashGenerator = new Rfc2898DeriveBytes(password, salt) { IterationCount = iterations }; + return hashGenerator.GetBytes(hashByteSize); + } + + public static byte[] GenerateSalt(int saltByteSize = SaltByteSize) + { + var saltGenerator = new RNGCryptoServiceProvider(); + var salt = new byte[saltByteSize]; + saltGenerator.GetBytes(salt); + return salt; + } + + public static bool VerifyPassword(string password, byte[] passwordSalt, byte[] passwordHash) + { + var computedHash = ComputeHash(password, passwordSalt); + return AreHashesEqual(computedHash, passwordHash); + } + + //Length constant verification - prevents timing attack + private static bool AreHashesEqual(IReadOnlyList firstHash, IReadOnlyList secondHash) + { + var minHashLength = firstHash.Count <= secondHash.Count ? firstHash.Count : secondHash.Count; + var xor = firstHash.Count ^ secondHash.Count; + for (var i = 0; i < minHashLength; i++) + xor |= firstHash[i] ^ secondHash[i]; + return 0 == xor; + } + } +} diff --git a/PlexRequests.Helpers/PlexRequests.Helpers.csproj b/PlexRequests.Helpers/PlexRequests.Helpers.csproj index 35d56a192..ec3c70a8d 100644 --- a/PlexRequests.Helpers/PlexRequests.Helpers.csproj +++ b/PlexRequests.Helpers/PlexRequests.Helpers.csproj @@ -35,6 +35,10 @@ ..\packages\Newtonsoft.Json.8.0.2\lib\net45\Newtonsoft.Json.dll True + + ..\packages\NLog.4.2.3\lib\net45\NLog.dll + True + @@ -47,12 +51,14 @@ + + diff --git a/PlexRequests.Helpers/UriHelper.cs b/PlexRequests.Helpers/UriHelper.cs index c33d5ddf4..707f70436 100644 --- a/PlexRequests.Helpers/UriHelper.cs +++ b/PlexRequests.Helpers/UriHelper.cs @@ -53,7 +53,10 @@ namespace PlexRequests.Helpers /// /// The value. /// The port. + /// if set to true [SSL]. + /// The subdir. /// + /// The URI is null, please check your settings to make sure you have configured the applications correctly. /// public static Uri ReturnUri(this string val, int port, bool ssl = default(bool)) { @@ -93,5 +96,21 @@ namespace PlexRequests.Helpers throw new Exception(exception.Message, exception); } } + + public static Uri ReturnUriWithSubDir(this string val, int port, bool ssl, string subDir) + { + var uriBuilder = new UriBuilder(val); + if (ssl) + { + uriBuilder.Scheme = Uri.UriSchemeHttps; + } + if (!string.IsNullOrEmpty(subDir)) + { + uriBuilder.Path = subDir; + } + uriBuilder.Port = port; + + return uriBuilder.Uri; + } } } \ No newline at end of file diff --git a/PlexRequests.Helpers/packages.config b/PlexRequests.Helpers/packages.config index 47cebb403..dc63c2a11 100644 --- a/PlexRequests.Helpers/packages.config +++ b/PlexRequests.Helpers/packages.config @@ -1,4 +1,5 @@  + \ No newline at end of file diff --git a/PlexRequests.Services.Tests/NotificationServiceTests.cs b/PlexRequests.Services.Tests/NotificationServiceTests.cs index ffbd75bfe..940bcedda 100644 --- a/PlexRequests.Services.Tests/NotificationServiceTests.cs +++ b/PlexRequests.Services.Tests/NotificationServiceTests.cs @@ -1,33 +1,37 @@ #region Copyright -// /************************************************************************ -// Copyright (c) 2016 Jamie Rees -// File: NotificationServiceTests.cs -// Created By: Jamie Rees -// -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to -// permit persons to whom the Software is furnished to do so, subject to -// the following conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -// ************************************************************************/ +/************************************************************************ + Copyright (c) 2016 Jamie Rees + File: NotificationServiceTests.cs + Created By: Jamie Rees + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ************************************************************************/ #endregion +using System; +using System.Threading.Tasks; + using Moq; using NUnit.Framework; +using PlexRequests.Services.Interfaces; using PlexRequests.Services.Notification; namespace PlexRequests.Services.Tests @@ -35,9 +39,15 @@ namespace PlexRequests.Services.Tests [TestFixture] public class NotificationServiceTests { + public NotificationService NotificationService { get; set; } + + [SetUp] + public void Setup() + { + NotificationService = new NotificationService(); + } [Test] - [Ignore("Need to rework due to static class")] public void SubscribeNewNotifier() { var notificationMock = new Mock(); @@ -49,7 +59,6 @@ namespace PlexRequests.Services.Tests } [Test] - [Ignore("Need to rework due to static class")] public void SubscribeExistingNotifier() { var notificationMock1 = new Mock(); @@ -68,7 +77,6 @@ namespace PlexRequests.Services.Tests } [Test] - [Ignore("Need to rework due to static class")] public void UnSubscribeMissingNotifier() { var notificationMock = new Mock(); @@ -79,7 +87,6 @@ namespace PlexRequests.Services.Tests } [Test] - [Ignore("Need to rework due to static class")] public void UnSubscribeNotifier() { var notificationMock = new Mock(); @@ -92,17 +99,15 @@ namespace PlexRequests.Services.Tests } [Test] - [Ignore("Need to rework due to static class")] public void PublishWithNoObservers() { - Assert.DoesNotThrow( - () => - { NotificationService.Publish(string.Empty, string.Empty); }); + Assert.DoesNotThrowAsync( + async() => + { await NotificationService.Publish(new NotificationModel()); }); } [Test] - [Ignore("Need to rework due to static class")] - public void PublishAllNotifiers() + public async Task PublishAllNotifiers() { var notificationMock1 = new Mock(); var notificationMock2 = new Mock(); @@ -112,11 +117,21 @@ namespace PlexRequests.Services.Tests NotificationService.Subscribe(notificationMock2.Object); Assert.That(NotificationService.Observers.Count, Is.EqualTo(2)); + var model = new NotificationModel { Title = "abc", Body = "test" }; + await NotificationService.Publish(model); - NotificationService.Publish("a","b"); + notificationMock1.Verify(x => x.NotifyAsync(model), Times.Once); + notificationMock2.Verify(x => x.NotifyAsync(model), Times.Once); + } - notificationMock1.Verify(x => x.Notify("a","b"), Times.Once); - notificationMock2.Verify(x => x.Notify("a","b"), Times.Once); + [Test] + public async Task PublishWithException() + { + var notificationMock = new Mock(); + notificationMock.Setup(x => x.NotifyAsync(It.IsAny())).Throws(); + notificationMock.SetupGet(x => x.NotificationName).Returns("Notification1"); + NotificationService.Subscribe(notificationMock.Object); + await NotificationService.Publish(new NotificationModel()); } } } \ No newline at end of file diff --git a/PlexRequests.Services/Notification/INotification.cs b/PlexRequests.Services/Interfaces/INotification.cs similarity index 73% rename from PlexRequests.Services/Notification/INotification.cs rename to PlexRequests.Services/Interfaces/INotification.cs index 6b85bd178..14b09f0e9 100644 --- a/PlexRequests.Services/Notification/INotification.cs +++ b/PlexRequests.Services/Interfaces/INotification.cs @@ -24,23 +24,16 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion -namespace PlexRequests.Services.Notification +using System.Threading.Tasks; + +using PlexRequests.Services.Notification; + +namespace PlexRequests.Services.Interfaces { public interface INotification { - /// - /// Gets the name of the notification. - /// - /// - /// The name of the notification. - /// string NotificationName { get; } - /// - /// Notifies the specified title. - /// - /// The title. - /// The requester. - /// - bool Notify(string title, string requester); + + Task NotifyAsync(NotificationModel model); } } \ No newline at end of file diff --git a/PlexRequests.Services/Interfaces/INotificationService.cs b/PlexRequests.Services/Interfaces/INotificationService.cs new file mode 100644 index 000000000..59db3b509 --- /dev/null +++ b/PlexRequests.Services/Interfaces/INotificationService.cs @@ -0,0 +1,40 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: INotificationService.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System.Threading.Tasks; + +using PlexRequests.Services.Notification; + +namespace PlexRequests.Services.Interfaces +{ + public interface INotificationService + { + Task Publish(NotificationModel model); + void Subscribe(INotification notification); + void UnSubscribe(INotification notification); + + } +} \ No newline at end of file diff --git a/PlexRequests.Services/Notification/EmailMessageNotification.cs b/PlexRequests.Services/Notification/EmailMessageNotification.cs index 2c5c84158..472b6a069 100644 --- a/PlexRequests.Services/Notification/EmailMessageNotification.cs +++ b/PlexRequests.Services/Notification/EmailMessageNotification.cs @@ -27,11 +27,13 @@ using System; using System.Net; using System.Net.Mail; +using System.Threading.Tasks; using NLog; using PlexRequests.Core; using PlexRequests.Core.SettingModels; +using PlexRequests.Services.Interfaces; namespace PlexRequests.Services.Notification { @@ -42,67 +44,120 @@ namespace PlexRequests.Services.Notification EmailNotificationSettings = settings; } - private static Logger Log = LogManager.GetCurrentClassLogger(); + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); private ISettingsService EmailNotificationSettings { get; } + private EmailNotificationSettings Settings => GetConfiguration(); public string NotificationName => "EmailMessageNotification"; - public bool Notify(string title, string requester) + + public async Task NotifyAsync(NotificationModel model) { var configuration = GetConfiguration(); if (!ValidateConfiguration(configuration)) + { + return; + } + + switch (model.NotificationType) + { + case NotificationType.NewRequest: + await EmailNewRequest(model); + break; + case NotificationType.Issue: + await EmailIssue(model); + break; + case NotificationType.RequestAvailable: + throw new NotImplementedException(); + + case NotificationType.RequestApproved: + throw new NotImplementedException(); + + case NotificationType.AdminNote: + throw new NotImplementedException(); + + default: + throw new ArgumentOutOfRangeException(); + } + + } + + private EmailNotificationSettings GetConfiguration() + { + var settings = EmailNotificationSettings.GetSettings(); + return settings; + } + + private bool ValidateConfiguration(EmailNotificationSettings settings) + { + if (!settings.Enabled) + { + return false; + } + if (string.IsNullOrEmpty(settings.EmailHost) || string.IsNullOrEmpty(settings.EmailUsername) || string.IsNullOrEmpty(settings.EmailPassword) || string.IsNullOrEmpty(settings.RecipientEmail) || string.IsNullOrEmpty(settings.EmailPort.ToString())) { return false; } + return true; + } + + private async Task EmailNewRequest(NotificationModel model) + { var message = new MailMessage { IsBodyHtml = true, - To = { new MailAddress(configuration.RecipientEmail) }, - Body = $"User {requester} has requested {title}!", - From = new MailAddress(configuration.EmailUsername), - Subject = $"New Request for {title}!" + To = { new MailAddress(Settings.RecipientEmail) }, + Body = $"Hello! The user '{model.User}' has requested {model.Title}! Please log in to approve this request. Request Date: {model.DateTime.ToString("f")}", + From = new MailAddress(Settings.EmailSender), + Subject = $"Plex Requests: New request for {model.Title}!" }; try { - using (var smtp = new SmtpClient(configuration.EmailHost, configuration.EmailPort)) + using (var smtp = new SmtpClient(Settings.EmailHost, Settings.EmailPort)) { - smtp.Credentials = new NetworkCredential(configuration.EmailUsername, configuration.EmailPassword); - smtp.EnableSsl = configuration.Ssl; - smtp.Send(message); - return true; + smtp.Credentials = new NetworkCredential(Settings.EmailUsername, Settings.EmailPassword); + smtp.EnableSsl = Settings.Ssl; + await smtp.SendMailAsync(message).ConfigureAwait(false); } } catch (SmtpException smtp) { - Log.Fatal(smtp); + Log.Error(smtp); } catch (Exception e) { - Log.Fatal(e); + Log.Error(e); } - return false; } - private EmailNotificationSettings GetConfiguration() + private async Task EmailIssue(NotificationModel model) { - var settings = EmailNotificationSettings.GetSettings(); - return settings; - } + var message = new MailMessage + { + IsBodyHtml = true, + To = { new MailAddress(Settings.RecipientEmail) }, + Body = $"Hello! The user '{model.User}' has reported a new issue {model.Body} for the title {model.Title}!", + From = new MailAddress(Settings.RecipientEmail), + Subject = $"Plex Requests: New issue for {model.Title}!" + }; - private bool ValidateConfiguration(EmailNotificationSettings settings) - { - if (!settings.Enabled) + try { - return false; + using (var smtp = new SmtpClient(Settings.EmailHost, Settings.EmailPort)) + { + smtp.Credentials = new NetworkCredential(Settings.EmailUsername, Settings.EmailPassword); + smtp.EnableSsl = Settings.Ssl; + await smtp.SendMailAsync(message).ConfigureAwait(false); + } + } + catch (SmtpException smtp) + { + Log.Error(smtp); } - if (string.IsNullOrEmpty(settings.EmailHost) || string.IsNullOrEmpty(settings.EmailUsername) - || string.IsNullOrEmpty(settings.EmailPassword) || string.IsNullOrEmpty(settings.RecipientEmail) - || string.IsNullOrEmpty(settings.EmailPort.ToString())) + catch (Exception e) { - return false; + Log.Error(e); } - - return true; } } } \ No newline at end of file diff --git a/PlexRequests.Services/Notification/NotificationModel.cs b/PlexRequests.Services/Notification/NotificationModel.cs new file mode 100644 index 000000000..264d3e609 --- /dev/null +++ b/PlexRequests.Services/Notification/NotificationModel.cs @@ -0,0 +1,39 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: NotificationModel.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System; + +namespace PlexRequests.Services.Notification +{ + public class NotificationModel + { + public string Title { get; set; } + public string Body { get; set; } + public DateTime DateTime { get; set; } + public NotificationType NotificationType { get; set; } + public string User { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Services/Notification/NotificationService.cs b/PlexRequests.Services/Notification/NotificationService.cs index 4ef7a8bd0..116f5aef9 100644 --- a/PlexRequests.Services/Notification/NotificationService.cs +++ b/PlexRequests.Services/Notification/NotificationService.cs @@ -24,67 +24,49 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion -using System.Collections.Generic; -using System.Threading; +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; using NLog; -using PlexRequests.Helpers; +using PlexRequests.Services.Interfaces; namespace PlexRequests.Services.Notification { - public static class NotificationService + public class NotificationService : INotificationService { - private static Logger Log = LogManager.GetCurrentClassLogger(); - public static Dictionary Observers { get; } + public ConcurrentDictionary Observers { get; } = new ConcurrentDictionary(); - static NotificationService() + public async Task Publish(NotificationModel model) { - Observers = new Dictionary(); + var notificationTasks = Observers.Values.Select(notification => NotifyAsync(notification, model)); + + await Task.WhenAll(notificationTasks).ConfigureAwait(false); } - public static void Publish(string title, string requester) + public void Subscribe(INotification notification) { - Log.Trace("Notifying all observers: "); - Log.Trace(Observers.DumpJson()); - foreach (var observer in Observers) - { - var notification = observer.Value; - - new Thread(() => - { - Thread.CurrentThread.IsBackground = true; - notification.Notify(title, requester); - }).Start(); - } + Observers.TryAdd(notification.NotificationName, notification); } - public static void Subscribe(INotification notification) + public void UnSubscribe(INotification notification) { - Log.Trace("Subscribing Observer {0}", notification.NotificationName); - INotification notificationValue; - if (Observers.TryGetValue(notification.NotificationName, out notificationValue)) - { - Log.Trace("Observer {0} already exists", notification.NotificationName); - // Observer already exists - return; - } - - Observers[notification.NotificationName] = notification; + Observers.TryRemove(notification.NotificationName, out notification); } - public static void UnSubscribe(INotification notification) + private static async Task NotifyAsync(INotification notification, NotificationModel model) { - Log.Trace("Unsubscribing Observer {0}", notification.NotificationName); - INotification notificationValue; - if (!Observers.TryGetValue(notification.NotificationName, out notificationValue)) + try + { + await notification.NotifyAsync(model).ConfigureAwait(false); + } + catch (Exception ex) { - Log.Trace("Observer {0} doesn't exist to Unsubscribe", notification.NotificationName); - // Observer doesn't exists - return; + Log.Error(ex, $"Notification '{notification.NotificationName}' failed with exception"); } - Observers.Remove(notification.NotificationName); } } } \ No newline at end of file diff --git a/PlexRequests.Services/Notification/NotificationType.cs b/PlexRequests.Services/Notification/NotificationType.cs new file mode 100644 index 000000000..bf919fe39 --- /dev/null +++ b/PlexRequests.Services/Notification/NotificationType.cs @@ -0,0 +1,37 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: NotificationType.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +namespace PlexRequests.Services.Notification +{ + public enum NotificationType + { + NewRequest, + Issue, + RequestAvailable, + RequestApproved, + AdminNote, + } +} diff --git a/PlexRequests.Services/Notification/PushbulletNotification.cs b/PlexRequests.Services/Notification/PushbulletNotification.cs index 5000919f7..4e2f02144 100644 --- a/PlexRequests.Services/Notification/PushbulletNotification.cs +++ b/PlexRequests.Services/Notification/PushbulletNotification.cs @@ -25,12 +25,14 @@ // ************************************************************************/ #endregion using System; +using System.Threading.Tasks; using NLog; using PlexRequests.Api.Interfaces; using PlexRequests.Core; using PlexRequests.Core.SettingModels; +using PlexRequests.Services.Interfaces; namespace PlexRequests.Services.Notification { @@ -39,42 +41,92 @@ namespace PlexRequests.Services.Notification public PushbulletNotification(IPushbulletApi pushbulletApi, ISettingsService settings) { PushbulletApi = pushbulletApi; - Settings = settings; + SettingsService = settings; } - private IPushbulletApi PushbulletApi { get; } - private ISettingsService Settings { get; } + private IPushbulletApi PushbulletApi { get; } + private ISettingsService SettingsService { get; } + private PushbulletNotificationSettings Settings => GetSettings(); private static Logger Log = LogManager.GetCurrentClassLogger(); public string NotificationName => "PushbulletNotification"; - public bool Notify(string title, string requester) + public async Task NotifyAsync(NotificationModel model) { - var settings = GetSettings(); + if (!ValidateConfiguration()) + { + return; + } - if (!settings.Enabled) + switch (model.NotificationType) + { + case NotificationType.NewRequest: + await PushNewRequestAsync(model); + break; + case NotificationType.Issue: + await PushIssueAsync(model); + break; + case NotificationType.RequestAvailable: + break; + case NotificationType.RequestApproved: + break; + case NotificationType.AdminNote: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private bool ValidateConfiguration() + { + if (!Settings.Enabled) { return false; } + if (string.IsNullOrEmpty(Settings.AccessToken)) + { + return false; + } + return true; + } - var message = $"{title} has been requested by {requester}"; - var pushTitle = $"Plex Requests: {title}"; + private PushbulletNotificationSettings GetSettings() + { + return SettingsService.GetSettings(); + } + + private async Task PushNewRequestAsync(NotificationModel model) + { + var message = $"{model.Title} has been requested by user: {model.User}"; + var pushTitle = $"Plex Requests: {model.Title} has been requested!"; try { - var result = PushbulletApi.Push(settings.AccessToken, pushTitle, message, settings.DeviceIdentifier); - if (result != null) + var result = await PushbulletApi.PushAsync(Settings.AccessToken, pushTitle, message, Settings.DeviceIdentifier); + if (result == null) { - return true; + Log.Error("Pushbullet api returned a null value, the notification did not get pushed"); } } catch (Exception e) { - Log.Fatal(e); + Log.Error(e); } - return false; } - private PushbulletNotificationSettings GetSettings() + private async Task PushIssueAsync(NotificationModel model) { - return Settings.GetSettings(); + var message = $"A new issue: {model.Body} has been reported by user: {model.User} for the title: {model.Title}"; + var pushTitle = $"Plex Requests: A new issue has been reported for {model.Title}"; + try + { + var result = await PushbulletApi.PushAsync(Settings.AccessToken, pushTitle, message, Settings.DeviceIdentifier); + if (result != null) + { + Log.Error("Pushbullet api returned a null value, the notification did not get pushed"); + } + } + catch (Exception e) + { + Log.Error(e); + } } } } \ No newline at end of file diff --git a/PlexRequests.Services/Notification/PushoverNotification.cs b/PlexRequests.Services/Notification/PushoverNotification.cs new file mode 100644 index 000000000..bca0b2c90 --- /dev/null +++ b/PlexRequests.Services/Notification/PushoverNotification.cs @@ -0,0 +1,130 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: PushbulletNotification.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System; +using System.Threading.Tasks; + +using NLog; + +using PlexRequests.Api.Interfaces; +using PlexRequests.Core; +using PlexRequests.Core.SettingModels; +using PlexRequests.Services.Interfaces; + +namespace PlexRequests.Services.Notification +{ + public class PushoverNotification : INotification + { + public PushoverNotification(IPushoverApi pushoverApi, ISettingsService settings) + { + PushoverApi = pushoverApi; + SettingsService = settings; + } + private IPushoverApi PushoverApi { get; } + private ISettingsService SettingsService { get; } + private PushoverNotificationSettings Settings => GetSettings(); + + private static Logger Log = LogManager.GetCurrentClassLogger(); + public string NotificationName => "PushoverNotification"; + public async Task NotifyAsync(NotificationModel model) + { + if (!ValidateConfiguration()) + { + return; + } + + switch (model.NotificationType) + { + case NotificationType.NewRequest: + await PushNewRequestAsync(model); + break; + case NotificationType.Issue: + await PushIssueAsync(model); + break; + case NotificationType.RequestAvailable: + break; + case NotificationType.RequestApproved: + break; + case NotificationType.AdminNote: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private bool ValidateConfiguration() + { + if (!Settings.Enabled) + { + return false; + } + if (string.IsNullOrEmpty(Settings.AccessToken) || string.IsNullOrEmpty(Settings.UserToken)) + { + return false; + } + return true; + } + + private PushoverNotificationSettings GetSettings() + { + return SettingsService.GetSettings(); + } + + private async Task PushNewRequestAsync(NotificationModel model) + { + var message = $"Plex Requests: {model.Title} has been requested by user: {model.User}"; + try + { + var result = await PushoverApi.PushAsync(Settings.AccessToken, message, Settings.UserToken); + if (result?.status != 1) + { + Log.Error("Pushover api returned a status that was not 1, the notification did not get pushed"); + } + } + catch (Exception e) + { + Log.Error(e); + } + } + + private async Task PushIssueAsync(NotificationModel model) + { + var message = $"Plex Requests: A new issue: {model.Body} has been reported by user: {model.User} for the title: {model.Title}"; + try + { + var result = await PushoverApi.PushAsync(Settings.AccessToken, message, Settings.UserToken); + if (result?.status != 1) + { + Log.Error("Pushover api returned a status that was not 1, the notification did not get pushed"); + } + } + catch (Exception e) + { + Log.Error(e); + } + } + } +} \ No newline at end of file diff --git a/PlexRequests.Services/PlexRequests.Services.csproj b/PlexRequests.Services/PlexRequests.Services.csproj index 29ad390d5..bbb334ca9 100644 --- a/PlexRequests.Services/PlexRequests.Services.csproj +++ b/PlexRequests.Services/PlexRequests.Services.csproj @@ -77,9 +77,13 @@ - + + + + + diff --git a/PlexRequests.Store/Models/LogEntity.cs b/PlexRequests.Store/Models/LogEntity.cs index 694b6ce8d..d1d45cfc1 100644 --- a/PlexRequests.Store/Models/LogEntity.cs +++ b/PlexRequests.Store/Models/LogEntity.cs @@ -26,16 +26,22 @@ #endregion using System; +using Dapper.Contrib.Extensions; +using Newtonsoft.Json; + namespace PlexRequests.Store.Models { + [Table("Logs")] public class LogEntity : Entity { - public string Username { get; set; } public DateTime Date { get; set; } public string Level { get; set; } public string Logger { get; set; } public string Message { get; set; } public string Callsite { get; set; } public string Exception { get; set; } + + [JsonIgnore] + public string DateString { get; set; } } } diff --git a/PlexRequests.Store/PlexRequests.Store.csproj b/PlexRequests.Store/PlexRequests.Store.csproj index 3772c77a4..2175ebc06 100644 --- a/PlexRequests.Store/PlexRequests.Store.csproj +++ b/PlexRequests.Store/PlexRequests.Store.csproj @@ -42,6 +42,10 @@ ..\Assemblies\Mono.Data.Sqlite.dll + + ..\packages\Newtonsoft.Json.8.0.2\lib\net45\Newtonsoft.Json.dll + True + ..\packages\NLog.4.2.3\lib\net45\NLog.dll True @@ -58,18 +62,20 @@ - - + + - + - + + + True @@ -77,7 +83,6 @@ Sql.resx - diff --git a/PlexRequests.Store/GenericRepository.cs b/PlexRequests.Store/Repository/GenericRepository.cs similarity index 77% rename from PlexRequests.Store/GenericRepository.cs rename to PlexRequests.Store/Repository/GenericRepository.cs index 331567463..17e7b9c0f 100644 --- a/PlexRequests.Store/GenericRepository.cs +++ b/PlexRequests.Store/Repository/GenericRepository.cs @@ -34,20 +34,23 @@ using NLog; using PlexRequests.Helpers; -namespace PlexRequests.Store +namespace PlexRequests.Store.Repository { public class GenericRepository : IRepository where T : Entity { - public GenericRepository(ISqliteConfiguration config) + private ICacheProvider Cache { get; } + public GenericRepository(ISqliteConfiguration config, ICacheProvider cache) { Config = config; + Cache = cache; } private static Logger Log = LogManager.GetCurrentClassLogger(); - private ISqliteConfiguration Config { get; set; } + private ISqliteConfiguration Config { get; } public long Insert(T entity) { + ResetCache(); using (var cnn = Config.DbConnection()) { cnn.Open(); @@ -57,12 +60,14 @@ namespace PlexRequests.Store public IEnumerable GetAll() { + using (var db = Config.DbConnection()) { db.Open(); var result = db.GetAll(); return result; } + } public T Get(string id) @@ -72,15 +77,23 @@ namespace PlexRequests.Store public T Get(int id) { - using (var db = Config.DbConnection()) - { - db.Open(); - return db.Get(id); - } + var key = "Get" + id; + var item = Cache.GetOrSet( + key, + () => + { + using (var db = Config.DbConnection()) + { + db.Open(); + return db.Get(id); + } + }); + return item; } public void Delete(T entity) { + ResetCache(); using (var db = Config.DbConnection()) { db.Open(); @@ -90,6 +103,7 @@ namespace PlexRequests.Store public bool Update(T entity) { + ResetCache(); Log.Trace("Updating entity"); Log.Trace(entity.DumpJson()); using (var db = Config.DbConnection()) @@ -101,6 +115,7 @@ namespace PlexRequests.Store public bool UpdateAll(IEnumerable entity) { + ResetCache(); Log.Trace("Updating all entities"); var result = new HashSet(); @@ -114,5 +129,11 @@ namespace PlexRequests.Store } return result.All(x => true); } + + private void ResetCache() + { + Cache.Remove("Get"); + Cache.Remove("GetAll"); + } } } diff --git a/PlexRequests.Store/IRepository.cs b/PlexRequests.Store/Repository/IRepository.cs similarity index 95% rename from PlexRequests.Store/IRepository.cs rename to PlexRequests.Store/Repository/IRepository.cs index d88f80aa0..4d301047c 100644 --- a/PlexRequests.Store/IRepository.cs +++ b/PlexRequests.Store/Repository/IRepository.cs @@ -26,7 +26,7 @@ #endregion using System.Collections.Generic; -namespace PlexRequests.Store +namespace PlexRequests.Store.Repository { public interface IRepository { diff --git a/PlexRequests.Store/IRequestRepository.cs b/PlexRequests.Store/Repository/IRequestRepository.cs similarity index 95% rename from PlexRequests.Store/IRequestRepository.cs rename to PlexRequests.Store/Repository/IRequestRepository.cs index e2a07b6b5..809628c51 100644 --- a/PlexRequests.Store/IRequestRepository.cs +++ b/PlexRequests.Store/Repository/IRequestRepository.cs @@ -28,7 +28,7 @@ using System.Collections.Generic; using PlexRequests.Store.Models; -namespace PlexRequests.Store +namespace PlexRequests.Store.Repository { public interface IRequestRepository { diff --git a/PlexRequests.Store/ISettingsRepository.cs b/PlexRequests.Store/Repository/ISettingsRepository.cs similarity index 95% rename from PlexRequests.Store/ISettingsRepository.cs rename to PlexRequests.Store/Repository/ISettingsRepository.cs index c0a8a866a..393da6ef7 100644 --- a/PlexRequests.Store/ISettingsRepository.cs +++ b/PlexRequests.Store/Repository/ISettingsRepository.cs @@ -28,7 +28,7 @@ using System.Collections.Generic; using PlexRequests.Store.Models; -namespace PlexRequests.Store +namespace PlexRequests.Store.Repository { public interface ISettingsRepository { diff --git a/PlexRequests.Store/RequestedModel.cs b/PlexRequests.Store/RequestedModel.cs index 247433329..18ef216af 100644 --- a/PlexRequests.Store/RequestedModel.cs +++ b/PlexRequests.Store/RequestedModel.cs @@ -23,8 +23,10 @@ namespace PlexRequests.Store public bool Available { get; set; } public IssueState Issues { get; set; } public string OtherMessage { get; set; } - public bool LatestTv { get; set; } public string AdminNote { get; set; } + public int[] SeasonList { get; set; } + public int SeasonCount { get; set; } + public string SeasonsRequested { get; set; } } public enum RequestType diff --git a/PlexRequests.Store/SqlTables.sql b/PlexRequests.Store/SqlTables.sql index 23d49a87c..f23b3c5fc 100644 --- a/PlexRequests.Store/SqlTables.sql +++ b/PlexRequests.Store/SqlTables.sql @@ -1,11 +1,14 @@ --Any DB changes need to be made in this file. -CREATE TABLE IF NOT EXISTS User +CREATE TABLE IF NOT EXISTS Users ( Id INTEGER PRIMARY KEY AUTOINCREMENT, - User varchar(50) NOT NULL , + UserGuid varchar(50) NOT NULL , UserName varchar(50) NOT NULL, - Password varchar(100) NOT NULL + Salt BLOB NOT NULL, + Hash BLOB NOT NULL, + Claims BLOB NOT NULL, + UserProperties BLOB ); @@ -15,6 +18,7 @@ CREATE TABLE IF NOT EXISTS GlobalSettings SettingsName varchar(50) NOT NULL, Content varchar(100) NOT NULL ); +CREATE UNIQUE INDEX IF NOT EXISTS GlobalSettings_Id ON GlobalSettings (Id); CREATE TABLE IF NOT EXISTS RequestBlobs ( @@ -23,12 +27,11 @@ CREATE TABLE IF NOT EXISTS RequestBlobs Type INTEGER NOT NULL, Content BLOB NOT NULL ); +CREATE UNIQUE INDEX IF NOT EXISTS RequestBlobs_Id ON RequestBlobs (Id); - -CREATE TABLE IF NOT EXISTS Log +CREATE TABLE IF NOT EXISTS Logs ( Id INTEGER PRIMARY KEY AUTOINCREMENT, - Username varchar(50) NOT NULL, Date varchar(100) NOT NULL, Level varchar(100) NOT NULL, Logger varchar(100) NOT NULL, @@ -36,3 +39,4 @@ CREATE TABLE IF NOT EXISTS Log CallSite varchar(100) NOT NULL, Exception varchar(100) NOT NULL ); +CREATE UNIQUE INDEX IF NOT EXISTS Logs_Id ON Logs (Id); diff --git a/PlexRequests.Store/UserModel.cs b/PlexRequests.Store/UserEntity.cs similarity index 87% rename from PlexRequests.Store/UserModel.cs rename to PlexRequests.Store/UserEntity.cs index 8bf3e0fc3..b3d53f8cc 100644 --- a/PlexRequests.Store/UserModel.cs +++ b/PlexRequests.Store/UserEntity.cs @@ -1,7 +1,7 @@ #region Copyright // /************************************************************************ // Copyright (c) 2016 Jamie Rees -// File: UserModel.cs +// File: UserEntity.cs // Created By: Jamie Rees // // Permission is hereby granted, free of charge, to any person obtaining @@ -28,11 +28,12 @@ using Dapper.Contrib.Extensions; namespace PlexRequests.Store { - [Table("User")] - public class UserModel : Entity + public class UserEntity { - public string User { get; set; } + [Key] + public int Id { get; set; } + public string UserName { get; set; } - public string Password { get; set; } + public string UserGuid { get; set; } } -} +} \ No newline at end of file diff --git a/PlexRequests.Store/UserRepository.cs b/PlexRequests.Store/UserRepository.cs index 4f2e6de90..467e0487c 100644 --- a/PlexRequests.Store/UserRepository.cs +++ b/PlexRequests.Store/UserRepository.cs @@ -30,16 +30,18 @@ using System.Linq; using Dapper.Contrib.Extensions; +using PlexRequests.Store.Repository; + namespace PlexRequests.Store { - public class UserRepository : IRepository where T : UserModel + public class UserRepository : IRepository where T : UserEntity { public UserRepository(ISqliteConfiguration config) { Config = config; } - private ISqliteConfiguration Config { get; set; } + private ISqliteConfiguration Config { get; } public long Insert(T entity) { using (var cnn = Config.DbConnection()) @@ -65,7 +67,7 @@ namespace PlexRequests.Store { db.Open(); var result = db.GetAll(); - var selected = result.FirstOrDefault(x => x.User == id); + var selected = result.FirstOrDefault(x => x.UserGuid == id); return selected; } } diff --git a/PlexRequests.Store/UsersModel.cs b/PlexRequests.Store/UsersModel.cs new file mode 100644 index 000000000..8a3753c6c --- /dev/null +++ b/PlexRequests.Store/UsersModel.cs @@ -0,0 +1,39 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: UserModel.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using Dapper.Contrib.Extensions; + +namespace PlexRequests.Store +{ + [Table("Users")] + public class UsersModel : UserEntity + { + public byte[] Hash { get; set; } + public byte[] Salt { get; set; } + public byte[] Claims { get; set; } + public byte[] UserProperties { get; set; } + } +} diff --git a/PlexRequests.Store/packages.config b/PlexRequests.Store/packages.config index 6ecb34bd0..e6a6a64fa 100644 --- a/PlexRequests.Store/packages.config +++ b/PlexRequests.Store/packages.config @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/PlexRequests.UI.Tests/AdminModuleTests.cs b/PlexRequests.UI.Tests/AdminModuleTests.cs index e42eb4a13..fc8686086 100644 --- a/PlexRequests.UI.Tests/AdminModuleTests.cs +++ b/PlexRequests.UI.Tests/AdminModuleTests.cs @@ -39,6 +39,9 @@ using PlexRequests.Api.Interfaces; using PlexRequests.Api.Models.Plex; using PlexRequests.Core; using PlexRequests.Core.SettingModels; +using PlexRequests.Services.Interfaces; +using PlexRequests.Store.Models; +using PlexRequests.Store.Repository; using PlexRequests.UI.Models; using PlexRequests.UI.Modules; @@ -55,10 +58,14 @@ namespace PlexRequests.UI.Tests private Mock> SickRageSettingsMock { get; set; } private Mock> EmailMock { get; set; } private Mock> PushbulletSettings { get; set; } + private Mock> PushoverSettings { get; set; } private Mock PlexMock { get; set; } private Mock SonarrApiMock { get; set; } private Mock PushbulletApi { get; set; } + private Mock PushoverApi { get; set; } private Mock CpApi { get; set; } + private Mock> LogRepo { get; set; } + private Mock NotificationService { get; set; } private ConfigurableBootstrapper Bootstrapper { get; set; } @@ -83,6 +90,10 @@ namespace PlexRequests.UI.Tests PushbulletSettings = new Mock>(); CpApi = new Mock(); SickRageSettingsMock = new Mock>(); + LogRepo = new Mock>(); + PushoverSettings = new Mock>(); + PushoverApi = new Mock(); + NotificationService = new Mock(); Bootstrapper = new ConfigurableBootstrapper(with => { @@ -99,6 +110,10 @@ namespace PlexRequests.UI.Tests with.Dependency(PushbulletSettings.Object); with.Dependency(CpApi.Object); with.Dependency(SickRageSettingsMock.Object); + with.Dependency(LogRepo.Object); + with.Dependency(PushoverSettings.Object); + with.Dependency(PushoverApi.Object); + with.Dependency(NotificationService.Object); with.RootPathProvider(); with.RequestStartup((container, pipelines, context) => { diff --git a/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj b/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj index 96823f840..e79b01dc4 100644 --- a/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj +++ b/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj @@ -117,6 +117,10 @@ {566EFA49-68F8-4716-9693-A6B3F2624DEA} PlexRequests.Services + + {92433867-2B7B-477B-A566-96C382427525} + PlexRequests.Store + {68F5F5F3-B8BB-4911-875F-6F00AAE04EA6} PlexRequests.UI diff --git a/PlexRequests.UI/Bootstrapper.cs b/PlexRequests.UI/Bootstrapper.cs index 8fd5aad2b..5e4f24343 100644 --- a/PlexRequests.UI/Bootstrapper.cs +++ b/PlexRequests.UI/Bootstrapper.cs @@ -47,6 +47,7 @@ using PlexRequests.Services; using PlexRequests.Services.Interfaces; using PlexRequests.Services.Notification; using PlexRequests.Store; +using PlexRequests.Store.Models; using PlexRequests.Store.Repository; using PlexRequests.UI.Jobs; using TaskFactory = FluentScheduler.TaskFactory; @@ -74,9 +75,11 @@ namespace PlexRequests.UI container.Register, SettingsServiceV2>(); container.Register, SettingsServiceV2>(); container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); // Repo's container.Register, GenericRepository>(); + container.Register, GenericRepository>(); container.Register(); container.Register(); @@ -88,10 +91,14 @@ namespace PlexRequests.UI // Api's container.Register(); container.Register(); + container.Register(); container.Register(); container.Register(); container.Register(); + // NotificationService + container.Register().AsSingleton(); + SubscribeAllObservers(container); base.ConfigureRequestContainer(container, context); } @@ -126,18 +133,27 @@ namespace PlexRequests.UI private void SubscribeAllObservers(TinyIoCContainer container) { + var notificationService = container.Resolve(); + var emailSettingsService = container.Resolve>(); var emailSettings = emailSettingsService.GetSettings(); if (emailSettings.Enabled) { - NotificationService.Subscribe(new EmailMessageNotification(emailSettingsService)); + notificationService.Subscribe(new EmailMessageNotification(emailSettingsService)); } var pushbulletService = container.Resolve>(); var pushbulletSettings = pushbulletService.GetSettings(); if (pushbulletSettings.Enabled) { - NotificationService.Subscribe(new PushbulletNotification(container.Resolve(), container.Resolve>())); + notificationService.Subscribe(new PushbulletNotification(container.Resolve(), pushbulletService)); + } + + var pushoverService = container.Resolve>(); + var pushoverSettings = pushoverService.GetSettings(); + if (pushoverSettings.Enabled) + { + notificationService.Subscribe(new PushoverNotification(container.Resolve(), pushoverService)); } } } diff --git a/PlexRequests.UI/Content/bootstrap.css b/PlexRequests.UI/Content/bootstrap.css index 7b3046590..83e3b6fa3 100644 --- a/PlexRequests.UI/Content/bootstrap.css +++ b/PlexRequests.UI/Content/bootstrap.css @@ -269,8 +269,8 @@ th { } @font-face { font-family: 'Glyphicons Halflings'; - src: url('../fonts/glyphicons-halflings-regular.eot'); - src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); + src: url('../Content/fonts/glyphicons-halflings-regular.eot'); + src: url('../Content/fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../Content/fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../Content/fonts/glyphicons-halflings-regular.woff') format('woff'), url('../Content/fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../Content/fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); } .glyphicon { position: relative; diff --git a/PlexRequests.UI/Content/custom.css b/PlexRequests.UI/Content/custom.css index a8c70de18..a517cc508 100644 --- a/PlexRequests.UI/Content/custom.css +++ b/PlexRequests.UI/Content/custom.css @@ -120,3 +120,9 @@ label { background-color: #5cb85c !important; border-color: #5cb85c !important; } +#movieList .mix { + display: none; } + +#tvList .mix { + display: none; } + diff --git a/PlexRequests.UI/Content/custom.min.css b/PlexRequests.UI/Content/custom.min.css index 37bb1257f..45dc19440 100644 --- a/PlexRequests.UI/Content/custom.min.css +++ b/PlexRequests.UI/Content/custom.min.css @@ -1 +1 @@ -@media(min-width:768px){.row{position:relative;}.bottom-align-text{position:absolute;bottom:0;right:0;}}@media(max-width:48em){.home{padding-top:1rem;}}@media(min-width:48em){.home{padding-top:4rem;}}.btn{border-radius:.25rem !important;}.multiSelect{background-color:#4e5d6c;}.form-control-custom{background-color:#4e5d6c !important;color:#fff !important;}h1{font-size:3.5rem !important;font-weight:600 !important;}.request-title{margin-top:0 !important;font-size:1.9rem !important;}p{font-size:1.1rem !important;}label{display:inline-block !important;margin-bottom:.5rem !important;font-size:16px !important;}.btn-danger-outline{color:#d9534f !important;background-color:transparent;background-image:none;border-color:#d9534f !important;}.btn-danger-outline:focus,.btn-danger-outline.focus,.btn-danger-outline:active,.btn-danger-outline.active,.btn-danger-outline:hover,.open>.btn-danger-outline.dropdown-toggle{color:#fff !important;background-color:#d9534f !important;border-color:#d9534f !important;}.btn-primary-outline{color:#ff761b !important;background-color:transparent;background-image:none;border-color:#ff761b !important;}.btn-primary-outline:focus,.btn-primary-outline.focus,.btn-primary-outline:active,.btn-primary-outline.active,.btn-primary-outline:hover,.open>.btn-primary-outline.dropdown-toggle{color:#fff !important;background-color:#df691a !important;border-color:#df691a !important;}.btn-info-outline{color:#5bc0de !important;background-color:transparent;background-image:none;border-color:#5bc0de !important;}.btn-info-outline:focus,.btn-info-outline.focus,.btn-info-outline:active,.btn-info-outline.active,.btn-info-outline:hover,.open>.btn-info-outline.dropdown-toggle{color:#fff !important;background-color:#5bc0de !important;border-color:#5bc0de !important;}.btn-warning-outline{color:#f0ad4e !important;background-color:transparent;background-image:none;border-color:#f0ad4e !important;}.btn-warning-outline:focus,.btn-warning-outline.focus,.btn-warning-outline:active,.btn-warning-outline.active,.btn-warning-outline:hover,.open>.btn-warning-outline.dropdown-toggle{color:#fff !important;background-color:#f0ad4e !important;border-color:#f0ad4e !important;}.btn-success-outline{color:#5cb85c !important;background-color:transparent;background-image:none;border-color:#5cb85c !important;}.btn-success-outline:focus,.btn-success-outline.focus,.btn-success-outline:active,.btn-success-outline.active,.btn-success-outline:hover,.open>.btn-success-outline.dropdown-toggle{color:#fff !important;background-color:#5cb85c !important;border-color:#5cb85c !important;} \ No newline at end of file +@media(min-width:768px){.row{position:relative;}.bottom-align-text{position:absolute;bottom:0;right:0;}}@media(max-width:48em){.home{padding-top:1rem;}}@media(min-width:48em){.home{padding-top:4rem;}}.btn{border-radius:.25rem !important;}.multiSelect{background-color:#4e5d6c;}.form-control-custom{background-color:#4e5d6c !important;color:#fff !important;}h1{font-size:3.5rem !important;font-weight:600 !important;}.request-title{margin-top:0 !important;font-size:1.9rem !important;}p{font-size:1.1rem !important;}label{display:inline-block !important;margin-bottom:.5rem !important;font-size:16px !important;}.btn-danger-outline{color:#d9534f !important;background-color:transparent;background-image:none;border-color:#d9534f !important;}.btn-danger-outline:focus,.btn-danger-outline.focus,.btn-danger-outline:active,.btn-danger-outline.active,.btn-danger-outline:hover,.open>.btn-danger-outline.dropdown-toggle{color:#fff !important;background-color:#d9534f !important;border-color:#d9534f !important;}.btn-primary-outline{color:#ff761b !important;background-color:transparent;background-image:none;border-color:#ff761b !important;}.btn-primary-outline:focus,.btn-primary-outline.focus,.btn-primary-outline:active,.btn-primary-outline.active,.btn-primary-outline:hover,.open>.btn-primary-outline.dropdown-toggle{color:#fff !important;background-color:#df691a !important;border-color:#df691a !important;}.btn-info-outline{color:#5bc0de !important;background-color:transparent;background-image:none;border-color:#5bc0de !important;}.btn-info-outline:focus,.btn-info-outline.focus,.btn-info-outline:active,.btn-info-outline.active,.btn-info-outline:hover,.open>.btn-info-outline.dropdown-toggle{color:#fff !important;background-color:#5bc0de !important;border-color:#5bc0de !important;}.btn-warning-outline{color:#f0ad4e !important;background-color:transparent;background-image:none;border-color:#f0ad4e !important;}.btn-warning-outline:focus,.btn-warning-outline.focus,.btn-warning-outline:active,.btn-warning-outline.active,.btn-warning-outline:hover,.open>.btn-warning-outline.dropdown-toggle{color:#fff !important;background-color:#f0ad4e !important;border-color:#f0ad4e !important;}.btn-success-outline{color:#5cb85c !important;background-color:transparent;background-image:none;border-color:#5cb85c !important;}.btn-success-outline:focus,.btn-success-outline.focus,.btn-success-outline:active,.btn-success-outline.active,.btn-success-outline:hover,.open>.btn-success-outline.dropdown-toggle{color:#fff !important;background-color:#5cb85c !important;border-color:#5cb85c !important;}#movieList .mix{display:none;}#tvList .mix{display:none;} \ No newline at end of file diff --git a/PlexRequests.UI/Content/custom.scss b/PlexRequests.UI/Content/custom.scss index a87a44a97..df29fcfc8 100644 --- a/PlexRequests.UI/Content/custom.scss +++ b/PlexRequests.UI/Content/custom.scss @@ -5,9 +5,7 @@ $info-colour: #5bc0de; $warning-colour: #f0ad4e; $danger-colour: #d9534f; $success-colour: #5cb85c; -$i: -!important -; +$i:!important; @media (min-width: 768px ) { .row { @@ -157,3 +155,10 @@ label { background-color: $success-colour $i; border-color: $success-colour $i; } + +#movieList .mix{ + display: none; +} +#tvList .mix{ + display: none; +} \ No newline at end of file diff --git a/PlexRequests.UI/Content/dataTables.bootstrap.css b/PlexRequests.UI/Content/dataTables.bootstrap.css new file mode 100644 index 000000000..9abe1b5b4 --- /dev/null +++ b/PlexRequests.UI/Content/dataTables.bootstrap.css @@ -0,0 +1,187 @@ +table.dataTable { + clear: both; + margin-top: 6px !important; + margin-bottom: 6px !important; + max-width: none !important; +} +table.dataTable td, +table.dataTable th { + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; +} +table.dataTable td.dataTables_empty, +table.dataTable th.dataTables_empty { + text-align: center; +} +table.dataTable.nowrap th, +table.dataTable.nowrap td { + white-space: nowrap; +} + +div.dataTables_wrapper div.dataTables_length label { + font-weight: normal; + text-align: left; + white-space: nowrap; +} +div.dataTables_wrapper div.dataTables_length select { + width: 75px; + display: inline-block; +} +div.dataTables_wrapper div.dataTables_filter { + text-align: right; +} +div.dataTables_wrapper div.dataTables_filter label { + font-weight: normal; + white-space: nowrap; + text-align: left; +} +div.dataTables_wrapper div.dataTables_filter input { + margin-left: 0.5em; + display: inline-block; + width: auto; +} +div.dataTables_wrapper div.dataTables_info { + padding-top: 8px; + white-space: nowrap; +} +div.dataTables_wrapper div.dataTables_paginate { + margin: 0; + white-space: nowrap; + text-align: right; +} +div.dataTables_wrapper div.dataTables_paginate ul.pagination { + margin: 2px 0; + white-space: nowrap; +} +div.dataTables_wrapper div.dataTables_processing { + position: absolute; + top: 50%; + left: 50%; + width: 200px; + margin-left: -100px; + margin-top: -26px; + text-align: center; + padding: 1em 0; +} + +table.dataTable thead > tr > th.sorting_asc, table.dataTable thead > tr > th.sorting_desc, table.dataTable thead > tr > th.sorting, +table.dataTable thead > tr > td.sorting_asc, +table.dataTable thead > tr > td.sorting_desc, +table.dataTable thead > tr > td.sorting { + padding-right: 30px; +} +table.dataTable thead > tr > th:active, +table.dataTable thead > tr > td:active { + outline: none; +} +table.dataTable thead .sorting, +table.dataTable thead .sorting_asc, +table.dataTable thead .sorting_desc, +table.dataTable thead .sorting_asc_disabled, +table.dataTable thead .sorting_desc_disabled { + cursor: pointer; + position: relative; +} +table.dataTable thead .sorting:after, +table.dataTable thead .sorting_asc:after, +table.dataTable thead .sorting_desc:after, +table.dataTable thead .sorting_asc_disabled:after, +table.dataTable thead .sorting_desc_disabled:after { + position: absolute; + bottom: 8px; + right: 8px; + display: block; + font-family: 'Glyphicons Halflings'; + opacity: 0.5; +} +table.dataTable thead .sorting:after { + opacity: 0.2; + content: "\e150"; + /* sort */ +} +table.dataTable thead .sorting_asc:after { + content: "\e155"; + /* sort-by-attributes */ +} +table.dataTable thead .sorting_desc:after { + content: "\e156"; + /* sort-by-attributes-alt */ +} +table.dataTable thead .sorting_asc_disabled:after, +table.dataTable thead .sorting_desc_disabled:after { + color: #eee; +} + +div.dataTables_scrollHead table.dataTable { + margin-bottom: 0 !important; +} + +div.dataTables_scrollBody table { + border-top: none; + margin-top: 0 !important; + margin-bottom: 0 !important; +} +div.dataTables_scrollBody table thead .sorting:after, +div.dataTables_scrollBody table thead .sorting_asc:after, +div.dataTables_scrollBody table thead .sorting_desc:after { + display: none; +} +div.dataTables_scrollBody table tbody tr:first-child th, +div.dataTables_scrollBody table tbody tr:first-child td { + border-top: none; +} + +div.dataTables_scrollFoot table { + margin-top: 0 !important; + border-top: none; +} + +@media screen and (max-width: 767px) { + div.dataTables_wrapper div.dataTables_length, + div.dataTables_wrapper div.dataTables_filter, + div.dataTables_wrapper div.dataTables_info, + div.dataTables_wrapper div.dataTables_paginate { + text-align: center; + } +} +table.dataTable.table-condensed > thead > tr > th { + padding-right: 20px; +} +table.dataTable.table-condensed .sorting:after, +table.dataTable.table-condensed .sorting_asc:after, +table.dataTable.table-condensed .sorting_desc:after { + top: 6px; + right: 6px; +} + +table.table-bordered.dataTable { + border-collapse: separate !important; +} +table.table-bordered.dataTable th, +table.table-bordered.dataTable td { + border-left-width: 0; +} +table.table-bordered.dataTable th:last-child, table.table-bordered.dataTable th:last-child, +table.table-bordered.dataTable td:last-child, +table.table-bordered.dataTable td:last-child { + border-right-width: 0; +} +table.table-bordered.dataTable tbody th, +table.table-bordered.dataTable tbody td { + border-bottom-width: 0; +} + +div.dataTables_scrollHead table.table-bordered { + border-bottom-width: 0; +} + +div.table-responsive > div.dataTables_wrapper > div.row { + margin: 0; +} +div.table-responsive > div.dataTables_wrapper > div.row > div[class^="col-"]:first-child { + padding-left: 0; +} +div.table-responsive > div.dataTables_wrapper > div.row > div[class^="col-"]:last-child { + padding-right: 0; +} diff --git a/PlexRequests.UI/Content/datatables.js b/PlexRequests.UI/Content/datatables.js new file mode 100644 index 000000000..e4f23def4 --- /dev/null +++ b/PlexRequests.UI/Content/datatables.js @@ -0,0 +1,19026 @@ +/* + * This combined file was created by the DataTables downloader builder: + * https://datatables.net/download + * + * To rebuild or modify this file with the latest versions of the included + * software please visit: + * https://datatables.net/download/#bs-3.3.6/dt-1.10.11,r-2.0.2 + * + * Included libraries: + * Bootstrap 3.3.6, DataTables 1.10.11, Responsive 2.0.2 + */ + +/*! + * Bootstrap v3.3.6 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under the MIT license + */ + +if (typeof jQuery === 'undefined') { + throw new Error('Bootstrap\'s JavaScript requires jQuery') +} + ++function ($) { + 'use strict'; + var version = $.fn.jquery.split(' ')[0].split('.') + if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 2)) { + throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 3') + } +}(jQuery); + +/* ======================================================================== + * Bootstrap: transition.js v3.3.6 + * http://getbootstrap.com/javascript/#transitions + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // CSS TRANSITION SUPPORT (Shoutout: http://www.modernizr.com/) + // ============================================================ + + function transitionEnd() { + var el = document.createElement('bootstrap') + + var transEndEventNames = { + WebkitTransition : 'webkitTransitionEnd', + MozTransition : 'transitionend', + OTransition : 'oTransitionEnd otransitionend', + transition : 'transitionend' + } + + for (var name in transEndEventNames) { + if (el.style[name] !== undefined) { + return { end: transEndEventNames[name] } + } + } + + return false // explicit for ie8 ( ._.) + } + + // http://blog.alexmaccaw.com/css-transitions + $.fn.emulateTransitionEnd = function (duration) { + var called = false + var $el = this + $(this).one('bsTransitionEnd', function () { called = true }) + var callback = function () { if (!called) $($el).trigger($.support.transition.end) } + setTimeout(callback, duration) + return this + } + + $(function () { + $.support.transition = transitionEnd() + + if (!$.support.transition) return + + $.event.special.bsTransitionEnd = { + bindType: $.support.transition.end, + delegateType: $.support.transition.end, + handle: function (e) { + if ($(e.target).is(this)) return e.handleObj.handler.apply(this, arguments) + } + } + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: alert.js v3.3.6 + * http://getbootstrap.com/javascript/#alerts + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // ALERT CLASS DEFINITION + // ====================== + + var dismiss = '[data-dismiss="alert"]' + var Alert = function (el) { + $(el).on('click', dismiss, this.close) + } + + Alert.VERSION = '3.3.6' + + Alert.TRANSITION_DURATION = 150 + + Alert.prototype.close = function (e) { + var $this = $(this) + var selector = $this.attr('data-target') + + if (!selector) { + selector = $this.attr('href') + selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 + } + + var $parent = $(selector) + + if (e) e.preventDefault() + + if (!$parent.length) { + $parent = $this.closest('.alert') + } + + $parent.trigger(e = $.Event('close.bs.alert')) + + if (e.isDefaultPrevented()) return + + $parent.removeClass('in') + + function removeElement() { + // detach from parent, fire event then clean up data + $parent.detach().trigger('closed.bs.alert').remove() + } + + $.support.transition && $parent.hasClass('fade') ? + $parent + .one('bsTransitionEnd', removeElement) + .emulateTransitionEnd(Alert.TRANSITION_DURATION) : + removeElement() + } + + + // ALERT PLUGIN DEFINITION + // ======================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.alert') + + if (!data) $this.data('bs.alert', (data = new Alert(this))) + if (typeof option == 'string') data[option].call($this) + }) + } + + var old = $.fn.alert + + $.fn.alert = Plugin + $.fn.alert.Constructor = Alert + + + // ALERT NO CONFLICT + // ================= + + $.fn.alert.noConflict = function () { + $.fn.alert = old + return this + } + + + // ALERT DATA-API + // ============== + + $(document).on('click.bs.alert.data-api', dismiss, Alert.prototype.close) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: button.js v3.3.6 + * http://getbootstrap.com/javascript/#buttons + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // BUTTON PUBLIC CLASS DEFINITION + // ============================== + + var Button = function (element, options) { + this.$element = $(element) + this.options = $.extend({}, Button.DEFAULTS, options) + this.isLoading = false + } + + Button.VERSION = '3.3.6' + + Button.DEFAULTS = { + loadingText: 'loading...' + } + + Button.prototype.setState = function (state) { + var d = 'disabled' + var $el = this.$element + var val = $el.is('input') ? 'val' : 'html' + var data = $el.data() + + state += 'Text' + + if (data.resetText == null) $el.data('resetText', $el[val]()) + + // push to event loop to allow forms to submit + setTimeout($.proxy(function () { + $el[val](data[state] == null ? this.options[state] : data[state]) + + if (state == 'loadingText') { + this.isLoading = true + $el.addClass(d).attr(d, d) + } else if (this.isLoading) { + this.isLoading = false + $el.removeClass(d).removeAttr(d) + } + }, this), 0) + } + + Button.prototype.toggle = function () { + var changed = true + var $parent = this.$element.closest('[data-toggle="buttons"]') + + if ($parent.length) { + var $input = this.$element.find('input') + if ($input.prop('type') == 'radio') { + if ($input.prop('checked')) changed = false + $parent.find('.active').removeClass('active') + this.$element.addClass('active') + } else if ($input.prop('type') == 'checkbox') { + if (($input.prop('checked')) !== this.$element.hasClass('active')) changed = false + this.$element.toggleClass('active') + } + $input.prop('checked', this.$element.hasClass('active')) + if (changed) $input.trigger('change') + } else { + this.$element.attr('aria-pressed', !this.$element.hasClass('active')) + this.$element.toggleClass('active') + } + } + + + // BUTTON PLUGIN DEFINITION + // ======================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.button') + var options = typeof option == 'object' && option + + if (!data) $this.data('bs.button', (data = new Button(this, options))) + + if (option == 'toggle') data.toggle() + else if (option) data.setState(option) + }) + } + + var old = $.fn.button + + $.fn.button = Plugin + $.fn.button.Constructor = Button + + + // BUTTON NO CONFLICT + // ================== + + $.fn.button.noConflict = function () { + $.fn.button = old + return this + } + + + // BUTTON DATA-API + // =============== + + $(document) + .on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) { + var $btn = $(e.target) + if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn') + Plugin.call($btn, 'toggle') + if (!($(e.target).is('input[type="radio"]') || $(e.target).is('input[type="checkbox"]'))) e.preventDefault() + }) + .on('focus.bs.button.data-api blur.bs.button.data-api', '[data-toggle^="button"]', function (e) { + $(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type)) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: carousel.js v3.3.6 + * http://getbootstrap.com/javascript/#carousel + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // CAROUSEL CLASS DEFINITION + // ========================= + + var Carousel = function (element, options) { + this.$element = $(element) + this.$indicators = this.$element.find('.carousel-indicators') + this.options = options + this.paused = null + this.sliding = null + this.interval = null + this.$active = null + this.$items = null + + this.options.keyboard && this.$element.on('keydown.bs.carousel', $.proxy(this.keydown, this)) + + this.options.pause == 'hover' && !('ontouchstart' in document.documentElement) && this.$element + .on('mouseenter.bs.carousel', $.proxy(this.pause, this)) + .on('mouseleave.bs.carousel', $.proxy(this.cycle, this)) + } + + Carousel.VERSION = '3.3.6' + + Carousel.TRANSITION_DURATION = 600 + + Carousel.DEFAULTS = { + interval: 5000, + pause: 'hover', + wrap: true, + keyboard: true + } + + Carousel.prototype.keydown = function (e) { + if (/input|textarea/i.test(e.target.tagName)) return + switch (e.which) { + case 37: this.prev(); break + case 39: this.next(); break + default: return + } + + e.preventDefault() + } + + Carousel.prototype.cycle = function (e) { + e || (this.paused = false) + + this.interval && clearInterval(this.interval) + + this.options.interval + && !this.paused + && (this.interval = setInterval($.proxy(this.next, this), this.options.interval)) + + return this + } + + Carousel.prototype.getItemIndex = function (item) { + this.$items = item.parent().children('.item') + return this.$items.index(item || this.$active) + } + + Carousel.prototype.getItemForDirection = function (direction, active) { + var activeIndex = this.getItemIndex(active) + var willWrap = (direction == 'prev' && activeIndex === 0) + || (direction == 'next' && activeIndex == (this.$items.length - 1)) + if (willWrap && !this.options.wrap) return active + var delta = direction == 'prev' ? -1 : 1 + var itemIndex = (activeIndex + delta) % this.$items.length + return this.$items.eq(itemIndex) + } + + Carousel.prototype.to = function (pos) { + var that = this + var activeIndex = this.getItemIndex(this.$active = this.$element.find('.item.active')) + + if (pos > (this.$items.length - 1) || pos < 0) return + + if (this.sliding) return this.$element.one('slid.bs.carousel', function () { that.to(pos) }) // yes, "slid" + if (activeIndex == pos) return this.pause().cycle() + + return this.slide(pos > activeIndex ? 'next' : 'prev', this.$items.eq(pos)) + } + + Carousel.prototype.pause = function (e) { + e || (this.paused = true) + + if (this.$element.find('.next, .prev').length && $.support.transition) { + this.$element.trigger($.support.transition.end) + this.cycle(true) + } + + this.interval = clearInterval(this.interval) + + return this + } + + Carousel.prototype.next = function () { + if (this.sliding) return + return this.slide('next') + } + + Carousel.prototype.prev = function () { + if (this.sliding) return + return this.slide('prev') + } + + Carousel.prototype.slide = function (type, next) { + var $active = this.$element.find('.item.active') + var $next = next || this.getItemForDirection(type, $active) + var isCycling = this.interval + var direction = type == 'next' ? 'left' : 'right' + var that = this + + if ($next.hasClass('active')) return (this.sliding = false) + + var relatedTarget = $next[0] + var slideEvent = $.Event('slide.bs.carousel', { + relatedTarget: relatedTarget, + direction: direction + }) + this.$element.trigger(slideEvent) + if (slideEvent.isDefaultPrevented()) return + + this.sliding = true + + isCycling && this.pause() + + if (this.$indicators.length) { + this.$indicators.find('.active').removeClass('active') + var $nextIndicator = $(this.$indicators.children()[this.getItemIndex($next)]) + $nextIndicator && $nextIndicator.addClass('active') + } + + var slidEvent = $.Event('slid.bs.carousel', { relatedTarget: relatedTarget, direction: direction }) // yes, "slid" + if ($.support.transition && this.$element.hasClass('slide')) { + $next.addClass(type) + $next[0].offsetWidth // force reflow + $active.addClass(direction) + $next.addClass(direction) + $active + .one('bsTransitionEnd', function () { + $next.removeClass([type, direction].join(' ')).addClass('active') + $active.removeClass(['active', direction].join(' ')) + that.sliding = false + setTimeout(function () { + that.$element.trigger(slidEvent) + }, 0) + }) + .emulateTransitionEnd(Carousel.TRANSITION_DURATION) + } else { + $active.removeClass('active') + $next.addClass('active') + this.sliding = false + this.$element.trigger(slidEvent) + } + + isCycling && this.cycle() + + return this + } + + + // CAROUSEL PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.carousel') + var options = $.extend({}, Carousel.DEFAULTS, $this.data(), typeof option == 'object' && option) + var action = typeof option == 'string' ? option : options.slide + + if (!data) $this.data('bs.carousel', (data = new Carousel(this, options))) + if (typeof option == 'number') data.to(option) + else if (action) data[action]() + else if (options.interval) data.pause().cycle() + }) + } + + var old = $.fn.carousel + + $.fn.carousel = Plugin + $.fn.carousel.Constructor = Carousel + + + // CAROUSEL NO CONFLICT + // ==================== + + $.fn.carousel.noConflict = function () { + $.fn.carousel = old + return this + } + + + // CAROUSEL DATA-API + // ================= + + var clickHandler = function (e) { + var href + var $this = $(this) + var $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7 + if (!$target.hasClass('carousel')) return + var options = $.extend({}, $target.data(), $this.data()) + var slideIndex = $this.attr('data-slide-to') + if (slideIndex) options.interval = false + + Plugin.call($target, options) + + if (slideIndex) { + $target.data('bs.carousel').to(slideIndex) + } + + e.preventDefault() + } + + $(document) + .on('click.bs.carousel.data-api', '[data-slide]', clickHandler) + .on('click.bs.carousel.data-api', '[data-slide-to]', clickHandler) + + $(window).on('load', function () { + $('[data-ride="carousel"]').each(function () { + var $carousel = $(this) + Plugin.call($carousel, $carousel.data()) + }) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: collapse.js v3.3.6 + * http://getbootstrap.com/javascript/#collapse + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // COLLAPSE PUBLIC CLASS DEFINITION + // ================================ + + var Collapse = function (element, options) { + this.$element = $(element) + this.options = $.extend({}, Collapse.DEFAULTS, options) + this.$trigger = $('[data-toggle="collapse"][href="#' + element.id + '"],' + + '[data-toggle="collapse"][data-target="#' + element.id + '"]') + this.transitioning = null + + if (this.options.parent) { + this.$parent = this.getParent() + } else { + this.addAriaAndCollapsedClass(this.$element, this.$trigger) + } + + if (this.options.toggle) this.toggle() + } + + Collapse.VERSION = '3.3.6' + + Collapse.TRANSITION_DURATION = 350 + + Collapse.DEFAULTS = { + toggle: true + } + + Collapse.prototype.dimension = function () { + var hasWidth = this.$element.hasClass('width') + return hasWidth ? 'width' : 'height' + } + + Collapse.prototype.show = function () { + if (this.transitioning || this.$element.hasClass('in')) return + + var activesData + var actives = this.$parent && this.$parent.children('.panel').children('.in, .collapsing') + + if (actives && actives.length) { + activesData = actives.data('bs.collapse') + if (activesData && activesData.transitioning) return + } + + var startEvent = $.Event('show.bs.collapse') + this.$element.trigger(startEvent) + if (startEvent.isDefaultPrevented()) return + + if (actives && actives.length) { + Plugin.call(actives, 'hide') + activesData || actives.data('bs.collapse', null) + } + + var dimension = this.dimension() + + this.$element + .removeClass('collapse') + .addClass('collapsing')[dimension](0) + .attr('aria-expanded', true) + + this.$trigger + .removeClass('collapsed') + .attr('aria-expanded', true) + + this.transitioning = 1 + + var complete = function () { + this.$element + .removeClass('collapsing') + .addClass('collapse in')[dimension]('') + this.transitioning = 0 + this.$element + .trigger('shown.bs.collapse') + } + + if (!$.support.transition) return complete.call(this) + + var scrollSize = $.camelCase(['scroll', dimension].join('-')) + + this.$element + .one('bsTransitionEnd', $.proxy(complete, this)) + .emulateTransitionEnd(Collapse.TRANSITION_DURATION)[dimension](this.$element[0][scrollSize]) + } + + Collapse.prototype.hide = function () { + if (this.transitioning || !this.$element.hasClass('in')) return + + var startEvent = $.Event('hide.bs.collapse') + this.$element.trigger(startEvent) + if (startEvent.isDefaultPrevented()) return + + var dimension = this.dimension() + + this.$element[dimension](this.$element[dimension]())[0].offsetHeight + + this.$element + .addClass('collapsing') + .removeClass('collapse in') + .attr('aria-expanded', false) + + this.$trigger + .addClass('collapsed') + .attr('aria-expanded', false) + + this.transitioning = 1 + + var complete = function () { + this.transitioning = 0 + this.$element + .removeClass('collapsing') + .addClass('collapse') + .trigger('hidden.bs.collapse') + } + + if (!$.support.transition) return complete.call(this) + + this.$element + [dimension](0) + .one('bsTransitionEnd', $.proxy(complete, this)) + .emulateTransitionEnd(Collapse.TRANSITION_DURATION) + } + + Collapse.prototype.toggle = function () { + this[this.$element.hasClass('in') ? 'hide' : 'show']() + } + + Collapse.prototype.getParent = function () { + return $(this.options.parent) + .find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]') + .each($.proxy(function (i, element) { + var $element = $(element) + this.addAriaAndCollapsedClass(getTargetFromTrigger($element), $element) + }, this)) + .end() + } + + Collapse.prototype.addAriaAndCollapsedClass = function ($element, $trigger) { + var isOpen = $element.hasClass('in') + + $element.attr('aria-expanded', isOpen) + $trigger + .toggleClass('collapsed', !isOpen) + .attr('aria-expanded', isOpen) + } + + function getTargetFromTrigger($trigger) { + var href + var target = $trigger.attr('data-target') + || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7 + + return $(target) + } + + + // COLLAPSE PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.collapse') + var options = $.extend({}, Collapse.DEFAULTS, $this.data(), typeof option == 'object' && option) + + if (!data && options.toggle && /show|hide/.test(option)) options.toggle = false + if (!data) $this.data('bs.collapse', (data = new Collapse(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.collapse + + $.fn.collapse = Plugin + $.fn.collapse.Constructor = Collapse + + + // COLLAPSE NO CONFLICT + // ==================== + + $.fn.collapse.noConflict = function () { + $.fn.collapse = old + return this + } + + + // COLLAPSE DATA-API + // ================= + + $(document).on('click.bs.collapse.data-api', '[data-toggle="collapse"]', function (e) { + var $this = $(this) + + if (!$this.attr('data-target')) e.preventDefault() + + var $target = getTargetFromTrigger($this) + var data = $target.data('bs.collapse') + var option = data ? 'toggle' : $this.data() + + Plugin.call($target, option) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: dropdown.js v3.3.6 + * http://getbootstrap.com/javascript/#dropdowns + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // DROPDOWN CLASS DEFINITION + // ========================= + + var backdrop = '.dropdown-backdrop' + var toggle = '[data-toggle="dropdown"]' + var Dropdown = function (element) { + $(element).on('click.bs.dropdown', this.toggle) + } + + Dropdown.VERSION = '3.3.6' + + function getParent($this) { + var selector = $this.attr('data-target') + + if (!selector) { + selector = $this.attr('href') + selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 + } + + var $parent = selector && $(selector) + + return $parent && $parent.length ? $parent : $this.parent() + } + + function clearMenus(e) { + if (e && e.which === 3) return + $(backdrop).remove() + $(toggle).each(function () { + var $this = $(this) + var $parent = getParent($this) + var relatedTarget = { relatedTarget: this } + + if (!$parent.hasClass('open')) return + + if (e && e.type == 'click' && /input|textarea/i.test(e.target.tagName) && $.contains($parent[0], e.target)) return + + $parent.trigger(e = $.Event('hide.bs.dropdown', relatedTarget)) + + if (e.isDefaultPrevented()) return + + $this.attr('aria-expanded', 'false') + $parent.removeClass('open').trigger($.Event('hidden.bs.dropdown', relatedTarget)) + }) + } + + Dropdown.prototype.toggle = function (e) { + var $this = $(this) + + if ($this.is('.disabled, :disabled')) return + + var $parent = getParent($this) + var isActive = $parent.hasClass('open') + + clearMenus() + + if (!isActive) { + if ('ontouchstart' in document.documentElement && !$parent.closest('.navbar-nav').length) { + // if mobile we use a backdrop because click events don't delegate + $(document.createElement('div')) + .addClass('dropdown-backdrop') + .insertAfter($(this)) + .on('click', clearMenus) + } + + var relatedTarget = { relatedTarget: this } + $parent.trigger(e = $.Event('show.bs.dropdown', relatedTarget)) + + if (e.isDefaultPrevented()) return + + $this + .trigger('focus') + .attr('aria-expanded', 'true') + + $parent + .toggleClass('open') + .trigger($.Event('shown.bs.dropdown', relatedTarget)) + } + + return false + } + + Dropdown.prototype.keydown = function (e) { + if (!/(38|40|27|32)/.test(e.which) || /input|textarea/i.test(e.target.tagName)) return + + var $this = $(this) + + e.preventDefault() + e.stopPropagation() + + if ($this.is('.disabled, :disabled')) return + + var $parent = getParent($this) + var isActive = $parent.hasClass('open') + + if (!isActive && e.which != 27 || isActive && e.which == 27) { + if (e.which == 27) $parent.find(toggle).trigger('focus') + return $this.trigger('click') + } + + var desc = ' li:not(.disabled):visible a' + var $items = $parent.find('.dropdown-menu' + desc) + + if (!$items.length) return + + var index = $items.index(e.target) + + if (e.which == 38 && index > 0) index-- // up + if (e.which == 40 && index < $items.length - 1) index++ // down + if (!~index) index = 0 + + $items.eq(index).trigger('focus') + } + + + // DROPDOWN PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.dropdown') + + if (!data) $this.data('bs.dropdown', (data = new Dropdown(this))) + if (typeof option == 'string') data[option].call($this) + }) + } + + var old = $.fn.dropdown + + $.fn.dropdown = Plugin + $.fn.dropdown.Constructor = Dropdown + + + // DROPDOWN NO CONFLICT + // ==================== + + $.fn.dropdown.noConflict = function () { + $.fn.dropdown = old + return this + } + + + // APPLY TO STANDARD DROPDOWN ELEMENTS + // =================================== + + $(document) + .on('click.bs.dropdown.data-api', clearMenus) + .on('click.bs.dropdown.data-api', '.dropdown form', function (e) { e.stopPropagation() }) + .on('click.bs.dropdown.data-api', toggle, Dropdown.prototype.toggle) + .on('keydown.bs.dropdown.data-api', toggle, Dropdown.prototype.keydown) + .on('keydown.bs.dropdown.data-api', '.dropdown-menu', Dropdown.prototype.keydown) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: modal.js v3.3.6 + * http://getbootstrap.com/javascript/#modals + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // MODAL CLASS DEFINITION + // ====================== + + var Modal = function (element, options) { + this.options = options + this.$body = $(document.body) + this.$element = $(element) + this.$dialog = this.$element.find('.modal-dialog') + this.$backdrop = null + this.isShown = null + this.originalBodyPad = null + this.scrollbarWidth = 0 + this.ignoreBackdropClick = false + + if (this.options.remote) { + this.$element + .find('.modal-content') + .load(this.options.remote, $.proxy(function () { + this.$element.trigger('loaded.bs.modal') + }, this)) + } + } + + Modal.VERSION = '3.3.6' + + Modal.TRANSITION_DURATION = 300 + Modal.BACKDROP_TRANSITION_DURATION = 150 + + Modal.DEFAULTS = { + backdrop: true, + keyboard: true, + show: true + } + + Modal.prototype.toggle = function (_relatedTarget) { + return this.isShown ? this.hide() : this.show(_relatedTarget) + } + + Modal.prototype.show = function (_relatedTarget) { + var that = this + var e = $.Event('show.bs.modal', { relatedTarget: _relatedTarget }) + + this.$element.trigger(e) + + if (this.isShown || e.isDefaultPrevented()) return + + this.isShown = true + + this.checkScrollbar() + this.setScrollbar() + this.$body.addClass('modal-open') + + this.escape() + this.resize() + + this.$element.on('click.dismiss.bs.modal', '[data-dismiss="modal"]', $.proxy(this.hide, this)) + + this.$dialog.on('mousedown.dismiss.bs.modal', function () { + that.$element.one('mouseup.dismiss.bs.modal', function (e) { + if ($(e.target).is(that.$element)) that.ignoreBackdropClick = true + }) + }) + + this.backdrop(function () { + var transition = $.support.transition && that.$element.hasClass('fade') + + if (!that.$element.parent().length) { + that.$element.appendTo(that.$body) // don't move modals dom position + } + + that.$element + .show() + .scrollTop(0) + + that.adjustDialog() + + if (transition) { + that.$element[0].offsetWidth // force reflow + } + + that.$element.addClass('in') + + that.enforceFocus() + + var e = $.Event('shown.bs.modal', { relatedTarget: _relatedTarget }) + + transition ? + that.$dialog // wait for modal to slide in + .one('bsTransitionEnd', function () { + that.$element.trigger('focus').trigger(e) + }) + .emulateTransitionEnd(Modal.TRANSITION_DURATION) : + that.$element.trigger('focus').trigger(e) + }) + } + + Modal.prototype.hide = function (e) { + if (e) e.preventDefault() + + e = $.Event('hide.bs.modal') + + this.$element.trigger(e) + + if (!this.isShown || e.isDefaultPrevented()) return + + this.isShown = false + + this.escape() + this.resize() + + $(document).off('focusin.bs.modal') + + this.$element + .removeClass('in') + .off('click.dismiss.bs.modal') + .off('mouseup.dismiss.bs.modal') + + this.$dialog.off('mousedown.dismiss.bs.modal') + + $.support.transition && this.$element.hasClass('fade') ? + this.$element + .one('bsTransitionEnd', $.proxy(this.hideModal, this)) + .emulateTransitionEnd(Modal.TRANSITION_DURATION) : + this.hideModal() + } + + Modal.prototype.enforceFocus = function () { + $(document) + .off('focusin.bs.modal') // guard against infinite focus loop + .on('focusin.bs.modal', $.proxy(function (e) { + if (this.$element[0] !== e.target && !this.$element.has(e.target).length) { + this.$element.trigger('focus') + } + }, this)) + } + + Modal.prototype.escape = function () { + if (this.isShown && this.options.keyboard) { + this.$element.on('keydown.dismiss.bs.modal', $.proxy(function (e) { + e.which == 27 && this.hide() + }, this)) + } else if (!this.isShown) { + this.$element.off('keydown.dismiss.bs.modal') + } + } + + Modal.prototype.resize = function () { + if (this.isShown) { + $(window).on('resize.bs.modal', $.proxy(this.handleUpdate, this)) + } else { + $(window).off('resize.bs.modal') + } + } + + Modal.prototype.hideModal = function () { + var that = this + this.$element.hide() + this.backdrop(function () { + that.$body.removeClass('modal-open') + that.resetAdjustments() + that.resetScrollbar() + that.$element.trigger('hidden.bs.modal') + }) + } + + Modal.prototype.removeBackdrop = function () { + this.$backdrop && this.$backdrop.remove() + this.$backdrop = null + } + + Modal.prototype.backdrop = function (callback) { + var that = this + var animate = this.$element.hasClass('fade') ? 'fade' : '' + + if (this.isShown && this.options.backdrop) { + var doAnimate = $.support.transition && animate + + this.$backdrop = $(document.createElement('div')) + .addClass('modal-backdrop ' + animate) + .appendTo(this.$body) + + this.$element.on('click.dismiss.bs.modal', $.proxy(function (e) { + if (this.ignoreBackdropClick) { + this.ignoreBackdropClick = false + return + } + if (e.target !== e.currentTarget) return + this.options.backdrop == 'static' + ? this.$element[0].focus() + : this.hide() + }, this)) + + if (doAnimate) this.$backdrop[0].offsetWidth // force reflow + + this.$backdrop.addClass('in') + + if (!callback) return + + doAnimate ? + this.$backdrop + .one('bsTransitionEnd', callback) + .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) : + callback() + + } else if (!this.isShown && this.$backdrop) { + this.$backdrop.removeClass('in') + + var callbackRemove = function () { + that.removeBackdrop() + callback && callback() + } + $.support.transition && this.$element.hasClass('fade') ? + this.$backdrop + .one('bsTransitionEnd', callbackRemove) + .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) : + callbackRemove() + + } else if (callback) { + callback() + } + } + + // these following methods are used to handle overflowing modals + + Modal.prototype.handleUpdate = function () { + this.adjustDialog() + } + + Modal.prototype.adjustDialog = function () { + var modalIsOverflowing = this.$element[0].scrollHeight > document.documentElement.clientHeight + + this.$element.css({ + paddingLeft: !this.bodyIsOverflowing && modalIsOverflowing ? this.scrollbarWidth : '', + paddingRight: this.bodyIsOverflowing && !modalIsOverflowing ? this.scrollbarWidth : '' + }) + } + + Modal.prototype.resetAdjustments = function () { + this.$element.css({ + paddingLeft: '', + paddingRight: '' + }) + } + + Modal.prototype.checkScrollbar = function () { + var fullWindowWidth = window.innerWidth + if (!fullWindowWidth) { // workaround for missing window.innerWidth in IE8 + var documentElementRect = document.documentElement.getBoundingClientRect() + fullWindowWidth = documentElementRect.right - Math.abs(documentElementRect.left) + } + this.bodyIsOverflowing = document.body.clientWidth < fullWindowWidth + this.scrollbarWidth = this.measureScrollbar() + } + + Modal.prototype.setScrollbar = function () { + var bodyPad = parseInt((this.$body.css('padding-right') || 0), 10) + this.originalBodyPad = document.body.style.paddingRight || '' + if (this.bodyIsOverflowing) this.$body.css('padding-right', bodyPad + this.scrollbarWidth) + } + + Modal.prototype.resetScrollbar = function () { + this.$body.css('padding-right', this.originalBodyPad) + } + + Modal.prototype.measureScrollbar = function () { // thx walsh + var scrollDiv = document.createElement('div') + scrollDiv.className = 'modal-scrollbar-measure' + this.$body.append(scrollDiv) + var scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth + this.$body[0].removeChild(scrollDiv) + return scrollbarWidth + } + + + // MODAL PLUGIN DEFINITION + // ======================= + + function Plugin(option, _relatedTarget) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.modal') + var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option == 'object' && option) + + if (!data) $this.data('bs.modal', (data = new Modal(this, options))) + if (typeof option == 'string') data[option](_relatedTarget) + else if (options.show) data.show(_relatedTarget) + }) + } + + var old = $.fn.modal + + $.fn.modal = Plugin + $.fn.modal.Constructor = Modal + + + // MODAL NO CONFLICT + // ================= + + $.fn.modal.noConflict = function () { + $.fn.modal = old + return this + } + + + // MODAL DATA-API + // ============== + + $(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) { + var $this = $(this) + var href = $this.attr('href') + var $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))) // strip for ie7 + var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data()) + + if ($this.is('a')) e.preventDefault() + + $target.one('show.bs.modal', function (showEvent) { + if (showEvent.isDefaultPrevented()) return // only register focus restorer if modal will actually get shown + $target.one('hidden.bs.modal', function () { + $this.is(':visible') && $this.trigger('focus') + }) + }) + Plugin.call($target, option, this) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: tooltip.js v3.3.6 + * http://getbootstrap.com/javascript/#tooltip + * Inspired by the original jQuery.tipsy by Jason Frame + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // TOOLTIP PUBLIC CLASS DEFINITION + // =============================== + + var Tooltip = function (element, options) { + this.type = null + this.options = null + this.enabled = null + this.timeout = null + this.hoverState = null + this.$element = null + this.inState = null + + this.init('tooltip', element, options) + } + + Tooltip.VERSION = '3.3.6' + + Tooltip.TRANSITION_DURATION = 150 + + Tooltip.DEFAULTS = { + animation: true, + placement: 'top', + selector: false, + template: '', + trigger: 'hover focus', + title: '', + delay: 0, + html: false, + container: false, + viewport: { + selector: 'body', + padding: 0 + } + } + + Tooltip.prototype.init = function (type, element, options) { + this.enabled = true + this.type = type + this.$element = $(element) + this.options = this.getOptions(options) + this.$viewport = this.options.viewport && $($.isFunction(this.options.viewport) ? this.options.viewport.call(this, this.$element) : (this.options.viewport.selector || this.options.viewport)) + this.inState = { click: false, hover: false, focus: false } + + if (this.$element[0] instanceof document.constructor && !this.options.selector) { + throw new Error('`selector` option must be specified when initializing ' + this.type + ' on the window.document object!') + } + + var triggers = this.options.trigger.split(' ') + + for (var i = triggers.length; i--;) { + var trigger = triggers[i] + + if (trigger == 'click') { + this.$element.on('click.' + this.type, this.options.selector, $.proxy(this.toggle, this)) + } else if (trigger != 'manual') { + var eventIn = trigger == 'hover' ? 'mouseenter' : 'focusin' + var eventOut = trigger == 'hover' ? 'mouseleave' : 'focusout' + + this.$element.on(eventIn + '.' + this.type, this.options.selector, $.proxy(this.enter, this)) + this.$element.on(eventOut + '.' + this.type, this.options.selector, $.proxy(this.leave, this)) + } + } + + this.options.selector ? + (this._options = $.extend({}, this.options, { trigger: 'manual', selector: '' })) : + this.fixTitle() + } + + Tooltip.prototype.getDefaults = function () { + return Tooltip.DEFAULTS + } + + Tooltip.prototype.getOptions = function (options) { + options = $.extend({}, this.getDefaults(), this.$element.data(), options) + + if (options.delay && typeof options.delay == 'number') { + options.delay = { + show: options.delay, + hide: options.delay + } + } + + return options + } + + Tooltip.prototype.getDelegateOptions = function () { + var options = {} + var defaults = this.getDefaults() + + this._options && $.each(this._options, function (key, value) { + if (defaults[key] != value) options[key] = value + }) + + return options + } + + Tooltip.prototype.enter = function (obj) { + var self = obj instanceof this.constructor ? + obj : $(obj.currentTarget).data('bs.' + this.type) + + if (!self) { + self = new this.constructor(obj.currentTarget, this.getDelegateOptions()) + $(obj.currentTarget).data('bs.' + this.type, self) + } + + if (obj instanceof $.Event) { + self.inState[obj.type == 'focusin' ? 'focus' : 'hover'] = true + } + + if (self.tip().hasClass('in') || self.hoverState == 'in') { + self.hoverState = 'in' + return + } + + clearTimeout(self.timeout) + + self.hoverState = 'in' + + if (!self.options.delay || !self.options.delay.show) return self.show() + + self.timeout = setTimeout(function () { + if (self.hoverState == 'in') self.show() + }, self.options.delay.show) + } + + Tooltip.prototype.isInStateTrue = function () { + for (var key in this.inState) { + if (this.inState[key]) return true + } + + return false + } + + Tooltip.prototype.leave = function (obj) { + var self = obj instanceof this.constructor ? + obj : $(obj.currentTarget).data('bs.' + this.type) + + if (!self) { + self = new this.constructor(obj.currentTarget, this.getDelegateOptions()) + $(obj.currentTarget).data('bs.' + this.type, self) + } + + if (obj instanceof $.Event) { + self.inState[obj.type == 'focusout' ? 'focus' : 'hover'] = false + } + + if (self.isInStateTrue()) return + + clearTimeout(self.timeout) + + self.hoverState = 'out' + + if (!self.options.delay || !self.options.delay.hide) return self.hide() + + self.timeout = setTimeout(function () { + if (self.hoverState == 'out') self.hide() + }, self.options.delay.hide) + } + + Tooltip.prototype.show = function () { + var e = $.Event('show.bs.' + this.type) + + if (this.hasContent() && this.enabled) { + this.$element.trigger(e) + + var inDom = $.contains(this.$element[0].ownerDocument.documentElement, this.$element[0]) + if (e.isDefaultPrevented() || !inDom) return + var that = this + + var $tip = this.tip() + + var tipId = this.getUID(this.type) + + this.setContent() + $tip.attr('id', tipId) + this.$element.attr('aria-describedby', tipId) + + if (this.options.animation) $tip.addClass('fade') + + var placement = typeof this.options.placement == 'function' ? + this.options.placement.call(this, $tip[0], this.$element[0]) : + this.options.placement + + var autoToken = /\s?auto?\s?/i + var autoPlace = autoToken.test(placement) + if (autoPlace) placement = placement.replace(autoToken, '') || 'top' + + $tip + .detach() + .css({ top: 0, left: 0, display: 'block' }) + .addClass(placement) + .data('bs.' + this.type, this) + + this.options.container ? $tip.appendTo(this.options.container) : $tip.insertAfter(this.$element) + this.$element.trigger('inserted.bs.' + this.type) + + var pos = this.getPosition() + var actualWidth = $tip[0].offsetWidth + var actualHeight = $tip[0].offsetHeight + + if (autoPlace) { + var orgPlacement = placement + var viewportDim = this.getPosition(this.$viewport) + + placement = placement == 'bottom' && pos.bottom + actualHeight > viewportDim.bottom ? 'top' : + placement == 'top' && pos.top - actualHeight < viewportDim.top ? 'bottom' : + placement == 'right' && pos.right + actualWidth > viewportDim.width ? 'left' : + placement == 'left' && pos.left - actualWidth < viewportDim.left ? 'right' : + placement + + $tip + .removeClass(orgPlacement) + .addClass(placement) + } + + var calculatedOffset = this.getCalculatedOffset(placement, pos, actualWidth, actualHeight) + + this.applyPlacement(calculatedOffset, placement) + + var complete = function () { + var prevHoverState = that.hoverState + that.$element.trigger('shown.bs.' + that.type) + that.hoverState = null + + if (prevHoverState == 'out') that.leave(that) + } + + $.support.transition && this.$tip.hasClass('fade') ? + $tip + .one('bsTransitionEnd', complete) + .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : + complete() + } + } + + Tooltip.prototype.applyPlacement = function (offset, placement) { + var $tip = this.tip() + var width = $tip[0].offsetWidth + var height = $tip[0].offsetHeight + + // manually read margins because getBoundingClientRect includes difference + var marginTop = parseInt($tip.css('margin-top'), 10) + var marginLeft = parseInt($tip.css('margin-left'), 10) + + // we must check for NaN for ie 8/9 + if (isNaN(marginTop)) marginTop = 0 + if (isNaN(marginLeft)) marginLeft = 0 + + offset.top += marginTop + offset.left += marginLeft + + // $.fn.offset doesn't round pixel values + // so we use setOffset directly with our own function B-0 + $.offset.setOffset($tip[0], $.extend({ + using: function (props) { + $tip.css({ + top: Math.round(props.top), + left: Math.round(props.left) + }) + } + }, offset), 0) + + $tip.addClass('in') + + // check to see if placing tip in new offset caused the tip to resize itself + var actualWidth = $tip[0].offsetWidth + var actualHeight = $tip[0].offsetHeight + + if (placement == 'top' && actualHeight != height) { + offset.top = offset.top + height - actualHeight + } + + var delta = this.getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight) + + if (delta.left) offset.left += delta.left + else offset.top += delta.top + + var isVertical = /top|bottom/.test(placement) + var arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight + var arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight' + + $tip.offset(offset) + this.replaceArrow(arrowDelta, $tip[0][arrowOffsetPosition], isVertical) + } + + Tooltip.prototype.replaceArrow = function (delta, dimension, isVertical) { + this.arrow() + .css(isVertical ? 'left' : 'top', 50 * (1 - delta / dimension) + '%') + .css(isVertical ? 'top' : 'left', '') + } + + Tooltip.prototype.setContent = function () { + var $tip = this.tip() + var title = this.getTitle() + + $tip.find('.tooltip-inner')[this.options.html ? 'html' : 'text'](title) + $tip.removeClass('fade in top bottom left right') + } + + Tooltip.prototype.hide = function (callback) { + var that = this + var $tip = $(this.$tip) + var e = $.Event('hide.bs.' + this.type) + + function complete() { + if (that.hoverState != 'in') $tip.detach() + that.$element + .removeAttr('aria-describedby') + .trigger('hidden.bs.' + that.type) + callback && callback() + } + + this.$element.trigger(e) + + if (e.isDefaultPrevented()) return + + $tip.removeClass('in') + + $.support.transition && $tip.hasClass('fade') ? + $tip + .one('bsTransitionEnd', complete) + .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : + complete() + + this.hoverState = null + + return this + } + + Tooltip.prototype.fixTitle = function () { + var $e = this.$element + if ($e.attr('title') || typeof $e.attr('data-original-title') != 'string') { + $e.attr('data-original-title', $e.attr('title') || '').attr('title', '') + } + } + + Tooltip.prototype.hasContent = function () { + return this.getTitle() + } + + Tooltip.prototype.getPosition = function ($element) { + $element = $element || this.$element + + var el = $element[0] + var isBody = el.tagName == 'BODY' + + var elRect = el.getBoundingClientRect() + if (elRect.width == null) { + // width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093 + elRect = $.extend({}, elRect, { width: elRect.right - elRect.left, height: elRect.bottom - elRect.top }) + } + var elOffset = isBody ? { top: 0, left: 0 } : $element.offset() + var scroll = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop() } + var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null + + return $.extend({}, elRect, scroll, outerDims, elOffset) + } + + Tooltip.prototype.getCalculatedOffset = function (placement, pos, actualWidth, actualHeight) { + return placement == 'bottom' ? { top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'top' ? { top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'left' ? { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth } : + /* placement == 'right' */ { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width } + + } + + Tooltip.prototype.getViewportAdjustedDelta = function (placement, pos, actualWidth, actualHeight) { + var delta = { top: 0, left: 0 } + if (!this.$viewport) return delta + + var viewportPadding = this.options.viewport && this.options.viewport.padding || 0 + var viewportDimensions = this.getPosition(this.$viewport) + + if (/right|left/.test(placement)) { + var topEdgeOffset = pos.top - viewportPadding - viewportDimensions.scroll + var bottomEdgeOffset = pos.top + viewportPadding - viewportDimensions.scroll + actualHeight + if (topEdgeOffset < viewportDimensions.top) { // top overflow + delta.top = viewportDimensions.top - topEdgeOffset + } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow + delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset + } + } else { + var leftEdgeOffset = pos.left - viewportPadding + var rightEdgeOffset = pos.left + viewportPadding + actualWidth + if (leftEdgeOffset < viewportDimensions.left) { // left overflow + delta.left = viewportDimensions.left - leftEdgeOffset + } else if (rightEdgeOffset > viewportDimensions.right) { // right overflow + delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset + } + } + + return delta + } + + Tooltip.prototype.getTitle = function () { + var title + var $e = this.$element + var o = this.options + + title = $e.attr('data-original-title') + || (typeof o.title == 'function' ? o.title.call($e[0]) : o.title) + + return title + } + + Tooltip.prototype.getUID = function (prefix) { + do prefix += ~~(Math.random() * 1000000) + while (document.getElementById(prefix)) + return prefix + } + + Tooltip.prototype.tip = function () { + if (!this.$tip) { + this.$tip = $(this.options.template) + if (this.$tip.length != 1) { + throw new Error(this.type + ' `template` option must consist of exactly 1 top-level element!') + } + } + return this.$tip + } + + Tooltip.prototype.arrow = function () { + return (this.$arrow = this.$arrow || this.tip().find('.tooltip-arrow')) + } + + Tooltip.prototype.enable = function () { + this.enabled = true + } + + Tooltip.prototype.disable = function () { + this.enabled = false + } + + Tooltip.prototype.toggleEnabled = function () { + this.enabled = !this.enabled + } + + Tooltip.prototype.toggle = function (e) { + var self = this + if (e) { + self = $(e.currentTarget).data('bs.' + this.type) + if (!self) { + self = new this.constructor(e.currentTarget, this.getDelegateOptions()) + $(e.currentTarget).data('bs.' + this.type, self) + } + } + + if (e) { + self.inState.click = !self.inState.click + if (self.isInStateTrue()) self.enter(self) + else self.leave(self) + } else { + self.tip().hasClass('in') ? self.leave(self) : self.enter(self) + } + } + + Tooltip.prototype.destroy = function () { + var that = this + clearTimeout(this.timeout) + this.hide(function () { + that.$element.off('.' + that.type).removeData('bs.' + that.type) + if (that.$tip) { + that.$tip.detach() + } + that.$tip = null + that.$arrow = null + that.$viewport = null + }) + } + + + // TOOLTIP PLUGIN DEFINITION + // ========================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.tooltip') + var options = typeof option == 'object' && option + + if (!data && /destroy|hide/.test(option)) return + if (!data) $this.data('bs.tooltip', (data = new Tooltip(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.tooltip + + $.fn.tooltip = Plugin + $.fn.tooltip.Constructor = Tooltip + + + // TOOLTIP NO CONFLICT + // =================== + + $.fn.tooltip.noConflict = function () { + $.fn.tooltip = old + return this + } + +}(jQuery); + +/* ======================================================================== + * Bootstrap: popover.js v3.3.6 + * http://getbootstrap.com/javascript/#popovers + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // POPOVER PUBLIC CLASS DEFINITION + // =============================== + + var Popover = function (element, options) { + this.init('popover', element, options) + } + + if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js') + + Popover.VERSION = '3.3.6' + + Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, { + placement: 'right', + trigger: 'click', + content: '', + template: '' + }) + + + // NOTE: POPOVER EXTENDS tooltip.js + // ================================ + + Popover.prototype = $.extend({}, $.fn.tooltip.Constructor.prototype) + + Popover.prototype.constructor = Popover + + Popover.prototype.getDefaults = function () { + return Popover.DEFAULTS + } + + Popover.prototype.setContent = function () { + var $tip = this.tip() + var title = this.getTitle() + var content = this.getContent() + + $tip.find('.popover-title')[this.options.html ? 'html' : 'text'](title) + $tip.find('.popover-content').children().detach().end()[ // we use append for html objects to maintain js events + this.options.html ? (typeof content == 'string' ? 'html' : 'append') : 'text' + ](content) + + $tip.removeClass('fade top bottom left right in') + + // IE8 doesn't accept hiding via the `:empty` pseudo selector, we have to do + // this manually by checking the contents. + if (!$tip.find('.popover-title').html()) $tip.find('.popover-title').hide() + } + + Popover.prototype.hasContent = function () { + return this.getTitle() || this.getContent() + } + + Popover.prototype.getContent = function () { + var $e = this.$element + var o = this.options + + return $e.attr('data-content') + || (typeof o.content == 'function' ? + o.content.call($e[0]) : + o.content) + } + + Popover.prototype.arrow = function () { + return (this.$arrow = this.$arrow || this.tip().find('.arrow')) + } + + + // POPOVER PLUGIN DEFINITION + // ========================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.popover') + var options = typeof option == 'object' && option + + if (!data && /destroy|hide/.test(option)) return + if (!data) $this.data('bs.popover', (data = new Popover(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.popover + + $.fn.popover = Plugin + $.fn.popover.Constructor = Popover + + + // POPOVER NO CONFLICT + // =================== + + $.fn.popover.noConflict = function () { + $.fn.popover = old + return this + } + +}(jQuery); + +/* ======================================================================== + * Bootstrap: scrollspy.js v3.3.6 + * http://getbootstrap.com/javascript/#scrollspy + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // SCROLLSPY CLASS DEFINITION + // ========================== + + function ScrollSpy(element, options) { + this.$body = $(document.body) + this.$scrollElement = $(element).is(document.body) ? $(window) : $(element) + this.options = $.extend({}, ScrollSpy.DEFAULTS, options) + this.selector = (this.options.target || '') + ' .nav li > a' + this.offsets = [] + this.targets = [] + this.activeTarget = null + this.scrollHeight = 0 + + this.$scrollElement.on('scroll.bs.scrollspy', $.proxy(this.process, this)) + this.refresh() + this.process() + } + + ScrollSpy.VERSION = '3.3.6' + + ScrollSpy.DEFAULTS = { + offset: 10 + } + + ScrollSpy.prototype.getScrollHeight = function () { + return this.$scrollElement[0].scrollHeight || Math.max(this.$body[0].scrollHeight, document.documentElement.scrollHeight) + } + + ScrollSpy.prototype.refresh = function () { + var that = this + var offsetMethod = 'offset' + var offsetBase = 0 + + this.offsets = [] + this.targets = [] + this.scrollHeight = this.getScrollHeight() + + if (!$.isWindow(this.$scrollElement[0])) { + offsetMethod = 'position' + offsetBase = this.$scrollElement.scrollTop() + } + + this.$body + .find(this.selector) + .map(function () { + var $el = $(this) + var href = $el.data('target') || $el.attr('href') + var $href = /^#./.test(href) && $(href) + + return ($href + && $href.length + && $href.is(':visible') + && [[$href[offsetMethod]().top + offsetBase, href]]) || null + }) + .sort(function (a, b) { return a[0] - b[0] }) + .each(function () { + that.offsets.push(this[0]) + that.targets.push(this[1]) + }) + } + + ScrollSpy.prototype.process = function () { + var scrollTop = this.$scrollElement.scrollTop() + this.options.offset + var scrollHeight = this.getScrollHeight() + var maxScroll = this.options.offset + scrollHeight - this.$scrollElement.height() + var offsets = this.offsets + var targets = this.targets + var activeTarget = this.activeTarget + var i + + if (this.scrollHeight != scrollHeight) { + this.refresh() + } + + if (scrollTop >= maxScroll) { + return activeTarget != (i = targets[targets.length - 1]) && this.activate(i) + } + + if (activeTarget && scrollTop < offsets[0]) { + this.activeTarget = null + return this.clear() + } + + for (i = offsets.length; i--;) { + activeTarget != targets[i] + && scrollTop >= offsets[i] + && (offsets[i + 1] === undefined || scrollTop < offsets[i + 1]) + && this.activate(targets[i]) + } + } + + ScrollSpy.prototype.activate = function (target) { + this.activeTarget = target + + this.clear() + + var selector = this.selector + + '[data-target="' + target + '"],' + + this.selector + '[href="' + target + '"]' + + var active = $(selector) + .parents('li') + .addClass('active') + + if (active.parent('.dropdown-menu').length) { + active = active + .closest('li.dropdown') + .addClass('active') + } + + active.trigger('activate.bs.scrollspy') + } + + ScrollSpy.prototype.clear = function () { + $(this.selector) + .parentsUntil(this.options.target, '.active') + .removeClass('active') + } + + + // SCROLLSPY PLUGIN DEFINITION + // =========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.scrollspy') + var options = typeof option == 'object' && option + + if (!data) $this.data('bs.scrollspy', (data = new ScrollSpy(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.scrollspy + + $.fn.scrollspy = Plugin + $.fn.scrollspy.Constructor = ScrollSpy + + + // SCROLLSPY NO CONFLICT + // ===================== + + $.fn.scrollspy.noConflict = function () { + $.fn.scrollspy = old + return this + } + + + // SCROLLSPY DATA-API + // ================== + + $(window).on('load.bs.scrollspy.data-api', function () { + $('[data-spy="scroll"]').each(function () { + var $spy = $(this) + Plugin.call($spy, $spy.data()) + }) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: tab.js v3.3.6 + * http://getbootstrap.com/javascript/#tabs + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // TAB CLASS DEFINITION + // ==================== + + var Tab = function (element) { + // jscs:disable requireDollarBeforejQueryAssignment + this.element = $(element) + // jscs:enable requireDollarBeforejQueryAssignment + } + + Tab.VERSION = '3.3.6' + + Tab.TRANSITION_DURATION = 150 + + Tab.prototype.show = function () { + var $this = this.element + var $ul = $this.closest('ul:not(.dropdown-menu)') + var selector = $this.data('target') + + if (!selector) { + selector = $this.attr('href') + selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 + } + + if ($this.parent('li').hasClass('active')) return + + var $previous = $ul.find('.active:last a') + var hideEvent = $.Event('hide.bs.tab', { + relatedTarget: $this[0] + }) + var showEvent = $.Event('show.bs.tab', { + relatedTarget: $previous[0] + }) + + $previous.trigger(hideEvent) + $this.trigger(showEvent) + + if (showEvent.isDefaultPrevented() || hideEvent.isDefaultPrevented()) return + + var $target = $(selector) + + this.activate($this.closest('li'), $ul) + this.activate($target, $target.parent(), function () { + $previous.trigger({ + type: 'hidden.bs.tab', + relatedTarget: $this[0] + }) + $this.trigger({ + type: 'shown.bs.tab', + relatedTarget: $previous[0] + }) + }) + } + + Tab.prototype.activate = function (element, container, callback) { + var $active = container.find('> .active') + var transition = callback + && $.support.transition + && ($active.length && $active.hasClass('fade') || !!container.find('> .fade').length) + + function next() { + $active + .removeClass('active') + .find('> .dropdown-menu > .active') + .removeClass('active') + .end() + .find('[data-toggle="tab"]') + .attr('aria-expanded', false) + + element + .addClass('active') + .find('[data-toggle="tab"]') + .attr('aria-expanded', true) + + if (transition) { + element[0].offsetWidth // reflow for transition + element.addClass('in') + } else { + element.removeClass('fade') + } + + if (element.parent('.dropdown-menu').length) { + element + .closest('li.dropdown') + .addClass('active') + .end() + .find('[data-toggle="tab"]') + .attr('aria-expanded', true) + } + + callback && callback() + } + + $active.length && transition ? + $active + .one('bsTransitionEnd', next) + .emulateTransitionEnd(Tab.TRANSITION_DURATION) : + next() + + $active.removeClass('in') + } + + + // TAB PLUGIN DEFINITION + // ===================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.tab') + + if (!data) $this.data('bs.tab', (data = new Tab(this))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.tab + + $.fn.tab = Plugin + $.fn.tab.Constructor = Tab + + + // TAB NO CONFLICT + // =============== + + $.fn.tab.noConflict = function () { + $.fn.tab = old + return this + } + + + // TAB DATA-API + // ============ + + var clickHandler = function (e) { + e.preventDefault() + Plugin.call($(this), 'show') + } + + $(document) + .on('click.bs.tab.data-api', '[data-toggle="tab"]', clickHandler) + .on('click.bs.tab.data-api', '[data-toggle="pill"]', clickHandler) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: affix.js v3.3.6 + * http://getbootstrap.com/javascript/#affix + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // AFFIX CLASS DEFINITION + // ====================== + + var Affix = function (element, options) { + this.options = $.extend({}, Affix.DEFAULTS, options) + + this.$target = $(this.options.target) + .on('scroll.bs.affix.data-api', $.proxy(this.checkPosition, this)) + .on('click.bs.affix.data-api', $.proxy(this.checkPositionWithEventLoop, this)) + + this.$element = $(element) + this.affixed = null + this.unpin = null + this.pinnedOffset = null + + this.checkPosition() + } + + Affix.VERSION = '3.3.6' + + Affix.RESET = 'affix affix-top affix-bottom' + + Affix.DEFAULTS = { + offset: 0, + target: window + } + + Affix.prototype.getState = function (scrollHeight, height, offsetTop, offsetBottom) { + var scrollTop = this.$target.scrollTop() + var position = this.$element.offset() + var targetHeight = this.$target.height() + + if (offsetTop != null && this.affixed == 'top') return scrollTop < offsetTop ? 'top' : false + + if (this.affixed == 'bottom') { + if (offsetTop != null) return (scrollTop + this.unpin <= position.top) ? false : 'bottom' + return (scrollTop + targetHeight <= scrollHeight - offsetBottom) ? false : 'bottom' + } + + var initializing = this.affixed == null + var colliderTop = initializing ? scrollTop : position.top + var colliderHeight = initializing ? targetHeight : height + + if (offsetTop != null && scrollTop <= offsetTop) return 'top' + if (offsetBottom != null && (colliderTop + colliderHeight >= scrollHeight - offsetBottom)) return 'bottom' + + return false + } + + Affix.prototype.getPinnedOffset = function () { + if (this.pinnedOffset) return this.pinnedOffset + this.$element.removeClass(Affix.RESET).addClass('affix') + var scrollTop = this.$target.scrollTop() + var position = this.$element.offset() + return (this.pinnedOffset = position.top - scrollTop) + } + + Affix.prototype.checkPositionWithEventLoop = function () { + setTimeout($.proxy(this.checkPosition, this), 1) + } + + Affix.prototype.checkPosition = function () { + if (!this.$element.is(':visible')) return + + var height = this.$element.height() + var offset = this.options.offset + var offsetTop = offset.top + var offsetBottom = offset.bottom + var scrollHeight = Math.max($(document).height(), $(document.body).height()) + + if (typeof offset != 'object') offsetBottom = offsetTop = offset + if (typeof offsetTop == 'function') offsetTop = offset.top(this.$element) + if (typeof offsetBottom == 'function') offsetBottom = offset.bottom(this.$element) + + var affix = this.getState(scrollHeight, height, offsetTop, offsetBottom) + + if (this.affixed != affix) { + if (this.unpin != null) this.$element.css('top', '') + + var affixType = 'affix' + (affix ? '-' + affix : '') + var e = $.Event(affixType + '.bs.affix') + + this.$element.trigger(e) + + if (e.isDefaultPrevented()) return + + this.affixed = affix + this.unpin = affix == 'bottom' ? this.getPinnedOffset() : null + + this.$element + .removeClass(Affix.RESET) + .addClass(affixType) + .trigger(affixType.replace('affix', 'affixed') + '.bs.affix') + } + + if (affix == 'bottom') { + this.$element.offset({ + top: scrollHeight - height - offsetBottom + }) + } + } + + + // AFFIX PLUGIN DEFINITION + // ======================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.affix') + var options = typeof option == 'object' && option + + if (!data) $this.data('bs.affix', (data = new Affix(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.affix + + $.fn.affix = Plugin + $.fn.affix.Constructor = Affix + + + // AFFIX NO CONFLICT + // ================= + + $.fn.affix.noConflict = function () { + $.fn.affix = old + return this + } + + + // AFFIX DATA-API + // ============== + + $(window).on('load', function () { + $('[data-spy="affix"]').each(function () { + var $spy = $(this) + var data = $spy.data() + + data.offset = data.offset || {} + + if (data.offsetBottom != null) data.offset.bottom = data.offsetBottom + if (data.offsetTop != null) data.offset.top = data.offsetTop + + Plugin.call($spy, data) + }) + }) + +}(jQuery); + + +/*! DataTables 1.10.11 + * ©2008-2015 SpryMedia Ltd - datatables.net/license + */ + +/** + * @summary DataTables + * @description Paginate, search and order HTML tables + * @version 1.10.11 + * @file jquery.dataTables.js + * @author SpryMedia Ltd (www.sprymedia.co.uk) + * @contact www.sprymedia.co.uk/contact + * @copyright Copyright 2008-2015 SpryMedia Ltd. + * + * This source file is free software, available under the following license: + * MIT license - http://datatables.net/license + * + * This source file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the license files for details. + * + * For details please refer to: http://www.datatables.net + */ + +/*jslint evil: true, undef: true, browser: true */ +/*globals $,require,jQuery,define,_selector_run,_selector_opts,_selector_first,_selector_row_indexes,_ext,_Api,_api_register,_api_registerPlural,_re_new_lines,_re_html,_re_formatted_numeric,_re_escape_regex,_empty,_intVal,_numToDecimal,_isNumber,_isHtml,_htmlNumeric,_pluck,_pluck_order,_range,_stripHtml,_unique,_fnBuildAjax,_fnAjaxUpdate,_fnAjaxParameters,_fnAjaxUpdateDraw,_fnAjaxDataSrc,_fnAddColumn,_fnColumnOptions,_fnAdjustColumnSizing,_fnVisibleToColumnIndex,_fnColumnIndexToVisible,_fnVisbleColumns,_fnGetColumns,_fnColumnTypes,_fnApplyColumnDefs,_fnHungarianMap,_fnCamelToHungarian,_fnLanguageCompat,_fnBrowserDetect,_fnAddData,_fnAddTr,_fnNodeToDataIndex,_fnNodeToColumnIndex,_fnGetCellData,_fnSetCellData,_fnSplitObjNotation,_fnGetObjectDataFn,_fnSetObjectDataFn,_fnGetDataMaster,_fnClearTable,_fnDeleteIndex,_fnInvalidate,_fnGetRowElements,_fnCreateTr,_fnBuildHead,_fnDrawHead,_fnDraw,_fnReDraw,_fnAddOptionsHtml,_fnDetectHeader,_fnGetUniqueThs,_fnFeatureHtmlFilter,_fnFilterComplete,_fnFilterCustom,_fnFilterColumn,_fnFilter,_fnFilterCreateSearch,_fnEscapeRegex,_fnFilterData,_fnFeatureHtmlInfo,_fnUpdateInfo,_fnInfoMacros,_fnInitialise,_fnInitComplete,_fnLengthChange,_fnFeatureHtmlLength,_fnFeatureHtmlPaginate,_fnPageChange,_fnFeatureHtmlProcessing,_fnProcessingDisplay,_fnFeatureHtmlTable,_fnScrollDraw,_fnApplyToChildren,_fnCalculateColumnWidths,_fnThrottle,_fnConvertToWidth,_fnGetWidestNode,_fnGetMaxLenString,_fnStringToCss,_fnSortFlatten,_fnSort,_fnSortAria,_fnSortListener,_fnSortAttachListener,_fnSortingClasses,_fnSortData,_fnSaveState,_fnLoadState,_fnSettingsFromNode,_fnLog,_fnMap,_fnBindAction,_fnCallbackReg,_fnCallbackFire,_fnLengthOverflow,_fnRenderer,_fnDataSource,_fnRowAttributes*/ + +(function( factory ) { + "use strict"; + + if ( typeof define === 'function' && define.amd ) { + // AMD + define( ['jquery'], function ( $ ) { + return factory( $, window, document ); + } ); + } + else if ( typeof exports === 'object' ) { + // CommonJS + module.exports = function (root, $) { + if ( ! root ) { + // CommonJS environments without a window global must pass a + // root. This will give an error otherwise + root = window; + } + + if ( ! $ ) { + $ = typeof window !== 'undefined' ? // jQuery's factory checks for a global window + require('jquery') : + require('jquery')( root ); + } + + return factory( $, root, root.document ); + }; + } + else { + // Browser + factory( jQuery, window, document ); + } +} +(function( $, window, document, undefined ) { + "use strict"; + + /** + * DataTables is a plug-in for the jQuery Javascript library. It is a highly + * flexible tool, based upon the foundations of progressive enhancement, + * which will add advanced interaction controls to any HTML table. For a + * full list of features please refer to + * [DataTables.net](href="http://datatables.net). + * + * Note that the `DataTable` object is not a global variable but is aliased + * to `jQuery.fn.DataTable` and `jQuery.fn.dataTable` through which it may + * be accessed. + * + * @class + * @param {object} [init={}] Configuration object for DataTables. Options + * are defined by {@link DataTable.defaults} + * @requires jQuery 1.7+ + * + * @example + * // Basic initialisation + * $(document).ready( function { + * $('#example').dataTable(); + * } ); + * + * @example + * // Initialisation with configuration options - in this case, disable + * // pagination and sorting. + * $(document).ready( function { + * $('#example').dataTable( { + * "paginate": false, + * "sort": false + * } ); + * } ); + */ + var DataTable; + + + /* + * It is useful to have variables which are scoped locally so only the + * DataTables functions can access them and they don't leak into global space. + * At the same time these functions are often useful over multiple files in the + * core and API, so we list, or at least document, all variables which are used + * by DataTables as private variables here. This also ensures that there is no + * clashing of variable names and that they can easily referenced for reuse. + */ + + + // Defined else where + // _selector_run + // _selector_opts + // _selector_first + // _selector_row_indexes + + var _ext; // DataTable.ext + var _Api; // DataTable.Api + var _api_register; // DataTable.Api.register + var _api_registerPlural; // DataTable.Api.registerPlural + + var _re_dic = {}; + var _re_new_lines = /[\r\n]/g; + var _re_html = /<.*?>/g; + var _re_date_start = /^[\w\+\-]/; + var _re_date_end = /[\w\+\-]$/; + + // Escape regular expression special characters + var _re_escape_regex = new RegExp( '(\\' + [ '/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\', '$', '^', '-' ].join('|\\') + ')', 'g' ); + + // http://en.wikipedia.org/wiki/Foreign_exchange_market + // - \u20BD - Russian ruble. + // - \u20a9 - South Korean Won + // - \u20BA - Turkish Lira + // - \u20B9 - Indian Rupee + // - R - Brazil (R$) and South Africa + // - fr - Swiss Franc + // - kr - Swedish krona, Norwegian krone and Danish krone + // - \u2009 is thin space and \u202F is narrow no-break space, both used in many + // standards as thousands separators. + var _re_formatted_numeric = /[',$£€¥%\u2009\u202F\u20BD\u20a9\u20BArfk]/gi; + + + var _empty = function ( d ) { + return !d || d === true || d === '-' ? true : false; + }; + + + var _intVal = function ( s ) { + var integer = parseInt( s, 10 ); + return !isNaN(integer) && isFinite(s) ? integer : null; + }; + + // Convert from a formatted number with characters other than `.` as the + // decimal place, to a Javascript number + var _numToDecimal = function ( num, decimalPoint ) { + // Cache created regular expressions for speed as this function is called often + if ( ! _re_dic[ decimalPoint ] ) { + _re_dic[ decimalPoint ] = new RegExp( _fnEscapeRegex( decimalPoint ), 'g' ); + } + return typeof num === 'string' && decimalPoint !== '.' ? + num.replace( /\./g, '' ).replace( _re_dic[ decimalPoint ], '.' ) : + num; + }; + + + var _isNumber = function ( d, decimalPoint, formatted ) { + var strType = typeof d === 'string'; + + // If empty return immediately so there must be a number if it is a + // formatted string (this stops the string "k", or "kr", etc being detected + // as a formatted number for currency + if ( _empty( d ) ) { + return true; + } + + if ( decimalPoint && strType ) { + d = _numToDecimal( d, decimalPoint ); + } + + if ( formatted && strType ) { + d = d.replace( _re_formatted_numeric, '' ); + } + + return !isNaN( parseFloat(d) ) && isFinite( d ); + }; + + + // A string without HTML in it can be considered to be HTML still + var _isHtml = function ( d ) { + return _empty( d ) || typeof d === 'string'; + }; + + + var _htmlNumeric = function ( d, decimalPoint, formatted ) { + if ( _empty( d ) ) { + return true; + } + + var html = _isHtml( d ); + return ! html ? + null : + _isNumber( _stripHtml( d ), decimalPoint, formatted ) ? + true : + null; + }; + + + var _pluck = function ( a, prop, prop2 ) { + var out = []; + var i=0, ien=a.length; + + // Could have the test in the loop for slightly smaller code, but speed + // is essential here + if ( prop2 !== undefined ) { + for ( ; i') + .css( { + position: 'fixed', + top: 0, + left: 0, + height: 1, + width: 1, + overflow: 'hidden' + } ) + .append( + $('
') + .css( { + position: 'absolute', + top: 1, + left: 1, + width: 100, + overflow: 'scroll' + } ) + .append( + $('
') + .css( { + width: '100%', + height: 10 + } ) + ) + ) + .appendTo( 'body' ); + + var outer = n.children(); + var inner = outer.children(); + + // Numbers below, in order, are: + // inner.offsetWidth, inner.clientWidth, outer.offsetWidth, outer.clientWidth + // + // IE6 XP: 100 100 100 83 + // IE7 Vista: 100 100 100 83 + // IE 8+ Windows: 83 83 100 83 + // Evergreen Windows: 83 83 100 83 + // Evergreen Mac with scrollbars: 85 85 100 85 + // Evergreen Mac without scrollbars: 100 100 100 100 + + // Get scrollbar width + browser.barWidth = outer[0].offsetWidth - outer[0].clientWidth; + + // IE6/7 will oversize a width 100% element inside a scrolling element, to + // include the width of the scrollbar, while other browsers ensure the inner + // element is contained without forcing scrolling + browser.bScrollOversize = inner[0].offsetWidth === 100 && outer[0].clientWidth !== 100; + + // In rtl text layout, some browsers (most, but not all) will place the + // scrollbar on the left, rather than the right. + browser.bScrollbarLeft = Math.round( inner.offset().left ) !== 1; + + // IE8- don't provide height and width for getBoundingClientRect + browser.bBounding = n[0].getBoundingClientRect().width ? true : false; + + n.remove(); + } + + $.extend( settings.oBrowser, DataTable.__browser ); + settings.oScroll.iBarWidth = DataTable.__browser.barWidth; + } + + + /** + * Array.prototype reduce[Right] method, used for browsers which don't support + * JS 1.6. Done this way to reduce code size, since we iterate either way + * @param {object} settings dataTables settings object + * @memberof DataTable#oApi + */ + function _fnReduce ( that, fn, init, start, end, inc ) + { + var + i = start, + value, + isSet = false; + + if ( init !== undefined ) { + value = init; + isSet = true; + } + + while ( i !== end ) { + if ( ! that.hasOwnProperty(i) ) { + continue; + } + + value = isSet ? + fn( value, that[i], i, that ) : + that[i]; + + isSet = true; + i += inc; + } + + return value; + } + + /** + * Add a column to the list used for the table with default values + * @param {object} oSettings dataTables settings object + * @param {node} nTh The th element for this column + * @memberof DataTable#oApi + */ + function _fnAddColumn( oSettings, nTh ) + { + // Add column to aoColumns array + var oDefaults = DataTable.defaults.column; + var iCol = oSettings.aoColumns.length; + var oCol = $.extend( {}, DataTable.models.oColumn, oDefaults, { + "nTh": nTh ? nTh : document.createElement('th'), + "sTitle": oDefaults.sTitle ? oDefaults.sTitle : nTh ? nTh.innerHTML : '', + "aDataSort": oDefaults.aDataSort ? oDefaults.aDataSort : [iCol], + "mData": oDefaults.mData ? oDefaults.mData : iCol, + idx: iCol + } ); + oSettings.aoColumns.push( oCol ); + + // Add search object for column specific search. Note that the `searchCols[ iCol ]` + // passed into extend can be undefined. This allows the user to give a default + // with only some of the parameters defined, and also not give a default + var searchCols = oSettings.aoPreSearchCols; + searchCols[ iCol ] = $.extend( {}, DataTable.models.oSearch, searchCols[ iCol ] ); + + // Use the default column options function to initialise classes etc + _fnColumnOptions( oSettings, iCol, $(nTh).data() ); + } + + + /** + * Apply options for a column + * @param {object} oSettings dataTables settings object + * @param {int} iCol column index to consider + * @param {object} oOptions object with sType, bVisible and bSearchable etc + * @memberof DataTable#oApi + */ + function _fnColumnOptions( oSettings, iCol, oOptions ) + { + var oCol = oSettings.aoColumns[ iCol ]; + var oClasses = oSettings.oClasses; + var th = $(oCol.nTh); + + // Try to get width information from the DOM. We can't get it from CSS + // as we'd need to parse the CSS stylesheet. `width` option can override + if ( ! oCol.sWidthOrig ) { + // Width attribute + oCol.sWidthOrig = th.attr('width') || null; + + // Style attribute + var t = (th.attr('style') || '').match(/width:\s*(\d+[pxem%]+)/); + if ( t ) { + oCol.sWidthOrig = t[1]; + } + } + + /* User specified column options */ + if ( oOptions !== undefined && oOptions !== null ) + { + // Backwards compatibility + _fnCompatCols( oOptions ); + + // Map camel case parameters to their Hungarian counterparts + _fnCamelToHungarian( DataTable.defaults.column, oOptions ); + + /* Backwards compatibility for mDataProp */ + if ( oOptions.mDataProp !== undefined && !oOptions.mData ) + { + oOptions.mData = oOptions.mDataProp; + } + + if ( oOptions.sType ) + { + oCol._sManualType = oOptions.sType; + } + + // `class` is a reserved word in Javascript, so we need to provide + // the ability to use a valid name for the camel case input + if ( oOptions.className && ! oOptions.sClass ) + { + oOptions.sClass = oOptions.className; + } + + $.extend( oCol, oOptions ); + _fnMap( oCol, oOptions, "sWidth", "sWidthOrig" ); + + /* iDataSort to be applied (backwards compatibility), but aDataSort will take + * priority if defined + */ + if ( oOptions.iDataSort !== undefined ) + { + oCol.aDataSort = [ oOptions.iDataSort ]; + } + _fnMap( oCol, oOptions, "aDataSort" ); + } + + /* Cache the data get and set functions for speed */ + var mDataSrc = oCol.mData; + var mData = _fnGetObjectDataFn( mDataSrc ); + var mRender = oCol.mRender ? _fnGetObjectDataFn( oCol.mRender ) : null; + + var attrTest = function( src ) { + return typeof src === 'string' && src.indexOf('@') !== -1; + }; + oCol._bAttrSrc = $.isPlainObject( mDataSrc ) && ( + attrTest(mDataSrc.sort) || attrTest(mDataSrc.type) || attrTest(mDataSrc.filter) + ); + oCol._setter = null; + + oCol.fnGetData = function (rowData, type, meta) { + var innerData = mData( rowData, type, undefined, meta ); + + return mRender && type ? + mRender( innerData, type, rowData, meta ) : + innerData; + }; + oCol.fnSetData = function ( rowData, val, meta ) { + return _fnSetObjectDataFn( mDataSrc )( rowData, val, meta ); + }; + + // Indicate if DataTables should read DOM data as an object or array + // Used in _fnGetRowElements + if ( typeof mDataSrc !== 'number' ) { + oSettings._rowReadObject = true; + } + + /* Feature sorting overrides column specific when off */ + if ( !oSettings.oFeatures.bSort ) + { + oCol.bSortable = false; + th.addClass( oClasses.sSortableNone ); // Have to add class here as order event isn't called + } + + /* Check that the class assignment is correct for sorting */ + var bAsc = $.inArray('asc', oCol.asSorting) !== -1; + var bDesc = $.inArray('desc', oCol.asSorting) !== -1; + if ( !oCol.bSortable || (!bAsc && !bDesc) ) + { + oCol.sSortingClass = oClasses.sSortableNone; + oCol.sSortingClassJUI = ""; + } + else if ( bAsc && !bDesc ) + { + oCol.sSortingClass = oClasses.sSortableAsc; + oCol.sSortingClassJUI = oClasses.sSortJUIAscAllowed; + } + else if ( !bAsc && bDesc ) + { + oCol.sSortingClass = oClasses.sSortableDesc; + oCol.sSortingClassJUI = oClasses.sSortJUIDescAllowed; + } + else + { + oCol.sSortingClass = oClasses.sSortable; + oCol.sSortingClassJUI = oClasses.sSortJUI; + } + } + + + /** + * Adjust the table column widths for new data. Note: you would probably want to + * do a redraw after calling this function! + * @param {object} settings dataTables settings object + * @memberof DataTable#oApi + */ + function _fnAdjustColumnSizing ( settings ) + { + /* Not interested in doing column width calculation if auto-width is disabled */ + if ( settings.oFeatures.bAutoWidth !== false ) + { + var columns = settings.aoColumns; + + _fnCalculateColumnWidths( settings ); + for ( var i=0 , iLen=columns.length ; i