diff --git a/PlexRequests.Api.Interfaces/IPlexApi.cs b/PlexRequests.Api.Interfaces/IPlexApi.cs index b13f1401b..075628385 100644 --- a/PlexRequests.Api.Interfaces/IPlexApi.cs +++ b/PlexRequests.Api.Interfaces/IPlexApi.cs @@ -40,5 +40,6 @@ namespace PlexRequests.Api.Interfaces PlexAccount GetAccount(string authToken); PlexLibraries GetLibrarySections(string authToken, Uri plexFullHost); PlexSearch GetLibrary(string authToken, Uri plexFullHost, string libraryId); + PlexMetadata GetMetadata(string authToken, Uri plexFullHost, string itemId); } } \ No newline at end of file diff --git a/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj b/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj index 8512ca932..cf321d344 100644 --- a/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj +++ b/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj @@ -64,6 +64,7 @@ + diff --git a/PlexRequests.Api.Interfaces/app.config b/PlexRequests.Api.Interfaces/app.config new file mode 100644 index 000000000..de5386a47 --- /dev/null +++ b/PlexRequests.Api.Interfaces/app.config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/PlexRequests.Api.Models/Movie/CouchPotatoProfiles.cs b/PlexRequests.Api.Models/Movie/CouchPotatoProfiles.cs index f63661ecb..2da2cd221 100644 --- a/PlexRequests.Api.Models/Movie/CouchPotatoProfiles.cs +++ b/PlexRequests.Api.Models/Movie/CouchPotatoProfiles.cs @@ -27,11 +27,15 @@ using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + namespace PlexRequests.Api.Models.Movie { public class ProfileList { public bool core { get; set; } + public bool hide { get; set; } public string _rev { get; set; } public List finish { get; set; } public List qualities { get; set; } @@ -40,9 +44,10 @@ namespace PlexRequests.Api.Models.Movie public string label { get; set; } public int minimum_score { get; set; } public List stop_after { get; set; } - public List wait_for { get; set; } + public List wait_for { get; set; } public int order { get; set; } - public List __invalid_name__3d { get; set; } + [JsonProperty(PropertyName = "3d")] + public List threeD { get; set; } } public class CouchPotatoProfiles diff --git a/PlexRequests.Api.Models/Plex/PlexMetadata.cs b/PlexRequests.Api.Models/Plex/PlexMetadata.cs new file mode 100644 index 000000000..e2ffc853e --- /dev/null +++ b/PlexRequests.Api.Models/Plex/PlexMetadata.cs @@ -0,0 +1,58 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: PlexMetadata.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.Xml.Serialization; + +namespace PlexRequests.Api.Models.Plex +{ + [XmlRoot(ElementName = "MediaContainer")] + public class PlexMetadata + { + [XmlElement(ElementName= "Video")] + public Video Video { get; set; } + [XmlElement(ElementName = "Directory")] + public Directory1 Directory { get; set; } + [XmlAttribute(AttributeName = "size")] + public string Size { get; set; } + [XmlAttribute(AttributeName = "allowSync")] + public string AllowSync { get; set; } + [XmlAttribute(AttributeName = "identifier")] + public string Identifier { get; set; } + [XmlAttribute(AttributeName = "librarySectionID")] + public string LibrarySectionID { get; set; } + [XmlAttribute(AttributeName = "librarySectionTitle")] + public string LibrarySectionTitle { get; set; } + [XmlAttribute(AttributeName = "librarySectionUUID")] + public string LibrarySectionUUID { get; set; } + [XmlAttribute(AttributeName = "mediaTagPrefix")] + public string MediaTagPrefix { get; set; } + [XmlAttribute(AttributeName = "mediaTagVersion")] + public string MediaTagVersion { get; set; } + } + +} \ No newline at end of file diff --git a/PlexRequests.Api.Models/Plex/PlexSearch.cs b/PlexRequests.Api.Models/Plex/PlexSearch.cs index b9fcd89a9..76193251b 100644 --- a/PlexRequests.Api.Models/Plex/PlexSearch.cs +++ b/PlexRequests.Api.Models/Plex/PlexSearch.cs @@ -133,6 +133,9 @@ namespace PlexRequests.Api.Models.Plex [XmlRoot(ElementName = "Video")] public class Video { + public string ProviderId { get; set; } + [XmlAttribute(AttributeName = "guid")] + public string Guid { get; set; } [XmlElement(ElementName = "Media")] public List Media { get; set; } [XmlElement(ElementName = "Genre")] @@ -241,6 +244,9 @@ namespace PlexRequests.Api.Models.Plex [XmlRoot(ElementName = "Directory")] public class Directory1 { + public string ProviderId { get; set; } + [XmlAttribute(AttributeName = "guid")] + public string Guid { get; set; } [XmlElement(ElementName = "Genre")] public List Genre { get; set; } [XmlElement(ElementName = "Role")] @@ -311,6 +317,7 @@ namespace PlexRequests.Api.Models.Plex [XmlRoot(ElementName = "MediaContainer")] public class PlexSearch { + [XmlElement(ElementName = "Directory")] public List Directory { get; set; } [XmlElement(ElementName = "Video")] diff --git a/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj b/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj index 8e670e858..86bcfd77f 100644 --- a/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj +++ b/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj @@ -64,6 +64,7 @@ + @@ -90,6 +91,7 @@ + diff --git a/PlexRequests.Api.Models/app.config b/PlexRequests.Api.Models/app.config new file mode 100644 index 000000000..de5386a47 --- /dev/null +++ b/PlexRequests.Api.Models/app.config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/PlexRequests.Api/CouchPotatoApi.cs b/PlexRequests.Api/CouchPotatoApi.cs index b63c85661..4ec0356c5 100644 --- a/PlexRequests.Api/CouchPotatoApi.cs +++ b/PlexRequests.Api/CouchPotatoApi.cs @@ -62,7 +62,7 @@ namespace PlexRequests.Api request.AddUrlSegment("imdbid", imdbid); request.AddUrlSegment("title", title); - var obj = RetryHandler.Execute(() => Api.ExecuteJson (request, baseUrl),new TimeSpan[] { + var obj = RetryHandler.Execute(() => Api.ExecuteJson (request, baseUrl),new[] { TimeSpan.FromSeconds (2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10)}, @@ -143,8 +143,8 @@ namespace PlexRequests.Api request.AddUrlSegment("status", string.Join(",", status)); try { - var obj = RetryHandler.Execute(() => Api.Execute (request, baseUrl), - new TimeSpan[] { + var obj = RetryHandler.Execute(() => Api.Execute (request, baseUrl), + new[] { TimeSpan.FromSeconds (5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30) diff --git a/PlexRequests.Api/PlexApi.cs b/PlexRequests.Api/PlexApi.cs index a58316b7d..7ef0e6249 100644 --- a/PlexRequests.Api/PlexApi.cs +++ b/PlexRequests.Api/PlexApi.cs @@ -220,6 +220,36 @@ namespace PlexRequests.Api } } + public PlexMetadata GetMetadata(string authToken, Uri plexFullHost, string itemId) + { + var request = new RestRequest + { + Method = Method.GET, + Resource = "library/metadata/{itemId}" + }; + + request.AddUrlSegment("itemId", itemId); + AddHeaders(ref request, authToken); + + try + { + var lib = RetryHandler.Execute(() => Api.ExecuteXml(request, plexFullHost), + new[] { + TimeSpan.FromSeconds (5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30) + }, + (exception, timespan) => Log.Error(exception, "Exception when calling GetMetadata for Plex, Retrying {0}", timespan)); + + return lib; + } + catch (Exception e) + { + Log.Error(e, "There has been a API Exception when attempting to get the Plex GetMetadata"); + return new PlexMetadata(); + } + } + private void AddHeaders(ref RestRequest request, string authToken) { request.AddHeader("X-Plex-Token", authToken); diff --git a/PlexRequests.Api/PlexRequests.Api.csproj b/PlexRequests.Api/PlexRequests.Api.csproj index d627e0ac9..9e24aebdd 100644 --- a/PlexRequests.Api/PlexRequests.Api.csproj +++ b/PlexRequests.Api/PlexRequests.Api.csproj @@ -31,6 +31,10 @@ 4 + + ..\packages\NLog.4.3.4\lib\net45\NLog.dll + True + ..\packages\RestSharp.105.2.3\lib\net45\RestSharp.dll True @@ -52,9 +56,6 @@ ..\packages\Newtonsoft.Json.8.0.2\lib\net45\Newtonsoft.Json.dll - - ..\packages\NLog.4.2.3\lib\net45\NLog.dll - ..\packages\TMDbLib.0.9.0.0-alpha\lib\net45\TMDbLib.dll diff --git a/PlexRequests.Api/packages.config b/PlexRequests.Api/packages.config index dd0b7d8d3..e7e63c904 100644 --- a/PlexRequests.Api/packages.config +++ b/PlexRequests.Api/packages.config @@ -3,7 +3,7 @@ - + diff --git a/PlexRequests.Core/CacheKeys.cs b/PlexRequests.Core/CacheKeys.cs index cd347b3c3..a2bbf2496 100644 --- a/PlexRequests.Core/CacheKeys.cs +++ b/PlexRequests.Core/CacheKeys.cs @@ -33,21 +33,21 @@ namespace PlexRequests.Core public const int SchedulerCaching = 60; } - public const string PlexLibaries = "PlexLibaries"; + public const string PlexLibaries = nameof(PlexLibaries); - public const string TvDbToken = "TheTvDbApiToken"; + public const string TvDbToken = nameof(TvDbToken); - public const string SonarrQualityProfiles = "SonarrQualityProfiles"; - public const string SonarrQueued = "SonarrQueued"; + public const string SonarrQualityProfiles = nameof(SonarrQualityProfiles); + public const string SonarrQueued = nameof(SonarrQueued); - public const string SickRageQualityProfiles = "SickRageQualityProfiles"; - public const string SickRageQueued = "SickRageQueued"; + public const string SickRageQualityProfiles = nameof(SickRageQualityProfiles); + public const string SickRageQueued = nameof(SickRageQueued); - public const string CouchPotatoQualityProfiles = "CouchPotatoQualityProfiles"; - public const string CouchPotatoQueued = "CouchPotatoQueued"; + public const string CouchPotatoQualityProfiles = nameof(CouchPotatoQualityProfiles); + public const string CouchPotatoQueued = nameof(CouchPotatoQueued); - public const string GetPlexRequestSettings = "GetPlexRequestSettings"; + public const string GetPlexRequestSettings = nameof(GetPlexRequestSettings); - public const string LastestProductVersion = "LatestProductVersion"; + public const string LastestProductVersion = nameof(LastestProductVersion); } } \ No newline at end of file diff --git a/PlexRequests.Core/IIssueService.cs b/PlexRequests.Core/IIssueService.cs new file mode 100644 index 000000000..dab00222e --- /dev/null +++ b/PlexRequests.Core/IIssueService.cs @@ -0,0 +1,43 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: IIssueService.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.Threading.Tasks; + +using PlexRequests.Core.Models; + +namespace PlexRequests.Core +{ + public interface IIssueService + { + Task AddIssueAsync(IssuesModel model); + Task UpdateIssueAsync(IssuesModel model); + Task DeleteIssueAsync(int id); + Task DeleteIssueAsync(IssuesModel model); + Task GetAsync(int id); + Task> GetAllAsync(); + } +} \ No newline at end of file diff --git a/PlexRequests.Core/IRequestService.cs b/PlexRequests.Core/IRequestService.cs index 20fe5e071..740216bec 100644 --- a/PlexRequests.Core/IRequestService.cs +++ b/PlexRequests.Core/IRequestService.cs @@ -49,7 +49,9 @@ namespace PlexRequests.Core Task GetAsync(int id); IEnumerable GetAll(); Task> GetAllAsync(); - bool BatchUpdate(List model); - bool BatchDelete(List model); + bool BatchUpdate(IEnumerable model); + Task BatchUpdateAsync(IEnumerable model); + bool BatchDelete(IEnumerable model); + Task BatchDeleteAsync(IEnumerable model); } } \ No newline at end of file diff --git a/PlexRequests.Core/JsonIssuesModelRequestService.cs b/PlexRequests.Core/JsonIssuesModelRequestService.cs new file mode 100644 index 000000000..dd6a8f9a7 --- /dev/null +++ b/PlexRequests.Core/JsonIssuesModelRequestService.cs @@ -0,0 +1,115 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: IssueJsonService.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.Linq; +using System.Text; +using System.Threading.Tasks; + +using Newtonsoft.Json; + +using PlexRequests.Core.Models; +using PlexRequests.Helpers; +using PlexRequests.Store.Models; +using PlexRequests.Store.Repository; + +namespace PlexRequests.Core +{ + public class IssueJsonService : IIssueService + { + public IssueJsonService(IRepository repo) + { + Repo = repo; + } + private IRepository Repo { get; } + public async Task AddIssueAsync(IssuesModel model) + { + var entity = new IssueBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), RequestId = model.RequestId }; + var id = await Repo.InsertAsync(entity); + + model.Id = id; + + entity = new IssueBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), RequestId = model.RequestId, Id = id }; + var result = await Repo.UpdateAsync(entity); + + return result ? id : -1; + } + + public async Task UpdateIssueAsync(IssuesModel model) + { + var entity = new IssueBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), RequestId = model.RequestId, Id = model.Id }; + return await Repo.UpdateAsync(entity); + } + + public async Task DeleteIssueAsync(int id) + { + var entity = await Repo.GetAsync(id); + + await Repo.DeleteAsync(entity); + } + + public async Task DeleteIssueAsync(IssuesModel model) + { + var entity = new IssueBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), RequestId = model.RequestId, Id = model.Id }; + + await Repo.DeleteAsync(entity); + } + + /// + /// Gets the Issues, if there is no issue in the DB we return a new . + /// + /// The identifier. + /// + public async Task GetAsync(int id) + { + var blob = await Repo.GetAsync(id); + + if (blob == null) + { + return new IssuesModel(); + } + var model = ByteConverterHelper.ReturnObject(blob.Content); + return model; + } + + /// + /// Gets all the Issues, if there is no issue in the DB we return a new IEnumerable<IssuesModel>. + /// + /// + public async Task> GetAllAsync() + { + var blobs = await Repo.GetAllAsync(); + + if (blobs == null) + { + return new List(); + } + return blobs.Select(b => Encoding.UTF8.GetString(b.Content)) + .Select(JsonConvert.DeserializeObject) + .ToList(); + } + } +} \ No newline at end of file diff --git a/PlexRequests.Core/JsonRequestService.cs b/PlexRequests.Core/JsonRequestModelRequestService.cs similarity index 87% rename from PlexRequests.Core/JsonRequestService.cs rename to PlexRequests.Core/JsonRequestModelRequestService.cs index d44069271..795f797aa 100644 --- a/PlexRequests.Core/JsonRequestService.cs +++ b/PlexRequests.Core/JsonRequestModelRequestService.cs @@ -1,176 +1,186 @@ -#region Copyright -// /************************************************************************ -// Copyright (c) 2016 Jamie Rees -// File: JsonRequestService.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.Linq; -using System.Text; -using System.Threading.Tasks; - -using Newtonsoft.Json; - -using PlexRequests.Helpers; -using PlexRequests.Store; -using PlexRequests.Store.Models; -using PlexRequests.Store.Repository; - -namespace PlexRequests.Core -{ - public class JsonRequestService : IRequestService - { - public JsonRequestService(IRequestRepository repo) - { - Repo = repo; - } - private IRequestRepository Repo { get; } - public long AddRequest(RequestedModel model) - { - var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId }; - var id = Repo.Insert(entity); - - model.Id = (int)id; - - entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = (int)id, MusicId = model.MusicBrainzId }; - var result = Repo.Update(entity); - - return result ? id : -1; - } - - public async Task AddRequestAsync(RequestedModel model) - { - var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId }; - var id = await Repo.InsertAsync(entity); - - model.Id = id; - - entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = id, MusicId = model.MusicBrainzId }; - var result = await Repo.UpdateAsync(entity); - - return result ? id : -1; - } - - public RequestedModel CheckRequest(int providerId) - { - var blobs = Repo.GetAll(); - var blob = blobs.FirstOrDefault(x => x.ProviderId == providerId); - return blob != null ? ByteConverterHelper.ReturnObject(blob.Content) : null; - } - - public async Task CheckRequestAsync(int providerId) - { - var blobs = await Repo.GetAllAsync(); - var blob = blobs.FirstOrDefault(x => x.ProviderId == providerId); - return blob != null ? ByteConverterHelper.ReturnObject(blob.Content) : null; - } - - public RequestedModel CheckRequest(string musicId) - { - var blobs = Repo.GetAll(); - var blob = blobs.FirstOrDefault(x => x.MusicId == musicId); - return blob != null ? ByteConverterHelper.ReturnObject(blob.Content) : null; - } - - public async Task CheckRequestAsync(string musicId) - { - var blobs = await Repo.GetAllAsync(); - var blob = blobs.FirstOrDefault(x => x.MusicId == musicId); - return blob != null ? ByteConverterHelper.ReturnObject(blob.Content) : null; - } - - public void DeleteRequest(RequestedModel request) - { - var blob = Repo.Get(request.Id); - Repo.Delete(blob); - } - - public async Task DeleteRequestAsync(RequestedModel request) - { - var blob = await Repo.GetAsync(request.Id); - await Repo.DeleteAsync(blob); - } - - public bool UpdateRequest(RequestedModel model) - { - var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = model.Id }; - return Repo.Update(entity); - } - - public async Task UpdateRequestAsync(RequestedModel model) - { - var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = model.Id }; - return await Repo.UpdateAsync(entity); - } - - public RequestedModel Get(int id) - { - var blob = Repo.Get(id); - if (blob == null) - { - return new RequestedModel(); - } - var model = ByteConverterHelper.ReturnObject(blob.Content); - return model; - } - - public async Task GetAsync(int id) - { - var blob = await Repo.GetAsync(id); - if (blob == null) - { - return new RequestedModel(); - } - var model = ByteConverterHelper.ReturnObject(blob.Content); - return model; - } - - public IEnumerable GetAll() - { - var blobs = Repo.GetAll(); - return blobs.Select(b => Encoding.UTF8.GetString(b.Content)) - .Select(JsonConvert.DeserializeObject) - .ToList(); - } - - public async Task> GetAllAsync() - { - var blobs = await Repo.GetAllAsync(); - return blobs.Select(b => Encoding.UTF8.GetString(b.Content)) - .Select(JsonConvert.DeserializeObject) - .ToList(); - } - - public bool BatchUpdate(List model) - { - 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 bool BatchDelete(List model) - { - var entities = model.Select(m => new RequestBlobs { Type = m.Type, Content = ByteConverterHelper.ReturnBytes(m), ProviderId = m.ProviderId, Id = m.Id }).ToList(); - return Repo.DeleteAll(entities); - } - } +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: JsonRequestModelRequestService.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.Linq; +using System.Text; +using System.Threading.Tasks; + +using Newtonsoft.Json; + +using PlexRequests.Helpers; +using PlexRequests.Store; +using PlexRequests.Store.Models; +using PlexRequests.Store.Repository; + +namespace PlexRequests.Core +{ + public class JsonRequestModelRequestService : IRequestService + { + public JsonRequestModelRequestService(IRequestRepository repo) + { + Repo = repo; + } + private IRequestRepository Repo { get; } + public long AddRequest(RequestedModel model) + { + var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId }; + var id = Repo.Insert(entity); + + model.Id = (int)id; + + entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = (int)id, MusicId = model.MusicBrainzId }; + var result = Repo.Update(entity); + + return result ? id : -1; + } + + public async Task AddRequestAsync(RequestedModel model) + { + var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId }; + var id = await Repo.InsertAsync(entity); + + model.Id = id; + + entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = id, MusicId = model.MusicBrainzId }; + var result = await Repo.UpdateAsync(entity); + + return result ? id : -1; + } + + public RequestedModel CheckRequest(int providerId) + { + var blobs = Repo.GetAll(); + var blob = blobs.FirstOrDefault(x => x.ProviderId == providerId); + return blob != null ? ByteConverterHelper.ReturnObject(blob.Content) : null; + } + + public async Task CheckRequestAsync(int providerId) + { + var blobs = await Repo.GetAllAsync(); + var blob = blobs.FirstOrDefault(x => x.ProviderId == providerId); + return blob != null ? ByteConverterHelper.ReturnObject(blob.Content) : null; + } + + public RequestedModel CheckRequest(string musicId) + { + var blobs = Repo.GetAll(); + var blob = blobs.FirstOrDefault(x => x.MusicId == musicId); + return blob != null ? ByteConverterHelper.ReturnObject(blob.Content) : null; + } + + public async Task CheckRequestAsync(string musicId) + { + var blobs = await Repo.GetAllAsync(); + var blob = blobs.FirstOrDefault(x => x.MusicId == musicId); + return blob != null ? ByteConverterHelper.ReturnObject(blob.Content) : null; + } + + public void DeleteRequest(RequestedModel request) + { + var blob = Repo.Get(request.Id); + Repo.Delete(blob); + } + + public async Task DeleteRequestAsync(RequestedModel request) + { + var blob = await Repo.GetAsync(request.Id); + await Repo.DeleteAsync(blob); + } + + public bool UpdateRequest(RequestedModel model) + { + var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = model.Id }; + return Repo.Update(entity); + } + + public async Task UpdateRequestAsync(RequestedModel model) + { + var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = model.Id }; + return await Repo.UpdateAsync(entity); + } + + public RequestedModel Get(int id) + { + var blob = Repo.Get(id); + if (blob == null) + { + return new RequestedModel(); + } + var model = ByteConverterHelper.ReturnObject(blob.Content); + return model; + } + + public async Task GetAsync(int id) + { + var blob = await Repo.GetAsync(id); + if (blob == null) + { + return new RequestedModel(); + } + var model = ByteConverterHelper.ReturnObject(blob.Content); + return model; + } + + public IEnumerable GetAll() + { + var blobs = Repo.GetAll(); + return blobs.Select(b => Encoding.UTF8.GetString(b.Content)) + .Select(JsonConvert.DeserializeObject) + .ToList(); + } + + public async Task> GetAllAsync() + { + var blobs = await Repo.GetAllAsync(); + return blobs.Select(b => Encoding.UTF8.GetString(b.Content)) + .Select(JsonConvert.DeserializeObject) + .ToList(); + } + + public bool BatchUpdate(IEnumerable model) + { + 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 async Task BatchUpdateAsync(IEnumerable model) + { + var entities = model.Select(m => new RequestBlobs { Type = m.Type, Content = ByteConverterHelper.ReturnBytes(m), ProviderId = m.ProviderId, Id = m.Id }).ToList(); + return await Repo.UpdateAllAsync(entities); + } + public bool BatchDelete(IEnumerable model) + { + var entities = model.Select(m => new RequestBlobs { Type = m.Type, Content = ByteConverterHelper.ReturnBytes(m), ProviderId = m.ProviderId, Id = m.Id }).ToList(); + return Repo.DeleteAll(entities); + } + + public async Task BatchDeleteAsync(IEnumerable model) + { + var entities = model.Select(m => new RequestBlobs { Type = m.Type, Content = ByteConverterHelper.ReturnBytes(m), ProviderId = m.ProviderId, Id = m.Id }).ToList(); + return await Repo.DeleteAllAsync(entities); + } + } } \ No newline at end of file diff --git a/PlexRequests.Core/Models/IssuesModel.cs b/PlexRequests.Core/Models/IssuesModel.cs new file mode 100644 index 000000000..4aefb61cd --- /dev/null +++ b/PlexRequests.Core/Models/IssuesModel.cs @@ -0,0 +1,62 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: IssuesModel.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 PlexRequests.Store; + +namespace PlexRequests.Core.Models +{ + public class IssuesModel : Entity + { + public IssuesModel() + { + Issues = new List(); + } + public string Title { get; set; } + public string PosterUrl { get; set; } + public int RequestId { get; set; } + public List Issues { get; set; } + public bool Deleted { get; set; } + public RequestType Type { get; set; } + public IssueStatus IssueStatus { get; set; } + public int ProviderId { get; set; } + } + + public class IssueModel + { + public string UserNote { get; set; } + public string UserReported { get; set; } + public IssueState Issue { get; set; } + public string AdminNote { get; set; } + } + + public enum IssueStatus + { + PendingIssue, + ResolvedIssue + } +} \ No newline at end of file diff --git a/PlexRequests.Core/PlexRequests.Core.csproj b/PlexRequests.Core/PlexRequests.Core.csproj index 0b454381e..495a09ed8 100644 --- a/PlexRequests.Core/PlexRequests.Core.csproj +++ b/PlexRequests.Core/PlexRequests.Core.csproj @@ -34,6 +34,10 @@ ..\Assemblies\Mono.Data.Sqlite.dll + + ..\packages\NLog.4.3.4\lib\net45\NLog.dll + True + @@ -54,9 +58,6 @@ ..\packages\Newtonsoft.Json.8.0.2\lib\net45\Newtonsoft.Json.dll - - ..\packages\NLog.4.2.3\lib\net45\NLog.dll - ..\packages\Octokit.0.19.0\lib\net45\Octokit.dll @@ -66,13 +67,18 @@ + - + + + + + diff --git a/PlexRequests.Core/SettingModels/LandingPageSettings.cs b/PlexRequests.Core/SettingModels/LandingPageSettings.cs new file mode 100644 index 000000000..81a20e9dd --- /dev/null +++ b/PlexRequests.Core/SettingModels/LandingPageSettings.cs @@ -0,0 +1,46 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: LandingPageSettings.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 Newtonsoft.Json; + +namespace PlexRequests.Core.SettingModels +{ + public class LandingPageSettings : Settings + { + public bool Enabled { get; set; } + public bool BeforeLogin { get; set; } + public bool NoticeEnable { get; set; } + public string NoticeMessage { get; set; } + public bool EnabledNoticeTime { get; set; } + public DateTime NoticeStart { get; set; } + public DateTime NoticeEnd { get; set; } + + [JsonIgnore] + public bool NoticeActive => DateTime.Now < NoticeEnd && DateTime.Now > NoticeStart; + } +} \ No newline at end of file diff --git a/PlexRequests.Core/SettingModels/PlexRequestSettings.cs b/PlexRequests.Core/SettingModels/PlexRequestSettings.cs index dc27da865..493d8bf39 100644 --- a/PlexRequests.Core/SettingModels/PlexRequestSettings.cs +++ b/PlexRequests.Core/SettingModels/PlexRequestSettings.cs @@ -41,8 +41,10 @@ namespace PlexRequests.Core.SettingModels public bool RequireTvShowApproval { get; set; } public bool RequireMusicApproval { get; set; } public bool UsersCanViewOnlyOwnRequests { get; set; } + public bool UsersCanViewOnlyOwnIssues { get; set; } public int WeeklyRequestLimit { get; set; } public string NoApprovalUsers { get; set; } + public bool CollectAnalyticData { get; set; } /// /// The CSS name of the theme we want diff --git a/PlexRequests.Core/SettingModels/PlexSettings.cs b/PlexRequests.Core/SettingModels/PlexSettings.cs index fa39af857..e56595c51 100644 --- a/PlexRequests.Core/SettingModels/PlexSettings.cs +++ b/PlexRequests.Core/SettingModels/PlexSettings.cs @@ -37,6 +37,7 @@ namespace PlexRequests.Core.SettingModels public int Port { get; set; } public bool Ssl { get; set; } public string SubDir { get; set; } + public bool AdvancedSearch { get; set; } [JsonIgnore] public Uri FullUri diff --git a/PlexRequests.Core/SettingModels/ScheduledJobsSettings.cs b/PlexRequests.Core/SettingModels/ScheduledJobsSettings.cs new file mode 100644 index 000000000..216f54f88 --- /dev/null +++ b/PlexRequests.Core/SettingModels/ScheduledJobsSettings.cs @@ -0,0 +1,47 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: ScheduledJobsSettings.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.SettingModels +{ + public class ScheduledJobsSettings : Settings + { + public ScheduledJobsSettings() + { + PlexAvailabilityChecker = 10; + SickRageCacher = 10; + SonarrCacher = 10; + CouchPotatoCacher = 10; + StoreBackup = 24; + StoreCleanup = 24; + } + public int PlexAvailabilityChecker { get; set; } + public int SickRageCacher { get; set; } + public int SonarrCacher { get; set; } + public int CouchPotatoCacher { get; set; } + public int StoreBackup { get; set; } + public int StoreCleanup { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Core/SettingsServiceV2.cs b/PlexRequests.Core/SettingsServiceV2.cs index 5705544d0..0e702873b 100644 --- a/PlexRequests.Core/SettingsServiceV2.cs +++ b/PlexRequests.Core/SettingsServiceV2.cs @@ -72,11 +72,7 @@ namespace PlexRequests.Core return new T(); } result.Content = DecryptSettings(result); - var obj = string.IsNullOrEmpty(result.Content) ? null : JsonConvert.DeserializeObject(result.Content, SerializerSettings.Settings); - - var model = obj; - - return model; + return string.IsNullOrEmpty(result.Content) ? null : JsonConvert.DeserializeObject(result.Content, SerializerSettings.Settings); } public bool SaveSettings(T model) diff --git a/PlexRequests.Core/Setup.cs b/PlexRequests.Core/Setup.cs index 87e16118e..2c015f55c 100644 --- a/PlexRequests.Core/Setup.cs +++ b/PlexRequests.Core/Setup.cs @@ -61,7 +61,7 @@ namespace PlexRequests.Core { MigrateToVersion1700(); } - if (version > 1800 && version <= 1899) + if (version > 1799 && version <= 1800) { MigrateToVersion1800(); } @@ -181,10 +181,13 @@ namespace PlexRequests.Core /// /// Migrates to version 1.8. /// This includes updating the admin account to have all roles. - /// Set the log level to info + /// Set the log level to Error + /// Enable Analytics by default /// private void MigrateToVersion1800() { + + // Give admin all roles/claims try { var userMapper = new UserMapper(new UserRepository(Db, new MemoryCacheProvider())); @@ -201,14 +204,15 @@ namespace PlexRequests.Core catch (Exception e) { Log.Error(e); - throw; } + + // Set log level try { var settingsService = new SettingsServiceV2(new SettingsJsonRepository(Db, new MemoryCacheProvider())); var logSettings = settingsService.GetSettings(); - logSettings.Level = LogLevel.Info.Ordinal; + logSettings.Level = LogLevel.Error.Ordinal; settingsService.SaveSettings(logSettings); LoggingHelper.ReconfigureLogLevel(LogLevel.FromOrdinal(logSettings.Level)); @@ -217,10 +221,24 @@ namespace PlexRequests.Core catch (Exception e) { Log.Error(e); - throw; } + // Enable analytics; + try + { + + var prSettings = new SettingsServiceV2(new SettingsJsonRepository(Db, new MemoryCacheProvider())); + var settings = prSettings.GetSettings(); + settings.CollectAnalyticData = true; + var updated = prSettings.SaveSettings(settings); + + } + catch (Exception e) + { + Log.Error(e); + } + } } } diff --git a/PlexRequests.Core/StatusChecker.cs b/PlexRequests.Core/StatusChecker.cs index 476a55a45..6d616cb06 100644 --- a/PlexRequests.Core/StatusChecker.cs +++ b/PlexRequests.Core/StatusChecker.cs @@ -51,7 +51,7 @@ namespace PlexRequests.Core return releases.FirstOrDefault(); } - public StatusModel GetStatus() + public async Task GetStatus() { var assemblyVersion = AssemblyHelper.GetProductVersion(); var model = new StatusModel @@ -59,23 +59,23 @@ namespace PlexRequests.Core Version = assemblyVersion, }; - var latestRelease = GetLatestRelease(); - if (latestRelease.Result == null) + var latestRelease = await GetLatestRelease(); + if (latestRelease == null) { return new StatusModel { Version = "Unknown" }; } - var latestVersionArray = latestRelease.Result.Name.Split(new[] { 'v' }, StringSplitOptions.RemoveEmptyEntries); + var latestVersionArray = latestRelease.Name.Split(new[] { 'v' }, StringSplitOptions.RemoveEmptyEntries); var latestVersion = latestVersionArray.Length > 1 ? latestVersionArray[1] : string.Empty; if (!latestVersion.Equals(assemblyVersion, StringComparison.InvariantCultureIgnoreCase)) { model.UpdateAvailable = true; - model.UpdateUri = latestRelease.Result.HtmlUrl; + model.UpdateUri = latestRelease.HtmlUrl; } - model.ReleaseNotes = latestRelease.Result.Body; - model.DownloadUri = latestRelease.Result.Assets[0].BrowserDownloadUrl; - model.ReleaseTitle = latestRelease.Result.Name; + model.ReleaseNotes = latestRelease.Body; + model.DownloadUri = latestRelease.Assets[0].BrowserDownloadUrl; + model.ReleaseTitle = latestRelease.Name; return model; } diff --git a/PlexRequests.Core/packages.config b/PlexRequests.Core/packages.config index d378174d8..41cff5672 100644 --- a/PlexRequests.Core/packages.config +++ b/PlexRequests.Core/packages.config @@ -3,7 +3,7 @@ - + \ No newline at end of file diff --git a/PlexRequests.Helpers.Tests/DateTimeHelperTests.cs b/PlexRequests.Helpers.Tests/DateTimeHelperTests.cs new file mode 100644 index 000000000..b327f1259 --- /dev/null +++ b/PlexRequests.Helpers.Tests/DateTimeHelperTests.cs @@ -0,0 +1,54 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: DateTimeHelperTests.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.Collections.Generic; + +using NUnit.Framework; + +namespace PlexRequests.Helpers.Tests +{ + [TestFixture] + public class DateTimeHelperTests + { + [TestCaseSource(nameof(OffsetUtcDateTimeData))] + public DateTime TestOffsetUtcDateTimeData(DateTime utcDateTime, int minuteOffset) + { + var offset = DateTimeHelper.OffsetUTCDateTime(utcDateTime, minuteOffset); + return offset.DateTime; + } + + private static IEnumerable OffsetUtcDateTimeData + { + get + { + yield return new TestCaseData(new DateTime(2016,01,01,12,00,00), -60).Returns(new DateTimeOffset(new DateTime(2016, 01, 01, 13, 00, 00)).DateTime); + yield return new TestCaseData(new DateTime(2016,01,01,12,00,00), -120).Returns(new DateTimeOffset(new DateTime(2016, 01, 01, 14, 00, 00)).DateTime); + yield return new TestCaseData(new DateTime(2016,01,01,12,00,00), 120).Returns(new DateTimeOffset(new DateTime(2016, 01, 01, 10, 00, 00)).DateTime); + } + } + } +} \ No newline at end of file diff --git a/PlexRequests.Helpers.Tests/PlexHelperTests.cs b/PlexRequests.Helpers.Tests/PlexHelperTests.cs new file mode 100644 index 000000000..d690bbbfe --- /dev/null +++ b/PlexRequests.Helpers.Tests/PlexHelperTests.cs @@ -0,0 +1,61 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: UriHelperTests.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.Collections.Generic; + +using NUnit.Framework; + +namespace PlexRequests.Helpers.Tests +{ + [TestFixture] + public class PlexHelperTests + { + [TestCaseSource(nameof(PlexGuids))] + public string CreateUriWithSubDir(string guid) + { + return PlexHelper.GetProviderIdFromPlexGuid(guid); + } + + + private static IEnumerable PlexGuids + { + get + { + yield return new TestCaseData("com.plexapp.agents.thetvdb://269586/3/17?lang=en").Returns("269586"); + yield return new TestCaseData("com.plexapp.agents.imdb://tt3300542?lang=en").Returns("tt3300542"); + yield return new TestCaseData("com.plexapp.agents.thetvdb://71326/10/5?lang=en").Returns("71326"); + yield return new TestCaseData("local://3450").Returns("3450"); + yield return new TestCaseData("com.plexapp.agents.imdb://tt1179933?lang=en").Returns("tt1179933"); + yield return new TestCaseData("com.plexapp.agents.imdb://tt0284837?lang=en").Returns("tt0284837"); + yield return new TestCaseData("com.plexapp.agents.imdb://tt0076759?lang=en").Returns("tt0076759"); + } + } + + + } +} \ 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 9f3adbd0d..308e966e7 100644 --- a/PlexRequests.Helpers.Tests/PlexRequests.Helpers.Tests.csproj +++ b/PlexRequests.Helpers.Tests/PlexRequests.Helpers.Tests.csproj @@ -71,10 +71,12 @@ + + diff --git a/PlexRequests.Helpers.Tests/app.config b/PlexRequests.Helpers.Tests/app.config index 1911601e2..f2cdc9810 100644 --- a/PlexRequests.Helpers.Tests/app.config +++ b/PlexRequests.Helpers.Tests/app.config @@ -1,15 +1,15 @@ - + - - + + - - + + - + diff --git a/PlexRequests.Helpers/Analytics/Action.cs b/PlexRequests.Helpers/Analytics/Action.cs new file mode 100644 index 000000000..a353dcc33 --- /dev/null +++ b/PlexRequests.Helpers/Analytics/Action.cs @@ -0,0 +1,39 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: Action.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.Helpers.Analytics +{ + public enum Action + { + Donate, + ClickButton, + Delete, + Create, + Save, + Update, + Start + } +} \ No newline at end of file diff --git a/PlexRequests.Helpers/Analytics/Analytics.cs b/PlexRequests.Helpers/Analytics/Analytics.cs new file mode 100644 index 000000000..4b4d74ed9 --- /dev/null +++ b/PlexRequests.Helpers/Analytics/Analytics.cs @@ -0,0 +1,240 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: Analytics.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.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using System.Web; + +using NLog; + +using HttpUtility = Nancy.Helpers.HttpUtility; + +namespace PlexRequests.Helpers.Analytics +{ + public class Analytics : IAnalytics + { + private const string AnalyticsUri = "http://www.google-analytics.com/collect"; + private const string RequestMethod = "POST"; + private const string TrackingId = "UA-77083919-2"; + + private static Logger Log = LogManager.GetCurrentClassLogger(); + + public void TrackEvent(Category category, Action action, string label, string username, int? value = null) + { + var cat = category.ToString(); + var act = action.ToString(); + Track(HitType.@event, username, cat, act, label, value); + } + + public async Task TrackEventAsync(Category category, Action action, string label, string username, int? value = null) + { + var cat = category.ToString(); + var act = action.ToString(); + await TrackAsync(HitType.@event, username, cat, act, label, value); + } + + public void TrackPageview(Category category, Action action, string label, string username, int? value = null) + { + var cat = category.ToString(); + var act = action.ToString(); + Track(HitType.@pageview, username, cat, act, label, value); + } + public async Task TrackPageviewAsync(Category category, Action action, string label, string username, int? value = null) + { + var cat = category.ToString(); + var act = action.ToString(); + await TrackAsync(HitType.@pageview, username, cat, act, label, value); + } + + public void TrackException(string message, string username, bool fatal) + { + var fatalInt = fatal ? 1 : 0; + Track(HitType.exception, message, fatalInt, username); + } + + public async Task TrackExceptionAsync(string message, string username, bool fatal) + { + var fatalInt = fatal ? 1 : 0; + await TrackAsync(HitType.exception, message, fatalInt, username); + } + + private void Track(HitType type, string username, string category, string action, string label, int? value = null) + { + if (string.IsNullOrEmpty(category)) throw new ArgumentNullException(nameof(category)); + if (string.IsNullOrEmpty(action)) throw new ArgumentNullException(nameof(action)); + if (string.IsNullOrEmpty(username)) throw new ArgumentNullException(nameof(username)); + + var postData = BuildRequestData(type, username, category, action, label, value, null, null); + + var postDataString = postData + .Aggregate("", (data, next) => string.Format($"{data}&{next.Key}={HttpUtility.UrlEncode(next.Value)}")) + .TrimEnd('&'); + + SendRequest(postDataString); + } + + private async Task TrackAsync(HitType type, string username, string category, string action, string label, int? value = null) + { + if (string.IsNullOrEmpty(category)) throw new ArgumentNullException(nameof(category)); + if (string.IsNullOrEmpty(action)) throw new ArgumentNullException(nameof(action)); + if (string.IsNullOrEmpty(username)) throw new ArgumentNullException(nameof(username)); + + var postData = BuildRequestData(type, username, category, action, label, value, null, null); + + var postDataString = postData + .Aggregate("", (data, next) => string.Format($"{data}&{next.Key}={HttpUtility.UrlEncode(next.Value)}")) + .TrimEnd('&'); + + await SendRequestAsync(postDataString); + } + private async Task TrackAsync(HitType type, string message, int fatal, string username) + { + if (string.IsNullOrEmpty(message)) throw new ArgumentNullException(nameof(message)); + if (string.IsNullOrEmpty(username)) throw new ArgumentNullException(nameof(username)); + + var postData = BuildRequestData(type, username, null, null, null, null, message, fatal); + + var postDataString = postData + .Aggregate("", (data, next) => string.Format($"{data}&{next.Key}={HttpUtility.UrlEncode(next.Value)}")) + .TrimEnd('&'); + + await SendRequestAsync(postDataString); + } + + private void Track(HitType type, string message, int fatal, string username) + { + if (string.IsNullOrEmpty(message)) throw new ArgumentNullException(nameof(message)); + if (string.IsNullOrEmpty(username)) throw new ArgumentNullException(nameof(username)); + + var postData = BuildRequestData(type, username, null, null, null, null, message, fatal); + + var postDataString = postData + .Aggregate("", (data, next) => string.Format($"{data}&{next.Key}={HttpUtility.UrlEncode(next.Value)}")) + .TrimEnd('&'); + + SendRequest(postDataString); + } + + private void SendRequest(string postDataString) + { + var request = (HttpWebRequest)WebRequest.Create(AnalyticsUri); + request.Method = RequestMethod; + // set the Content-Length header to the correct value + request.ContentLength = Encoding.UTF8.GetByteCount(postDataString); + + // write the request body to the request + using (var writer = new StreamWriter(request.GetRequestStream())) + { + writer.Write(postDataString); + } + + try + { + var webResponse = (HttpWebResponse)request.GetResponse(); + if (webResponse.StatusCode != HttpStatusCode.OK) + { + throw new HttpException((int)webResponse.StatusCode, "Google Analytics tracking did not return OK 200"); + } + } + catch (Exception ex) + { + Log.Error(ex, "Analytics tracking failed"); + } + } + private async Task SendRequestAsync(string postDataString) + { + var request = (HttpWebRequest)WebRequest.Create(AnalyticsUri); + request.Method = RequestMethod; + // set the Content-Length header to the correct value + request.ContentLength = Encoding.UTF8.GetByteCount(postDataString); + + // write the request body to the request + using (var writer = new StreamWriter(await request.GetRequestStreamAsync())) + { + await writer.WriteAsync(postDataString); + } + + try + { + var webResponse = (HttpWebResponse)await request.GetResponseAsync(); + if (webResponse.StatusCode != HttpStatusCode.OK) + { + throw new HttpException((int)webResponse.StatusCode, "Google Analytics tracking did not return OK 200"); + } + } + catch (Exception ex) + { + Log.Error(ex, "Analytics tracking failed"); + } + } + + private Dictionary BuildRequestData(HitType type, string username, string category, string action, string label, int? value, string exceptionDescription, int? fatal) + { + var postData = new Dictionary + { + { "v", "1" }, + { "tid", TrackingId }, + { "t", type.ToString() }, + {"cid", Guid.NewGuid().ToString() } + }; + + if (!string.IsNullOrEmpty(username)) + { + postData.Add("uid", username); + } + if (!string.IsNullOrEmpty(label)) + { + postData.Add("el", label); + } + if (value.HasValue) + { + postData.Add("ev", value.ToString()); + } + if (!string.IsNullOrEmpty(category)) + { + postData.Add("ec", category); + } + if (!string.IsNullOrEmpty(action)) + { + postData.Add("ea", action); + } + if (!string.IsNullOrEmpty(exceptionDescription)) + { + postData.Add("exd", exceptionDescription); + } + if (fatal.HasValue) + { + postData.Add("exf", fatal.ToString()); + } + return postData; + } + } +} \ No newline at end of file diff --git a/PlexRequests.Helpers/Analytics/Category.cs b/PlexRequests.Helpers/Analytics/Category.cs new file mode 100644 index 000000000..7bedb16a5 --- /dev/null +++ b/PlexRequests.Helpers/Analytics/Category.cs @@ -0,0 +1,41 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: Category.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.Helpers.Analytics +{ + public enum Category + { + Startup, + Search, + Requests, + Admin, + LandingPage, + Api, + Issues, + UserLogin, + Services, + } +} \ No newline at end of file diff --git a/PlexRequests.Helpers/Analytics/HitType.cs b/PlexRequests.Helpers/Analytics/HitType.cs new file mode 100644 index 000000000..c3882f96a --- /dev/null +++ b/PlexRequests.Helpers/Analytics/HitType.cs @@ -0,0 +1,36 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: HitType.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 +// ReSharper disable InconsistentNaming +namespace PlexRequests.Helpers.Analytics +{ + internal enum HitType + { + @event, + @pageview, + @exception + } +} \ No newline at end of file diff --git a/PlexRequests.Helpers/Analytics/IAnalytics.cs b/PlexRequests.Helpers/Analytics/IAnalytics.cs new file mode 100644 index 000000000..4e228f545 --- /dev/null +++ b/PlexRequests.Helpers/Analytics/IAnalytics.cs @@ -0,0 +1,92 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: IAnalytics.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; + +namespace PlexRequests.Helpers.Analytics +{ + public interface IAnalytics + { + /// + /// Tracks the event. + /// + /// The category. + /// The action. + /// The label. + /// The username. + /// The value. + void TrackEvent(Category category, Action action, string label, string username, int? value = null); + + /// + /// Tracks the event asynchronous. + /// + /// The category. + /// The action. + /// The label. + /// The username. + /// The value. + /// + Task TrackEventAsync(Category category, Action action, string label, string username, int? value = null); + + /// + /// Tracks the page view. + /// + /// The category. + /// The action. + /// The label. + /// The username. + /// The value. + void TrackPageview(Category category, Action action, string label, string username, int? value = null); + + /// + /// Tracks the page view asynchronous. + /// + /// The category. + /// The action. + /// The label. + /// The username. + /// The value. + /// + Task TrackPageviewAsync(Category category, Action action, string label, string username, int? value = null); + + /// + /// Tracks the exception. + /// + /// The message. + /// The username. + /// if set to true [fatal]. + void TrackException(string message, string username, bool fatal); + + /// + /// Tracks the exception asynchronous. + /// + /// The message. + /// The username. + /// if set to true [fatal]. + /// + Task TrackExceptionAsync(string message, string username, bool fatal); + } +} \ No newline at end of file diff --git a/PlexRequests.Helpers/LoggingHelper.cs b/PlexRequests.Helpers/LoggingHelper.cs index ce9497a8b..73562eebe 100644 --- a/PlexRequests.Helpers/LoggingHelper.cs +++ b/PlexRequests.Helpers/LoggingHelper.cs @@ -139,7 +139,6 @@ namespace PlexRequests.Helpers if (level == LogLevel.Info) { rule.EnableLoggingForLevel(LogLevel.Info); - rule.EnableLoggingForLevel(LogLevel.Debug); rule.EnableLoggingForLevel(LogLevel.Warn); rule.EnableLoggingForLevel(LogLevel.Error); rule.EnableLoggingForLevel(LogLevel.Fatal); diff --git a/PlexRequests.Helpers/PlexHelper.cs b/PlexRequests.Helpers/PlexHelper.cs new file mode 100644 index 000000000..06afebba4 --- /dev/null +++ b/PlexRequests.Helpers/PlexHelper.cs @@ -0,0 +1,47 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: PlexHelper.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.Helpers +{ + public class PlexHelper + { + public static string GetProviderIdFromPlexGuid(string guid) + { + if (string.IsNullOrEmpty(guid)) + return guid; + + var guidSplit = guid.Split(new[] {'/', '?'}, StringSplitOptions.RemoveEmptyEntries); + if (guidSplit.Length > 1) + { + return guidSplit[1]; + } + return string.Empty; + } + } +} \ No newline at end of file diff --git a/PlexRequests.Helpers/PlexRequests.Helpers.csproj b/PlexRequests.Helpers/PlexRequests.Helpers.csproj index 538d65080..8bdfb5ed8 100644 --- a/PlexRequests.Helpers/PlexRequests.Helpers.csproj +++ b/PlexRequests.Helpers/PlexRequests.Helpers.csproj @@ -31,9 +31,26 @@ 4 + + ..\packages\Hangfire.Core.1.5.7\lib\net45\Hangfire.Core.dll + True + + + ..\packages\Nancy.1.4.3\lib\net40\Nancy.dll + True + + + ..\packages\NLog.4.3.4\lib\net45\NLog.dll + True + + + ..\packages\Owin.1.0\lib\net40\Owin.dll + True + + @@ -43,11 +60,13 @@ ..\packages\Newtonsoft.Json.8.0.2\lib\net45\Newtonsoft.Json.dll - - ..\packages\NLog.4.2.3\lib\net45\NLog.dll - + + + + + @@ -60,6 +79,7 @@ + @@ -67,6 +87,7 @@ + diff --git a/PlexRequests.Helpers/app.config b/PlexRequests.Helpers/app.config new file mode 100644 index 000000000..de5386a47 --- /dev/null +++ b/PlexRequests.Helpers/app.config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/PlexRequests.Helpers/packages.config b/PlexRequests.Helpers/packages.config index e9ff15713..9fa4953ae 100644 --- a/PlexRequests.Helpers/packages.config +++ b/PlexRequests.Helpers/packages.config @@ -1,5 +1,8 @@  + + - + + \ No newline at end of file diff --git a/PlexRequests.Services.Tests/PlexAvailabilityCheckerTests.cs b/PlexRequests.Services.Tests/PlexAvailabilityCheckerTests.cs index 476989215..7235639af 100644 --- a/PlexRequests.Services.Tests/PlexAvailabilityCheckerTests.cs +++ b/PlexRequests.Services.Tests/PlexAvailabilityCheckerTests.cs @@ -25,20 +25,19 @@ // ************************************************************************/ #endregion using System; -using System.Collections.Generic; using Moq; using NUnit.Framework; using PlexRequests.Api.Interfaces; -using PlexRequests.Api.Models.Plex; using PlexRequests.Core; using PlexRequests.Core.SettingModels; -using PlexRequests.Helpers.Exceptions; using PlexRequests.Services.Interfaces; -using PlexRequests.Store; using PlexRequests.Helpers; +using PlexRequests.Services.Jobs; +using PlexRequests.Store.Models; +using PlexRequests.Store.Repository; namespace PlexRequests.Services.Tests { @@ -46,19 +45,40 @@ namespace PlexRequests.Services.Tests public class PlexAvailabilityCheckerTests { public IAvailabilityChecker Checker { get; set; } + private Mock> SettingsMock { get; set; } + private Mock> AuthMock { get; set; } + private Mock RequestMock { get; set; } + private Mock PlexMock { get; set; } + private Mock CacheMock { get; set; } + private Mock NotificationMock { get; set; } + private Mock JobRec { get; set; } + private Mock> NotifyUsers { get; set; } - //[Test] - //public void IsAvailableWithEmptySettingsTest() - //{ - // var settingsMock = new Mock>(); - // var authMock = new Mock>(); - // var requestMock = new Mock(); - // var plexMock = new Mock(); - // var cacheMock = new Mock(); - // Checker = new PlexAvailabilityChecker(settingsMock.Object, authMock.Object, requestMock.Object, plexMock.Object, cacheMock.Object); + [SetUp] + public void Setup() + { + SettingsMock = new Mock>(); + AuthMock = new Mock>(); + RequestMock = new Mock(); + PlexMock = new Mock(); + NotificationMock = new Mock(); + CacheMock = new Mock(); + NotifyUsers = new Mock>(); + JobRec = new Mock(); + Checker = new PlexAvailabilityChecker(SettingsMock.Object, AuthMock.Object, RequestMock.Object, PlexMock.Object, CacheMock.Object, NotificationMock.Object, JobRec.Object, NotifyUsers.Object); - // Assert.Throws(() => Checker.IsAvailable("title", "2013", null, PlexType.TvShow), "We should be throwing an exception since we cannot talk to the services."); - //} + } + + [Test] + public void InvalidSettings() + { + Checker.CheckAndUpdateAll(); + PlexMock.Verify(x => x.GetLibrary(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + PlexMock.Verify(x => x.GetAccount(It.IsAny()), Times.Never); + PlexMock.Verify(x => x.GetLibrarySections(It.IsAny(), It.IsAny()), Times.Never); + PlexMock.Verify(x => x.GetStatus(It.IsAny(), It.IsAny()), Times.Never); + PlexMock.Verify(x => x.GetUsers(It.IsAny()), Times.Never); + } //[Test] //public void IsAvailableTest() @@ -531,7 +551,7 @@ namespace PlexRequests.Services.Tests // { // Title = "Hi", // Year = "2017", - // ParentTitle = "c" + //ParentTitle = "c" // }, // new Directory1 // { diff --git a/PlexRequests.Services.Tests/PlexRequests.Services.Tests.csproj b/PlexRequests.Services.Tests/PlexRequests.Services.Tests.csproj index b6a3c6dc0..298d5e64d 100644 --- a/PlexRequests.Services.Tests/PlexRequests.Services.Tests.csproj +++ b/PlexRequests.Services.Tests/PlexRequests.Services.Tests.csproj @@ -36,6 +36,14 @@ 4 + + ..\packages\Common.Logging.3.0.0\lib\net40\Common.Logging.dll + True + + + ..\packages\Common.Logging.Core.3.0.0\lib\net40\Common.Logging.Core.dll + True + ..\packages\Moq.4.2.1510.2205\lib\net40\Moq.dll True @@ -44,6 +52,10 @@ ..\packages\NUnit.3.2.0\lib\net45\nunit.framework.dll True + + ..\packages\Quartz.2.3.3\lib\net40\Quartz.dll + True + @@ -63,6 +75,9 @@ Designer + + Designer + diff --git a/PlexRequests.Services.Tests/job_scheduling_data_2_0.xsd b/PlexRequests.Services.Tests/job_scheduling_data_2_0.xsd new file mode 100644 index 000000000..d1dabc1a9 --- /dev/null +++ b/PlexRequests.Services.Tests/job_scheduling_data_2_0.xsd @@ -0,0 +1,361 @@ + + + + + + + Root level node + + + + + + Commands to be executed before scheduling the jobs and triggers in this file. + + + + + Directives to be followed while scheduling the jobs and triggers in this file. + + + + + + + + + + + + + + Version of the XML Schema instance + + + + + + + + + + Delete all jobs, if any, in the identified group. "*" can be used to identify all groups. Will also result in deleting all triggers related to the jobs. + + + + + Delete all triggers, if any, in the identified group. "*" can be used to identify all groups. Will also result in deletion of related jobs that are non-durable. + + + + + Delete the identified job if it exists (will also result in deleting all triggers related to it). + + + + + + + + + + + Delete the identified trigger if it exists (will also result in deletion of related jobs that are non-durable). + + + + + + + + + + + + + + + + Whether the existing scheduling data (with same identifiers) will be overwritten. If false, and ignore-duplicates is not false, and jobs or triggers with the same names already exist as those in the file, an error will occur. + + + + + If true (and overwrite-existing-data is false) then any job/triggers encountered in this file that have names that already exist in the scheduler will be ignored, and no error will be produced. + + + + + If true trigger's start time is calculated based on earlier run time instead of fixed value. Trigger's start time must be undefined for this to work. + + + + + + + + Define a JobDetail + + + + + + + + + + + + + + + + + Define a JobDataMap + + + + + + + + + Define a JobDataMap entry + + + + + + + + + + Define a Trigger + + + + + + + + + + + Common Trigger definitions + + + + + + + + + + + + + + + + + + + + + + + Define a SimpleTrigger + + + + + + + + + + + + + + + + + Define a CronTrigger + + + + + + + + + + + + + + + Define a DateIntervalTrigger + + + + + + + + + + + + + + + + Cron expression (see JavaDoc for examples) + + Special thanks to Chris Thatcher (thatcher@butterfly.net) for the regular expression! + + Regular expressions are not my strong point but I believe this is complete, + with the caveat that order for expressions like 3-0 is not legal but will pass, + and month and day names must be capitalized. + If you want to examine the correctness look for the [\s] to denote the + seperation of individual regular expressions. This is how I break them up visually + to examine them: + + SECONDS: + ( + ((([0-9]|[0-5][0-9])(-([0-9]|[0-5][0-9]))?,)*([0-9]|[0-5][0-9])(-([0-9]|[0-5][0-9]))?) + | (([\*]|[0-9]|[0-5][0-9])/([0-9]|[0-5][0-9])) + | ([\?]) + | ([\*]) + ) [\s] + MINUTES: + ( + ((([0-9]|[0-5][0-9])(-([0-9]|[0-5][0-9]))?,)*([0-9]|[0-5][0-9])(-([0-9]|[0-5][0-9]))?) + | (([\*]|[0-9]|[0-5][0-9])/([0-9]|[0-5][0-9])) + | ([\?]) + | ([\*]) + ) [\s] + HOURS: + ( + ((([0-9]|[0-1][0-9]|[2][0-3])(-([0-9]|[0-1][0-9]|[2][0-3]))?,)*([0-9]|[0-1][0-9]|[2][0-3])(-([0-9]|[0-1][0-9]|[2][0-3]))?) + | (([\*]|[0-9]|[0-1][0-9]|[2][0-3])/([0-9]|[0-1][0-9]|[2][0-3])) + | ([\?]) + | ([\*]) + ) [\s] + DAY OF MONTH: + ( + ((([1-9]|[0][1-9]|[1-2][0-9]|[3][0-1])(-([1-9]|[0][1-9]|[1-2][0-9]|[3][0-1]))?,)*([1-9]|[0][1-9]|[1-2][0-9]|[3][0-1])(-([1-9]|[0][1-9]|[1-2][0-9]|[3][0-1]))?(C)?) + | (([1-9]|[0][1-9]|[1-2][0-9]|[3][0-1])/([1-9]|[0][1-9]|[1-2][0-9]|[3][0-1])(C)?) + | (L(-[0-9])?) + | (L(-[1-2][0-9])?) + | (L(-[3][0-1])?) + | (LW) + | ([1-9]W) + | ([1-3][0-9]W) + | ([\?]) + | ([\*]) + )[\s] + MONTH: + ( + ((([1-9]|0[1-9]|1[0-2])(-([1-9]|0[1-9]|1[0-2]))?,)*([1-9]|0[1-9]|1[0-2])(-([1-9]|0[1-9]|1[0-2]))?) + | (([1-9]|0[1-9]|1[0-2])/([1-9]|0[1-9]|1[0-2])) + | (((JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(-(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))?,)*(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(-(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))?) + | ((JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)/(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)) + | ([\?]) + | ([\*]) + )[\s] + DAY OF WEEK: + ( + (([1-7](-([1-7]))?,)*([1-7])(-([1-7]))?) + | ([1-7]/([1-7])) + | (((MON|TUE|WED|THU|FRI|SAT|SUN)(-(MON|TUE|WED|THU|FRI|SAT|SUN))?,)*(MON|TUE|WED|THU|FRI|SAT|SUN)(-(MON|TUE|WED|THU|FRI|SAT|SUN))?(C)?) + | ((MON|TUE|WED|THU|FRI|SAT|SUN)/(MON|TUE|WED|THU|FRI|SAT|SUN)(C)?) + | (([1-7]|(MON|TUE|WED|THU|FRI|SAT|SUN))(L|LW)?) + | (([1-7]|MON|TUE|WED|THU|FRI|SAT|SUN)#([1-7])?) + | ([\?]) + | ([\*]) + ) + YEAR (OPTIONAL): + ( + [\s]? + ([\*])? + | ((19[7-9][0-9])|(20[0-9][0-9]))? + | (((19[7-9][0-9])|(20[0-9][0-9]))/((19[7-9][0-9])|(20[0-9][0-9])))? + | ((((19[7-9][0-9])|(20[0-9][0-9]))(-((19[7-9][0-9])|(20[0-9][0-9])))?,)*((19[7-9][0-9])|(20[0-9][0-9]))(-((19[7-9][0-9])|(20[0-9][0-9])))?)? + ) + + + + + + + + + + Number of times to repeat the Trigger (-1 for indefinite) + + + + + + + + + + Simple Trigger Misfire Instructions + + + + + + + + + + + + + + Cron Trigger Misfire Instructions + + + + + + + + + + + Date Interval Trigger Misfire Instructions + + + + + + + + + + + Interval Units + + + + + + + + + + + + + \ No newline at end of file diff --git a/PlexRequests.Services.Tests/packages.config b/PlexRequests.Services.Tests/packages.config index e3f1355a2..50f0bcc7b 100644 --- a/PlexRequests.Services.Tests/packages.config +++ b/PlexRequests.Services.Tests/packages.config @@ -1,5 +1,8 @@  + + + \ No newline at end of file diff --git a/PlexRequests.Services/Interfaces/IAvailabilityChecker.cs b/PlexRequests.Services/Interfaces/IAvailabilityChecker.cs index 14eda1731..6d8af1846 100644 --- a/PlexRequests.Services/Interfaces/IAvailabilityChecker.cs +++ b/PlexRequests.Services/Interfaces/IAvailabilityChecker.cs @@ -33,9 +33,9 @@ namespace PlexRequests.Services.Interfaces { void CheckAndUpdateAll(); List GetPlexMovies(); - bool IsMovieAvailable(PlexMovie[] plexMovies, string title, string year); + bool IsMovieAvailable(PlexMovie[] plexMovies, string title, string year, string providerId = null); List GetPlexTvShows(); - bool IsTvShowAvailable(PlexTvShow[] plexShows, string title, string year); + bool IsTvShowAvailable(PlexTvShow[] plexShows, string title, string year, string providerId = null); List GetPlexAlbums(); bool IsAlbumAvailable(PlexAlbum[] plexAlbums, string title, string year, string artist); } diff --git a/PlexRequests.Services/Interfaces/IJobRecord.cs b/PlexRequests.Services/Interfaces/IJobRecord.cs index 66c3be88c..da6bc6b3f 100644 --- a/PlexRequests.Services/Interfaces/IJobRecord.cs +++ b/PlexRequests.Services/Interfaces/IJobRecord.cs @@ -24,10 +24,17 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion +using System.Collections.Generic; +using System.Threading.Tasks; + +using PlexRequests.Store.Models; + namespace PlexRequests.Services.Interfaces { public interface IJobRecord { void Record(string jobName); + Task> GetJobsAsync(); + IEnumerable GetJobs(); } } \ No newline at end of file diff --git a/PlexRequests.Services/Jobs/JobRecord.cs b/PlexRequests.Services/Jobs/JobRecord.cs index fd97f785f..3538a7b0b 100644 --- a/PlexRequests.Services/Jobs/JobRecord.cs +++ b/PlexRequests.Services/Jobs/JobRecord.cs @@ -25,13 +25,15 @@ // ************************************************************************/ #endregion using System; +using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using PlexRequests.Services.Interfaces; using PlexRequests.Store.Models; using PlexRequests.Store.Repository; -namespace PlexRequests.Services +namespace PlexRequests.Services.Jobs { public class JobRecord : IJobRecord { @@ -39,7 +41,9 @@ namespace PlexRequests.Services { Repo = repo; } + private IRepository Repo { get; } + public void Record(string jobName) { var allJobs = Repo.GetAll(); @@ -55,5 +59,15 @@ namespace PlexRequests.Services Repo.Insert(job); } } + + public async Task> GetJobsAsync() + { + return await Repo.GetAllAsync(); + } + + public IEnumerable GetJobs() + { + return Repo.GetAll(); + } } } \ No newline at end of file diff --git a/PlexRequests.Services/Jobs/PlexAvailabilityChecker.cs b/PlexRequests.Services/Jobs/PlexAvailabilityChecker.cs index b22961317..227540bb5 100644 --- a/PlexRequests.Services/Jobs/PlexAvailabilityChecker.cs +++ b/PlexRequests.Services/Jobs/PlexAvailabilityChecker.cs @@ -35,6 +35,7 @@ using PlexRequests.Api.Models.Plex; using PlexRequests.Core; using PlexRequests.Core.SettingModels; using PlexRequests.Helpers; +using PlexRequests.Helpers.Analytics; using PlexRequests.Services.Interfaces; using PlexRequests.Services.Models; using PlexRequests.Services.Notification; @@ -44,6 +45,8 @@ using PlexRequests.Store.Repository; using Quartz; +using Action = PlexRequests.Helpers.Analytics.Action; + namespace PlexRequests.Services.Jobs { public class PlexAvailabilityChecker : IJob, IAvailabilityChecker @@ -72,14 +75,12 @@ namespace PlexRequests.Services.Jobs private IRepository UserNotifyRepo { get; } public void CheckAndUpdateAll() { - Log.Trace("Getting the settings"); var plexSettings = Plex.GetSettings(); var authSettings = Auth.GetSettings(); - Log.Trace("Getting all the requests"); if (!ValidateSettings(plexSettings, authSettings)) { - Log.Info("Validation of the plex settings failed."); + Log.Debug("Validation of the plex settings failed."); return; } @@ -87,7 +88,7 @@ namespace PlexRequests.Services.Jobs if (libraries == null || !libraries.Any()) { - Log.Info("Did not find any libraries in Plex."); + Log.Debug("Did not find any libraries in Plex."); return; } @@ -97,31 +98,26 @@ namespace PlexRequests.Services.Jobs var requests = RequestService.GetAll(); var requestedModels = requests as RequestedModel[] ?? requests.Where(x => !x.Available).ToArray(); - Log.Trace("Requests Count {0}", requestedModels.Length); if (!requestedModels.Any()) { - Log.Info("There are no requests to check."); + Log.Debug("There are no requests to check."); return; } var modifiedModel = new List(); foreach (var r in requestedModels) { - Log.Trace("We are going to see if Plex has the following title: {0}", r.Title); - - Log.Trace("Search results from Plex for the following request: {0}", r.Title); - var releaseDate = r.ReleaseDate == DateTime.MinValue ? string.Empty : r.ReleaseDate.ToString("yyyy"); bool matchResult; switch (r.Type) { case RequestType.Movie: - matchResult = IsMovieAvailable(movies, r.Title, releaseDate); + matchResult = IsMovieAvailable(movies, r.Title, releaseDate, r.ImdbId); break; case RequestType.TvShow: - matchResult = IsTvShowAvailable(shows, r.Title, releaseDate); + matchResult = IsTvShowAvailable(shows, r.Title, releaseDate, r.TvDbId); break; case RequestType.Album: matchResult = IsAlbumAvailable(albums, r.Title, r.ReleaseDate.Year.ToString(), r.ArtistName); @@ -137,11 +133,9 @@ namespace PlexRequests.Services.Jobs continue; } - Log.Trace("The result from Plex where the title's match was null, so that means the content is not yet in Plex."); } - Log.Trace("Updating the requests now"); - Log.Trace("Requests that will be updated count {0}", modifiedModel.Count); + Log.Debug("Requests that will be updated count {0}", modifiedModel.Count); if (modifiedModel.Any()) { @@ -167,19 +161,36 @@ namespace PlexRequests.Services.Jobs foreach (var lib in movieLibs) { - movies.AddRange(lib.Video.Select(x => new PlexMovie() // movies are in the Video list + movies.AddRange(lib.Video.Select(video => new PlexMovie { - Title = x.Title, - ReleaseYear = x.Year + ReleaseYear = video.Year, + Title = video.Title, + ProviderId = video.ProviderId, })); } } return movies; } - public bool IsMovieAvailable(PlexMovie[] plexMovies, string title, string year) + public bool IsMovieAvailable(PlexMovie[] plexMovies, string title, string year, string providerId = null) { - return plexMovies.Any(x => x.Title.Equals(title, StringComparison.CurrentCultureIgnoreCase) && x.ReleaseYear.Equals(year, StringComparison.CurrentCultureIgnoreCase)); + var advanced = !string.IsNullOrEmpty(providerId); + foreach (var movie in plexMovies) + { + if (advanced) + { + if (movie.ProviderId.Equals(providerId, StringComparison.InvariantCultureIgnoreCase)) + { + return true; + } + } + if (movie.Title.Equals(title, StringComparison.CurrentCultureIgnoreCase) && + movie.ReleaseYear.Equals(year, StringComparison.CurrentCultureIgnoreCase)) + { + return true; + } + } + return false; } public List GetPlexTvShows() @@ -199,18 +210,33 @@ namespace PlexRequests.Services.Jobs shows.AddRange(lib.Directory.Select(x => new PlexTvShow() // shows are in the directory list { Title = x.Title, - ReleaseYear = x.Year + ReleaseYear = x.Year, + ProviderId = x.ProviderId, })); } } return shows; } - public bool IsTvShowAvailable(PlexTvShow[] plexShows, string title, string year) + public bool IsTvShowAvailable(PlexTvShow[] plexShows, string title, string year, string providerId = null) { - return plexShows.Any(x => - (x.Title.Equals(title, StringComparison.CurrentCultureIgnoreCase) || x.Title.StartsWith(title, StringComparison.CurrentCultureIgnoreCase)) && - x.ReleaseYear.Equals(year, StringComparison.CurrentCultureIgnoreCase)); + var advanced = !string.IsNullOrEmpty(providerId); + foreach (var show in plexShows) + { + if (advanced) + { + if (show.ProviderId.Equals(providerId, StringComparison.InvariantCultureIgnoreCase)) + { + return true; + } + } + if (show.Title.Equals(title, StringComparison.CurrentCultureIgnoreCase) && + show.ReleaseYear.Equals(year, StringComparison.CurrentCultureIgnoreCase)) + { + return true; + } + } + return false; } public List GetPlexAlbums() @@ -248,24 +274,41 @@ namespace PlexRequests.Services.Jobs private List CachedLibraries(AuthenticationSettings authSettings, PlexSettings plexSettings, bool setCache) { - Log.Trace("Obtaining library sections from Plex"); - - List results = new List(); + var results = new List(); if (!ValidateSettings(plexSettings, authSettings)) { Log.Warn("The settings are not configured"); - return results; // don't error out here, just let it go! + return results; // don't error out here, just let it go! let it goo!!! } try { if (setCache) { - Log.Trace("Plex Lib API Call"); results = GetLibraries(authSettings, plexSettings); - - Log.Trace("Plex Lib Cache Set Call"); + if (plexSettings.AdvancedSearch) + { + for (var i = 0; i < results.Count; i++) + { + for (var j = 0; j < results[i].Directory.Count; j++) + { + var currentItem = results[i].Directory[j]; + var metaData = PlexApi.GetMetadata(authSettings.PlexAuthToken, plexSettings.FullUri, + currentItem.RatingKey); + var providerId = PlexHelper.GetProviderIdFromPlexGuid(metaData.Directory.Guid); + results[i].Directory[j].ProviderId = providerId; + } + for (var j = 0; j < results[i].Video.Count; j++) + { + var currentItem = results[i].Video[j]; + var metaData = PlexApi.GetMetadata(authSettings.PlexAuthToken, plexSettings.FullUri, + currentItem.RatingKey); + var providerId = PlexHelper.GetProviderIdFromPlexGuid(metaData.Video.Guid); + results[i].Video[j].ProviderId = providerId; + } + } + } if (results != null) { Cache.Set(CacheKeys.PlexLibaries, results, CacheKeys.TimeFrameMinutes.SchedulerCaching); @@ -273,12 +316,8 @@ namespace PlexRequests.Services.Jobs } else { - Log.Trace("Plex Lib GetSet Call"); results = Cache.GetOrSet(CacheKeys.PlexLibaries, () => - { - Log.Trace("Plex Lib API Call (inside getset)"); - return GetLibraries(authSettings, plexSettings); - }, CacheKeys.TimeFrameMinutes.SchedulerCaching); + GetLibraries(authSettings, plexSettings), CacheKeys.TimeFrameMinutes.SchedulerCaching); } } catch (Exception ex) @@ -298,7 +337,6 @@ namespace PlexRequests.Services.Jobs { foreach (var dir in sections.Directories) { - Log.Trace("Obtaining results from Plex for the following library section: {0}", dir.Title); var lib = PlexApi.GetLibrary(authSettings.PlexAuthToken, plexSettings.FullUri, dir.Key); if (lib != null) { @@ -307,7 +345,6 @@ namespace PlexRequests.Services.Jobs } } - Log.Trace("Returning Plex Libs"); return libs; } @@ -335,7 +372,6 @@ namespace PlexRequests.Services.Jobs foreach (var model in modelChanged) { var selectedUsers = users.Select(x => x.Username).Intersect(model.RequestedUsers); - Log.Debug("Selected Users {0}", selectedUsers.DumpJson()); foreach (var user in selectedUsers) { Log.Info("Notifying user {0}", user); diff --git a/PlexRequests.Services/Jobs/StoreBackup.cs b/PlexRequests.Services/Jobs/StoreBackup.cs index e36f73eb4..dea5d7df0 100644 --- a/PlexRequests.Services/Jobs/StoreBackup.cs +++ b/PlexRequests.Services/Jobs/StoreBackup.cs @@ -26,6 +26,7 @@ #endregion using System; using System.IO; +using System.Linq; using NLog; @@ -130,7 +131,18 @@ namespace PlexRequests.Services.Jobs private bool DoWeNeedToBackup(string backupPath) { var files = Directory.GetFiles(backupPath); - //TODO Get the latest file and if it's within an hour of DateTime.Now then don't bother backing up. + var last = files.LastOrDefault(); + if (!string.IsNullOrEmpty(last)) + { + var dt = ParseName(Path.GetFileName(last)); + if (dt < DateTime.Now.AddHours(-1)) + { + return true; + } + return false; + } + + // We don't have a backup return true; } @@ -140,7 +152,6 @@ namespace PlexRequests.Services.Jobs if (names.Length > 1) { DateTime parsed; - //DateTime.TryParseExcat(names[1], "yyyy-MM-dd hh.mm.ss",CultureInfo.CurrentUICulture, DateTimeStyles.None, out parsed); DateTime.TryParse(names[2], out parsed); return parsed; diff --git a/PlexRequests.Services/Jobs/StoreCleanup.cs b/PlexRequests.Services/Jobs/StoreCleanup.cs index a394211f5..5c6b0987b 100644 --- a/PlexRequests.Services/Jobs/StoreCleanup.cs +++ b/PlexRequests.Services/Jobs/StoreCleanup.cs @@ -25,6 +25,7 @@ // ************************************************************************/ #endregion using System; +using System.Collections.Generic; using System.Linq; using NLog; @@ -51,14 +52,21 @@ namespace PlexRequests.Services.Jobs private IRepository Repo { get; } + private const int ItemsToDelete = 1000; + private void Cleanup() { try { var items = Repo.GetAll(); - var orderedItems = items.Where(x => x.Date < DateTime.Now.AddDays(-7)); + var ordered = items.OrderByDescending(x => x.Date).ToList(); + var itemsToDelete = new List(); + if (ordered.Count > ItemsToDelete) + { + itemsToDelete = ordered.Skip(ItemsToDelete).ToList(); + } - foreach (var o in orderedItems) + foreach (var o in itemsToDelete) { Repo.Delete(o); } diff --git a/PlexRequests.Services/Models/PlexMovie.cs b/PlexRequests.Services/Models/PlexMovie.cs index 57ca3549b..0149698ba 100644 --- a/PlexRequests.Services/Models/PlexMovie.cs +++ b/PlexRequests.Services/Models/PlexMovie.cs @@ -4,5 +4,6 @@ { public string Title { get; set; } public string ReleaseYear { get; set; } + public string ProviderId { get; set; } } } diff --git a/PlexRequests.Services/Models/PlexTvShow.cs b/PlexRequests.Services/Models/PlexTvShow.cs index 64dfda356..43375f0ca 100644 --- a/PlexRequests.Services/Models/PlexTvShow.cs +++ b/PlexRequests.Services/Models/PlexTvShow.cs @@ -4,5 +4,6 @@ { public string Title { get; set; } public string ReleaseYear { get; set; } + public string ProviderId { get; set; } } } diff --git a/PlexRequests.Services/PlexRequests.Services.csproj b/PlexRequests.Services/PlexRequests.Services.csproj index caafe8d77..fc61aab55 100644 --- a/PlexRequests.Services/PlexRequests.Services.csproj +++ b/PlexRequests.Services/PlexRequests.Services.csproj @@ -32,6 +32,10 @@ + + ..\packages\NLog.4.3.4\lib\net45\NLog.dll + True + @@ -61,9 +65,6 @@ ..\Assemblies\Mono.Data.Sqlite.dll - - ..\packages\NLog.4.2.3\lib\net45\NLog.dll - ..\packages\Quartz.2.3.3\lib\net40\Quartz.dll diff --git a/PlexRequests.Services/packages.config b/PlexRequests.Services/packages.config index bed7e714f..3276cd22e 100644 --- a/PlexRequests.Services/packages.config +++ b/PlexRequests.Services/packages.config @@ -4,6 +4,6 @@ - + \ No newline at end of file diff --git a/PlexRequests.Store/Models/IssueBlobs.cs b/PlexRequests.Store/Models/IssueBlobs.cs new file mode 100644 index 000000000..5dfa99914 --- /dev/null +++ b/PlexRequests.Store/Models/IssueBlobs.cs @@ -0,0 +1,38 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: RequestBlobs.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.Models +{ + [Table("IssueBlobs")] + public class IssueBlobs : Entity + { + public int RequestId { get; set; } + public byte[] Content { get; set; } + public RequestType Type { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Store/PlexRequests.Store.csproj b/PlexRequests.Store/PlexRequests.Store.csproj index c5dd29053..335ea07db 100644 --- a/PlexRequests.Store/PlexRequests.Store.csproj +++ b/PlexRequests.Store/PlexRequests.Store.csproj @@ -42,6 +42,10 @@ ..\Assemblies\Mono.Data.Sqlite.dll + + ..\packages\NLog.4.3.4\lib\net45\NLog.dll + True + @@ -55,13 +59,11 @@ ..\packages\Newtonsoft.Json.8.0.2\lib\net45\Newtonsoft.Json.dll - - ..\packages\NLog.4.2.3\lib\net45\NLog.dll - + @@ -89,6 +91,7 @@ + Always diff --git a/PlexRequests.Store/Repository/BaseGenericRepository.cs b/PlexRequests.Store/Repository/BaseGenericRepository.cs index ac8c45203..f92fdb218 100644 --- a/PlexRequests.Store/Repository/BaseGenericRepository.cs +++ b/PlexRequests.Store/Repository/BaseGenericRepository.cs @@ -83,8 +83,6 @@ namespace PlexRequests.Store.Repository public bool Update(T entity) { ResetCache(); - Log.Trace("Updating entity"); - Log.Trace(entity.DumpJson()); using (var db = Config.DbConnection()) { db.Open(); @@ -95,8 +93,6 @@ namespace PlexRequests.Store.Repository public async Task UpdateAsync(T entity) { ResetCache(); - Log.Trace("Updating entity"); - Log.Trace(entity.DumpJson()); using (var db = Config.DbConnection()) { db.Open(); diff --git a/PlexRequests.Store/RequestedModel.cs b/PlexRequests.Store/RequestedModel.cs index e8fc41e08..c63ede547 100644 --- a/PlexRequests.Store/RequestedModel.cs +++ b/PlexRequests.Store/RequestedModel.cs @@ -17,6 +17,7 @@ namespace PlexRequests.Store // ReSharper disable once IdentifierTypo public int ProviderId { get; set; } public string ImdbId { get; set; } + public string TvDbId { get; set; } public string Overview { get; set; } public string Title { get; set; } public string PosterPath { get; set; } @@ -40,6 +41,7 @@ namespace PlexRequests.Store public List RequestedUsers { get; set; } public string ArtistName { get; set; } public string ArtistId { get; set; } + public int IssueId { get; set; } [JsonIgnore] public List AllUsers @@ -83,6 +85,6 @@ namespace PlexRequests.Store NoSubtitles = 1, WrongContent = 2, PlaybackIssues = 3, - Other = 4 // Provide a message + Other = 4, // Provide a message } } diff --git a/PlexRequests.Store/SqlTables.sql b/PlexRequests.Store/SqlTables.sql index 74d5f2c13..6e89f589c 100644 --- a/PlexRequests.Store/SqlTables.sql +++ b/PlexRequests.Store/SqlTables.sql @@ -72,4 +72,13 @@ CREATE TABLE IF NOT EXISTS UsersToNotify Id INTEGER PRIMARY KEY AUTOINCREMENT, Username varchar(100) NOT NULL ); -CREATE UNIQUE INDEX IF NOT EXISTS UsersToNotify_Id ON UsersToNotify (Id); \ No newline at end of file +CREATE UNIQUE INDEX IF NOT EXISTS UsersToNotify_Id ON UsersToNotify (Id); + +CREATE TABLE IF NOT EXISTS IssueBlobs +( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + RequestId INTEGER, + Type INTEGER NOT NULL, + Content BLOB NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS IssueBlobs_Id ON IssueBlobs (Id); \ No newline at end of file diff --git a/PlexRequests.Store/app.config b/PlexRequests.Store/app.config new file mode 100644 index 000000000..de5386a47 --- /dev/null +++ b/PlexRequests.Store/app.config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/PlexRequests.Store/packages.config b/PlexRequests.Store/packages.config index 457f93723..da6daaa12 100644 --- a/PlexRequests.Store/packages.config +++ b/PlexRequests.Store/packages.config @@ -3,5 +3,5 @@ - + \ No newline at end of file diff --git a/PlexRequests.UI.Tests/AdminModuleTests.cs b/PlexRequests.UI.Tests/AdminModuleTests.cs index f563d13e7..66245d649 100644 --- a/PlexRequests.UI.Tests/AdminModuleTests.cs +++ b/PlexRequests.UI.Tests/AdminModuleTests.cs @@ -60,6 +60,7 @@ namespace PlexRequests.UI.Tests private Mock> PlexSettingsMock { get; set; } private Mock> SonarrSettingsMock { get; set; } private Mock> SickRageSettingsMock { get; set; } + private Mock> ScheduledJobsSettingsMock { get; set; } private Mock> EmailMock { get; set; } private Mock> PushbulletSettings { get; set; } private Mock> PushoverSettings { get; set; } @@ -69,11 +70,13 @@ namespace PlexRequests.UI.Tests private Mock PushbulletApi { get; set; } private Mock PushoverApi { get; set; } private Mock CpApi { get; set; } + private Mock RecorderMock { get; set; } private Mock> LogRepo { get; set; } private Mock NotificationService { get; set; } private Mock Cache { get; set; } private Mock> Log { get; set; } private Mock> SlackSettings { get; set; } + private Mock> LandingPageSettings { get; set; } private Mock SlackApi { get; set; } private ConfigurableBootstrapper Bootstrapper { get; set; } @@ -109,6 +112,10 @@ namespace PlexRequests.UI.Tests Log = new Mock>(); SlackApi = new Mock(); SlackSettings = new Mock>(); + LandingPageSettings = new Mock>(); + ScheduledJobsSettingsMock = new Mock>(); + RecorderMock = new Mock(); + Bootstrapper = new ConfigurableBootstrapper(with => { @@ -133,7 +140,10 @@ namespace PlexRequests.UI.Tests with.Dependency(Cache.Object); with.Dependency(Log.Object); with.Dependency(SlackApi.Object); + with.Dependency(LandingPageSettings.Object); with.Dependency(SlackSettings.Object); + with.Dependency(ScheduledJobsSettingsMock.Object); + with.Dependency(RecorderMock.Object); with.RootPathProvider(); with.RequestStartup((container, pipelines, context) => { diff --git a/PlexRequests.UI.Tests/LandingPageTests.cs b/PlexRequests.UI.Tests/LandingPageTests.cs new file mode 100644 index 000000000..8789519ee --- /dev/null +++ b/PlexRequests.UI.Tests/LandingPageTests.cs @@ -0,0 +1,57 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: LandingPageTests.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.Collections.Generic; + +using NUnit.Framework; + +using PlexRequests.UI.Models; + +namespace PlexRequests.UI.Tests +{ + [TestFixture] + public class LandingPageTests + { + [TestCaseSource(nameof(NoticeEnabledData))] + public bool TestNoticeEnabled(DateTime start, DateTime end) + { + return new LandingPageViewModel { NoticeEnd = end, NoticeStart = start }.NoticeActive; + } + + private static IEnumerable NoticeEnabledData + { + get + { + yield return new TestCaseData(DateTime.Now, DateTime.Now.AddDays(1)).Returns(true); + yield return new TestCaseData(DateTime.Now, DateTime.Now.AddDays(99)).Returns(true); + yield return new TestCaseData(DateTime.Now.AddDays(2), DateTime.Now).Returns(false); // End in past + yield return new TestCaseData(DateTime.Now.AddDays(2), DateTime.Now.AddDays(3)).Returns(false); // Not started yet + yield return new TestCaseData(DateTime.Now.AddDays(-5), DateTime.Now.AddDays(-1)).Returns(false); // Finished yesterday + } + } + } +} \ No newline at end of file diff --git a/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj b/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj index 083fd2286..5ec37a74a 100644 --- a/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj +++ b/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj @@ -101,7 +101,9 @@ + + diff --git a/PlexRequests.UI.Tests/StringHelperTests.cs b/PlexRequests.UI.Tests/StringHelperTests.cs new file mode 100644 index 000000000..e1cbb811a --- /dev/null +++ b/PlexRequests.UI.Tests/StringHelperTests.cs @@ -0,0 +1,76 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: StringHelperTests.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 NUnit.Framework; + +using PlexRequests.Core.Models; +using PlexRequests.UI.Helpers; + +namespace PlexRequests.UI.Tests +{ + [TestFixture] + public class StringHelperTests + { + [TestCaseSource(nameof(StringData))] + public string FirstCharToUpperTest(string input) + { + return input.FirstCharToUpper(); + } + + [TestCaseSource(nameof(StringCaseData))] + public string ToCamelCaseWordsTest(string input) + { + return input.ToCamelCaseWords(); + } + + private static IEnumerable StringData + { + get + { + yield return new TestCaseData("abcCba").Returns("AbcCba"); + yield return new TestCaseData("").Returns(""); + yield return new TestCaseData("12DSAda").Returns("12DSAda"); + } + } + + private static IEnumerable StringCaseData + { + get + { + yield return new TestCaseData("abcCba").Returns("Abc Cba"); + yield return new TestCaseData("").Returns(""); + yield return new TestCaseData("JamieRees").Returns("Jamie Rees"); + yield return new TestCaseData("Jamierees").Returns("Jamierees"); + yield return new TestCaseData("ThisIsANewString").Returns("This Is A New String"); + yield return new TestCaseData("").Returns(""); + yield return new TestCaseData(IssueStatus.PendingIssue.ToString()).Returns("Pending Issue"); + yield return new TestCaseData(IssueStatus.ResolvedIssue.ToString()).Returns("Resolved Issue"); + } + } + } +} \ No newline at end of file diff --git a/PlexRequests.UI.Tests/TestRootPathProvider.cs b/PlexRequests.UI.Tests/TestRootPathProvider.cs index 4f7063f41..544eb88d6 100644 --- a/PlexRequests.UI.Tests/TestRootPathProvider.cs +++ b/PlexRequests.UI.Tests/TestRootPathProvider.cs @@ -25,15 +25,11 @@ // ************************************************************************/ #endregion using System; -using System.Diagnostics; using System.IO; using System.Linq; -using System.Reflection; using Nancy; -using PlexRequests.UI.Modules; - namespace PlexRequests.UI.Tests { public class TestRootPathProvider : IRootPathProvider @@ -42,7 +38,6 @@ namespace PlexRequests.UI.Tests public string GetRootPath() { - //return @"C:\Applications\51\DeliveryDateCalculator\StandAndDeliver\Views\Home\"; if (!string.IsNullOrEmpty(_CachedRootPath)) return _CachedRootPath; diff --git a/PlexRequests.UI.Tests/UserLoginModuleTests.cs b/PlexRequests.UI.Tests/UserLoginModuleTests.cs index cc4db6843..22d000575 100644 --- a/PlexRequests.UI.Tests/UserLoginModuleTests.cs +++ b/PlexRequests.UI.Tests/UserLoginModuleTests.cs @@ -25,6 +25,7 @@ // ************************************************************************/ #endregion using System.Collections.Generic; +using System.Threading.Tasks; using Moq; @@ -45,11 +46,11 @@ using PlexRequests.UI.Modules; namespace PlexRequests.UI.Tests { [TestFixture] - //[Ignore("Needs some work")] public class UserLoginModuleTests { private Mock> AuthMock { get; set; } private Mock> PlexRequestMock { get; set; } + private Mock> LandingPageMock { get; set; } private ConfigurableBootstrapper Bootstrapper { get; set; } private Mock PlexMock { get; set; } @@ -58,14 +59,18 @@ namespace PlexRequests.UI.Tests { AuthMock = new Mock>(); PlexMock = new Mock(); + LandingPageMock = new Mock>(); PlexRequestMock = new Mock>(); PlexRequestMock.Setup(x => x.GetSettings()).Returns(new PlexRequestSettings()); + PlexRequestMock.Setup(x => x.GetSettingsAsync()).Returns(Task.FromResult(new PlexRequestSettings())); + LandingPageMock.Setup(x => x.GetSettings()).Returns(new LandingPageSettings()); Bootstrapper = new ConfigurableBootstrapper(with => { with.Module(); with.Dependency(PlexRequestMock.Object); with.Dependency(AuthMock.Object); with.Dependency(PlexMock.Object); + with.Dependency(LandingPageMock.Object); with.RootPathProvider(); }); } @@ -76,8 +81,6 @@ namespace PlexRequests.UI.Tests var expectedSettings = new AuthenticationSettings { UserAuthentication = false, PlexAuthToken = "abc" }; AuthMock.Setup(x => x.GetSettings()).Returns(expectedSettings); - - Bootstrapper.WithSession(new Dictionary()); var browser = new Browser(Bootstrapper); diff --git a/PlexRequests.UI/Bootstrapper.cs b/PlexRequests.UI/Bootstrapper.cs index 4eef3be38..176404b02 100644 --- a/PlexRequests.UI/Bootstrapper.cs +++ b/PlexRequests.UI/Bootstrapper.cs @@ -52,9 +52,12 @@ using PlexRequests.Store.Repository; using PlexRequests.UI.Helpers; using Nancy.Json; +using PlexRequests.Helpers.Analytics; using PlexRequests.Services.Jobs; using PlexRequests.UI.Jobs; +using Quartz; +using Quartz.Impl; using Quartz.Spi; namespace PlexRequests.UI @@ -65,74 +68,13 @@ namespace PlexRequests.UI // by overriding the various methods and properties. // For more information https://github.com/NancyFx/Nancy/wiki/Bootstrapper - - protected override void ConfigureRequestContainer(TinyIoCContainer container, NancyContext context) - { - - container.Register().AsSingleton(); - - // Settings - container.Register, SettingsServiceV2>(); - container.Register, SettingsServiceV2>(); - container.Register, SettingsServiceV2>(); - container.Register, SettingsServiceV2>(); - container.Register, SettingsServiceV2>(); - container.Register, SettingsServiceV2>(); - container.Register, SettingsServiceV2>(); - container.Register, SettingsServiceV2>(); - container.Register, SettingsServiceV2>(); - container.Register, SettingsServiceV2>(); - container.Register, SettingsServiceV2>(); - container.Register, SettingsServiceV2>(); - - // Repo's - container.Register, GenericRepository>(); - container.Register, GenericRepository>(); - container.Register, GenericRepository>(); - container.Register(); - container.Register(); - container.Register(); - - // Services - container.Register(); - container.Register(); - container.Register(); - container.Register(); - container.Register(); - - // Api's - container.Register(); - container.Register(); - container.Register(); - container.Register(); - container.Register(); - container.Register(); - container.Register(); - container.Register(); - container.Register(); - - // NotificationService - container.Register().AsSingleton(); - - JsonSettings.MaxJsonLength = int.MaxValue; - - SubscribeAllObservers(container); - base.ConfigureRequestContainer(container, context); - var loc = ServiceLocator.Instance; - loc.SetContainer(container); - } - - protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines) { - container.Register(new DbConfiguration(new SqliteFactory())); - container.Register, UserRepository>(); - container.Register(); - container.Register(); + ConfigureContainer(container); + JsonSettings.MaxJsonLength = int.MaxValue; CookieBasedSessions.Enable(pipelines, CryptographyConfiguration.Default); - StaticConfiguration.DisableErrorTraces = false; base.ApplicationStartup(container, pipelines); @@ -140,7 +82,7 @@ namespace PlexRequests.UI var settings = new SettingsServiceV2(new SettingsJsonRepository(new DbConfiguration(new SqliteFactory()), new MemoryCacheProvider())); var baseUrl = settings.GetSettings().BaseUrl; var redirect = string.IsNullOrEmpty(baseUrl) ? "~/login" : $"~/{baseUrl}/login"; - + // Enable forms auth var formsAuthConfiguration = new FormsAuthenticationConfiguration { @@ -154,13 +96,15 @@ namespace PlexRequests.UI ServicePointManager.ServerCertificateValidationCallback += (sender, certificate, chain, sslPolicyErrors) => true; + SubscribeAllObservers(container); + } protected override void ConfigureConventions(NancyConventions nancyConventions) { base.ConfigureConventions(nancyConventions); - var settings = new SettingsServiceV2(new SettingsJsonRepository(new DbConfiguration(new SqliteFactory()),new MemoryCacheProvider())); + var settings = new SettingsServiceV2(new SettingsJsonRepository(new DbConfiguration(new SqliteFactory()), new MemoryCacheProvider())); var assetLocation = settings.GetSettings().BaseUrl; nancyConventions.StaticContentsConventions.Add( StaticContentConventionBuilder.AddDirectory($"{assetLocation}/Content", "Content") @@ -203,7 +147,7 @@ namespace PlexRequests.UI notificationService.Subscribe(new SlackNotification(container.Resolve(), slackService)); } } - + protected override void RequestStartup(TinyIoCContainer container, IPipelines pipelines, NancyContext context) { //CORS Enable @@ -216,5 +160,69 @@ namespace PlexRequests.UI }); base.RequestStartup(container, pipelines, context); } + + private void ConfigureContainer(TinyIoCContainer container) + { + container.Register().AsSingleton(); + container.Register(new DbConfiguration(new SqliteFactory())); + container.Register, UserRepository>(); + container.Register(); + container.Register(); + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + + // Notification Service + container.Register().AsSingleton(); + // Settings + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + + // Repo's + container.Register, GenericRepository>(); + container.Register, GenericRepository>(); + container.Register, GenericRepository>(); + container.Register, GenericRepository>(); + container.Register(); + container.Register(); + container.Register(); + container.Register(); + + // Services + container.Register(); + container.Register(); + container.Register(); + container.Register(); + container.Register(); + + container.Register(); + container.Register(); + container.Register(); + + + // Api + container.Register(); + container.Register(); + container.Register(); + container.Register(); + container.Register(); + container.Register(); + container.Register(); + container.Register(); + container.Register(); + + var loc = ServiceLocator.Instance; + loc.SetContainer(container); + } } } \ No newline at end of file diff --git a/PlexRequests.UI/Content/Themes/plex.css b/PlexRequests.UI/Content/Themes/plex.css index c0a7597af..dcb015580 100644 --- a/PlexRequests.UI/Content/Themes/plex.css +++ b/PlexRequests.UI/Content/Themes/plex.css @@ -6,7 +6,7 @@ .nav-tabs > li.active > a:focus { background: #df691a; } -scroll-top-wrapper { +.scroll-top-wrapper { background-color: #333333; } .scroll-top-wrapper:hover { @@ -156,3 +156,23 @@ button.list-group-item:focus { background-clip: padding-box; outline: 0; } +.badge { + display: inline-block; + min-width: 10px; + padding: 3px 7px; + font-size: 12px; + font-weight: 300; + color: #ebebeb; + line-height: 1; + vertical-align: middle; + white-space: nowrap; + text-align: center; + background-color: #333333; + border-radius: 10px; } + +.bootstrap-datetimepicker-widget.dropdown-menu { + background-color: #333333; } + +.bootstrap-datetimepicker-widget.dropdown-menu.bottom:after { + border-bottom: 6px solid #333333 !important; } + diff --git a/PlexRequests.UI/Content/Themes/plex.min.css b/PlexRequests.UI/Content/Themes/plex.min.css index 2fa5bbcc9..6f1189790 100644 --- a/PlexRequests.UI/Content/Themes/plex.min.css +++ b/PlexRequests.UI/Content/Themes/plex.min.css @@ -1 +1 @@ -.form-control-custom{background-color:#333 !important;}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{background:#df691a;}scroll-top-wrapper{background-color:#333;}.scroll-top-wrapper:hover{background-color:#df691a;}body{font-family:Open Sans Regular,Helvetica Neue,Helvetica,Arial,sans-serif;color:#eee;background-color:#1f1f1f;}.table-striped>tbody>tr:nth-of-type(odd){background-color:#333;}.table-hover>tbody>tr:hover{background-color:#282828;}fieldset{padding:15px;}legend{border-bottom:1px solid #333;}.form-control{color:#fefefe;background-color:#333;}.radio input[type="radio"],.radio-inline input[type="radio"],.checkbox input[type="checkbox"],.checkbox-inline input[type="checkbox"]{margin-left:-0;}.form-horizontal .radio,.form-horizontal .checkbox,.form-horizontal .radio-inline,.form-horizontal .checkbox-inline{margin-top:-15px;}.dropdown-menu{background-color:#282828;}.dropdown-menu .divider{background-color:#333;}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{background-color:#333;}.input-group-addon{background-color:#333;}.nav>li>a:hover,.nav>li>a:focus{background-color:#df691a;}.nav-tabs>li>a:hover{border-color:#df691a #df691a transparent;}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{background-color:#df691a;border:1px solid #df691a;}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border:1px solid #df691a;}.navbar-default{background-color:#0a0a0a;}.navbar-default .navbar-brand{color:#df691a;}.navbar-default .navbar-nav>li>a:hover,.navbar-default .navbar-nav>li>a:focus{color:#f0ad4e;background-color:#282828;}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:hover,.navbar-default .navbar-nav>.active>a:focus{background-color:#282828;}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:hover,.navbar-default .navbar-nav>.open>a:focus{background-color:#df691a;color:#fff;}.pagination>li>a,.pagination>li>span{background-color:#282828;}.pagination>li>a:hover,.pagination>li>span:hover,.pagination>li>a:focus,.pagination>li>span:focus{background-color:#333;}.pagination>.disabled>span,.pagination>.disabled>span:hover,.pagination>.disabled>span:focus,.pagination>.disabled>a,.pagination>.disabled>a:hover,.pagination>.disabled>a:focus{color:#fefefe;background-color:#333;}.list-group-item{background-color:#282828;}a.list-group-item:hover,button.list-group-item:hover,a.list-group-item:focus,button.list-group-item:focus{background-color:#333;}.input-addon,.input-group-addon{color:#df691a;}.modal-header,.modal-footer{background-color:#282828;}.modal-content{position:relative;background-color:#282828;border:1px solid transparent;border-radius:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5);-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;outline:0;} \ No newline at end of file +.form-control-custom{background-color:#333 !important;}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{background:#df691a;}.scroll-top-wrapper{background-color:#333;}.scroll-top-wrapper:hover{background-color:#df691a;}body{font-family:Open Sans Regular,Helvetica Neue,Helvetica,Arial,sans-serif;color:#eee;background-color:#1f1f1f;}.table-striped>tbody>tr:nth-of-type(odd){background-color:#333;}.table-hover>tbody>tr:hover{background-color:#282828;}fieldset{padding:15px;}legend{border-bottom:1px solid #333;}.form-control{color:#fefefe;background-color:#333;}.radio input[type="radio"],.radio-inline input[type="radio"],.checkbox input[type="checkbox"],.checkbox-inline input[type="checkbox"]{margin-left:-0;}.form-horizontal .radio,.form-horizontal .checkbox,.form-horizontal .radio-inline,.form-horizontal .checkbox-inline{margin-top:-15px;}.dropdown-menu{background-color:#282828;}.dropdown-menu .divider{background-color:#333;}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{background-color:#333;}.input-group-addon{background-color:#333;}.nav>li>a:hover,.nav>li>a:focus{background-color:#df691a;}.nav-tabs>li>a:hover{border-color:#df691a #df691a transparent;}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{background-color:#df691a;border:1px solid #df691a;}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border:1px solid #df691a;}.navbar-default{background-color:#0a0a0a;}.navbar-default .navbar-brand{color:#df691a;}.navbar-default .navbar-nav>li>a:hover,.navbar-default .navbar-nav>li>a:focus{color:#f0ad4e;background-color:#282828;}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:hover,.navbar-default .navbar-nav>.active>a:focus{background-color:#282828;}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:hover,.navbar-default .navbar-nav>.open>a:focus{background-color:#df691a;color:#fff;}.pagination>li>a,.pagination>li>span{background-color:#282828;}.pagination>li>a:hover,.pagination>li>span:hover,.pagination>li>a:focus,.pagination>li>span:focus{background-color:#333;}.pagination>.disabled>span,.pagination>.disabled>span:hover,.pagination>.disabled>span:focus,.pagination>.disabled>a,.pagination>.disabled>a:hover,.pagination>.disabled>a:focus{color:#fefefe;background-color:#333;}.list-group-item{background-color:#282828;}a.list-group-item:hover,button.list-group-item:hover,a.list-group-item:focus,button.list-group-item:focus{background-color:#333;}.input-addon,.input-group-addon{color:#df691a;}.modal-header,.modal-footer{background-color:#282828;}.modal-content{position:relative;background-color:#282828;border:1px solid transparent;border-radius:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5);-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;outline:0;}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:300;color:#ebebeb;line-height:1;vertical-align:middle;white-space:nowrap;text-align:center;background-color:#333;border-radius:10px;}.bootstrap-datetimepicker-widget.dropdown-menu{background-color:#333;}.bootstrap-datetimepicker-widget.dropdown-menu.bottom:after{border-bottom:6px solid #333 !important;} \ No newline at end of file diff --git a/PlexRequests.UI/Content/Themes/plex.scss b/PlexRequests.UI/Content/Themes/plex.scss index 1de0e2092..f5cf256ca 100644 --- a/PlexRequests.UI/Content/Themes/plex.scss +++ b/PlexRequests.UI/Content/Themes/plex.scss @@ -13,7 +13,7 @@ $i: !important; background: $primary-colour; } -scroll-top-wrapper { +.scroll-top-wrapper { background-color: $bg-colour; } @@ -194,3 +194,26 @@ button.list-group-item:focus { background-clip: padding-box; outline: 0; } + +.badge { + display: inline-block; + min-width: 10px; + padding: 3px 7px; + font-size: 12px; + font-weight: 300; + color: #ebebeb; + line-height: 1; + vertical-align: middle; + white-space: nowrap; + text-align: center; + background-color: $bg-colour; + border-radius: 10px; +} + +.bootstrap-datetimepicker-widget.dropdown-menu { + background-color: $bg-colour; +} + +.bootstrap-datetimepicker-widget.dropdown-menu.bottom:after { + border-bottom: 6px solid $bg-colour $i; +} \ No newline at end of file diff --git a/PlexRequests.UI/Content/analytics.js b/PlexRequests.UI/Content/analytics.js new file mode 100644 index 000000000..22f671f41 --- /dev/null +++ b/PlexRequests.UI/Content/analytics.js @@ -0,0 +1,7 @@ +(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), + m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) + })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); + +ga('create', 'UA-77083919-2', 'auto'); +ga('send', 'pageview'); diff --git a/PlexRequests.UI/Content/awesome-bootstrap-checkbox.scss b/PlexRequests.UI/Content/awesome-bootstrap-checkbox.scss deleted file mode 100644 index 464893ddb..000000000 --- a/PlexRequests.UI/Content/awesome-bootstrap-checkbox.scss +++ /dev/null @@ -1,250 +0,0 @@ - -// -// Checkboxes -// -------------------------------------------------- - - -$font-family-icon: 'FontAwesome' !default; -$fa-var-check: "\f00c" !default; -$check-icon: $fa-var-check !default; - -@mixin checkbox-variant($parent, $color) { - #{$parent} input[type="checkbox"]:checked + label, - #{$parent} input[type="radio"]:checked + label { - &::before { - background-color: $color; - border-color: $color; - } - &::after{ - color: #fff; - } - } -} - -@mixin checkbox-variant-indeterminate($parent, $color) { - #{$parent} input[type="checkbox"]:indeterminate + label, - #{$parent} input[type="radio"]:indeterminate + label { - &::before { - background-color: $color; - border-color: $color; - } - &::after{ - background-color: #fff; - } - } -} - -.abc-checkbox{ - padding-left: 20px; - - label{ - display: inline-block; - vertical-align: middle; - position: relative; - padding-left: 5px; - - &::before{ - cursor: pointer; - content: ""; - display: inline-block; - position: absolute; - width: 17px; - height: 17px; - left: 0; - margin-left: -20px; - border: 1px solid $input-border-color; - border-radius: 3px; - background-color: #fff; - @include transition(border 0.15s ease-in-out, color 0.15s ease-in-out); - } - - &::after{ - cursor: pointer; - display: inline-block; - position: absolute; - width: 16px; - height: 16px; - left: 0; - top: 0; - margin-left: -20px; - padding-left: 3px; - padding-top: 1px; - font-size: 11px; - color: $input-color; - } - } - - input[type="checkbox"], - input[type="radio"] { - cursor: pointer; - opacity: 0; - z-index: 1; - - &:focus + label::before{ - @include tab-focus(); - } - - &:checked + label::after{ - font-family: $font-family-icon; - content: $check-icon; - } - - &:indeterminate + label::after{ - display: block; - content: ""; - width: 10px; - height: 3px; - background-color: #555555; - border-radius: 2px; - margin-left: -16.5px; - margin-top: 7px; - } - - &:disabled + label{ - opacity: 0.65; - - &::before{ - background-color: $input-bg-disabled; - cursor: not-allowed; - } - } - - } - - &.abc-checkbox-circle label::before{ - border-radius: 50%; - } - - &.checkbox-inline{ - margin-top: 0; - } -} - -@include checkbox-variant('.abc-checkbox-primary', $brand-primary); -@include checkbox-variant('.abc-checkbox-danger', $brand-danger); -@include checkbox-variant('.abc-checkbox-info', $brand-info); -@include checkbox-variant('.abc-checkbox-warning', $brand-warning); -@include checkbox-variant('.abc-checkbox-success', $brand-success); - - -@include checkbox-variant-indeterminate('.abc-checkbox-primary', $brand-primary); -@include checkbox-variant-indeterminate('.abc-checkbox-danger', $brand-danger); -@include checkbox-variant-indeterminate('.abc-checkbox-info', $brand-info); -@include checkbox-variant-indeterminate('.abc-checkbox-warning', $brand-warning); -@include checkbox-variant-indeterminate('.abc-checkbox-success', $brand-success); - -// -// Radios -// -------------------------------------------------- - -@mixin radio-variant($parent, $color) { - #{$parent} input[type="radio"]{ - + label{ - &::after{ - background-color: $color; - } - } - &:checked + label{ - &::before { - border-color: $color; - } - &::after{ - background-color: $color; - } - } - } -} - -.abc-radio{ - padding-left: 20px; - - label{ - display: inline-block; - vertical-align: middle; - position: relative; - padding-left: 5px; - - &::before{ - cursor: pointer; - content: ""; - display: inline-block; - position: absolute; - width: 17px; - height: 17px; - left: 0; - margin-left: -20px; - border: 1px solid $input-border-color; - border-radius: 50%; - background-color: #fff; - @include transition(border 0.15s ease-in-out); - } - - &::after{ - cursor: pointer; - display: inline-block; - position: absolute; - content: " "; - width: 11px; - height: 11px; - left: 3px; - top: 3px; - margin-left: -20px; - border-radius: 50%; - background-color: $input-color; - transform: scale(0, 0); - - transition: transform .1s cubic-bezier(.8,-0.33,.2,1.33); - //curve - http://cubic-bezier.com/#.8,-0.33,.2,1.33 - } - } - - input[type="radio"]{ - cursor: pointer; - opacity: 0; - z-index: 1; - - &:focus + label::before{ - @include tab-focus(); - } - - &:checked + label::after{ - transform: scale(1, 1); - } - - &:disabled + label{ - opacity: 0.65; - - &::before{ - cursor: not-allowed; - } - } - - } - - &.radio-inline{ - margin-top: 0; - } -} - -@include radio-variant('.abc-radio-primary', $brand-primary); -@include radio-variant('.abc-radio-danger', $brand-danger); -@include radio-variant('.abc-radio-info', $brand-info); -@include radio-variant('.abc-radio-warning', $brand-warning); -@include radio-variant('.abc-radio-success', $brand-success); - - -input[type="checkbox"], -input[type="radio"] { - &.styled:checked + label:after { - font-family: $font-family-icon; - content: $check-icon; - } - .styled:checked + label { - &::before { - color: #fff; - } - &::after { - color: #fff; - } - } -} diff --git a/PlexRequests.UI/Content/base.css b/PlexRequests.UI/Content/base.css index d0479a8d1..c149b1b96 100644 --- a/PlexRequests.UI/Content/base.css +++ b/PlexRequests.UI/Content/base.css @@ -4,7 +4,9 @@ .bottom-align-text { position: absolute; bottom: 0; - right: 0; } } + right: 0; } + .landing-block .media { + max-width: 450px; } } @media (max-width: 48em) { .home { @@ -294,3 +296,36 @@ label { margin-right: 15px; margin-left: 15px; } +.bootstrap-datetimepicker-widget.dropdown-menu { + background-color: #4e5d6c; } + +.bootstrap-datetimepicker-widget.dropdown-menu.bottom:after { + border-bottom: 6px solid #4e5d6c !important; } + +.bootstrap-datetimepicker-widget table td.active, +.bootstrap-datetimepicker-widget table td.active:hover { + color: #fff !important; } + +.landing-header { + display: block; + margin: 60px auto; } + +.landing-block { + background: #2f2f2f !important; + padding: 5px; } + +.landing-block .media { + margin: 30px auto; + max-width: 450px; } + +.landing-block .media .media-left { + display: inline-block; + float: left; + width: 70px; } + +.landing-block .media .media-left i.fa { + font-size: 3em; } + +.landing-title { + font-weight: bold; } + diff --git a/PlexRequests.UI/Content/base.min.css b/PlexRequests.UI/Content/base.min.css index a16d98f54..655178aec 100644 --- a/PlexRequests.UI/Content/base.min.css +++ b/PlexRequests.UI/Content/base.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;}}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:hover,.navbar-default .navbar-nav>.active>a:focus{color:#fff;}hr{border:1px dashed #777;}.btn{border-radius:.25rem !important;}.btn-group-separated .btn,.btn-group-separated .btn+.btn{margin-left:3px;}.multiSelect{background-color:#4e5d6c;}.form-control-custom{background-color:#4e5d6c !important;color:#fff !important;border-radius:0;box-shadow:0 0 0 !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;}.nav-tabs>li{font-size:13px;line-height:21px;}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{background:#4e5d6c;}.nav-tabs>li>a>.fa{padding:3px 5px 3px 3px;}.nav-tabs>li.nav-tab-right{float:right;}.nav-tabs>li.nav-tab-right a{margin-right:0;margin-left:2px;}.nav-tabs>li.nav-tab-icononly .fa{padding:3px;}.navbar .nav a .fa,.dropdown-menu a .fa{font-size:130%;top:1px;position:relative;display:inline-block;margin-right:5px;}.dropdown-menu a .fa{top:2px;}.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;}.scroll-top-wrapper{position:fixed;opacity:0;visibility:hidden;overflow:hidden;text-align:center;z-index:99999999;background-color:#4e5d6c;color:#eee;width:50px;height:48px;line-height:48px;right:30px;bottom:30px;padding-top:2px;border-top-left-radius:10px;border-top-right-radius:10px;border-bottom-right-radius:10px;border-bottom-left-radius:10px;-webkit-transition:all .5s ease-in-out;-moz-transition:all .5s ease-in-out;-ms-transition:all .5s ease-in-out;-o-transition:all .5s ease-in-out;transition:all .5s ease-in-out;}.scroll-top-wrapper:hover{background-color:#637689;}.scroll-top-wrapper.show{visibility:visible;cursor:pointer;opacity:1;}.scroll-top-wrapper i.fa{line-height:inherit;}.no-search-results{text-align:center;}.no-search-results .no-search-results-icon{font-size:10em;color:#4e5d6c;}.no-search-results .no-search-results-text{margin:20px 0;color:#ccc;}.form-control-search{padding:13px 105px 13px 16px;height:100%;}.form-control-withbuttons{padding-right:105px;}.input-group-addon .btn-group{position:absolute;right:45px;z-index:3;top:10px;box-shadow:0 0 0;}.input-group-addon .btn-group .btn{border:1px solid rgba(255,255,255,.7) !important;padding:3px 12px;color:rgba(255,255,255,.7) !important;}.btn-split .btn{border-radius:0 !important;}.btn-split .btn:not(.dropdown-toggle){border-radius:.25rem 0 0 .25rem !important;}.btn-split .btn.dropdown-toggle{border-radius:0 .25rem .25rem 0 !important;padding:12px 8px;}#updateAvailable{background-color:#df691a;text-align:center;font-size:15px;padding:3px 0;}.checkbox label{display:inline-block;cursor:pointer;position:relative;padding-left:25px;margin-right:15px;font-size:13px;margin-bottom:10px;}.checkbox label:before{content:"";display:inline-block;width:18px;height:18px;margin-right:10px;position:absolute;left:0;bottom:1px;border:2px solid #eee;border-radius:3px;}.checkbox input[type=checkbox]{display:none;}.checkbox input[type=checkbox]:checked+label:before{content:"✓";font-size:13px;color:#fafafa;text-align:center;line-height:13px;}.input-group-sm{padding-top:2px;padding-bottom:2px;}.tab-pane .form-horizontal .form-group{margin-right:15px;margin-left:15px;} \ No newline at end of file +@media(min-width:768px){.row{position:relative;}.bottom-align-text{position:absolute;bottom:0;right:0;}.landing-block .media{max-width:450px;}}@media(max-width:48em){.home{padding-top:1rem;}}@media(min-width:48em){.home{padding-top:4rem;}}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:hover,.navbar-default .navbar-nav>.active>a:focus{color:#fff;}hr{border:1px dashed #777;}.btn{border-radius:.25rem !important;}.btn-group-separated .btn,.btn-group-separated .btn+.btn{margin-left:3px;}.multiSelect{background-color:#4e5d6c;}.form-control-custom{background-color:#4e5d6c !important;color:#fff !important;border-radius:0;box-shadow:0 0 0 !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;}.nav-tabs>li{font-size:13px;line-height:21px;}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{background:#4e5d6c;}.nav-tabs>li>a>.fa{padding:3px 5px 3px 3px;}.nav-tabs>li.nav-tab-right{float:right;}.nav-tabs>li.nav-tab-right a{margin-right:0;margin-left:2px;}.nav-tabs>li.nav-tab-icononly .fa{padding:3px;}.navbar .nav a .fa,.dropdown-menu a .fa{font-size:130%;top:1px;position:relative;display:inline-block;margin-right:5px;}.dropdown-menu a .fa{top:2px;}.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;}.scroll-top-wrapper{position:fixed;opacity:0;visibility:hidden;overflow:hidden;text-align:center;z-index:99999999;background-color:#4e5d6c;color:#eee;width:50px;height:48px;line-height:48px;right:30px;bottom:30px;padding-top:2px;border-top-left-radius:10px;border-top-right-radius:10px;border-bottom-right-radius:10px;border-bottom-left-radius:10px;-webkit-transition:all .5s ease-in-out;-moz-transition:all .5s ease-in-out;-ms-transition:all .5s ease-in-out;-o-transition:all .5s ease-in-out;transition:all .5s ease-in-out;}.scroll-top-wrapper:hover{background-color:#637689;}.scroll-top-wrapper.show{visibility:visible;cursor:pointer;opacity:1;}.scroll-top-wrapper i.fa{line-height:inherit;}.no-search-results{text-align:center;}.no-search-results .no-search-results-icon{font-size:10em;color:#4e5d6c;}.no-search-results .no-search-results-text{margin:20px 0;color:#ccc;}.form-control-search{padding:13px 105px 13px 16px;height:100%;}.form-control-withbuttons{padding-right:105px;}.input-group-addon .btn-group{position:absolute;right:45px;z-index:3;top:10px;box-shadow:0 0 0;}.input-group-addon .btn-group .btn{border:1px solid rgba(255,255,255,.7) !important;padding:3px 12px;color:rgba(255,255,255,.7) !important;}.btn-split .btn{border-radius:0 !important;}.btn-split .btn:not(.dropdown-toggle){border-radius:.25rem 0 0 .25rem !important;}.btn-split .btn.dropdown-toggle{border-radius:0 .25rem .25rem 0 !important;padding:12px 8px;}#updateAvailable{background-color:#df691a;text-align:center;font-size:15px;padding:3px 0;}.checkbox label{display:inline-block;cursor:pointer;position:relative;padding-left:25px;margin-right:15px;font-size:13px;margin-bottom:10px;}.checkbox label:before{content:"";display:inline-block;width:18px;height:18px;margin-right:10px;position:absolute;left:0;bottom:1px;border:2px solid #eee;border-radius:3px;}.checkbox input[type=checkbox]{display:none;}.checkbox input[type=checkbox]:checked+label:before{content:"✓";font-size:13px;color:#fafafa;text-align:center;line-height:13px;}.input-group-sm{padding-top:2px;padding-bottom:2px;}.tab-pane .form-horizontal .form-group{margin-right:15px;margin-left:15px;}.bootstrap-datetimepicker-widget.dropdown-menu{background-color:#4e5d6c;}.bootstrap-datetimepicker-widget.dropdown-menu.bottom:after{border-bottom:6px solid #4e5d6c !important;}.bootstrap-datetimepicker-widget table td.active,.bootstrap-datetimepicker-widget table td.active:hover{color:#fff !important;}.landing-header{display:block;margin:60px auto;}.landing-block{background:#2f2f2f !important;padding:5px;}.landing-block .media{margin:30px auto;max-width:450px;}.landing-block .media .media-left{display:inline-block;float:left;width:70px;}.landing-block .media .media-left i.fa{font-size:3em;}.landing-title{font-weight:bold;} \ No newline at end of file diff --git a/PlexRequests.UI/Content/base.scss b/PlexRequests.UI/Content/base.scss index 7cb008433..8a2b8533b 100644 --- a/PlexRequests.UI/Content/base.scss +++ b/PlexRequests.UI/Content/base.scss @@ -6,7 +6,9 @@ $info-colour: #5bc0de; $warning-colour: #f0ad4e; $danger-colour: #d9534f; $success-colour: #5cb85c; -$i: !important; +$i: +!important +; @media (min-width: 768px ) { .row { @@ -18,6 +20,10 @@ $i: !important; bottom: 0; right: 0; } + + .landing-block .media { + max-width: 450px; + } } @media (max-width: 48em) { @@ -32,8 +38,8 @@ $i: !important; } } -.navbar-default .navbar-nav > .active > a, -.navbar-default .navbar-nav > .active > a:hover, +.navbar-default .navbar-nav > .active > a, +.navbar-default .navbar-nav > .active > a:hover, .navbar-default .navbar-nav > .active > a:focus { color: #fff; } @@ -46,7 +52,7 @@ hr { border-radius: .25rem $i; } -.btn-group-separated .btn, +.btn-group-separated .btn, .btn-group-separated .btn + .btn { margin-left: 3px; } @@ -324,41 +330,88 @@ $border-radius: 10px; } .checkbox label { - display: inline-block; - cursor: pointer; - position: relative; - padding-left: 25px; - margin-right: 15px; - font-size: 13px; - margin-bottom: 10px; } - - .checkbox label:before { - content: ""; - display: inline-block; - width: 18px; - height: 18px; - margin-right: 10px; - position: absolute; - left: 0; - bottom: 1px; - border: 2px solid #eee; - border-radius: 3px; } - - .checkbox input[type=checkbox] { - display: none; } - - .checkbox input[type=checkbox]:checked + label:before { - content: "\2713"; - font-size: 13px; - color: #fafafa; - text-align: center; - line-height: 13px; } + display: inline-block; + cursor: pointer; + position: relative; + padding-left: 25px; + margin-right: 15px; + font-size: 13px; + margin-bottom: 10px; +} -.input-group-sm{ +.checkbox label:before { + content: ""; + display: inline-block; + width: 18px; + height: 18px; + margin-right: 10px; + position: absolute; + left: 0; + bottom: 1px; + border: 2px solid #eee; + border-radius: 3px; +} + +.checkbox input[type=checkbox] { + display: none; +} + +.checkbox input[type=checkbox]:checked + label:before { + content: "\2713"; + font-size: 13px; + color: #fafafa; + text-align: center; + line-height: 13px; +} + +.input-group-sm { padding-top: 2px; padding-bottom: 2px; } .tab-pane .form-horizontal .form-group { - margin-right: 15px; - margin-left: 15px; } \ No newline at end of file + margin-right: 15px; + margin-left: 15px; +} + +.bootstrap-datetimepicker-widget.dropdown-menu { + background-color: $form-color; +} + +.bootstrap-datetimepicker-widget.dropdown-menu.bottom:after { + border-bottom: 6px solid $form-color $i; +} + +.bootstrap-datetimepicker-widget table td.active, +.bootstrap-datetimepicker-widget table td.active:hover { + color: #fff !important; +} + +.landing-header { + display: block; + margin: 60px auto; +} + +.landing-block { + background: #2f2f2f !important; + padding: 5px; +} + +.landing-block .media { + margin: 30px auto; + max-width: 450px; +} + +.landing-block .media .media-left { + display: inline-block; + float: left; + width: 70px; +} + +.landing-block .media .media-left i.fa { + font-size: 3em; +} + +.landing-title { + font-weight: bold; +} diff --git a/PlexRequests.UI/Content/bootstrap-datetimepicker-build.less b/PlexRequests.UI/Content/bootstrap-datetimepicker-build.less new file mode 100644 index 000000000..3f0b1881c --- /dev/null +++ b/PlexRequests.UI/Content/bootstrap-datetimepicker-build.less @@ -0,0 +1,17 @@ +// Import bootstrap variables including default color palette and fonts +@import "bootstrap/less/variables.less"; + +// Import datepicker component +@import "_bootstrap-datetimepicker.less"; + +//this is here so the compiler doesn't complain about a missing bootstrap mixin +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + border: 0; +} diff --git a/PlexRequests.UI/Content/bootstrap-datetimepicker.min.js b/PlexRequests.UI/Content/bootstrap-datetimepicker.min.js new file mode 100644 index 000000000..bb7896c0f --- /dev/null +++ b/PlexRequests.UI/Content/bootstrap-datetimepicker.min.js @@ -0,0 +1,2552 @@ +/*! version : 4.17.37 + ========================================================= + bootstrap-datetimejs + https://github.com/Eonasdan/bootstrap-datetimepicker + Copyright (c) 2015 Jonathan Peterson + ========================================================= + */ +/* + The MIT License (MIT) + + Copyright (c) 2015 Jonathan Peterson + + 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. + */ +/*global define:false */ +/*global exports:false */ +/*global require:false */ +/*global jQuery:false */ +/*global moment:false */ +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // AMD is used - Register as an anonymous module. + define(['jquery', 'moment'], factory); + } else if (typeof exports === 'object') { + factory(require('jquery'), require('moment')); + } else { + // Neither AMD nor CommonJS used. Use global variables. + if (typeof jQuery === 'undefined') { + throw 'bootstrap-datetimepicker requires jQuery to be loaded first'; + } + if (typeof moment === 'undefined') { + throw 'bootstrap-datetimepicker requires Moment.js to be loaded first'; + } + factory(jQuery, moment); + } +}(function ($, moment) { + 'use strict'; + if (!moment) { + throw new Error('bootstrap-datetimepicker requires Moment.js to be loaded first'); + } + + var dateTimePicker = function (element, options) { + var picker = {}, + date, + viewDate, + unset = true, + input, + component = false, + widget = false, + use24Hours, + minViewModeNumber = 0, + actualFormat, + parseFormats, + currentViewMode, + datePickerModes = [ + { + clsName: 'days', + navFnc: 'M', + navStep: 1 + }, + { + clsName: 'months', + navFnc: 'y', + navStep: 1 + }, + { + clsName: 'years', + navFnc: 'y', + navStep: 10 + }, + { + clsName: 'decades', + navFnc: 'y', + navStep: 100 + } + ], + viewModes = ['days', 'months', 'years', 'decades'], + verticalModes = ['top', 'bottom', 'auto'], + horizontalModes = ['left', 'right', 'auto'], + toolbarPlacements = ['default', 'top', 'bottom'], + keyMap = { + 'up': 38, + 38: 'up', + 'down': 40, + 40: 'down', + 'left': 37, + 37: 'left', + 'right': 39, + 39: 'right', + 'tab': 9, + 9: 'tab', + 'escape': 27, + 27: 'escape', + 'enter': 13, + 13: 'enter', + 'pageUp': 33, + 33: 'pageUp', + 'pageDown': 34, + 34: 'pageDown', + 'shift': 16, + 16: 'shift', + 'control': 17, + 17: 'control', + 'space': 32, + 32: 'space', + 't': 84, + 84: 't', + 'delete': 46, + 46: 'delete' + }, + keyState = {}, + + /******************************************************************************** + * + * Private functions + * + ********************************************************************************/ + getMoment = function (d) { + var tzEnabled = false, + returnMoment, + currentZoneOffset, + incomingZoneOffset, + timeZoneIndicator, + dateWithTimeZoneInfo; + + if (moment.tz !== undefined && options.timeZone !== undefined && options.timeZone !== null && options.timeZone !== '') { + tzEnabled = true; + } + if (d === undefined || d === null) { + if (tzEnabled) { + returnMoment = moment().tz(options.timeZone).startOf('d'); + } else { + returnMoment = moment().startOf('d'); + } + } else { + if (tzEnabled) { + currentZoneOffset = moment().tz(options.timeZone).utcOffset(); + incomingZoneOffset = moment(d, parseFormats, options.useStrict).utcOffset(); + if (incomingZoneOffset !== currentZoneOffset) { + timeZoneIndicator = moment().tz(options.timeZone).format('Z'); + dateWithTimeZoneInfo = moment(d, parseFormats, options.useStrict).format('YYYY-MM-DD[T]HH:mm:ss') + timeZoneIndicator; + returnMoment = moment(dateWithTimeZoneInfo, parseFormats, options.useStrict).tz(options.timeZone); + } else { + returnMoment = moment(d, parseFormats, options.useStrict).tz(options.timeZone); + } + } else { + returnMoment = moment(d, parseFormats, options.useStrict); + } + } + return returnMoment; + }, + isEnabled = function (granularity) { + if (typeof granularity !== 'string' || granularity.length > 1) { + throw new TypeError('isEnabled expects a single character string parameter'); + } + switch (granularity) { + case 'y': + return actualFormat.indexOf('Y') !== -1; + case 'M': + return actualFormat.indexOf('M') !== -1; + case 'd': + return actualFormat.toLowerCase().indexOf('d') !== -1; + case 'h': + case 'H': + return actualFormat.toLowerCase().indexOf('h') !== -1; + case 'm': + return actualFormat.indexOf('m') !== -1; + case 's': + return actualFormat.indexOf('s') !== -1; + default: + return false; + } + }, + hasTime = function () { + return (isEnabled('h') || isEnabled('m') || isEnabled('s')); + }, + + hasDate = function () { + return (isEnabled('y') || isEnabled('M') || isEnabled('d')); + }, + + getDatePickerTemplate = function () { + var headTemplate = $('') + .append($('') + .append($('').addClass('prev').attr('data-action', 'previous') + .append($('').addClass(options.icons.previous)) + ) + .append($('').addClass('picker-switch').attr('data-action', 'pickerSwitch').attr('colspan', (options.calendarWeeks ? '6' : '5'))) + .append($('').addClass('next').attr('data-action', 'next') + .append($('').addClass(options.icons.next)) + ) + ), + contTemplate = $('') + .append($('') + .append($('').attr('colspan', (options.calendarWeeks ? '8' : '7'))) + ); + + return [ + $('
').addClass('datepicker-days') + .append($('').addClass('table-condensed') + .append(headTemplate) + .append($('')) + ), + $('
').addClass('datepicker-months') + .append($('
').addClass('table-condensed') + .append(headTemplate.clone()) + .append(contTemplate.clone()) + ), + $('
').addClass('datepicker-years') + .append($('
').addClass('table-condensed') + .append(headTemplate.clone()) + .append(contTemplate.clone()) + ), + $('
').addClass('datepicker-decades') + .append($('
').addClass('table-condensed') + .append(headTemplate.clone()) + .append(contTemplate.clone()) + ) + ]; + }, + + getTimePickerMainTemplate = function () { + var topRow = $(''), + middleRow = $(''), + bottomRow = $(''); + + if (isEnabled('h')) { + topRow.append($('
') + .append($('').attr({ href: '#', tabindex: '-1', 'title': options.tooltips.incrementHour }).addClass('btn').attr('data-action', 'incrementHours') + .append($('').addClass(options.icons.up)))); + middleRow.append($('') + .append($('').addClass('timepicker-hour').attr({ 'data-time-component': 'hours', 'title': options.tooltips.pickHour }).attr('data-action', 'showHours'))); + bottomRow.append($('') + .append($('').attr({ href: '#', tabindex: '-1', 'title': options.tooltips.decrementHour }).addClass('btn').attr('data-action', 'decrementHours') + .append($('').addClass(options.icons.down)))); + } + if (isEnabled('m')) { + if (isEnabled('h')) { + topRow.append($('').addClass('separator')); + middleRow.append($('').addClass('separator').html(':')); + bottomRow.append($('').addClass('separator')); + } + topRow.append($('') + .append($('').attr({ href: '#', tabindex: '-1', 'title': options.tooltips.incrementMinute }).addClass('btn').attr('data-action', 'incrementMinutes') + .append($('').addClass(options.icons.up)))); + middleRow.append($('') + .append($('').addClass('timepicker-minute').attr({ 'data-time-component': 'minutes', 'title': options.tooltips.pickMinute }).attr('data-action', 'showMinutes'))); + bottomRow.append($('') + .append($('').attr({ href: '#', tabindex: '-1', 'title': options.tooltips.decrementMinute }).addClass('btn').attr('data-action', 'decrementMinutes') + .append($('').addClass(options.icons.down)))); + } + if (isEnabled('s')) { + if (isEnabled('m')) { + topRow.append($('').addClass('separator')); + middleRow.append($('').addClass('separator').html(':')); + bottomRow.append($('').addClass('separator')); + } + topRow.append($('') + .append($('').attr({ href: '#', tabindex: '-1', 'title': options.tooltips.incrementSecond }).addClass('btn').attr('data-action', 'incrementSeconds') + .append($('').addClass(options.icons.up)))); + middleRow.append($('') + .append($('').addClass('timepicker-second').attr({ 'data-time-component': 'seconds', 'title': options.tooltips.pickSecond }).attr('data-action', 'showSeconds'))); + bottomRow.append($('') + .append($('').attr({ href: '#', tabindex: '-1', 'title': options.tooltips.decrementSecond }).addClass('btn').attr('data-action', 'decrementSeconds') + .append($('').addClass(options.icons.down)))); + } + + if (!use24Hours) { + topRow.append($('').addClass('separator')); + middleRow.append($('') + .append($('').addClass('separator')); + } + + return $('
').addClass('timepicker-picker') + .append($('').addClass('table-condensed') + .append([topRow, middleRow, bottomRow])); + }, + + getTimePickerTemplate = function () { + var hoursView = $('
').addClass('timepicker-hours') + .append($('
').addClass('table-condensed')), + minutesView = $('
').addClass('timepicker-minutes') + .append($('
').addClass('table-condensed')), + secondsView = $('
').addClass('timepicker-seconds') + .append($('
').addClass('table-condensed')), + ret = [getTimePickerMainTemplate()]; + + if (isEnabled('h')) { + ret.push(hoursView); + } + if (isEnabled('m')) { + ret.push(minutesView); + } + if (isEnabled('s')) { + ret.push(secondsView); + } + + return ret; + }, + + getToolbar = function () { + var row = []; + if (options.showTodayButton) { + row.push($('
').append($('').attr({ 'data-action': 'today', 'title': options.tooltips.today }).append($('').addClass(options.icons.today)))); + } + if (!options.sideBySide && hasDate() && hasTime()) { + row.push($('').append($('').attr({ 'data-action': 'togglePicker', 'title': options.tooltips.selectTime }).append($('').addClass(options.icons.time)))); + } + if (options.showClear) { + row.push($('').append($('').attr({ 'data-action': 'clear', 'title': options.tooltips.clear }).append($('').addClass(options.icons.clear)))); + } + if (options.showClose) { + row.push($('').append($('').attr({ 'data-action': 'close', 'title': options.tooltips.close }).append($('').addClass(options.icons.close)))); + } + return $('').addClass('table-condensed').append($('').append($('').append(row))); + }, + + getTemplate = function () { + var template = $('
').addClass('bootstrap-datetimepicker-widget dropdown-menu'), + dateView = $('
').addClass('datepicker').append(getDatePickerTemplate()), + timeView = $('
').addClass('timepicker').append(getTimePickerTemplate()), + content = $('
    ').addClass('list-unstyled'), + toolbar = $('
  • ').addClass('picker-switch' + (options.collapse ? ' accordion-toggle' : '')).append(getToolbar()); + + if (options.inline) { + template.removeClass('dropdown-menu'); + } + + if (use24Hours) { + template.addClass('usetwentyfour'); + } + if (isEnabled('s') && !use24Hours) { + template.addClass('wider'); + } + + if (options.sideBySide && hasDate() && hasTime()) { + template.addClass('timepicker-sbs'); + if (options.toolbarPlacement === 'top') { + template.append(toolbar); + } + template.append( + $('
    ').addClass('row') + .append(dateView.addClass('col-md-6')) + .append(timeView.addClass('col-md-6')) + ); + if (options.toolbarPlacement === 'bottom') { + template.append(toolbar); + } + return template; + } + + if (options.toolbarPlacement === 'top') { + content.append(toolbar); + } + if (hasDate()) { + content.append($('
  • ').addClass((options.collapse && hasTime() ? 'collapse in' : '')).append(dateView)); + } + if (options.toolbarPlacement === 'default') { + content.append(toolbar); + } + if (hasTime()) { + content.append($('
  • ').addClass((options.collapse && hasDate() ? 'collapse' : '')).append(timeView)); + } + if (options.toolbarPlacement === 'bottom') { + content.append(toolbar); + } + return template.append(content); + }, + + dataToOptions = function () { + var eData, + dataOptions = {}; + + if (element.is('input') || options.inline) { + eData = element.data(); + } else { + eData = element.find('input').data(); + } + + if (eData.dateOptions && eData.dateOptions instanceof Object) { + dataOptions = $.extend(true, dataOptions, eData.dateOptions); + } + + $.each(options, function (key) { + var attributeName = 'date' + key.charAt(0).toUpperCase() + key.slice(1); + if (eData[attributeName] !== undefined) { + dataOptions[key] = eData[attributeName]; + } + }); + return dataOptions; + }, + + place = function () { + var position = (component || element).position(), + offset = (component || element).offset(), + vertical = options.widgetPositioning.vertical, + horizontal = options.widgetPositioning.horizontal, + parent; + + if (options.widgetParent) { + parent = options.widgetParent.append(widget); + } else if (element.is('input')) { + parent = element.after(widget).parent(); + } else if (options.inline) { + parent = element.append(widget); + return; + } else { + parent = element; + element.children().first().after(widget); + } + + // Top and bottom logic + if (vertical === 'auto') { + if (offset.top + widget.height() * 1.5 >= $(window).height() + $(window).scrollTop() && + widget.height() + element.outerHeight() < offset.top) { + vertical = 'top'; + } else { + vertical = 'bottom'; + } + } + + // Left and right logic + if (horizontal === 'auto') { + if (parent.width() < offset.left + widget.outerWidth() / 2 && + offset.left + widget.outerWidth() > $(window).width()) { + horizontal = 'right'; + } else { + horizontal = 'left'; + } + } + + if (vertical === 'top') { + widget.addClass('top').removeClass('bottom'); + } else { + widget.addClass('bottom').removeClass('top'); + } + + if (horizontal === 'right') { + widget.addClass('pull-right'); + } else { + widget.removeClass('pull-right'); + } + + // find the first parent element that has a relative css positioning + if (parent.css('position') !== 'relative') { + parent = parent.parents().filter(function () { + return $(this).css('position') === 'relative'; + }).first(); + } + + if (parent.length === 0) { + throw new Error('datetimepicker component should be placed within a relative positioned container'); + } + + widget.css({ + top: vertical === 'top' ? 'auto' : position.top + element.outerHeight(), + bottom: vertical === 'top' ? position.top + element.outerHeight() : 'auto', + left: horizontal === 'left' ? (parent === element ? 0 : position.left) : 'auto', + right: horizontal === 'left' ? 'auto' : parent.outerWidth() - element.outerWidth() - (parent === element ? 0 : position.left) + }); + }, + + notifyEvent = function (e) { + if (e.type === 'dp.change' && ((e.date && e.date.isSame(e.oldDate)) || (!e.date && !e.oldDate))) { + return; + } + element.trigger(e); + }, + + viewUpdate = function (e) { + if (e === 'y') { + e = 'YYYY'; + } + notifyEvent({ + type: 'dp.update', + change: e, + viewDate: viewDate.clone() + }); + }, + + showMode = function (dir) { + if (!widget) { + return; + } + if (dir) { + currentViewMode = Math.max(minViewModeNumber, Math.min(3, currentViewMode + dir)); + } + widget.find('.datepicker > div').hide().filter('.datepicker-' + datePickerModes[currentViewMode].clsName).show(); + }, + + fillDow = function () { + var row = $('
'), + currentDate = viewDate.clone().startOf('w').startOf('d'); + + if (options.calendarWeeks === true) { + row.append($(''); + if (options.calendarWeeks) { + row.append(''); + } + html.push(row); + } + clsName = ''; + if (currentDate.isBefore(viewDate, 'M')) { + clsName += ' old'; + } + if (currentDate.isAfter(viewDate, 'M')) { + clsName += ' new'; + } + if (currentDate.isSame(date, 'd') && !unset) { + clsName += ' active'; + } + if (!isValid(currentDate, 'd')) { + clsName += ' disabled'; + } + if (currentDate.isSame(getMoment(), 'd')) { + clsName += ' today'; + } + if (currentDate.day() === 0 || currentDate.day() === 6) { + clsName += ' weekend'; + } + row.append(''); + currentDate.add(1, 'd'); + } + + daysView.find('tbody').empty().append(html); + + updateMonths(); + + updateYears(); + + updateDecades(); + }, + + fillHours = function () { + var table = widget.find('.timepicker-hours table'), + currentHour = viewDate.clone().startOf('d'), + html = [], + row = $(''); + + if (viewDate.hour() > 11 && !use24Hours) { + currentHour.hour(12); + } + while (currentHour.isSame(viewDate, 'd') && (use24Hours || (viewDate.hour() < 12 && currentHour.hour() < 12) || viewDate.hour() > 11)) { + if (currentHour.hour() % 4 === 0) { + row = $(''); + html.push(row); + } + row.append(''); + currentHour.add(1, 'h'); + } + table.empty().append(html); + }, + + fillMinutes = function () { + var table = widget.find('.timepicker-minutes table'), + currentMinute = viewDate.clone().startOf('h'), + html = [], + row = $(''), + step = options.stepping === 1 ? 5 : options.stepping; + + while (viewDate.isSame(currentMinute, 'h')) { + if (currentMinute.minute() % (step * 4) === 0) { + row = $(''); + html.push(row); + } + row.append(''); + currentMinute.add(step, 'm'); + } + table.empty().append(html); + }, + + fillSeconds = function () { + var table = widget.find('.timepicker-seconds table'), + currentSecond = viewDate.clone().startOf('m'), + html = [], + row = $(''); + + while (viewDate.isSame(currentSecond, 'm')) { + if (currentSecond.second() % 20 === 0) { + row = $(''); + html.push(row); + } + row.append(''); + currentSecond.add(5, 's'); + } + + table.empty().append(html); + }, + + fillTime = function () { + var toggle, newDate, timeComponents = widget.find('.timepicker span[data-time-component]'); + + if (!use24Hours) { + toggle = widget.find('.timepicker [data-action=togglePeriod]'); + newDate = date.clone().add((date.hours() >= 12) ? -12 : 12, 'h'); + + toggle.text(date.format('A')); + + if (isValid(newDate, 'h')) { + toggle.removeClass('disabled'); + } else { + toggle.addClass('disabled'); + } + } + timeComponents.filter('[data-time-component=hours]').text(date.format(use24Hours ? 'HH' : 'hh')); + timeComponents.filter('[data-time-component=minutes]').text(date.format('mm')); + timeComponents.filter('[data-time-component=seconds]').text(date.format('ss')); + + fillHours(); + fillMinutes(); + fillSeconds(); + }, + + update = function () { + if (!widget) { + return; + } + fillDate(); + fillTime(); + }, + + setValue = function (targetMoment) { + var oldDate = unset ? null : date; + + // case of calling setValue(null or false) + if (!targetMoment) { + unset = true; + input.val(''); + element.data('date', ''); + notifyEvent({ + type: 'dp.change', + date: false, + oldDate: oldDate + }); + update(); + return; + } + + targetMoment = targetMoment.clone().locale(options.locale); + + if (options.stepping !== 1) { + targetMoment.minutes((Math.round(targetMoment.minutes() / options.stepping) * options.stepping) % 60).seconds(0); + } + + if (isValid(targetMoment)) { + date = targetMoment; + viewDate = date.clone(); + input.val(date.format(actualFormat)); + element.data('date', date.format(actualFormat)); + unset = false; + update(); + notifyEvent({ + type: 'dp.change', + date: date.clone(), + oldDate: oldDate + }); + } else { + if (!options.keepInvalid) { + input.val(unset ? '' : date.format(actualFormat)); + } + notifyEvent({ + type: 'dp.error', + date: targetMoment + }); + } + }, + + hide = function () { + ///Hides the widget. Possibly will emit dp.hide + var transitioning = false; + if (!widget) { + return picker; + } + // Ignore event if in the middle of a picker transition + widget.find('.collapse').each(function () { + var collapseData = $(this).data('collapse'); + if (collapseData && collapseData.transitioning) { + transitioning = true; + return false; + } + return true; + }); + if (transitioning) { + return picker; + } + if (component && component.hasClass('btn')) { + component.toggleClass('active'); + } + widget.hide(); + + $(window).off('resize', place); + widget.off('click', '[data-action]'); + widget.off('mousedown', false); + + widget.remove(); + widget = false; + + notifyEvent({ + type: 'dp.hide', + date: date.clone() + }); + + input.blur(); + + return picker; + }, + + clear = function () { + setValue(null); + }, + + /******************************************************************************** + * + * Widget UI interaction functions + * + ********************************************************************************/ + actions = { + next: function () { + var navFnc = datePickerModes[currentViewMode].navFnc; + viewDate.add(datePickerModes[currentViewMode].navStep, navFnc); + fillDate(); + viewUpdate(navFnc); + }, + + previous: function () { + var navFnc = datePickerModes[currentViewMode].navFnc; + viewDate.subtract(datePickerModes[currentViewMode].navStep, navFnc); + fillDate(); + viewUpdate(navFnc); + }, + + pickerSwitch: function () { + showMode(1); + }, + + selectMonth: function (e) { + var month = $(e.target).closest('tbody').find('span').index($(e.target)); + viewDate.month(month); + if (currentViewMode === minViewModeNumber) { + setValue(date.clone().year(viewDate.year()).month(viewDate.month())); + if (!options.inline) { + hide(); + } + } else { + showMode(-1); + fillDate(); + } + viewUpdate('M'); + }, + + selectYear: function (e) { + var year = parseInt($(e.target).text(), 10) || 0; + viewDate.year(year); + if (currentViewMode === minViewModeNumber) { + setValue(date.clone().year(viewDate.year())); + if (!options.inline) { + hide(); + } + } else { + showMode(-1); + fillDate(); + } + viewUpdate('YYYY'); + }, + + selectDecade: function (e) { + var year = parseInt($(e.target).data('selection'), 10) || 0; + viewDate.year(year); + if (currentViewMode === minViewModeNumber) { + setValue(date.clone().year(viewDate.year())); + if (!options.inline) { + hide(); + } + } else { + showMode(-1); + fillDate(); + } + viewUpdate('YYYY'); + }, + + selectDay: function (e) { + var day = viewDate.clone(); + if ($(e.target).is('.old')) { + day.subtract(1, 'M'); + } + if ($(e.target).is('.new')) { + day.add(1, 'M'); + } + setValue(day.date(parseInt($(e.target).text(), 10))); + if (!hasTime() && !options.keepOpen && !options.inline) { + hide(); + } + }, + + incrementHours: function () { + var newDate = date.clone().add(1, 'h'); + if (isValid(newDate, 'h')) { + setValue(newDate); + } + }, + + incrementMinutes: function () { + var newDate = date.clone().add(options.stepping, 'm'); + if (isValid(newDate, 'm')) { + setValue(newDate); + } + }, + + incrementSeconds: function () { + var newDate = date.clone().add(1, 's'); + if (isValid(newDate, 's')) { + setValue(newDate); + } + }, + + decrementHours: function () { + var newDate = date.clone().subtract(1, 'h'); + if (isValid(newDate, 'h')) { + setValue(newDate); + } + }, + + decrementMinutes: function () { + var newDate = date.clone().subtract(options.stepping, 'm'); + if (isValid(newDate, 'm')) { + setValue(newDate); + } + }, + + decrementSeconds: function () { + var newDate = date.clone().subtract(1, 's'); + if (isValid(newDate, 's')) { + setValue(newDate); + } + }, + + togglePeriod: function () { + setValue(date.clone().add((date.hours() >= 12) ? -12 : 12, 'h')); + }, + + togglePicker: function (e) { + var $this = $(e.target), + $parent = $this.closest('ul'), + expanded = $parent.find('.in'), + closed = $parent.find('.collapse:not(.in)'), + collapseData; + + if (expanded && expanded.length) { + collapseData = expanded.data('collapse'); + if (collapseData && collapseData.transitioning) { + return; + } + if (expanded.collapse) { // if collapse plugin is available through bootstrap.js then use it + expanded.collapse('hide'); + closed.collapse('show'); + } else { // otherwise just toggle in class on the two views + expanded.removeClass('in'); + closed.addClass('in'); + } + if ($this.is('span')) { + $this.toggleClass(options.icons.time + ' ' + options.icons.date); + } else { + $this.find('span').toggleClass(options.icons.time + ' ' + options.icons.date); + } + + // NOTE: uncomment if toggled state will be restored in show() + //if (component) { + // component.find('span').toggleClass(options.icons.time + ' ' + options.icons.date); + //} + } + }, + + showPicker: function () { + widget.find('.timepicker > div:not(.timepicker-picker)').hide(); + widget.find('.timepicker .timepicker-picker').show(); + }, + + showHours: function () { + widget.find('.timepicker .timepicker-picker').hide(); + widget.find('.timepicker .timepicker-hours').show(); + }, + + showMinutes: function () { + widget.find('.timepicker .timepicker-picker').hide(); + widget.find('.timepicker .timepicker-minutes').show(); + }, + + showSeconds: function () { + widget.find('.timepicker .timepicker-picker').hide(); + widget.find('.timepicker .timepicker-seconds').show(); + }, + + selectHour: function (e) { + var hour = parseInt($(e.target).text(), 10); + + if (!use24Hours) { + if (date.hours() >= 12) { + if (hour !== 12) { + hour += 12; + } + } else { + if (hour === 12) { + hour = 0; + } + } + } + setValue(date.clone().hours(hour)); + actions.showPicker.call(picker); + }, + + selectMinute: function (e) { + setValue(date.clone().minutes(parseInt($(e.target).text(), 10))); + actions.showPicker.call(picker); + }, + + selectSecond: function (e) { + setValue(date.clone().seconds(parseInt($(e.target).text(), 10))); + actions.showPicker.call(picker); + }, + + clear: clear, + + today: function () { + var todaysDate = getMoment(); + if (isValid(todaysDate, 'd')) { + setValue(todaysDate); + } + }, + + close: hide + }, + + doAction = function (e) { + if ($(e.currentTarget).is('.disabled')) { + return false; + } + actions[$(e.currentTarget).data('action')].apply(picker, arguments); + return false; + }, + + show = function () { + ///Shows the widget. Possibly will emit dp.show and dp.change + var currentMoment, + useCurrentGranularity = { + 'year': function (m) { + return m.month(0).date(1).hours(0).seconds(0).minutes(0); + }, + 'month': function (m) { + return m.date(1).hours(0).seconds(0).minutes(0); + }, + 'day': function (m) { + return m.hours(0).seconds(0).minutes(0); + }, + 'hour': function (m) { + return m.seconds(0).minutes(0); + }, + 'minute': function (m) { + return m.seconds(0); + } + }; + + if (input.prop('disabled') || (!options.ignoreReadonly && input.prop('readonly')) || widget) { + return picker; + } + if (input.val() !== undefined && input.val().trim().length !== 0) { + setValue(parseInputDate(input.val().trim())); + } else if (options.useCurrent && unset && ((input.is('input') && input.val().trim().length === 0) || options.inline)) { + currentMoment = getMoment(); + if (typeof options.useCurrent === 'string') { + currentMoment = useCurrentGranularity[options.useCurrent](currentMoment); + } + setValue(currentMoment); + } + + widget = getTemplate(); + + fillDow(); + fillMonths(); + + widget.find('.timepicker-hours').hide(); + widget.find('.timepicker-minutes').hide(); + widget.find('.timepicker-seconds').hide(); + + update(); + showMode(); + + $(window).on('resize', place); + widget.on('click', '[data-action]', doAction); // this handles clicks on the widget + widget.on('mousedown', false); + + if (component && component.hasClass('btn')) { + component.toggleClass('active'); + } + widget.show(); + place(); + + if (options.focusOnShow && !input.is(':focus')) { + input.focus(); + } + + notifyEvent({ + type: 'dp.show' + }); + return picker; + }, + + toggle = function () { + /// Shows or hides the widget + return (widget ? hide() : show()); + }, + + parseInputDate = function (inputDate) { + if (options.parseInputDate === undefined) { + if (moment.isMoment(inputDate) || inputDate instanceof Date) { + inputDate = moment(inputDate); + } else { + inputDate = getMoment(inputDate); + } + } else { + inputDate = options.parseInputDate(inputDate); + } + inputDate.locale(options.locale); + return inputDate; + }, + + keydown = function (e) { + var handler = null, + index, + index2, + pressedKeys = [], + pressedModifiers = {}, + currentKey = e.which, + keyBindKeys, + allModifiersPressed, + pressed = 'p'; + + keyState[currentKey] = pressed; + + for (index in keyState) { + if (keyState.hasOwnProperty(index) && keyState[index] === pressed) { + pressedKeys.push(index); + if (parseInt(index, 10) !== currentKey) { + pressedModifiers[index] = true; + } + } + } + + for (index in options.keyBinds) { + if (options.keyBinds.hasOwnProperty(index) && typeof (options.keyBinds[index]) === 'function') { + keyBindKeys = index.split(' '); + if (keyBindKeys.length === pressedKeys.length && keyMap[currentKey] === keyBindKeys[keyBindKeys.length - 1]) { + allModifiersPressed = true; + for (index2 = keyBindKeys.length - 2; index2 >= 0; index2--) { + if (!(keyMap[keyBindKeys[index2]] in pressedModifiers)) { + allModifiersPressed = false; + break; + } + } + if (allModifiersPressed) { + handler = options.keyBinds[index]; + break; + } + } + } + } + + if (handler) { + handler.call(picker, widget); + e.stopPropagation(); + e.preventDefault(); + } + }, + + keyup = function (e) { + keyState[e.which] = 'r'; + e.stopPropagation(); + e.preventDefault(); + }, + + change = function (e) { + var val = $(e.target).val().trim(), + parsedDate = val ? parseInputDate(val) : null; + setValue(parsedDate); + e.stopImmediatePropagation(); + return false; + }, + + attachDatePickerElementEvents = function () { + input.on({ + 'change': change, + 'blur': options.debug ? '' : hide, + 'keydown': keydown, + 'keyup': keyup, + 'focus': options.allowInputToggle ? show : '' + }); + + if (element.is('input')) { + input.on({ + 'focus': show + }); + } else if (component) { + component.on('click', toggle); + component.on('mousedown', false); + } + }, + + detachDatePickerElementEvents = function () { + input.off({ + 'change': change, + 'blur': blur, + 'keydown': keydown, + 'keyup': keyup, + 'focus': options.allowInputToggle ? hide : '' + }); + + if (element.is('input')) { + input.off({ + 'focus': show + }); + } else if (component) { + component.off('click', toggle); + component.off('mousedown', false); + } + }, + + indexGivenDates = function (givenDatesArray) { + // Store given enabledDates and disabledDates as keys. + // This way we can check their existence in O(1) time instead of looping through whole array. + // (for example: options.enabledDates['2014-02-27'] === true) + var givenDatesIndexed = {}; + $.each(givenDatesArray, function () { + var dDate = parseInputDate(this); + if (dDate.isValid()) { + givenDatesIndexed[dDate.format('YYYY-MM-DD')] = true; + } + }); + return (Object.keys(givenDatesIndexed).length) ? givenDatesIndexed : false; + }, + + indexGivenHours = function (givenHoursArray) { + // Store given enabledHours and disabledHours as keys. + // This way we can check their existence in O(1) time instead of looping through whole array. + // (for example: options.enabledHours['2014-02-27'] === true) + var givenHoursIndexed = {}; + $.each(givenHoursArray, function () { + givenHoursIndexed[this] = true; + }); + return (Object.keys(givenHoursIndexed).length) ? givenHoursIndexed : false; + }, + + initFormatting = function () { + var format = options.format || 'L LT'; + + actualFormat = format.replace(/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, function (formatInput) { + var newinput = date.localeData().longDateFormat(formatInput) || formatInput; + return newinput.replace(/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, function (formatInput2) { //temp fix for #740 + return date.localeData().longDateFormat(formatInput2) || formatInput2; + }); + }); + + + parseFormats = options.extraFormats ? options.extraFormats.slice() : []; + if (parseFormats.indexOf(format) < 0 && parseFormats.indexOf(actualFormat) < 0) { + parseFormats.push(actualFormat); + } + + use24Hours = (actualFormat.toLowerCase().indexOf('a') < 1 && actualFormat.replace(/\[.*?\]/g, '').indexOf('h') < 1); + + if (isEnabled('y')) { + minViewModeNumber = 2; + } + if (isEnabled('M')) { + minViewModeNumber = 1; + } + if (isEnabled('d')) { + minViewModeNumber = 0; + } + + currentViewMode = Math.max(minViewModeNumber, currentViewMode); + + if (!unset) { + setValue(date); + } + }; + + /******************************************************************************** + * + * Public API functions + * ===================== + * + * Important: Do not expose direct references to private objects or the options + * object to the outer world. Always return a clone when returning values or make + * a clone when setting a private variable. + * + ********************************************************************************/ + picker.destroy = function () { + ///Destroys the widget and removes all attached event listeners + hide(); + detachDatePickerElementEvents(); + element.removeData('DateTimePicker'); + element.removeData('date'); + }; + + picker.toggle = toggle; + + picker.show = show; + + picker.hide = hide; + + picker.disable = function () { + ///Disables the input element, the component is attached to, by adding a disabled="true" attribute to it. + ///If the widget was visible before that call it is hidden. Possibly emits dp.hide + hide(); + if (component && component.hasClass('btn')) { + component.addClass('disabled'); + } + input.prop('disabled', true); + return picker; + }; + + picker.enable = function () { + ///Enables the input element, the component is attached to, by removing disabled attribute from it. + if (component && component.hasClass('btn')) { + component.removeClass('disabled'); + } + input.prop('disabled', false); + return picker; + }; + + picker.ignoreReadonly = function (ignoreReadonly) { + if (arguments.length === 0) { + return options.ignoreReadonly; + } + if (typeof ignoreReadonly !== 'boolean') { + throw new TypeError('ignoreReadonly () expects a boolean parameter'); + } + options.ignoreReadonly = ignoreReadonly; + return picker; + }; + + picker.options = function (newOptions) { + if (arguments.length === 0) { + return $.extend(true, {}, options); + } + + if (!(newOptions instanceof Object)) { + throw new TypeError('options() options parameter should be an object'); + } + $.extend(true, options, newOptions); + $.each(options, function (key, value) { + if (picker[key] !== undefined) { + picker[key](value); + } else { + throw new TypeError('option ' + key + ' is not recognized!'); + } + }); + return picker; + }; + + picker.date = function (newDate) { + /// + ///Returns the component's model current date, a moment object or null if not set. + ///date.clone() + /// + /// + ///Sets the components model current moment to it. Passing a null value unsets the components model current moment. Parsing of the newDate parameter is made using moment library with the options.format and options.useStrict components configuration. + ///Takes string, Date, moment, null parameter. + /// + if (arguments.length === 0) { + if (unset) { + return null; + } + return date.clone(); + } + + if (newDate !== null && typeof newDate !== 'string' && !moment.isMoment(newDate) && !(newDate instanceof Date)) { + throw new TypeError('date() parameter must be one of [null, string, moment or Date]'); + } + + setValue(newDate === null ? null : parseInputDate(newDate)); + return picker; + }; + + picker.format = function (newFormat) { + ///test su + ///info about para + ///returns foo + if (arguments.length === 0) { + return options.format; + } + + if ((typeof newFormat !== 'string') && ((typeof newFormat !== 'boolean') || (newFormat !== false))) { + throw new TypeError('format() expects a sting or boolean:false parameter ' + newFormat); + } + + options.format = newFormat; + if (actualFormat) { + initFormatting(); // reinit formatting + } + return picker; + }; + + picker.timeZone = function (newZone) { + if (arguments.length === 0) { + return options.timeZone; + } + + options.timeZone = newZone; + + return picker; + }; + + picker.dayViewHeaderFormat = function (newFormat) { + if (arguments.length === 0) { + return options.dayViewHeaderFormat; + } + + if (typeof newFormat !== 'string') { + throw new TypeError('dayViewHeaderFormat() expects a string parameter'); + } + + options.dayViewHeaderFormat = newFormat; + return picker; + }; + + picker.extraFormats = function (formats) { + if (arguments.length === 0) { + return options.extraFormats; + } + + if (formats !== false && !(formats instanceof Array)) { + throw new TypeError('extraFormats() expects an array or false parameter'); + } + + options.extraFormats = formats; + if (parseFormats) { + initFormatting(); // reinit formatting + } + return picker; + }; + + picker.disabledDates = function (dates) { + /// + ///Returns an array with the currently set disabled dates on the component. + ///options.disabledDates + /// + /// + ///Setting this takes precedence over options.minDate, options.maxDate configuration. Also calling this function removes the configuration of + ///options.enabledDates if such exist. + ///Takes an [ string or Date or moment ] of values and allows the user to select only from those days. + /// + if (arguments.length === 0) { + return (options.disabledDates ? $.extend({}, options.disabledDates) : options.disabledDates); + } + + if (!dates) { + options.disabledDates = false; + update(); + return picker; + } + if (!(dates instanceof Array)) { + throw new TypeError('disabledDates() expects an array parameter'); + } + options.disabledDates = indexGivenDates(dates); + options.enabledDates = false; + update(); + return picker; + }; + + picker.enabledDates = function (dates) { + /// + ///Returns an array with the currently set enabled dates on the component. + ///options.enabledDates + /// + /// + ///Setting this takes precedence over options.minDate, options.maxDate configuration. Also calling this function removes the configuration of options.disabledDates if such exist. + ///Takes an [ string or Date or moment ] of values and allows the user to select only from those days. + /// + if (arguments.length === 0) { + return (options.enabledDates ? $.extend({}, options.enabledDates) : options.enabledDates); + } + + if (!dates) { + options.enabledDates = false; + update(); + return picker; + } + if (!(dates instanceof Array)) { + throw new TypeError('enabledDates() expects an array parameter'); + } + options.enabledDates = indexGivenDates(dates); + options.disabledDates = false; + update(); + return picker; + }; + + picker.daysOfWeekDisabled = function (daysOfWeekDisabled) { + if (arguments.length === 0) { + return options.daysOfWeekDisabled.splice(0); + } + + if ((typeof daysOfWeekDisabled === 'boolean') && !daysOfWeekDisabled) { + options.daysOfWeekDisabled = false; + update(); + return picker; + } + + if (!(daysOfWeekDisabled instanceof Array)) { + throw new TypeError('daysOfWeekDisabled() expects an array parameter'); + } + options.daysOfWeekDisabled = daysOfWeekDisabled.reduce(function (previousValue, currentValue) { + currentValue = parseInt(currentValue, 10); + if (currentValue > 6 || currentValue < 0 || isNaN(currentValue)) { + return previousValue; + } + if (previousValue.indexOf(currentValue) === -1) { + previousValue.push(currentValue); + } + return previousValue; + }, []).sort(); + if (options.useCurrent && !options.keepInvalid) { + var tries = 0; + while (!isValid(date, 'd')) { + date.add(1, 'd'); + if (tries === 7) { + throw 'Tried 7 times to find a valid date'; + } + tries++; + } + setValue(date); + } + update(); + return picker; + }; + + picker.maxDate = function (maxDate) { + if (arguments.length === 0) { + return options.maxDate ? options.maxDate.clone() : options.maxDate; + } + + if ((typeof maxDate === 'boolean') && maxDate === false) { + options.maxDate = false; + update(); + return picker; + } + + if (typeof maxDate === 'string') { + if (maxDate === 'now' || maxDate === 'moment') { + maxDate = getMoment(); + } + } + + var parsedDate = parseInputDate(maxDate); + + if (!parsedDate.isValid()) { + throw new TypeError('maxDate() Could not parse date parameter: ' + maxDate); + } + if (options.minDate && parsedDate.isBefore(options.minDate)) { + throw new TypeError('maxDate() date parameter is before options.minDate: ' + parsedDate.format(actualFormat)); + } + options.maxDate = parsedDate; + if (options.useCurrent && !options.keepInvalid && date.isAfter(maxDate)) { + setValue(options.maxDate); + } + if (viewDate.isAfter(parsedDate)) { + viewDate = parsedDate.clone().subtract(options.stepping, 'm'); + } + update(); + return picker; + }; + + picker.minDate = function (minDate) { + if (arguments.length === 0) { + return options.minDate ? options.minDate.clone() : options.minDate; + } + + if ((typeof minDate === 'boolean') && minDate === false) { + options.minDate = false; + update(); + return picker; + } + + if (typeof minDate === 'string') { + if (minDate === 'now' || minDate === 'moment') { + minDate = getMoment(); + } + } + + var parsedDate = parseInputDate(minDate); + + if (!parsedDate.isValid()) { + throw new TypeError('minDate() Could not parse date parameter: ' + minDate); + } + if (options.maxDate && parsedDate.isAfter(options.maxDate)) { + throw new TypeError('minDate() date parameter is after options.maxDate: ' + parsedDate.format(actualFormat)); + } + options.minDate = parsedDate; + if (options.useCurrent && !options.keepInvalid && date.isBefore(minDate)) { + setValue(options.minDate); + } + if (viewDate.isBefore(parsedDate)) { + viewDate = parsedDate.clone().add(options.stepping, 'm'); + } + update(); + return picker; + }; + + picker.defaultDate = function (defaultDate) { + /// + ///Returns a moment with the options.defaultDate option configuration or false if not set + ///date.clone() + /// + /// + ///Will set the picker's inital date. If a boolean:false value is passed the options.defaultDate parameter is cleared. + ///Takes a string, Date, moment, boolean:false + /// + if (arguments.length === 0) { + return options.defaultDate ? options.defaultDate.clone() : options.defaultDate; + } + if (!defaultDate) { + options.defaultDate = false; + return picker; + } + + if (typeof defaultDate === 'string') { + if (defaultDate === 'now' || defaultDate === 'moment') { + defaultDate = getMoment(); + } + } + + var parsedDate = parseInputDate(defaultDate); + if (!parsedDate.isValid()) { + throw new TypeError('defaultDate() Could not parse date parameter: ' + defaultDate); + } + if (!isValid(parsedDate)) { + throw new TypeError('defaultDate() date passed is invalid according to component setup validations'); + } + + options.defaultDate = parsedDate; + + if ((options.defaultDate && options.inline) || input.val().trim() === '') { + setValue(options.defaultDate); + } + return picker; + }; + + picker.locale = function (locale) { + if (arguments.length === 0) { + return options.locale; + } + + if (!moment.localeData(locale)) { + throw new TypeError('locale() locale ' + locale + ' is not loaded from moment locales!'); + } + + options.locale = locale; + date.locale(options.locale); + viewDate.locale(options.locale); + + if (actualFormat) { + initFormatting(); // reinit formatting + } + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.stepping = function (stepping) { + if (arguments.length === 0) { + return options.stepping; + } + + stepping = parseInt(stepping, 10); + if (isNaN(stepping) || stepping < 1) { + stepping = 1; + } + options.stepping = stepping; + return picker; + }; + + picker.useCurrent = function (useCurrent) { + var useCurrentOptions = ['year', 'month', 'day', 'hour', 'minute']; + if (arguments.length === 0) { + return options.useCurrent; + } + + if ((typeof useCurrent !== 'boolean') && (typeof useCurrent !== 'string')) { + throw new TypeError('useCurrent() expects a boolean or string parameter'); + } + if (typeof useCurrent === 'string' && useCurrentOptions.indexOf(useCurrent.toLowerCase()) === -1) { + throw new TypeError('useCurrent() expects a string parameter of ' + useCurrentOptions.join(', ')); + } + options.useCurrent = useCurrent; + return picker; + }; + + picker.collapse = function (collapse) { + if (arguments.length === 0) { + return options.collapse; + } + + if (typeof collapse !== 'boolean') { + throw new TypeError('collapse() expects a boolean parameter'); + } + if (options.collapse === collapse) { + return picker; + } + options.collapse = collapse; + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.icons = function (icons) { + if (arguments.length === 0) { + return $.extend({}, options.icons); + } + + if (!(icons instanceof Object)) { + throw new TypeError('icons() expects parameter to be an Object'); + } + $.extend(options.icons, icons); + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.tooltips = function (tooltips) { + if (arguments.length === 0) { + return $.extend({}, options.tooltips); + } + + if (!(tooltips instanceof Object)) { + throw new TypeError('tooltips() expects parameter to be an Object'); + } + $.extend(options.tooltips, tooltips); + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.useStrict = function (useStrict) { + if (arguments.length === 0) { + return options.useStrict; + } + + if (typeof useStrict !== 'boolean') { + throw new TypeError('useStrict() expects a boolean parameter'); + } + options.useStrict = useStrict; + return picker; + }; + + picker.sideBySide = function (sideBySide) { + if (arguments.length === 0) { + return options.sideBySide; + } + + if (typeof sideBySide !== 'boolean') { + throw new TypeError('sideBySide() expects a boolean parameter'); + } + options.sideBySide = sideBySide; + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.viewMode = function (viewMode) { + if (arguments.length === 0) { + return options.viewMode; + } + + if (typeof viewMode !== 'string') { + throw new TypeError('viewMode() expects a string parameter'); + } + + if (viewModes.indexOf(viewMode) === -1) { + throw new TypeError('viewMode() parameter must be one of (' + viewModes.join(', ') + ') value'); + } + + options.viewMode = viewMode; + currentViewMode = Math.max(viewModes.indexOf(viewMode), minViewModeNumber); + + showMode(); + return picker; + }; + + picker.toolbarPlacement = function (toolbarPlacement) { + if (arguments.length === 0) { + return options.toolbarPlacement; + } + + if (typeof toolbarPlacement !== 'string') { + throw new TypeError('toolbarPlacement() expects a string parameter'); + } + if (toolbarPlacements.indexOf(toolbarPlacement) === -1) { + throw new TypeError('toolbarPlacement() parameter must be one of (' + toolbarPlacements.join(', ') + ') value'); + } + options.toolbarPlacement = toolbarPlacement; + + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.widgetPositioning = function (widgetPositioning) { + if (arguments.length === 0) { + return $.extend({}, options.widgetPositioning); + } + + if (({}).toString.call(widgetPositioning) !== '[object Object]') { + throw new TypeError('widgetPositioning() expects an object variable'); + } + if (widgetPositioning.horizontal) { + if (typeof widgetPositioning.horizontal !== 'string') { + throw new TypeError('widgetPositioning() horizontal variable must be a string'); + } + widgetPositioning.horizontal = widgetPositioning.horizontal.toLowerCase(); + if (horizontalModes.indexOf(widgetPositioning.horizontal) === -1) { + throw new TypeError('widgetPositioning() expects horizontal parameter to be one of (' + horizontalModes.join(', ') + ')'); + } + options.widgetPositioning.horizontal = widgetPositioning.horizontal; + } + if (widgetPositioning.vertical) { + if (typeof widgetPositioning.vertical !== 'string') { + throw new TypeError('widgetPositioning() vertical variable must be a string'); + } + widgetPositioning.vertical = widgetPositioning.vertical.toLowerCase(); + if (verticalModes.indexOf(widgetPositioning.vertical) === -1) { + throw new TypeError('widgetPositioning() expects vertical parameter to be one of (' + verticalModes.join(', ') + ')'); + } + options.widgetPositioning.vertical = widgetPositioning.vertical; + } + update(); + return picker; + }; + + picker.calendarWeeks = function (calendarWeeks) { + if (arguments.length === 0) { + return options.calendarWeeks; + } + + if (typeof calendarWeeks !== 'boolean') { + throw new TypeError('calendarWeeks() expects parameter to be a boolean value'); + } + + options.calendarWeeks = calendarWeeks; + update(); + return picker; + }; + + picker.showTodayButton = function (showTodayButton) { + if (arguments.length === 0) { + return options.showTodayButton; + } + + if (typeof showTodayButton !== 'boolean') { + throw new TypeError('showTodayButton() expects a boolean parameter'); + } + + options.showTodayButton = showTodayButton; + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.showClear = function (showClear) { + if (arguments.length === 0) { + return options.showClear; + } + + if (typeof showClear !== 'boolean') { + throw new TypeError('showClear() expects a boolean parameter'); + } + + options.showClear = showClear; + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.widgetParent = function (widgetParent) { + if (arguments.length === 0) { + return options.widgetParent; + } + + if (typeof widgetParent === 'string') { + widgetParent = $(widgetParent); + } + + if (widgetParent !== null && (typeof widgetParent !== 'string' && !(widgetParent instanceof $))) { + throw new TypeError('widgetParent() expects a string or a jQuery object parameter'); + } + + options.widgetParent = widgetParent; + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.keepOpen = function (keepOpen) { + if (arguments.length === 0) { + return options.keepOpen; + } + + if (typeof keepOpen !== 'boolean') { + throw new TypeError('keepOpen() expects a boolean parameter'); + } + + options.keepOpen = keepOpen; + return picker; + }; + + picker.focusOnShow = function (focusOnShow) { + if (arguments.length === 0) { + return options.focusOnShow; + } + + if (typeof focusOnShow !== 'boolean') { + throw new TypeError('focusOnShow() expects a boolean parameter'); + } + + options.focusOnShow = focusOnShow; + return picker; + }; + + picker.inline = function (inline) { + if (arguments.length === 0) { + return options.inline; + } + + if (typeof inline !== 'boolean') { + throw new TypeError('inline() expects a boolean parameter'); + } + + options.inline = inline; + return picker; + }; + + picker.clear = function () { + clear(); + return picker; + }; + + picker.keyBinds = function (keyBinds) { + options.keyBinds = keyBinds; + return picker; + }; + + picker.getMoment = function (d) { + return getMoment(d); + }; + + picker.debug = function (debug) { + if (typeof debug !== 'boolean') { + throw new TypeError('debug() expects a boolean parameter'); + } + + options.debug = debug; + return picker; + }; + + picker.allowInputToggle = function (allowInputToggle) { + if (arguments.length === 0) { + return options.allowInputToggle; + } + + if (typeof allowInputToggle !== 'boolean') { + throw new TypeError('allowInputToggle() expects a boolean parameter'); + } + + options.allowInputToggle = allowInputToggle; + return picker; + }; + + picker.showClose = function (showClose) { + if (arguments.length === 0) { + return options.showClose; + } + + if (typeof showClose !== 'boolean') { + throw new TypeError('showClose() expects a boolean parameter'); + } + + options.showClose = showClose; + return picker; + }; + + picker.keepInvalid = function (keepInvalid) { + if (arguments.length === 0) { + return options.keepInvalid; + } + + if (typeof keepInvalid !== 'boolean') { + throw new TypeError('keepInvalid() expects a boolean parameter'); + } + options.keepInvalid = keepInvalid; + return picker; + }; + + picker.datepickerInput = function (datepickerInput) { + if (arguments.length === 0) { + return options.datepickerInput; + } + + if (typeof datepickerInput !== 'string') { + throw new TypeError('datepickerInput() expects a string parameter'); + } + + options.datepickerInput = datepickerInput; + return picker; + }; + + picker.parseInputDate = function (parseInputDate) { + if (arguments.length === 0) { + return options.parseInputDate; + } + + if (typeof parseInputDate !== 'function') { + throw new TypeError('parseInputDate() sholud be as function'); + } + + options.parseInputDate = parseInputDate; + + return picker; + }; + + picker.disabledTimeIntervals = function (disabledTimeIntervals) { + /// + ///Returns an array with the currently set disabled dates on the component. + ///options.disabledTimeIntervals + /// + /// + ///Setting this takes precedence over options.minDate, options.maxDate configuration. Also calling this function removes the configuration of + ///options.enabledDates if such exist. + ///Takes an [ string or Date or moment ] of values and allows the user to select only from those days. + /// + if (arguments.length === 0) { + return (options.disabledTimeIntervals ? $.extend({}, options.disabledTimeIntervals) : options.disabledTimeIntervals); + } + + if (!disabledTimeIntervals) { + options.disabledTimeIntervals = false; + update(); + return picker; + } + if (!(disabledTimeIntervals instanceof Array)) { + throw new TypeError('disabledTimeIntervals() expects an array parameter'); + } + options.disabledTimeIntervals = disabledTimeIntervals; + update(); + return picker; + }; + + picker.disabledHours = function (hours) { + /// + ///Returns an array with the currently set disabled hours on the component. + ///options.disabledHours + /// + /// + ///Setting this takes precedence over options.minDate, options.maxDate configuration. Also calling this function removes the configuration of + ///options.enabledHours if such exist. + ///Takes an [ int ] of values and disallows the user to select only from those hours. + /// + if (arguments.length === 0) { + return (options.disabledHours ? $.extend({}, options.disabledHours) : options.disabledHours); + } + + if (!hours) { + options.disabledHours = false; + update(); + return picker; + } + if (!(hours instanceof Array)) { + throw new TypeError('disabledHours() expects an array parameter'); + } + options.disabledHours = indexGivenHours(hours); + options.enabledHours = false; + if (options.useCurrent && !options.keepInvalid) { + var tries = 0; + while (!isValid(date, 'h')) { + date.add(1, 'h'); + if (tries === 24) { + throw 'Tried 24 times to find a valid date'; + } + tries++; + } + setValue(date); + } + update(); + return picker; + }; + + picker.enabledHours = function (hours) { + /// + ///Returns an array with the currently set enabled hours on the component. + ///options.enabledHours + /// + /// + ///Setting this takes precedence over options.minDate, options.maxDate configuration. Also calling this function removes the configuration of options.disabledHours if such exist. + ///Takes an [ int ] of values and allows the user to select only from those hours. + /// + if (arguments.length === 0) { + return (options.enabledHours ? $.extend({}, options.enabledHours) : options.enabledHours); + } + + if (!hours) { + options.enabledHours = false; + update(); + return picker; + } + if (!(hours instanceof Array)) { + throw new TypeError('enabledHours() expects an array parameter'); + } + options.enabledHours = indexGivenHours(hours); + options.disabledHours = false; + if (options.useCurrent && !options.keepInvalid) { + var tries = 0; + while (!isValid(date, 'h')) { + date.add(1, 'h'); + if (tries === 24) { + throw 'Tried 24 times to find a valid date'; + } + tries++; + } + setValue(date); + } + update(); + return picker; + }; + + picker.viewDate = function (newDate) { + /// + ///Returns the component's model current viewDate, a moment object or null if not set. + ///viewDate.clone() + /// + /// + ///Sets the components model current moment to it. Passing a null value unsets the components model current moment. Parsing of the newDate parameter is made using moment library with the options.format and options.useStrict components configuration. + ///Takes string, viewDate, moment, null parameter. + /// + if (arguments.length === 0) { + return viewDate.clone(); + } + + if (!newDate) { + viewDate = date.clone(); + return picker; + } + + if (typeof newDate !== 'string' && !moment.isMoment(newDate) && !(newDate instanceof Date)) { + throw new TypeError('viewDate() parameter must be one of [string, moment or Date]'); + } + + viewDate = parseInputDate(newDate); + viewUpdate(); + return picker; + }; + + // initializing element and component attributes + if (element.is('input')) { + input = element; + } else { + input = element.find(options.datepickerInput); + if (input.size() === 0) { + input = element.find('input'); + } else if (!input.is('input')) { + throw new Error('CSS class "' + options.datepickerInput + '" cannot be applied to non input element'); + } + } + + if (element.hasClass('input-group')) { + // in case there is more then one 'input-group-addon' Issue #48 + if (element.find('.datepickerbutton').size() === 0) { + component = element.find('.input-group-addon'); + } else { + component = element.find('.datepickerbutton'); + } + } + + if (!options.inline && !input.is('input')) { + throw new Error('Could not initialize DateTimePicker without an input element'); + } + + // Set defaults for date here now instead of in var declaration + date = getMoment(); + viewDate = date.clone(); + + $.extend(true, options, dataToOptions()); + + picker.options(options); + + initFormatting(); + + attachDatePickerElementEvents(); + + if (input.prop('disabled')) { + picker.disable(); + } + if (input.is('input') && input.val().trim().length !== 0) { + setValue(parseInputDate(input.val().trim())); + } + else if (options.defaultDate && input.attr('placeholder') === undefined) { + setValue(options.defaultDate); + } + if (options.inline) { + show(); + } + return picker; + }; + + /******************************************************************************** + * + * jQuery plugin constructor and defaults object + * + ********************************************************************************/ + + $.fn.datetimepicker = function (options) { + return this.each(function () { + var $this = $(this); + if (!$this.data('DateTimePicker')) { + // create a private copy of the defaults object + options = $.extend(true, {}, $.fn.datetimepicker.defaults, options); + $this.data('DateTimePicker', dateTimePicker($this, options)); + } + }); + }; + + $.fn.datetimepicker.defaults = { + timeZone: 'Etc/UTC', + format: false, + dayViewHeaderFormat: 'MMMM YYYY', + extraFormats: false, + stepping: 1, + minDate: false, + maxDate: false, + useCurrent: true, + collapse: true, + locale: moment.locale(), + defaultDate: false, + disabledDates: false, + enabledDates: false, + icons: { + time: 'glyphicon glyphicon-time', + date: 'glyphicon glyphicon-calendar', + up: 'glyphicon glyphicon-chevron-up', + down: 'glyphicon glyphicon-chevron-down', + previous: 'glyphicon glyphicon-chevron-left', + next: 'glyphicon glyphicon-chevron-right', + today: 'glyphicon glyphicon-screenshot', + clear: 'glyphicon glyphicon-trash', + close: 'glyphicon glyphicon-remove' + }, + tooltips: { + today: 'Go to today', + clear: 'Clear selection', + close: 'Close the picker', + selectMonth: 'Select Month', + prevMonth: 'Previous Month', + nextMonth: 'Next Month', + selectYear: 'Select Year', + prevYear: 'Previous Year', + nextYear: 'Next Year', + selectDecade: 'Select Decade', + prevDecade: 'Previous Decade', + nextDecade: 'Next Decade', + prevCentury: 'Previous Century', + nextCentury: 'Next Century', + pickHour: 'Pick Hour', + incrementHour: 'Increment Hour', + decrementHour: 'Decrement Hour', + pickMinute: 'Pick Minute', + incrementMinute: 'Increment Minute', + decrementMinute: 'Decrement Minute', + pickSecond: 'Pick Second', + incrementSecond: 'Increment Second', + decrementSecond: 'Decrement Second', + togglePeriod: 'Toggle Period', + selectTime: 'Select Time' + }, + useStrict: false, + sideBySide: false, + daysOfWeekDisabled: false, + calendarWeeks: false, + viewMode: 'days', + toolbarPlacement: 'default', + showTodayButton: false, + showClear: false, + showClose: false, + widgetPositioning: { + horizontal: 'auto', + vertical: 'auto' + }, + widgetParent: null, + ignoreReadonly: false, + keepOpen: false, + focusOnShow: true, + inline: false, + keepInvalid: false, + datepickerInput: '.datepickerinput', + keyBinds: { + up: function (widget) { + if (!widget) { + return; + } + var d = this.date() || this.getMoment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().subtract(7, 'd')); + } else { + this.date(d.clone().add(this.stepping(), 'm')); + } + }, + down: function (widget) { + if (!widget) { + this.show(); + return; + } + var d = this.date() || this.getMoment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().add(7, 'd')); + } else { + this.date(d.clone().subtract(this.stepping(), 'm')); + } + }, + 'control up': function (widget) { + if (!widget) { + return; + } + var d = this.date() || this.getMoment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().subtract(1, 'y')); + } else { + this.date(d.clone().add(1, 'h')); + } + }, + 'control down': function (widget) { + if (!widget) { + return; + } + var d = this.date() || this.getMoment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().add(1, 'y')); + } else { + this.date(d.clone().subtract(1, 'h')); + } + }, + left: function (widget) { + if (!widget) { + return; + } + var d = this.date() || this.getMoment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().subtract(1, 'd')); + } + }, + right: function (widget) { + if (!widget) { + return; + } + var d = this.date() || this.getMoment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().add(1, 'd')); + } + }, + pageUp: function (widget) { + if (!widget) { + return; + } + var d = this.date() || this.getMoment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().subtract(1, 'M')); + } + }, + pageDown: function (widget) { + if (!widget) { + return; + } + var d = this.date() || this.getMoment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().add(1, 'M')); + } + }, + enter: function () { + this.hide(); + }, + escape: function () { + this.hide(); + }, + //tab: function (widget) { //this break the flow of the form. disabling for now + // var toggle = widget.find('.picker-switch a[data-action="togglePicker"]'); + // if(toggle.length > 0) toggle.click(); + //}, + 'control space': function (widget) { + if (widget.find('.timepicker').is(':visible')) { + widget.find('.btn[data-action="togglePeriod"]').click(); + } + }, + t: function () { + this.date(this.getMoment()); + }, + 'delete': function () { + this.clear(); + } + }, + debug: false, + allowInputToggle: false, + disabledTimeIntervals: false, + disabledHours: false, + enabledHours: false, + viewDate: false + }; +})); diff --git a/PlexRequests.UI/Content/datepicker.css b/PlexRequests.UI/Content/datepicker.css new file mode 100644 index 000000000..3b1489d18 --- /dev/null +++ b/PlexRequests.UI/Content/datepicker.css @@ -0,0 +1,202 @@ +/*! + * Datetimepicker for Bootstrap 3 + * ! version : 4.7.14 + * https://github.com/Eonasdan/bootstrap-datetimepicker/ + */ +.sr-only, .bootstrap-datetimepicker-widget .btn[data-action="incrementHours"]::after, .bootstrap-datetimepicker-widget .btn[data-action="incrementMinutes"]::after, .bootstrap-datetimepicker-widget .btn[data-action="decrementHours"]::after, .bootstrap-datetimepicker-widget .btn[data-action="decrementMinutes"]::after, .bootstrap-datetimepicker-widget .btn[data-action="showHours"]::after, .bootstrap-datetimepicker-widget .btn[data-action="showMinutes"]::after, .bootstrap-datetimepicker-widget .btn[data-action="togglePeriod"]::after, .bootstrap-datetimepicker-widget .btn[data-action="clear"]::after, .bootstrap-datetimepicker-widget .btn[data-action="today"]::after, .bootstrap-datetimepicker-widget .picker-switch::after, .bootstrap-datetimepicker-widget table th.prev::after, .bootstrap-datetimepicker-widget table th.next::after { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; } + +.bootstrap-datetimepicker-widget { + list-style: none; } + .bootstrap-datetimepicker-widget.dropdown-menu { + margin: 2px 0; + padding: 4px; + width: 19em; } + @media (min-width: 768px) { + .bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs { + width: 38em; } } + @media (min-width: 768px) { + .bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs { + width: 38em; } } + @media (min-width: 768px) { + .bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs { + width: 38em; } } + .bootstrap-datetimepicker-widget.dropdown-menu:before, .bootstrap-datetimepicker-widget.dropdown-menu:after { + content: ''; + display: inline-block; + position: absolute; } + .bootstrap-datetimepicker-widget.dropdown-menu.bottom:before { + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-bottom: 7px solid #ccc; + border-bottom-color: rgba(0, 0, 0, 0.2); + top: -7px; + left: 7px; } + .bootstrap-datetimepicker-widget.dropdown-menu.bottom:after { + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid white; + top: -6px; + left: 8px; } + .bootstrap-datetimepicker-widget.dropdown-menu.top:before { + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-top: 7px solid #ccc; + border-top-color: rgba(0, 0, 0, 0.2); + bottom: -7px; + left: 6px; } + .bootstrap-datetimepicker-widget.dropdown-menu.top:after { + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid white; + bottom: -6px; + left: 7px; } + .bootstrap-datetimepicker-widget.dropdown-menu.pull-right:before { + left: auto; + right: 6px; } + .bootstrap-datetimepicker-widget.dropdown-menu.pull-right:after { + left: auto; + right: 7px; } + .bootstrap-datetimepicker-widget .list-unstyled { + margin: 0; } + .bootstrap-datetimepicker-widget a[data-action] { + padding: 6px 0; } + .bootstrap-datetimepicker-widget a[data-action]:active { + box-shadow: none; } + .bootstrap-datetimepicker-widget .timepicker-hour, .bootstrap-datetimepicker-widget .timepicker-minute, .bootstrap-datetimepicker-widget .timepicker-second { + width: 54px; + font-weight: bold; + font-size: 1.2em; + margin: 0; } + .bootstrap-datetimepicker-widget button[data-action] { + padding: 6px; } + .bootstrap-datetimepicker-widget .btn[data-action="incrementHours"]::after { + content: "Increment Hours"; } + .bootstrap-datetimepicker-widget .btn[data-action="incrementMinutes"]::after { + content: "Increment Minutes"; } + .bootstrap-datetimepicker-widget .btn[data-action="decrementHours"]::after { + content: "Decrement Hours"; } + .bootstrap-datetimepicker-widget .btn[data-action="decrementMinutes"]::after { + content: "Decrement Minutes"; } + .bootstrap-datetimepicker-widget .btn[data-action="showHours"]::after { + content: "Show Hours"; } + .bootstrap-datetimepicker-widget .btn[data-action="showMinutes"]::after { + content: "Show Minutes"; } + .bootstrap-datetimepicker-widget .btn[data-action="togglePeriod"]::after { + content: "Toggle AM/PM"; } + .bootstrap-datetimepicker-widget .btn[data-action="clear"]::after { + content: "Clear the picker"; } + .bootstrap-datetimepicker-widget .btn[data-action="today"]::after { + content: "Set the date to today"; } + .bootstrap-datetimepicker-widget .picker-switch { + text-align: center; } + .bootstrap-datetimepicker-widget .picker-switch::after { + content: "Toggle Date and Time Screens"; } + .bootstrap-datetimepicker-widget .picker-switch td { + padding: 0; + margin: 0; + height: auto; + width: auto; + line-height: inherit; } + .bootstrap-datetimepicker-widget .picker-switch td span { + line-height: 2.5; + height: 2.5em; + width: 100%; } + .bootstrap-datetimepicker-widget table { + width: 100%; + margin: 0; } + .bootstrap-datetimepicker-widget table td, + .bootstrap-datetimepicker-widget table th { + text-align: center; + border-radius: #333333; } + .bootstrap-datetimepicker-widget table th { + height: 20px; + line-height: 20px; + width: 20px; } + .bootstrap-datetimepicker-widget table th.picker-switch { + width: 145px; } + .bootstrap-datetimepicker-widget table th.disabled, .bootstrap-datetimepicker-widget table th.disabled:hover { + background: none; + color: gray; + cursor: not-allowed; } + .bootstrap-datetimepicker-widget table th.prev::after { + content: "Previous Month"; } + .bootstrap-datetimepicker-widget table th.next::after { + content: "Next Month"; } + .bootstrap-datetimepicker-widget table thead tr:first-child th { + cursor: pointer; } + .bootstrap-datetimepicker-widget table thead tr:first-child th:hover { + background: gray; } + .bootstrap-datetimepicker-widget table td { + height: 54px; + line-height: 54px; + width: 54px; } + .bootstrap-datetimepicker-widget table td.cw { + font-size: .8em; + height: 20px; + line-height: 20px; + color: gray; } + .bootstrap-datetimepicker-widget table td.day { + height: 20px; + line-height: 20px; + width: 20px; } + .bootstrap-datetimepicker-widget table td.day:hover, .bootstrap-datetimepicker-widget table td.hour:hover, .bootstrap-datetimepicker-widget table td.minute:hover, .bootstrap-datetimepicker-widget table td.second:hover { + background: gray; + cursor: pointer; } + .bootstrap-datetimepicker-widget table td.old, .bootstrap-datetimepicker-widget table td.new { + color: gray; } + .bootstrap-datetimepicker-widget table td.today { + position: relative; } + .bootstrap-datetimepicker-widget table td.today:before { + content: ''; + display: inline-block; + border: 0 0 7px 7px solid transparent; + border-bottom-color: #df691a; + border-top-color: rgba(0, 0, 0, 0.2); + position: absolute; + bottom: 4px; + right: 4px; } + .bootstrap-datetimepicker-widget table td.active, .bootstrap-datetimepicker-widget table td.active:hover { + background-color: #df691a; + color: #ff761b; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); } + .bootstrap-datetimepicker-widget table td.active.today:before { + border-bottom-color: #fff; } + .bootstrap-datetimepicker-widget table td.disabled, .bootstrap-datetimepicker-widget table td.disabled:hover { + background: none; + color: gray; + cursor: not-allowed; } + .bootstrap-datetimepicker-widget table td span { + display: inline-block; + width: 54px; + height: 54px; + line-height: 54px; + margin: 2px 1.5px; + cursor: pointer; + border-radius: #333333; } + .bootstrap-datetimepicker-widget table td span:hover { + background: gray; } + .bootstrap-datetimepicker-widget table td span.active { + background-color: #df691a; + color: #ff761b; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); } + .bootstrap-datetimepicker-widget table td span.old { + color: gray; } + .bootstrap-datetimepicker-widget table td span.disabled, .bootstrap-datetimepicker-widget table td span.disabled:hover { + background: none; + color: gray; + cursor: not-allowed; } + .bootstrap-datetimepicker-widget.usetwentyfour td.hour { + height: 27px; + line-height: 27px; } + +.input-group.date .input-group-addon { + cursor: pointer; } + diff --git a/PlexRequests.UI/Content/datepicker.min.css b/PlexRequests.UI/Content/datepicker.min.css new file mode 100644 index 000000000..d6662dbfe --- /dev/null +++ b/PlexRequests.UI/Content/datepicker.min.css @@ -0,0 +1,6 @@ +/*! + * Datetimepicker for Bootstrap 3 + * ! version : 4.7.14 + * https://github.com/Eonasdan/bootstrap-datetimepicker/ + */ +.sr-only,.bootstrap-datetimepicker-widget .btn[data-action="incrementHours"]::after,.bootstrap-datetimepicker-widget .btn[data-action="incrementMinutes"]::after,.bootstrap-datetimepicker-widget .btn[data-action="decrementHours"]::after,.bootstrap-datetimepicker-widget .btn[data-action="decrementMinutes"]::after,.bootstrap-datetimepicker-widget .btn[data-action="showHours"]::after,.bootstrap-datetimepicker-widget .btn[data-action="showMinutes"]::after,.bootstrap-datetimepicker-widget .btn[data-action="togglePeriod"]::after,.bootstrap-datetimepicker-widget .btn[data-action="clear"]::after,.bootstrap-datetimepicker-widget .btn[data-action="today"]::after,.bootstrap-datetimepicker-widget .picker-switch::after,.bootstrap-datetimepicker-widget table th.prev::after,.bootstrap-datetimepicker-widget table th.next::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);border:0;}.bootstrap-datetimepicker-widget{list-style:none;}.bootstrap-datetimepicker-widget.dropdown-menu{margin:2px 0;padding:4px;width:19em;}@media(min-width:768px){.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs{width:38em;}}@media(min-width:768px){.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs{width:38em;}}@media(min-width:768px){.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs{width:38em;}}.bootstrap-datetimepicker-widget.dropdown-menu:before,.bootstrap-datetimepicker-widget.dropdown-menu:after{content:'';display:inline-block;position:absolute;}.bootstrap-datetimepicker-widget.dropdown-menu.bottom:before{border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:7px solid #ccc;border-bottom-color:rgba(0,0,0,.2);top:-7px;left:7px;}.bootstrap-datetimepicker-widget.dropdown-menu.bottom:after{border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid #fff;top:-6px;left:8px;}.bootstrap-datetimepicker-widget.dropdown-menu.top:before{border-left:7px solid transparent;border-right:7px solid transparent;border-top:7px solid #ccc;border-top-color:rgba(0,0,0,.2);bottom:-7px;left:6px;}.bootstrap-datetimepicker-widget.dropdown-menu.top:after{border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid #fff;bottom:-6px;left:7px;}.bootstrap-datetimepicker-widget.dropdown-menu.pull-right:before{left:auto;right:6px;}.bootstrap-datetimepicker-widget.dropdown-menu.pull-right:after{left:auto;right:7px;}.bootstrap-datetimepicker-widget .list-unstyled{margin:0;}.bootstrap-datetimepicker-widget a[data-action]{padding:6px 0;}.bootstrap-datetimepicker-widget a[data-action]:active{box-shadow:none;}.bootstrap-datetimepicker-widget .timepicker-hour,.bootstrap-datetimepicker-widget .timepicker-minute,.bootstrap-datetimepicker-widget .timepicker-second{width:54px;font-weight:bold;font-size:1.2em;margin:0;}.bootstrap-datetimepicker-widget button[data-action]{padding:6px;}.bootstrap-datetimepicker-widget .btn[data-action="incrementHours"]::after{content:"Increment Hours";}.bootstrap-datetimepicker-widget .btn[data-action="incrementMinutes"]::after{content:"Increment Minutes";}.bootstrap-datetimepicker-widget .btn[data-action="decrementHours"]::after{content:"Decrement Hours";}.bootstrap-datetimepicker-widget .btn[data-action="decrementMinutes"]::after{content:"Decrement Minutes";}.bootstrap-datetimepicker-widget .btn[data-action="showHours"]::after{content:"Show Hours";}.bootstrap-datetimepicker-widget .btn[data-action="showMinutes"]::after{content:"Show Minutes";}.bootstrap-datetimepicker-widget .btn[data-action="togglePeriod"]::after{content:"Toggle AM/PM";}.bootstrap-datetimepicker-widget .btn[data-action="clear"]::after{content:"Clear the picker";}.bootstrap-datetimepicker-widget .btn[data-action="today"]::after{content:"Set the date to today";}.bootstrap-datetimepicker-widget .picker-switch{text-align:center;}.bootstrap-datetimepicker-widget .picker-switch::after{content:"Toggle Date and Time Screens";}.bootstrap-datetimepicker-widget .picker-switch td{padding:0;margin:0;height:auto;width:auto;line-height:inherit;}.bootstrap-datetimepicker-widget .picker-switch td span{line-height:2.5;height:2.5em;width:100%;}.bootstrap-datetimepicker-widget table{width:100%;margin:0;}.bootstrap-datetimepicker-widget table td,.bootstrap-datetimepicker-widget table th{text-align:center;border-radius:#333;}.bootstrap-datetimepicker-widget table th{height:20px;line-height:20px;width:20px;}.bootstrap-datetimepicker-widget table th.picker-switch{width:145px;}.bootstrap-datetimepicker-widget table th.disabled,.bootstrap-datetimepicker-widget table th.disabled:hover{background:none;color:#808080;cursor:not-allowed;}.bootstrap-datetimepicker-widget table th.prev::after{content:"Previous Month";}.bootstrap-datetimepicker-widget table th.next::after{content:"Next Month";}.bootstrap-datetimepicker-widget table thead tr:first-child th{cursor:pointer;}.bootstrap-datetimepicker-widget table thead tr:first-child th:hover{background:#808080;}.bootstrap-datetimepicker-widget table td{height:54px;line-height:54px;width:54px;}.bootstrap-datetimepicker-widget table td.cw{font-size:.8em;height:20px;line-height:20px;color:#808080;}.bootstrap-datetimepicker-widget table td.day{height:20px;line-height:20px;width:20px;}.bootstrap-datetimepicker-widget table td.day:hover,.bootstrap-datetimepicker-widget table td.hour:hover,.bootstrap-datetimepicker-widget table td.minute:hover,.bootstrap-datetimepicker-widget table td.second:hover{background:#808080;cursor:pointer;}.bootstrap-datetimepicker-widget table td.old,.bootstrap-datetimepicker-widget table td.new{color:#808080;}.bootstrap-datetimepicker-widget table td.today{position:relative;}.bootstrap-datetimepicker-widget table td.today:before{content:'';display:inline-block;border:0 0 7px 7px solid transparent;border-bottom-color:#df691a;border-top-color:rgba(0,0,0,.2);position:absolute;bottom:4px;right:4px;}.bootstrap-datetimepicker-widget table td.active,.bootstrap-datetimepicker-widget table td.active:hover{background-color:#df691a;color:#ff761b;text-shadow:0 -1px 0 rgba(0,0,0,.25);}.bootstrap-datetimepicker-widget table td.active.today:before{border-bottom-color:#fff;}.bootstrap-datetimepicker-widget table td.disabled,.bootstrap-datetimepicker-widget table td.disabled:hover{background:none;color:#808080;cursor:not-allowed;}.bootstrap-datetimepicker-widget table td span{display:inline-block;width:54px;height:54px;line-height:54px;margin:2px 1.5px;cursor:pointer;border-radius:#333;}.bootstrap-datetimepicker-widget table td span:hover{background:#808080;}.bootstrap-datetimepicker-widget table td span.active{background-color:#df691a;color:#ff761b;text-shadow:0 -1px 0 rgba(0,0,0,.25);}.bootstrap-datetimepicker-widget table td span.old{color:#808080;}.bootstrap-datetimepicker-widget table td span.disabled,.bootstrap-datetimepicker-widget table td span.disabled:hover{background:none;color:#808080;cursor:not-allowed;}.bootstrap-datetimepicker-widget.usetwentyfour td.hour{height:27px;line-height:27px;}.input-group.date .input-group-addon{cursor:pointer;} \ No newline at end of file diff --git a/PlexRequests.UI/Content/datepicker.scss b/PlexRequests.UI/Content/datepicker.scss new file mode 100644 index 000000000..1ddbae1d9 --- /dev/null +++ b/PlexRequests.UI/Content/datepicker.scss @@ -0,0 +1,353 @@ +/*! + * Datetimepicker for Bootstrap 3 + * ! version : 4.7.14 + * https://github.com/Eonasdan/bootstrap-datetimepicker/ + */ +$bs-datetimepicker-timepicker-font-size: 1.2em !default; +$bs-datetimepicker-active-bg: #df691a !default; +$bs-datetimepicker-active-color: #ff761b !default; +$bs-datetimepicker-border-radius: #333333 !default; +$bs-datetimepicker-btn-hover-bg: gray !default; +$bs-datetimepicker-disabled-color: gray !default; +$bs-datetimepicker-alternate-color: gray !default; +$bs-datetimepicker-secondary-border-color: #ccc !default; +$bs-datetimepicker-secondary-border-color-rgba: rgba(0, 0, 0, 0.2) !default; +$bs-datetimepicker-primary-border-color: white !default; +$bs-datetimepicker-text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25) !default; +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + border: 0; +} + +.bootstrap-datetimepicker-widget { + list-style: none; + + &.dropdown-menu { + margin: 2px 0; + padding: 4px; + width: 19em; + + &.timepicker-sbs { + @media (min-width: 768px) { + width: 38em; + } + + @media (min-width: 768px) { + width: 38em; + } + + @media (min-width: 768px) { + width: 38em; + } + } + + &:before, &:after { + content: ''; + display: inline-block; + position: absolute; + } + + &.bottom { + &:before { + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-bottom: 7px solid $bs-datetimepicker-secondary-border-color; + border-bottom-color: $bs-datetimepicker-secondary-border-color-rgba; + top: -7px; + left: 7px; + } + + &:after { + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid $bs-datetimepicker-primary-border-color; + top: -6px; + left: 8px; + } + } + + &.top { + &:before { + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-top: 7px solid $bs-datetimepicker-secondary-border-color; + border-top-color: $bs-datetimepicker-secondary-border-color-rgba; + bottom: -7px; + left: 6px; + } + + &:after { + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid $bs-datetimepicker-primary-border-color; + bottom: -6px; + left: 7px; + } + } + + &.pull-right { + &:before { + left: auto; + right: 6px; + } + + &:after { + left: auto; + right: 7px; + } + } + } + + .list-unstyled { + margin: 0; + } + + a[data-action] { + padding: 6px 0; + } + + a[data-action]:active { + box-shadow: none; + } + + .timepicker-hour, .timepicker-minute, .timepicker-second { + width: 54px; + font-weight: bold; + font-size: $bs-datetimepicker-timepicker-font-size; + margin: 0; + } + + button[data-action] { + padding: 6px; + } + + .btn[data-action="incrementHours"]::after { + @extend .sr-only; + content: "Increment Hours"; + } + + .btn[data-action="incrementMinutes"]::after { + @extend .sr-only; + content: "Increment Minutes"; + } + + .btn[data-action="decrementHours"]::after { + @extend .sr-only; + content: "Decrement Hours"; + } + + .btn[data-action="decrementMinutes"]::after { + @extend .sr-only; + content: "Decrement Minutes"; + } + + .btn[data-action="showHours"]::after { + @extend .sr-only; + content: "Show Hours"; + } + + .btn[data-action="showMinutes"]::after { + @extend .sr-only; + content: "Show Minutes"; + } + + .btn[data-action="togglePeriod"]::after { + @extend .sr-only; + content: "Toggle AM/PM"; + } + + .btn[data-action="clear"]::after { + @extend .sr-only; + content: "Clear the picker"; + } + + .btn[data-action="today"]::after { + @extend .sr-only; + content: "Set the date to today"; + } + + .picker-switch { + text-align: center; + + &::after { + @extend .sr-only; + content: "Toggle Date and Time Screens"; + } + + td { + padding: 0; + margin: 0; + height: auto; + width: auto; + line-height: inherit; + + span { + line-height: 2.5; + height: 2.5em; + width: 100%; + } + } + } + + table { + width: 100%; + margin: 0; + + + & td, + & th { + text-align: center; + border-radius: $bs-datetimepicker-border-radius; + } + + & th { + height: 20px; + line-height: 20px; + width: 20px; + + &.picker-switch { + width: 145px; + } + + &.disabled, + &.disabled:hover { + background: none; + color: $bs-datetimepicker-disabled-color; + cursor: not-allowed; + } + + &.prev::after { + @extend .sr-only; + content: "Previous Month"; + } + + &.next::after { + @extend .sr-only; + content: "Next Month"; + } + } + + & thead tr:first-child th { + cursor: pointer; + + &:hover { + background: $bs-datetimepicker-btn-hover-bg; + } + } + + & td { + height: 54px; + line-height: 54px; + width: 54px; + + &.cw { + font-size: .8em; + height: 20px; + line-height: 20px; + color: $bs-datetimepicker-alternate-color; + } + + &.day { + height: 20px; + line-height: 20px; + width: 20px; + } + + &.day:hover, + &.hour:hover, + &.minute:hover, + &.second:hover { + background: $bs-datetimepicker-btn-hover-bg; + cursor: pointer; + } + + &.old, + &.new { + color: $bs-datetimepicker-alternate-color; + } + + &.today { + position: relative; + + &:before { + content: ''; + display: inline-block; + border: 0 0 7px 7px solid transparent; + border-bottom-color: $bs-datetimepicker-active-bg; + border-top-color: $bs-datetimepicker-secondary-border-color-rgba; + position: absolute; + bottom: 4px; + right: 4px; + } + } + + &.active, + &.active:hover { + background-color: $bs-datetimepicker-active-bg; + color: $bs-datetimepicker-active-color; + text-shadow: $bs-datetimepicker-text-shadow; + } + + &.active.today:before { + border-bottom-color: #fff; + } + + &.disabled, + &.disabled:hover { + background: none; + color: $bs-datetimepicker-disabled-color; + cursor: not-allowed; + } + + span { + display: inline-block; + width: 54px; + height: 54px; + line-height: 54px; + margin: 2px 1.5px; + cursor: pointer; + border-radius: $bs-datetimepicker-border-radius; + + &:hover { + background: $bs-datetimepicker-btn-hover-bg; + } + + &.active { + background-color: $bs-datetimepicker-active-bg; + color: $bs-datetimepicker-active-color; + text-shadow: $bs-datetimepicker-text-shadow; + } + + &.old { + color: $bs-datetimepicker-alternate-color; + } + + &.disabled, + &.disabled:hover { + background: none; + color: $bs-datetimepicker-disabled-color; + cursor: not-allowed; + } + } + } + } + + &.usetwentyfour { + td.hour { + height: 27px; + line-height: 27px; + } + } +} + +.input-group.date { + & .input-group-addon { + cursor: pointer; + } +} \ No newline at end of file diff --git a/PlexRequests.UI/Content/images/logo.png b/PlexRequests.UI/Content/images/logo.png new file mode 100644 index 000000000..68df0f5cd Binary files /dev/null and b/PlexRequests.UI/Content/images/logo.png differ diff --git a/PlexRequests.UI/Content/issue-details.js b/PlexRequests.UI/Content/issue-details.js new file mode 100644 index 000000000..28b10c997 --- /dev/null +++ b/PlexRequests.UI/Content/issue-details.js @@ -0,0 +1,46 @@ +var base = $('#baseUrl').text(); + + +// Note Modal click +$(".theNoteSaveButton").click(function (e) { + var comment = $("#noteArea").val(); + e.preventDefault(); + + var $form = $("#noteForm"); + var data = $form.serialize(); + + + $.ajax({ + type: $form.prop("method"), + url: $form.prop("action"), + data: data, + dataType: "json", + success: function (response) { + if (checkJsonResponse(response)) { + location.reload(); + } + }, + error: function (e) { + console.log(e); + generateNotify("Something went wrong!", "danger"); + } + }); +}); +// Update the note modal +$('#noteModal').on('show.bs.modal', function (event) { + var button = $(event.relatedTarget); // Button that triggered the modal + var id = button.data('identifier'); // Extract info from data-* attributes + var issue = button.data('issue'); + var modal = $(this); + modal.find('.theNoteSaveButton').val(id); // Add ID to the button + var requestField = modal.find('.noteId'); + requestField.val(id); // Add ID to the hidden field + var noteType = modal.find('.issue'); + noteType.val(issue); + + }); + + + + + diff --git a/PlexRequests.UI/Content/issues.js b/PlexRequests.UI/Content/issues.js new file mode 100644 index 000000000..f97ea0ba0 --- /dev/null +++ b/PlexRequests.UI/Content/issues.js @@ -0,0 +1,272 @@ +Handlebars.registerHelper('if_eq', function (a, b, opts) { + if (a == b) + return !opts ? null : opts.fn(this); + else + return !opts ? null : opts.inverse(this); +}); + +var issueSource = $("#issue-template").html(); +var issueTemplate = Handlebars.compile(issueSource); + +var base = $('#baseUrl').text(); + + + +initLoad(); + +$('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { + var target = $(e.target).attr('href'); + + if (target === "#resolvedTab") { + loadResolvedIssues(); + } +}); + + +// Report Issue +$(document).on("click", ".dropdownIssue", function (e) { + var issue = $(this).attr("issue-select"); + var id = e.target.id; + // Other issue so the modal is opening + if (issue == 4) { + return; + } + e.preventDefault(); + + var $form = $('#report' + id); + var data = $form.serialize(); + data = data + "&issue=" + issue; + + $.ajax({ + type: $form.prop('method'), + url: $form.prop('action'), + data: data, + dataType: "json", + success: function (response) { + if (checkJsonResponse(response)) { + generateNotify("Success! Added Issue.", "success"); + } + }, + error: function (e) { + console.log(e); + generateNotify("Something went wrong!", "danger"); + } + }); +}); + +// Modal click +$(".theSaveButton").click(function (e) { + var comment = $("#commentArea").val(); + e.preventDefault(); + + var $form = $("#commentForm"); + var data = $form.serialize(); + data = data + "&issue=" + 4 + "&comment=" + comment; + + $.ajax({ + type: $form.prop("method"), + url: $form.prop("action"), + data: data, + dataType: "json", + success: function (response) { + if (checkJsonResponse(response)) { + generateNotify("Success! Added Issue.", "success"); + $("#myModal").modal("hide"); + } + }, + error: function (e) { + console.log(e); + generateNotify("Something went wrong!", "danger"); + } + }); +}); + +// Note Modal click +$(".theNoteSaveButton").click(function (e) { + var comment = $("#noteArea").val(); + e.preventDefault(); + + var $form = $("#noteForm"); + var data = $form.serialize(); + + + $.ajax({ + type: $form.prop("method"), + url: $form.prop("action"), + data: data, + dataType: "json", + success: function (response) { + if (checkJsonResponse(response)) { + generateNotify("Success! Added Note.", "success"); + $("#myModal").modal("hide"); + $('#adminNotesArea' + e.target.value).html("
Note from Admin: " + comment + "
"); + } + }, + error: function (e) { + console.log(e); + generateNotify("Something went wrong!", "danger"); + } + }); +}); + +// Update the modal +$('#myModal').on('show.bs.modal', function (event) { + var button = $(event.relatedTarget); // Button that triggered the modal + var id = button.data('identifier'); // Extract info from data-* attributes + + + var modal = $(this); + modal.find('.theSaveButton').val(id); // Add ID to the button + var requestField = modal.find('input'); + requestField.val(id); // Add ID to the hidden field + +}); + +// Update the note modal +$('#noteModal').on('show.bs.modal', function (event) { + var button = $(event.relatedTarget); // Button that triggered the modal + var id = button.data('identifier'); // Extract info from data-* attributes + + var modal = $(this); + modal.find('.theNoteSaveButton').val(id); // Add ID to the button + var requestField = modal.find('.noteId'); + requestField.val(id); // Add ID to the hidden field +}); + +// Delete +$(document).on("click", ".delete", function (e) { + e.preventDefault(); + var buttonId = e.target.id; + var $form = $('#delete' + buttonId); + + $.ajax({ + type: $form.prop('method'), + url: $form.prop('action'), + data: $form.serialize(), + dataType: "json", + success: function (response) { + + if (checkJsonResponse(response)) { + generateNotify("Success! Request Deleted.", "success"); + + $("#" + buttonId + "Template").slideUp(); + } + }, + error: function (e) { + console.log(e); + generateNotify("Something went wrong!", "danger"); + } + }); + +}); + +// Clear issues +$(document).on("click", ".clear", function (e) { + e.preventDefault(); + var buttonId = e.target.id; + var $form = $('#clear' + buttonId); + + $.ajax({ + type: $form.prop('method'), + url: $form.prop('action'), + data: $form.serialize(), + dataType: "json", + success: function (response) { + + if (checkJsonResponse(response)) { + generateNotify("Success! Issues Cleared.", "info"); + $('#issueArea' + buttonId).html("
Issue: None
"); + } + }, + error: function (e) { + console.log(e); + generateNotify("Something went wrong!", "danger"); + } + }); + +}); + + +function initLoad() { + loadCounts(); + loadPendingIssues(); +} + +function loadCounts() { + var url = createBaseUrl(base, "/issues/tabCount"); + $.ajax({ + type: "get", + url: url, + dataType: "json", + success: function (response) { + if (response.length > 0) { + response.forEach(function (result) { + if (result.count > 0) { + + if (result.name == 0) { + $('#pendingCount').addClass("badge"); + $('#pendingCount').html(result.count); + } else if (result.name == 1) { + $('#inProgressCount').addClass("badge"); + $('#inProgressCount').html(result.count); + } else if (result.name == 2) { + $('#resolvedCount').addClass("badge"); + $('#resolvedCount').html(result.count); + } + + } + }); + }; + } + }); +} + +function loadPendingIssues() { + loadIssues("pending", $('#pendingIssues')); +} + +function loadResolvedIssues() { + var $element = $('#resolvedIssues'); + $element.html(""); + loadIssues("resolved", $element); +} + +function loadIssues(type, element) { + var url = createBaseUrl(base, "/issues/" + type); + var linkUrl = createBaseUrl(base, "/issues/"); + $.ajax({ + type: "get", + url: url, + dataType: "json", + success: function (response) { + if (response.length > 0) { + response.forEach(function (result) { + var context = buildIssueContext(result); + var html = issueTemplate(context); + element.append(html); + + $("#" + result.id + "link").attr("href", linkUrl + result.id); + }); + }; + }, + error: function (e) { + console.log(e); + generateNotify("Could not load Pending issues", "danger"); + } + }); +} + + +// Builds the issue context. +function buildIssueContext(result) { + var context = { + id: result.id, + requestId: result.requestId, + type: result.type, + title: result.title, + issues: result.issues + }; + + return context; +} + diff --git a/PlexRequests.UI/Content/requests-1.7.js b/PlexRequests.UI/Content/requests-1.7.js index 37538e97c..041b67bcc 100644 --- a/PlexRequests.UI/Content/requests-1.7.js +++ b/PlexRequests.UI/Content/requests-1.7.js @@ -190,6 +190,7 @@ $('#deleteMovies').click(function (e) { } }); }); + $('#deleteTVShows').click(function (e) { e.preventDefault(); if (!confirm("Are you sure you want to delete all TV show requests?")) return; @@ -223,6 +224,39 @@ $('#deleteTVShows').click(function (e) { }); }); +$('#deleteMusic').click(function (e) { + e.preventDefault(); + if (!confirm("Are you sure you want to delete all album requests?")) return; + + var buttonId = e.target.id; + var origHtml = $(this).html(); + + if ($('#' + buttonId).text() === " Loading...") { + return; + } + + loadingButton(buttonId, "warning"); + var url = createBaseUrl(base, '/approval/deletealltvshows'); + $.ajax({ + type: 'post', + url: url, + dataType: "json", + success: function (response) { + if (checkJsonResponse(response)) { + generateNotify("Success! All TV Show requests deleted!", "success"); + tvLoad(); + } + }, + error: function (e) { + console.log(e); + generateNotify("Something went wrong!", "danger"); + }, + complete: function (e) { + finishLoading(buttonId, "success", origHtml); + } + }); +}); + // filtering/sorting $('.filter,.sort', '.dropdown-menu').click(function (e) { var $this = $(this); @@ -411,31 +445,6 @@ $(document).on("click", ".approve-with-quality", function (e) { }); -// Clear issues -$(document).on("click", ".clear", function (e) { - e.preventDefault(); - var buttonId = e.target.id; - var $form = $('#clear' + buttonId); - - $.ajax({ - type: $form.prop('method'), - url: $form.prop('action'), - data: $form.serialize(), - dataType: "json", - success: function (response) { - - if (checkJsonResponse(response)) { - generateNotify("Success! Issues Cleared.", "info"); - $('#issueArea' + buttonId).html("
Issue: None
"); - } - }, - error: function (e) { - console.log(e); - generateNotify("Something went wrong!", "danger"); - } - }); - -}); // Change Availability $(document).on("click", ".change", function (e) { @@ -620,10 +629,8 @@ function buildRequestContext(result, type) { released: result.released, available: result.available, admin: result.admin, - issues: result.issues, - otherMessage: result.otherMessage, + issueId: result.issueId, requestId: result.id, - adminNote: result.adminNotes, imdb: result.imdbId, seriesRequested: result.tvSeriesRequestType, coverArtUrl: result.coverArtUrl, diff --git a/PlexRequests.UI/Content/search-1.7.js b/PlexRequests.UI/Content/search-1.7.js index 96e6a3bd0..2baa49750 100644 --- a/PlexRequests.UI/Content/search-1.7.js +++ b/PlexRequests.UI/Content/search-1.7.js @@ -184,6 +184,78 @@ $(function () { }); }); + // Report Issue + $(document).on("click", ".dropdownIssue", function (e) { + var issue = $(this).attr("issue-select"); + var id = e.target.id; + // Other issue so the modal is opening + if (issue == 4) { + return; + } + e.preventDefault(); + + var $form = $('#report' + id); + var data = $form.serialize(); + data = data + "&issue=" + issue; + + $.ajax({ + type: $form.prop('method'), + url: $form.prop('action'), + data: data, + dataType: "json", + success: function (response) { + if (checkJsonResponse(response)) { + generateNotify("Successfully Reported Issue.", "success"); + } + }, + error: function (e) { + console.log(e); + generateNotify("Something went wrong!", "danger"); + } + }); + }); + + // Save Modal click + $(".theSaveButton").click(function (e) { + var comment = $("#commentArea").val(); + e.preventDefault(); + + var $form = $("#commentForm"); + var data = $form.serialize(); + data = data + "&comment=" + comment; + + $.ajax({ + type: $form.prop("method"), + url: $form.prop("action"), + data: data, + dataType: "json", + success: function (response) { + if (checkJsonResponse(response)) { + generateNotify("Success! Added Issue.", "success"); + $("#myModal").modal("hide"); + } + }, + error: function (e) { + console.log(e); + generateNotify("Something went wrong!", "danger"); + } + }); + }); + + // Update the modal + $('#issuesModal').on('show.bs.modal', function (event) { + var button = $(event.relatedTarget); // Button that triggered the modal + var id = button.data('identifier'); // Extract info from data-* attributes + var type = button.data('type'); // Extract info from data-* attributes + + var modal = $(this); + modal.find('.theSaveButton').val(id); // Add ID to the button + + + $('#providerIdModal').val(id); + $('#typeModal').val(type); + }); + function focusSearch($content) { if ($content.length > 0) { $('input[type=text].form-control', $content).first().focus(); diff --git a/PlexRequests.UI/Content/site-1.7.js b/PlexRequests.UI/Content/site-1.7.js index 4887b10b0..13b81ddde 100644 --- a/PlexRequests.UI/Content/site-1.7.js +++ b/PlexRequests.UI/Content/site-1.7.js @@ -14,6 +14,10 @@ function Humanize(date) { return moment.duration(mNow - mDate).humanize() + (mNow.isBefore(mDate) ? ' from now' : ' ago'); } +function utcToLocal(date) { + return moment(date).local(); +} + function generateNotify(message, type) { // type = danger, warning, info, successs $.notify({ @@ -45,7 +49,7 @@ function loadingButton(elementId, originalCss) { $element.removeClass("btn-" + originalCss + "-outline").addClass("btn-primary-outline").addClass('disabled').html(" Loading..."); // handle split-buttons - var $dropdown = $element.next('.dropdown-toggle') + var $dropdown = $element.next('.dropdown-toggle'); if ($dropdown.length > 0) { $dropdown.removeClass("btn-" + originalCss + "-outline").addClass("btn-primary-outline").addClass('disabled'); } @@ -56,7 +60,7 @@ function finishLoading(elementId, originalCss, html) { $element.removeClass("btn-primary-outline").removeClass('disabled').addClass("btn-" + originalCss + "-outline").html(html); // handle split-buttons - var $dropdown = $element.next('.dropdown-toggle') + var $dropdown = $element.next('.dropdown-toggle'); if ($dropdown.length > 0) { $dropdown.removeClass("btn-primary-outline").removeClass('disabled').addClass("btn-" + originalCss + "-outline"); } diff --git a/PlexRequests.UI/Helpers/BaseUrlHelper.cs b/PlexRequests.UI/Helpers/BaseUrlHelper.cs index f186054b9..ae84fb180 100644 --- a/PlexRequests.UI/Helpers/BaseUrlHelper.cs +++ b/PlexRequests.UI/Helpers/BaseUrlHelper.cs @@ -59,13 +59,14 @@ namespace PlexRequests.UI.Helpers } if (settings.ThemeName == "PlexBootstrap.css") settings.ThemeName = Themes.PlexTheme; if (settings.ThemeName == "OriginalBootstrap.css") settings.ThemeName = Themes.OriginalTheme; - + sb.AppendLine($""); sb.AppendLine($""); sb.AppendLine($""); sb.AppendLine($""); sb.AppendLine($""); sb.AppendLine($""); + sb.AppendLine($""); sb.AppendLine($""); sb.AppendLine($""); @@ -75,6 +76,7 @@ namespace PlexRequests.UI.Helpers sb.AppendLine($""); sb.AppendLine($""); sb.AppendLine($""); + sb.AppendLine($""); return helper.Raw(sb.ToString()); @@ -102,6 +104,28 @@ namespace PlexRequests.UI.Helpers sb.AppendLine($""); return helper.Raw(sb.ToString()); + } + + public static IHtmlString LoadIssueAssets(this HtmlHelpers helper) + { + var sb = new StringBuilder(); + var assetLocation = GetBaseUrl(); + + var content = GetContentUrl(assetLocation); + + sb.AppendLine($""); + + return helper.Raw(sb.ToString()); + } + + public static IHtmlString LoadIssueDetailsAssets(this HtmlHelpers helper) + { + var assetLocation = GetBaseUrl(); + var content = GetContentUrl(assetLocation); + + var asset = $""; + + return helper.Raw(asset); } public static IHtmlString LoadTableAssets(this HtmlHelpers helper) @@ -117,6 +141,22 @@ namespace PlexRequests.UI.Helpers return helper.Raw(sb.ToString()); } + public static IHtmlString LoadAnalytics(this HtmlHelpers helper) + { + var settings = GetSettings(); + if (!settings.CollectAnalyticData) + { + return helper.Raw(string.Empty); + } + + var assetLocation = GetBaseUrl(); + var content = GetContentUrl(assetLocation); + + var asset = $""; + + return helper.Raw(asset); + } + public static IHtmlString GetSidebarUrl(this HtmlHelpers helper, NancyContext context, string url, string title) { var returnString = string.Empty; @@ -154,6 +194,27 @@ namespace PlexRequests.UI.Helpers returnString = $"
  • {title}
  • "; } + return helper.Raw(returnString); + } + + public static IHtmlString GetNavbarUrl(this HtmlHelpers helper, NancyContext context, string url, string title, string fontIcon, string extraHtml) + { + var returnString = string.Empty; + var content = GetLinkUrl(GetBaseUrl()); + if (!string.IsNullOrEmpty(content)) + { + url = $"/{content}{url}"; + } + + if (context.Request.Path == url) + { + returnString = $"
  • {title} {extraHtml}
  • "; + } + else + { + returnString = $"
  • {title} {extraHtml}
  • "; + } + return helper.Raw(returnString); } diff --git a/PlexRequests.UI/Helpers/EmptyViewBase.cs b/PlexRequests.UI/Helpers/EmptyViewBase.cs new file mode 100644 index 000000000..ac8dccdc6 --- /dev/null +++ b/PlexRequests.UI/Helpers/EmptyViewBase.cs @@ -0,0 +1,44 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: EmptyViewBase.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 Nancy.ViewEngines.Razor; + +namespace PlexRequests.UI.Helpers +{ + public class EmptyViewBase : NancyRazorViewBase + { + + public EmptyViewBase() + { + Layout = "Shared/Blank.cshtml"; + } + + public override void Execute() + { + + } + } +} \ No newline at end of file diff --git a/PlexRequests.UI/Helpers/StringHelper.cs b/PlexRequests.UI/Helpers/StringHelper.cs index e1368bb64..421ddb9ae 100644 --- a/PlexRequests.UI/Helpers/StringHelper.cs +++ b/PlexRequests.UI/Helpers/StringHelper.cs @@ -1,4 +1,29 @@ -using System; +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: StringHelper.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.Linq; using System.Text.RegularExpressions; @@ -8,15 +33,18 @@ namespace PlexRequests.UI.Helpers { public static string FirstCharToUpper(this string input) { - if (String.IsNullOrEmpty(input)) + if (string.IsNullOrEmpty(input)) return input; - return input.First().ToString().ToUpper() + String.Join("", input.Skip(1)); + var firstUpper = char.ToUpper(input[0]); + return firstUpper + string.Join("", input.Skip(1)); } - public static string CamelCaseToWords(this string input) + public static string ToCamelCaseWords(this string input) { + if (string.IsNullOrEmpty(input)) + return input; return Regex.Replace(input.FirstCharToUpper(), "([a-z](?=[A-Z])|[A-Z](?=[A-Z][a-z]))", "$1 "); } } -} +} \ No newline at end of file diff --git a/PlexRequests.UI/Helpers/TvSender.cs b/PlexRequests.UI/Helpers/TvSender.cs index a00851ec4..5074cb9fc 100644 --- a/PlexRequests.UI/Helpers/TvSender.cs +++ b/PlexRequests.UI/Helpers/TvSender.cs @@ -53,13 +53,17 @@ namespace PlexRequests.UI.Helpers public SonarrAddSeries SendToSonarr(SonarrSettings sonarrSettings, RequestedModel model, string qualityId) { - int qualityProfile; - - if (!string.IsNullOrEmpty(qualityId) || !int.TryParse(qualityId, out qualityProfile)) // try to parse the passed in quality, otherwise use the settings default quality + + var qualityProfile = 0; + + if (!string.IsNullOrEmpty(qualityId)) // try to parse the passed in quality, otherwise use the settings default quality { - int.TryParse(sonarrSettings.QualityProfile, out qualityProfile); + if (!int.TryParse(qualityId, out qualityProfile)) + { + int.TryParse(sonarrSettings.QualityProfile, out qualityProfile); + } } - + var result = SonarrApi.AddSeries(model.ProviderId, model.Title, qualityProfile, sonarrSettings.SeasonFolders, sonarrSettings.RootPath, model.SeasonCount, model.SeasonList, sonarrSettings.ApiKey, sonarrSettings.FullUri); @@ -83,16 +87,11 @@ namespace PlexRequests.UI.Helpers qualityId = sickRageSettings.QualityProfile; } - Log.Trace("Calling `AddSeries` with the following settings:"); - Log.Trace(sickRageSettings.DumpJson()); - Log.Trace("And the following `model`:"); - Log.Trace(model.DumpJson()); var apiResult = SickrageApi.AddSeries(model.ProviderId, model.SeasonCount, model.SeasonList, qualityId, - sickRageSettings.ApiKey, sickRageSettings.FullUri); + sickRageSettings.ApiKey, sickRageSettings.FullUri); var result = apiResult.Result; - Log.Trace("SickRage Add Result: "); - Log.Trace(result.DumpJson()); + return result; } diff --git a/PlexRequests.UI/Jobs/Scheduler.cs b/PlexRequests.UI/Jobs/Scheduler.cs index 63ffe8b64..dc17a07d5 100644 --- a/PlexRequests.UI/Jobs/Scheduler.cs +++ b/PlexRequests.UI/Jobs/Scheduler.cs @@ -30,16 +30,20 @@ using System.Linq; using NLog; +using PlexRequests.Core; +using PlexRequests.Core.SettingModels; using PlexRequests.Services.Jobs; +using PlexRequests.UI.Helpers; using Quartz; using Quartz.Impl; namespace PlexRequests.UI.Jobs { - internal sealed class Scheduler + internal sealed class Scheduler : IJobScheduler { private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + private IServiceLocator Service => ServiceLocator.Instance; private readonly ISchedulerFactory _factory; @@ -99,48 +103,51 @@ namespace PlexRequests.UI.Jobs private IEnumerable CreateTriggers() { + var settingsService = Service.Resolve>(); + var s = settingsService.GetSettings(); + var triggers = new List(); var plexAvailabilityChecker = TriggerBuilder.Create() .WithIdentity("PlexAvailabilityChecker", "Plex") .StartNow() - .WithSimpleSchedule(x => x.WithIntervalInMinutes(10).RepeatForever()) + .WithSimpleSchedule(x => x.WithIntervalInMinutes(s.PlexAvailabilityChecker).RepeatForever()) .Build(); var srCacher = TriggerBuilder.Create() .WithIdentity("SickRageCacher", "Cache") .StartNow() - .WithSimpleSchedule(x => x.WithIntervalInMinutes(10).RepeatForever()) + .WithSimpleSchedule(x => x.WithIntervalInMinutes(s.SickRageCacher).RepeatForever()) .Build(); var sonarrCacher = TriggerBuilder.Create() .WithIdentity("SonarrCacher", "Cache") .StartNow() - .WithSimpleSchedule(x => x.WithIntervalInMinutes(10).RepeatForever()) + .WithSimpleSchedule(x => x.WithIntervalInMinutes(s.SonarrCacher).RepeatForever()) .Build(); var cpCacher = TriggerBuilder.Create() .WithIdentity("CouchPotatoCacher", "Cache") .StartNow() - .WithSimpleSchedule(x => x.WithIntervalInMinutes(10).RepeatForever()) + .WithSimpleSchedule(x => x.WithIntervalInMinutes(s.CouchPotatoCacher).RepeatForever()) .Build(); var storeBackup = TriggerBuilder.Create() .WithIdentity("StoreBackup", "Database") .StartNow() - .WithSimpleSchedule(x => x.WithIntervalInHours(24).RepeatForever()) + .WithSimpleSchedule(x => x.WithIntervalInHours(s.StoreBackup).RepeatForever()) .Build(); var storeCleanup = TriggerBuilder.Create() .WithIdentity("StoreCleanup", "Database") .StartNow() - .WithSimpleSchedule(x => x.WithIntervalInHours(24).RepeatForever()) + .WithSimpleSchedule(x => x.WithIntervalInHours(s.StoreCleanup).RepeatForever()) .Build(); @@ -154,4 +161,9 @@ namespace PlexRequests.UI.Jobs return triggers; } } + + public interface IJobScheduler + { + void StartScheduler(); + } } \ No newline at end of file diff --git a/PlexRequests.UI/Models/IssuesDetailsViewModel.cs b/PlexRequests.UI/Models/IssuesDetailsViewModel.cs new file mode 100644 index 000000000..b2126fd9d --- /dev/null +++ b/PlexRequests.UI/Models/IssuesDetailsViewModel.cs @@ -0,0 +1,51 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: IssuesDetailsViewModel.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 PlexRequests.Core.Models; +using PlexRequests.Store; + +namespace PlexRequests.UI.Models +{ + public class IssuesDetailsViewModel + { + public IssuesDetailsViewModel() + { + Issues = new List(); + } + + public int Id { get; set; } + public string Title { get; set; } + public string PosterUrl { get; set; } + public int RequestId { get; set; } + public List Issues { get; set; } + public bool Deleted { get; set; } + public RequestType Type { get; set; } + public IssueStatus IssueStatus { get; set; } + public int ProviderId { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.UI/Models/IssuesViewMOdel.cs b/PlexRequests.UI/Models/IssuesViewMOdel.cs new file mode 100644 index 000000000..d05b2a8e8 --- /dev/null +++ b/PlexRequests.UI/Models/IssuesViewMOdel.cs @@ -0,0 +1,41 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: IssuesViewModel.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 PlexRequests.Store; + +namespace PlexRequests.UI.Models +{ + public class IssuesViewModel + { + public int Id { get; set; } + public int RequestId { get; set; } + public string Title { get; set; } + public string Issues { get; set; } + public string Type { get; set; } + + } +} \ No newline at end of file diff --git a/PlexRequests.UI/Models/LandingPageViewModel.cs b/PlexRequests.UI/Models/LandingPageViewModel.cs new file mode 100644 index 000000000..300f49762 --- /dev/null +++ b/PlexRequests.UI/Models/LandingPageViewModel.cs @@ -0,0 +1,36 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: LandingPageViewModel.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 PlexRequests.Core.SettingModels; + +namespace PlexRequests.UI.Models +{ + public class LandingPageViewModel : LandingPageSettings + { + public string ContinueUrl { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.UI/Models/RequestViewModel.cs b/PlexRequests.UI/Models/RequestViewModel.cs index 136c511aa..0b434d3c5 100644 --- a/PlexRequests.UI/Models/RequestViewModel.cs +++ b/PlexRequests.UI/Models/RequestViewModel.cs @@ -49,9 +49,7 @@ namespace PlexRequests.UI.Models public string ReleaseYear { get; set; } public bool Available { get; set; } public bool Admin { get; set; } - public string Issues { get; set; } - public string OtherMessage { get; set; } - public string AdminNotes { get; set; } + public int IssueId { get; set; } public string TvSeriesRequestType { get; set; } public string MusicBrainzId { get; set; } public QualityModel[] Qualities { get; set; } diff --git a/PlexRequests.UI/Models/ScheduledJobsViewModel.cs b/PlexRequests.UI/Models/ScheduledJobsViewModel.cs new file mode 100644 index 000000000..da444bd6f --- /dev/null +++ b/PlexRequests.UI/Models/ScheduledJobsViewModel.cs @@ -0,0 +1,38 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: ScheduledJobsViewModel.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.Collections.Generic; + +using PlexRequests.Core.SettingModels; + +namespace PlexRequests.UI.Models +{ + public class ScheduledJobsViewModel : ScheduledJobsSettings + { + public Dictionary JobRecorder { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.UI/Modules/AdminModule.cs b/PlexRequests.UI/Modules/AdminModule.cs index bdf0248a6..daf5f0967 100644 --- a/PlexRequests.UI/Modules/AdminModule.cs +++ b/PlexRequests.UI/Modules/AdminModule.cs @@ -23,41 +23,43 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ -using System.Net; -using PlexRequests.Helpers.Exceptions; + #endregion +using System; +using System.Diagnostics; +using System.Threading.Tasks; using System.Collections.Generic; using System.Dynamic; using System.Linq; -using MarkdownSharp; +using System.Net; using Nancy; using Nancy.Extensions; using Nancy.ModelBinding; using Nancy.Responses.Negotiation; using Nancy.Validation; - +using Nancy.Json; +using Nancy.Security; using NLog; +using MarkdownSharp; + using PlexRequests.Api; using PlexRequests.Api.Interfaces; using PlexRequests.Core; using PlexRequests.Core.SettingModels; using PlexRequests.Helpers; +using PlexRequests.Helpers.Exceptions; using PlexRequests.Services.Interfaces; using PlexRequests.Services.Notification; using PlexRequests.Store.Models; using PlexRequests.Store.Repository; using PlexRequests.UI.Helpers; using PlexRequests.UI.Models; -using System; -using System.Diagnostics; -using Nancy.Json; -using Nancy.Security; namespace PlexRequests.UI.Modules { @@ -83,7 +85,10 @@ namespace PlexRequests.UI.Modules private INotificationService NotificationService { get; } private ICacheProvider Cache { get; } private ISettingsService SlackSettings { get; } + private ISettingsService LandingSettings { get; } + private ISettingsService ScheduledJobSettings { get; } private ISlackApi SlackApi { get; } + private IJobRecord JobRecorder { get; } private static Logger Log = LogManager.GetCurrentClassLogger(); public AdminModule(ISettingsService prService, @@ -105,7 +110,8 @@ namespace PlexRequests.UI.Modules ISettingsService headphones, ISettingsService logs, ICacheProvider cache, ISettingsService slackSettings, - ISlackApi slackApi) : base("admin", prService) + ISlackApi slackApi, ISettingsService lp, + ISettingsService scheduler, IJobRecord rec) : base("admin", prService) { PrService = prService; CpService = cpService; @@ -128,13 +134,16 @@ namespace PlexRequests.UI.Modules Cache = cache; SlackSettings = slackSettings; SlackApi = slackApi; + LandingSettings = lp; + ScheduledJobSettings = scheduler; + JobRecorder = rec; this.RequiresClaims(UserClaims.Admin); - + Get["/"] = _ => Admin(); - Get["/authentication"] = _ => Authentication(); - Post["/authentication"] = _ => SaveAuthentication(); + Get["/authentication", true] = async (x, ct) => await Authentication(); + Post["/authentication", true] = async (x, ct) => await SaveAuthentication(); Post["/"] = _ => SaveAdmin(); @@ -160,7 +169,7 @@ namespace PlexRequests.UI.Modules Get["/emailnotification"] = _ => EmailNotifications(); Post["/emailnotification"] = _ => SaveEmailNotifications(); Post["/testemailnotification"] = _ => TestEmailNotifications(); - Get["/status"] = _ => Status(); + Get["/status", true] = async (x,ct) => await Status(); Get["/pushbulletnotification"] = _ => PushbulletNotifications(); Post["/pushbulletnotification"] = _ => SavePushbulletNotifications(); @@ -178,7 +187,7 @@ namespace PlexRequests.UI.Modules Get["/headphones"] = _ => Headphones(); Post["/headphones"] = _ => SaveHeadphones(); - Post["/createapikey"] = x => CreateApiKey(); + Post["/createapikey"] = x => CreateApiKey(); Post["/autoupdate"] = x => AutoUpdate(); @@ -186,20 +195,26 @@ namespace PlexRequests.UI.Modules Get["/slacknotification"] = _ => SlackNotifications(); Post["/slacknotification"] = _ => SaveSlackNotifications(); + + Get["/landingpage", true] = async (x, ct) => await LandingPage(); + Post["/landingpage", true] = async (x, ct) => await SaveLandingPage(); + + Get["/scheduledjobs", true] = async (x, ct) => await GetScheduledJobs(); + Post["/scheduledjobs", true] = async (x, ct) => await SaveScheduledJobs(); } - private Negotiator Authentication() + private async Task Authentication() { - var settings = AuthService.GetSettings(); + var settings = await AuthService.GetSettingsAsync(); return View["/Authentication", settings]; } - private Response SaveAuthentication() + private async Task SaveAuthentication() { var model = this.Bind(); - var result = AuthService.SaveSettings(model); + var result = await AuthService.SaveSettingsAsync(model); if (result) { if (!string.IsNullOrEmpty(BaseUrl)) @@ -218,8 +233,6 @@ namespace PlexRequests.UI.Modules private Negotiator Admin() { var settings = PrService.GetSettings(); - Log.Trace("Getting Settings:"); - Log.Trace(settings.DumpJson()); return View["Settings", settings]; } @@ -227,23 +240,23 @@ namespace PlexRequests.UI.Modules private Response SaveAdmin() { var model = this.Bind(); - var valid = this.Validate (model); - if (!valid.IsValid) { - return Response.AsJson(valid.SendJsonError()); - } + var valid = this.Validate(model); + if (!valid.IsValid) + { + return Response.AsJson(valid.SendJsonError()); + } - if (!string.IsNullOrWhiteSpace (model.BaseUrl)) { - if (model.BaseUrl.StartsWith ("/") || model.BaseUrl.StartsWith ("\\")) - { - model.BaseUrl = model.BaseUrl.Remove (0, 1); - } - } + if (!string.IsNullOrWhiteSpace(model.BaseUrl)) + { + if (model.BaseUrl.StartsWith("/", StringComparison.CurrentCultureIgnoreCase) || model.BaseUrl.StartsWith("\\", StringComparison.CurrentCultureIgnoreCase)) + { + model.BaseUrl = model.BaseUrl.Remove(0, 1); + } + } var result = PrService.SaveSettings(model); - if (result) { - return Response.AsJson (new JsonResponseModel{ Result = true }); - } - - return Response.AsJson (new JsonResponseModel{ Result = false, Message = "We could not save to the database, please try again" }); + return Response.AsJson(result + ? new JsonResponseModel { Result = true } + : new JsonResponseModel { Result = false, Message = "We could not save to the database, please try again" }); } private Response RequestAuthToken() @@ -291,40 +304,40 @@ namespace PlexRequests.UI.Modules return Response.AsJson(new { Result = true, Users = string.Empty }); } - try { - var users = PlexApi.GetUsers(token); - if (users == null) - { - return Response.AsJson(string.Empty); - } - if (users.User == null || users.User?.Length == 0) - { - return Response.AsJson(string.Empty); - } + try + { + var users = PlexApi.GetUsers(token); + if (users == null) + { + return Response.AsJson(string.Empty); + } + if (users.User == null || users.User?.Length == 0) + { + return Response.AsJson(string.Empty); + } - var usernames = users.User.Select(x => x.Title); - return Response.AsJson(new {Result = true, Users = usernames}); - } catch (Exception ex) { - Log.Error (ex); - if (ex is WebException || ex is ApiRequestException) { - return Response.AsJson (new { Result = false, Message ="Could not load the user list! We have connectivity problems connecting to Plex, Please ensure we can access Plex.Tv, The error has been logged." }); - } + var usernames = users.User.Select(x => x.Title); + return Response.AsJson(new { Result = true, Users = usernames }); + } + catch (Exception ex) + { + Log.Error(ex); + if (ex is WebException || ex is ApiRequestException) + { + return Response.AsJson(new { Result = false, Message = "Could not load the user list! We have connectivity problems connecting to Plex, Please ensure we can access Plex.Tv, The error has been logged." }); + } - return Response.AsJson (new { Result = false, Message = ex.Message}); - } + return Response.AsJson(new { Result = false, Message = ex.Message }); + } } private Negotiator CouchPotato() { - dynamic model = new ExpandoObject(); var settings = CpService.GetSettings(); - model = settings; - return View["CouchPotato", model]; + return View["CouchPotato", settings]; } - - private Response SaveCouchPotato() { var couchPotatoSettings = this.Bind(); @@ -481,7 +494,7 @@ namespace PlexRequests.UI.Modules { return Response.AsJson(valid.SendJsonError()); } - + var result = EmailService.SaveSettings(settings); if (settings.Enabled) @@ -499,11 +512,11 @@ namespace PlexRequests.UI.Modules : new JsonResponseModel { Result = false, Message = "Could not update the settings, take a look at the logs." }); } - private Negotiator Status() + private async Task Status() { var checker = new StatusChecker(); - var status = checker.GetStatus(); - var md = new Markdown(new MarkdownOptions { AutoNewLines = true }); + var status = await Cache.GetOrSetAsync(CacheKeys.LastestProductVersion, async () => await checker.GetStatus(), 30); + var md = new Markdown(new MarkdownOptions { AutoNewLines = true, AutoHyperlink = true}); status.ReleaseNotes = md.Transform(status.ReleaseNotes); return View["Status", status]; } @@ -512,12 +525,12 @@ namespace PlexRequests.UI.Modules { var url = Request.Form["url"]; - var startInfo = Type.GetType("Mono.Runtime") != null - ? new ProcessStartInfo("mono PlexRequests.Updater.exe") { Arguments = url } + var startInfo = Type.GetType("Mono.Runtime") != null + ? new ProcessStartInfo("mono PlexRequests.Updater.exe") { Arguments = url } : new ProcessStartInfo("PlexRequests.Updater.exe") { Arguments = url }; Process.Start(startInfo); - + Environment.Exit(0); return Nancy.Response.NoBody; } @@ -536,7 +549,6 @@ namespace PlexRequests.UI.Modules { return Response.AsJson(valid.SendJsonError()); } - Log.Trace(settings.DumpJson()); var result = PushbulletService.SaveSettings(settings); if (settings.Enabled) @@ -599,7 +611,6 @@ namespace PlexRequests.UI.Modules { return Response.AsJson(valid.SendJsonError()); } - Log.Trace(settings.DumpJson()); var result = PushoverService.SaveSettings(settings); if (settings.Enabled) @@ -723,7 +734,6 @@ namespace PlexRequests.UI.Modules Log.Info("Error validating Headphones settings, message: {0}", error.Message); return Response.AsJson(error); } - Log.Trace(settings.DumpJson()); var result = HeadphonesService.SaveSettings(settings); @@ -733,17 +743,17 @@ namespace PlexRequests.UI.Modules : new JsonResponseModel { Result = false, Message = "Could not update the settings, take a look at the logs." }); } - private Response CreateApiKey() - { - this.RequiresClaims(UserClaims.Admin); - var apiKey = Guid.NewGuid().ToString("N"); + private Response CreateApiKey() + { + this.RequiresClaims(UserClaims.Admin); + var apiKey = Guid.NewGuid().ToString("N"); var settings = PrService.GetSettings(); - settings.ApiKey = apiKey; + settings.ApiKey = apiKey; PrService.SaveSettings(settings); - return Response.AsJson(apiKey); - } + return Response.AsJson(apiKey); + } private Response TestSlackNotification() { @@ -804,5 +814,63 @@ namespace PlexRequests.UI.Modules ? new JsonResponseModel { Result = true, Message = "Successfully Updated the Settings for Slack Notifications!" } : new JsonResponseModel { Result = false, Message = "Could not update the settings, take a look at the logs." }); } + + private async Task LandingPage() + { + var settings = await LandingSettings.GetSettingsAsync(); + + return View["LandingPage", settings]; + } + + private async Task SaveLandingPage() + { + var settings = this.Bind(); + + var plexSettings = await PlexService.GetSettingsAsync(); + if (string.IsNullOrEmpty(plexSettings.Ip)) + { + return Response.AsJson(new JsonResponseModel { Result = false, Message = "We cannot enable the landing page if Plex is not setup!" }); + } + + if (settings.Enabled && settings.EnabledNoticeTime && string.IsNullOrEmpty(settings.NoticeMessage)) + { + return Response.AsJson(new JsonResponseModel { Result = false, Message = "If you are going to enabled the notice, then we need a message!" }); + } + + var result = await LandingSettings.SaveSettingsAsync(settings); + + return Response.AsJson(result + ? new JsonResponseModel { Result = true } + : new JsonResponseModel { Result = false, Message = "Could not save to Db Please check the logs" }); + } + + private async Task GetScheduledJobs() + { + var s = await ScheduledJobSettings.GetSettingsAsync(); + var allJobs = await JobRecorder.GetJobsAsync(); + var jobsDict = allJobs.ToDictionary(k => k.Name, v => v.LastRun); + var model = new ScheduledJobsViewModel + { + CouchPotatoCacher = s.CouchPotatoCacher, + PlexAvailabilityChecker = s.PlexAvailabilityChecker, + SickRageCacher = s.SickRageCacher, + SonarrCacher = s.SonarrCacher, + StoreBackup = s.StoreBackup, + StoreCleanup = s.StoreCleanup, + JobRecorder = jobsDict + }; + return View["SchedulerSettings", model]; + } + + private async Task SaveScheduledJobs() + { + var settings = this.Bind(); + + var result = await ScheduledJobSettings.SaveSettingsAsync(settings); + + return Response.AsJson(result + ? new JsonResponseModel { Result = true } + : new JsonResponseModel { Result = false, Message = "Could not save to Db Please check the logs" }); + } } } \ No newline at end of file diff --git a/PlexRequests.UI/Modules/ApprovalModule.cs b/PlexRequests.UI/Modules/ApprovalModule.cs index 47ba387ef..f9d7018b2 100644 --- a/PlexRequests.UI/Modules/ApprovalModule.cs +++ b/PlexRequests.UI/Modules/ApprovalModule.cs @@ -28,6 +28,8 @@ using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; +using System.Threading.Tasks; + using Nancy; using Nancy.Security; @@ -62,12 +64,13 @@ namespace PlexRequests.UI.Modules HeadphonesSettings = hpSettings; HeadphoneApi = hpApi; - Post["/approve"] = parameters => Approve((int)Request.Form.requestid, (string)Request.Form.qualityId); - Post["/approveall"] = x => ApproveAll(); - Post["/approveallmovies"] = x => ApproveAllMovies(); - Post["/approvealltvshows"] = x => ApproveAllTVShows(); - Post["/deleteallmovies"] = x => DeleteAllMovies(); - Post["/deletealltvshows"] = x => DeleteAllTVShows(); + Post["/approve", true] = async (x, ct) => await Approve((int)Request.Form.requestid, (string)Request.Form.qualityId); + Post["/approveall", true] = async (x, ct) => await ApproveAll(); + Post["/approveallmovies", true] = async (x, ct) => await ApproveAllMovies(); + Post["/approvealltvshows", true] = async (x, ct) => await ApproveAllTVShows(); + Post["/deleteallmovies", true] = async (x, ct) => await DeleteAllMovies(); + Post["/deletealltvshows", true] = async (x, ct) => await DeleteAllTVShows(); + Post["/deleteallalbums", true] = async (x, ct) => await DeleteAllAlbums(); } private IRequestService Service { get; } @@ -87,12 +90,12 @@ namespace PlexRequests.UI.Modules /// /// The request identifier. /// - private Response Approve(int requestId, string qualityId) + private async Task Approve(int requestId, string qualityId) { Log.Info("approving request {0}", requestId); // Get the request from the DB - var request = Service.Get(requestId); + var request = await Service.GetAsync(requestId); if (request == null) { @@ -103,21 +106,21 @@ namespace PlexRequests.UI.Modules switch (request.Type) { case RequestType.Movie: - return RequestMovieAndUpdateStatus(request, qualityId); + return await RequestMovieAndUpdateStatus(request, qualityId); case RequestType.TvShow: - return RequestTvAndUpdateStatus(request, qualityId); + return await RequestTvAndUpdateStatus(request, qualityId); case RequestType.Album: - return RequestAlbumAndUpdateStatus(request); + return await RequestAlbumAndUpdateStatus(request); default: throw new ArgumentOutOfRangeException(nameof(request)); } } - private Response RequestTvAndUpdateStatus(RequestedModel request, string qualityId) + private async Task RequestTvAndUpdateStatus(RequestedModel request, string qualityId) { var sender = new TvSender(SonarrApi, SickRageApi); - var sonarrSettings = SonarrSettings.GetSettings(); + var sonarrSettings = await SonarrSettings.GetSettingsAsync(); if (sonarrSettings.Enabled) { Log.Trace("Sending to Sonarr"); @@ -128,7 +131,7 @@ namespace PlexRequests.UI.Modules { Log.Info("Sent successfully, Approving request now."); request.Approved = true; - var requestResult = Service.UpdateRequest(request); + var requestResult = await Service.UpdateRequestAsync(request); Log.Trace("Approval result: {0}", requestResult); if (requestResult) { @@ -145,7 +148,7 @@ namespace PlexRequests.UI.Modules } - var srSettings = SickRageSettings.GetSettings(); + var srSettings = await SickRageSettings.GetSettingsAsync(); if (srSettings.Enabled) { Log.Trace("Sending to SickRage"); @@ -156,7 +159,7 @@ namespace PlexRequests.UI.Modules { Log.Info("Sent successfully, Approving request now."); request.Approved = true; - var requestResult = Service.UpdateRequest(request); + var requestResult = await Service.UpdateRequestAsync(request); Log.Trace("Approval result: {0}", requestResult); return Response.AsJson(requestResult ? new JsonResponseModel { Result = true } @@ -171,15 +174,15 @@ namespace PlexRequests.UI.Modules request.Approved = true; - var res = Service.UpdateRequest(request); + var res = await Service.UpdateRequestAsync(request); return Response.AsJson(res ? new JsonResponseModel { Result = true, Message = "This has been approved, but It has not been sent to Sonarr/SickRage because it has not been configured" } : new JsonResponseModel { Result = false, Message = "Updated SickRage but could not approve it in PlexRequests :(" }); } - private Response RequestMovieAndUpdateStatus(RequestedModel request, string qualityId) + private async Task RequestMovieAndUpdateStatus(RequestedModel request, string qualityId) { - var cpSettings = CpService.GetSettings(); + var cpSettings = await CpService.GetSettingsAsync(); Log.Info("Adding movie to CouchPotato : {0}", request.Title); if (!cpSettings.Enabled) @@ -189,7 +192,7 @@ namespace PlexRequests.UI.Modules Log.Warn("We approved movie: {0} but could not add it to CouchPotato because it has not been setup", request.Title); // Update the record - var inserted = Service.UpdateRequest(request); + var inserted = await Service.UpdateRequestAsync(request); return Response.AsJson(inserted ? new JsonResponseModel { Result = true, Message = "This has been approved, but It has not been sent to CouchPotato because it has not been configured." } : new JsonResponseModel @@ -207,7 +210,7 @@ namespace PlexRequests.UI.Modules request.Approved = true; // Update the record - var inserted = Service.UpdateRequest(request); + var inserted = await Service.UpdateRequestAsync(request); return Response.AsJson(inserted ? new JsonResponseModel { Result = true } @@ -227,9 +230,9 @@ namespace PlexRequests.UI.Modules }); } - private Response RequestAlbumAndUpdateStatus(RequestedModel request) + private async Task RequestAlbumAndUpdateStatus(RequestedModel request) { - var hpSettings = HeadphonesSettings.GetSettings(); + var hpSettings = await HeadphonesSettings.GetSettingsAsync(); Log.Info("Adding album to Headphones : {0}", request.Title); if (!hpSettings.Enabled) { @@ -238,7 +241,7 @@ namespace PlexRequests.UI.Modules Log.Warn("We approved Album: {0} but could not add it to Headphones because it has not been setup", request.Title); // Update the record - var inserted = Service.UpdateRequest(request); + var inserted = await Service.UpdateRequestAsync(request); return Response.AsJson(inserted ? new JsonResponseModel { Result = true, Message = "This has been approved, but It has not been sent to Headphones because it has not been configured." } : new JsonResponseModel @@ -255,10 +258,11 @@ namespace PlexRequests.UI.Modules return Response.AsJson(new JsonResponseModel { Result = true, Message = "We have sent the approval to Headphones for processing, This can take a few minutes." }); } - private Response ApproveAllMovies() + private async Task ApproveAllMovies() { - var requests = Service.GetAll().Where(x => x.CanApprove && x.Type == RequestType.Movie); + var requests = await Service.GetAllAsync(); + requests = requests.Where(x => x.CanApprove && x.Type == RequestType.Movie); var requestedModels = requests as RequestedModel[] ?? requests.ToArray(); if (!requestedModels.Any()) { @@ -267,7 +271,7 @@ namespace PlexRequests.UI.Modules try { - return UpdateRequests(requestedModels); + return await UpdateRequestsAsync(requestedModels); } catch (Exception e) { @@ -276,10 +280,11 @@ namespace PlexRequests.UI.Modules } } - private Response DeleteAllMovies() + private async Task DeleteAllMovies() { - var requests = Service.GetAll().Where(x => x.Type == RequestType.Movie); + var requests = await Service.GetAllAsync(); + requests = requests.Where(x => x.Type == RequestType.Movie); var requestedModels = requests as RequestedModel[] ?? requests.ToArray(); if (!requestedModels.Any()) { @@ -288,7 +293,7 @@ namespace PlexRequests.UI.Modules try { - return DeleteRequests(requestedModels); + return await DeleteRequestsAsync(requestedModels); } catch (Exception e) { @@ -297,9 +302,32 @@ namespace PlexRequests.UI.Modules } } - private Response ApproveAllTVShows() + private async Task DeleteAllAlbums() { - var requests = Service.GetAll().Where(x => x.CanApprove && x.Type == RequestType.TvShow); + + var requests = await Service.GetAllAsync(); + requests = requests.Where(x => x.Type == RequestType.Album); + var requestedModels = requests as RequestedModel[] ?? requests.ToArray(); + if (!requestedModels.Any()) + { + return Response.AsJson(new JsonResponseModel { Result = false, Message = "There are no album requests to delete. Please refresh." }); + } + + try + { + return await DeleteRequestsAsync(requestedModels); + } + catch (Exception e) + { + Log.Fatal(e); + return Response.AsJson(new JsonResponseModel { Result = false, Message = "Something bad happened, please check the logs!" }); + } + } + + private async Task ApproveAllTVShows() + { + var requests = await Service.GetAllAsync(); + requests = requests.Where(x => x.CanApprove && x.Type == RequestType.TvShow); var requestedModels = requests as RequestedModel[] ?? requests.ToArray(); if (!requestedModels.Any()) { @@ -308,7 +336,7 @@ namespace PlexRequests.UI.Modules try { - return UpdateRequests(requestedModels); + return await UpdateRequestsAsync(requestedModels); } catch (Exception e) { @@ -317,10 +345,11 @@ namespace PlexRequests.UI.Modules } } - private Response DeleteAllTVShows() + private async Task DeleteAllTVShows() { - var requests = Service.GetAll().Where(x => x.Type == RequestType.TvShow); + var requests = await Service.GetAllAsync(); + requests = requests.Where(x => x.Type == RequestType.TvShow); var requestedModels = requests as RequestedModel[] ?? requests.ToArray(); if (!requestedModels.Any()) { @@ -329,7 +358,7 @@ namespace PlexRequests.UI.Modules try { - return DeleteRequests(requestedModels); + return await DeleteRequestsAsync(requestedModels); } catch (Exception e) { @@ -342,9 +371,10 @@ namespace PlexRequests.UI.Modules /// Approves all. /// /// - private Response ApproveAll() + private async Task ApproveAll() { - var requests = Service.GetAll().Where(x => x.CanApprove); + var requests = await Service.GetAllAsync(); + requests = requests.Where(x => x.CanApprove); var requestedModels = requests as RequestedModel[] ?? requests.ToArray(); if (!requestedModels.Any()) { @@ -353,7 +383,7 @@ namespace PlexRequests.UI.Modules try { - return UpdateRequests(requestedModels); + return await UpdateRequestsAsync(requestedModels); } catch (Exception e) { @@ -363,11 +393,11 @@ namespace PlexRequests.UI.Modules } - private Response DeleteRequests(RequestedModel[] requestedModels) + private async Task DeleteRequestsAsync(IEnumerable requestedModels) { try { - var result = Service.BatchDelete(requestedModels.ToList()); + var result = await Service.BatchDeleteAsync(requestedModels); return Response.AsJson(result ? new JsonResponseModel { Result = true } : new JsonResponseModel { Result = false, Message = "We could not delete all of the requests. Please try again or check the logs." }); @@ -379,9 +409,9 @@ namespace PlexRequests.UI.Modules } } - private Response UpdateRequests(RequestedModel[] requestedModels) + private async Task UpdateRequestsAsync(RequestedModel[] requestedModels) { - var cpSettings = CpService.GetSettings(); + var cpSettings = await CpService.GetSettingsAsync(); var updatedRequests = new List(); foreach (var r in requestedModels) { @@ -409,8 +439,8 @@ namespace PlexRequests.UI.Modules if (r.Type == RequestType.TvShow) { var sender = new TvSender(SonarrApi, SickRageApi); - var sr = SickRageSettings.GetSettings(); - var sonarr = SonarrSettings.GetSettings(); + var sr = await SickRageSettings.GetSettingsAsync(); + var sonarr = await SonarrSettings.GetSettingsAsync(); if (sr.Enabled) { var res = sender.SendToSickRage(sr, r); @@ -449,7 +479,7 @@ namespace PlexRequests.UI.Modules } try { - var result = Service.BatchUpdate(updatedRequests); + var result = await Service.BatchUpdateAsync(updatedRequests); return Response.AsJson(result ? new JsonResponseModel { Result = true } : new JsonResponseModel { Result = false, Message = "We could not approve all of the requests. Please try again or check the logs." }); diff --git a/PlexRequests.UI/Modules/BaseAuthModule.cs b/PlexRequests.UI/Modules/BaseAuthModule.cs index 708a4cb49..4fbc0e096 100644 --- a/PlexRequests.UI/Modules/BaseAuthModule.cs +++ b/PlexRequests.UI/Modules/BaseAuthModule.cs @@ -23,85 +23,36 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ -using System.Linq; - #endregion using Nancy; using Nancy.Extensions; using PlexRequests.UI.Models; -using System; - using PlexRequests.Core; using PlexRequests.Core.SettingModels; -using PlexRequests.Helpers; namespace PlexRequests.UI.Modules { public abstract class BaseAuthModule : BaseModule { - private string _username; - private int _dateTimeOffset = -1; - - protected string Username - { - get - { - if (string.IsNullOrEmpty(_username)) - { - _username = Session[SessionKeys.UsernameKey].ToString(); - } - return _username; - } - } - - protected bool IsAdmin - { - get - { - if (Context.CurrentUser == null) - { - return false; - } - var claims = Context.CurrentUser.Claims.ToList(); - if (claims.Contains(UserClaims.Admin) || claims.Contains(UserClaims.PowerUser)) - { - return true; - } - return false; - } - } - - protected int DateTimeOffset - { - get - { - if (_dateTimeOffset == -1) - { - _dateTimeOffset = (int?)Session[SessionKeys.ClientDateTimeOffsetKey] ?? new DateTimeOffset().Offset.Minutes; - } - return _dateTimeOffset; - } - } - protected BaseAuthModule(ISettingsService pr) : base(pr) { - Service = pr; + PlexRequestSettings = pr; Before += (ctx) => CheckAuth(); } protected BaseAuthModule(string modulePath, ISettingsService pr) : base(modulePath, pr) { - Service = pr; + PlexRequestSettings = pr; Before += (ctx) => CheckAuth(); } - private ISettingsService Service { get; } + protected ISettingsService PlexRequestSettings { get; } private Response CheckAuth() { - var settings = Service.GetSettings(); + var settings = PlexRequestSettings.GetSettings(); var baseUrl = settings.BaseUrl; var redirectPath = string.IsNullOrEmpty(baseUrl) ? "~/userlogin" : $"~/{baseUrl}/userlogin"; diff --git a/PlexRequests.UI/Modules/BaseModule.cs b/PlexRequests.UI/Modules/BaseModule.cs index 335fc16f5..2ebce8fa7 100644 --- a/PlexRequests.UI/Modules/BaseModule.cs +++ b/PlexRequests.UI/Modules/BaseModule.cs @@ -24,10 +24,15 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion +using System; +using System.Linq; + using Nancy; using PlexRequests.Core; using PlexRequests.Core.SettingModels; +using PlexRequests.Helpers; +using PlexRequests.UI.Models; namespace PlexRequests.UI.Modules { @@ -56,5 +61,45 @@ namespace PlexRequests.UI.Modules ModulePath = settingModulePath; } + + private int _dateTimeOffset = -1; + protected int DateTimeOffset + { + get + { + if (_dateTimeOffset == -1) + { + _dateTimeOffset = (int?)Session[SessionKeys.ClientDateTimeOffsetKey] ?? new DateTimeOffset().Offset.Minutes; + } + return _dateTimeOffset; + } + } + private string _username; + + protected string Username + { + get + { + if (string.IsNullOrEmpty(_username)) + { + _username = Session[SessionKeys.UsernameKey].ToString(); + } + return _username; + } + } + + protected bool IsAdmin + { + get + { + if (Context.CurrentUser == null) + { + return false; + } + var claims = Context.CurrentUser.Claims.ToList(); + return claims.Contains(UserClaims.Admin) || claims.Contains(UserClaims.PowerUser); + } + } + } } \ No newline at end of file diff --git a/PlexRequests.UI/Modules/IndexModule.cs b/PlexRequests.UI/Modules/IndexModule.cs index 81cdfa7e2..80ed7947b 100644 --- a/PlexRequests.UI/Modules/IndexModule.cs +++ b/PlexRequests.UI/Modules/IndexModule.cs @@ -24,8 +24,8 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion -using Nancy; using Nancy.Extensions; +using Nancy.Responses; using PlexRequests.Core; using PlexRequests.Core.SettingModels; @@ -36,9 +36,14 @@ namespace PlexRequests.UI.Modules { public IndexModule(ISettingsService pr) : base(pr) { - Get["/"] = parameters => Context.GetRedirect(!string.IsNullOrEmpty(BaseUrl) ? $"~/{BaseUrl}/search" : "~/search"); + Get["/"] = x => Index(); - Get["/Index"] = parameters => Context.GetRedirect(!string.IsNullOrEmpty(BaseUrl) ? $"~/{BaseUrl}/search" : "~/search"); + Get["/Index"] = x => Index(); + } + + public RedirectResponse Index() + { + return Context.GetRedirect(!string.IsNullOrEmpty(BaseUrl) ? $"~/{BaseUrl}/search" : "~/search"); } } } \ No newline at end of file diff --git a/PlexRequests.UI/Modules/IssuesModule.cs b/PlexRequests.UI/Modules/IssuesModule.cs new file mode 100644 index 000000000..82cb1d2f0 --- /dev/null +++ b/PlexRequests.UI/Modules/IssuesModule.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Web.UI.WebControls; +using Nancy; +using Nancy.Extensions; +using Nancy.Responses.Negotiation; +using Nancy.Security; + +using NLog; + +using PlexRequests.Api; +using PlexRequests.Core; +using PlexRequests.Core.Models; +using PlexRequests.Core.SettingModels; +using PlexRequests.Helpers; +using PlexRequests.Services.Interfaces; +using PlexRequests.Services.Notification; +using PlexRequests.Store; +using PlexRequests.UI.Helpers; +using PlexRequests.UI.Models; + +namespace PlexRequests.UI.Modules +{ + public class IssuesModule : BaseAuthModule + { + public IssuesModule(ISettingsService pr, IIssueService issueService, IRequestService request, INotificationService n) : base("issues", pr) + { + IssuesService = issueService; + RequestService = request; + NotificationService = n; + + Get["/"] = x => Index(); + + Get["/{id}", true] = async (x, ct) => await Details(x.id); + + Post["/issue", true] = async (x, ct) => await ReportRequestIssue((int)Request.Form.requestId, (IssueState)(int)Request.Form.issue, null); + + Get["/pending", true] = async (x, ct) => await GetIssues(IssueStatus.PendingIssue); + Get["/resolved", true] = async (x, ct) => await GetIssues(IssueStatus.ResolvedIssue); + + Post["/remove", true] = async (x, ct) => await RemoveIssue((int)Request.Form.issueId); + Post["/resolvedUpdate", true] = async (x, ct) => await ChangeStatus((int)Request.Form.issueId, IssueStatus.ResolvedIssue); + + Post["/clear", true] = async (x, ct) => await ClearIssue((int)Request.Form.issueId, (IssueState)(int)Request.Form.issue); + + Get["/issuecount", true] = async (x, ct) => await IssueCount(); + Get["/tabCount", true] = async (x, ct) => await TabCount(); + + Post["/issuecomment", true] = async (x, ct) => await ReportRequestIssue((int)Request.Form.provierId, IssueState.Other, (string)Request.Form.commentArea); + + Post["/nonrequestissue", true] = async (x, ct) => await ReportNonRequestIssue((int)Request.Form.providerId, (string)Request.Form.type, (IssueState)(int)Request.Form.issue, null); + + Post["/nonrequestissuecomment", true] = async (x, ct) => await ReportNonRequestIssue((int)Request.Form.providerId, (string)Request.Form.type, IssueState.Other, (string)Request.Form.commentArea); + + + Post["/addnote", true] = async (x, ct) => await AddNote((int)Request.Form.requestId, (string)Request.Form.noteArea, (IssueState)(int)Request.Form.issue); + } + + private IIssueService IssuesService { get; } + private IRequestService RequestService { get; } + private INotificationService NotificationService { get; } + private static Logger Log = LogManager.GetCurrentClassLogger(); + + public Negotiator Index() + { + return View["Index"]; + } + + private async Task GetIssues(IssueStatus status) + { + var issues = await IssuesService.GetAllAsync(); + issues = await FilterIssuesAsync(issues, status == IssueStatus.ResolvedIssue); + + var issuesModels = issues as IssuesModel[] ?? issues.Where(x => x.IssueStatus == status).ToArray(); + var viewModel = new List(); + + foreach (var i in issuesModels) + { + var model = new IssuesViewModel { Id = i.Id, RequestId = i.RequestId, Title = i.Title, Type = i.Type.ToString().ToCamelCaseWords(), }; + + // Create a string with all of the current issue states with a "," delimiter in e.g. Wrong Content, Playback Issues + var state = i.Issues.Select(x => x.Issue).ToArray(); + var issueState = string.Empty; + for (var j = 0; j < state.Length; j++) + { + var word = state[j].ToString().ToCamelCaseWords(); + if (j != state.Length - 1) + { + issueState += $"{word}, "; + } + else + { + issueState += word; + } + } + model.Issues = issueState; + + viewModel.Add(model); + } + + return Response.AsJson(viewModel); + } + + public async Task IssueCount() + { + var issues = await IssuesService.GetAllAsync(); + + var myIssues = await FilterIssuesAsync(issues); + + var count = myIssues.Count(); + + return Response.AsJson(count); + } + + public async Task TabCount() + { + var issues = await IssuesService.GetAllAsync(); + + var myIssues = await FilterIssuesAsync(issues); + + var count = new List(); + + var issuesModels = myIssues as IssuesModel[] ?? myIssues.ToArray(); + var pending = issuesModels.Where(x => x.IssueStatus == IssueStatus.PendingIssue); + var resolved = issuesModels.Where(x => x.IssueStatus == IssueStatus.ResolvedIssue); + + count.Add(new { Name = IssueStatus.PendingIssue, Count = pending.Count() }); + count.Add(new { Name = IssueStatus.ResolvedIssue, Count = resolved.Count() }); + + return Response.AsJson(count); + } + + public async Task Details(int id) + { + var issue = await IssuesService.GetAsync(id); + if (issue == null) + return Index(); + + issue = Order(issue); + var m = new IssuesDetailsViewModel + { + Issues = issue.Issues, + RequestId = issue.RequestId, + Title = issue.Title, + IssueStatus = issue.IssueStatus, + Deleted = issue.Deleted, + Type = issue.Type, + ProviderId = issue.ProviderId, + PosterUrl = issue.PosterUrl, + Id = issue.Id + }; + return View["Details", m]; + } + + private async Task ReportRequestIssue(int requestId, IssueState issue, string comment) + { + + var model = new IssueModel + { + Issue = issue, + UserReported = Username, + UserNote = !string.IsNullOrEmpty(comment) + ? $"{Username} - {comment}" + : string.Empty, + }; + + var request = await RequestService.GetAsync(requestId); + + var issueEntity = await IssuesService.GetAllAsync(); + var existingIssue = issueEntity.FirstOrDefault(x => x.RequestId == requestId); + + var notifyModel = new NotificationModel + { + User = Username, + NotificationType = NotificationType.Issue, + Title = request.Title, + DateTime = DateTime.Now, + Body = issue == IssueState.Other ? comment : issue.ToString().ToCamelCaseWords() + }; + + // An issue already exists + if (existingIssue != null) + { + if (existingIssue.Issues.Any(x => x.Issue == issue)) + { + return + Response.AsJson(new JsonResponseModel + { + Result = false, + Message = "This issue has already been reported!" + }); + + } + existingIssue.Issues.Add(model); + var result = await IssuesService.UpdateIssueAsync(existingIssue); + + + await NotificationService.Publish(notifyModel); + + return Response.AsJson(result + ? new JsonResponseModel { Result = true } + : new JsonResponseModel { Result = false }); + } + + // New issue + var issues = new IssuesModel + { + Title = request.Title, + PosterUrl = request.PosterPath, + RequestId = requestId, + Type = request.Type, + IssueStatus = IssueStatus.PendingIssue + }; + issues.Issues.Add(model); + + var issueId = await IssuesService.AddIssueAsync(issues); + + request.IssueId = issueId; + await RequestService.UpdateRequestAsync(request); + + await NotificationService.Publish(notifyModel); + return Response.AsJson(new JsonResponseModel { Result = true }); + } + + private async Task ReportNonRequestIssue(int providerId, string type, IssueState issue, string comment) + { + var currentIssues = await IssuesService.GetAllAsync(); + var notifyModel = new NotificationModel + { + User = Username, + NotificationType = NotificationType.Issue, + DateTime = DateTime.Now, + Body = issue == IssueState.Other ? comment : issue.ToString().ToCamelCaseWords() + }; + var model = new IssueModel + { + Issue = issue, + UserReported = Username, + UserNote = !string.IsNullOrEmpty(comment) + ? $"{Username} - {comment}" + : string.Empty, + }; + + var existing = currentIssues.FirstOrDefault(x => x.ProviderId == providerId && !x.Deleted && x.IssueStatus == IssueStatus.PendingIssue); + if (existing != null) + { + existing.Issues.Add(model); + await IssuesService.UpdateIssueAsync(existing); + return Response.AsJson(new JsonResponseModel { Result = true }); + } + + if (type == "movie") + { + var movieApi = new TheMovieDbApi(); + + var result = await movieApi.GetMovieInformation(providerId); + if (result != null) + { + notifyModel.Title = result.Title; + // New issue + var issues = new IssuesModel + { + Title = result.Title, + PosterUrl = "https://image.tmdb.org/t/p/w150/" + result.PosterPath, + ProviderId = providerId, + Type = RequestType.Movie, + IssueStatus = IssueStatus.PendingIssue + }; + issues.Issues.Add(model); + + var issueId = await IssuesService.AddIssueAsync(issues); + + await NotificationService.Publish(notifyModel); + return Response.AsJson(new JsonResponseModel { Result = true }); + } + } + + if (type == "tv") + { + var tv = new TvMazeApi(); + var result = tv.ShowLookupByTheTvDbId(providerId); + if (result != null) + { + var banner = result.image?.medium; + if (!string.IsNullOrEmpty(banner)) + { + banner = banner.Replace("http", "https"); + } + + notifyModel.Title = result.name; + // New issue + var issues = new IssuesModel + { + Title = result.name, + PosterUrl = banner, + ProviderId = providerId, + Type = RequestType.TvShow, + IssueStatus = IssueStatus.PendingIssue + }; + issues.Issues.Add(model); + + var issueId = await IssuesService.AddIssueAsync(issues); + + await NotificationService.Publish(notifyModel); + return Response.AsJson(new JsonResponseModel { Result = true }); + } + } + + + + return Response.AsJson(new JsonResponseModel { Result = false, Message = "Album Reports are not supported yet!"}); + } + + /// + /// Filters the issues. Checks to see if we have set UsersCanViewOnlyOwnIssues in the database and filters upon the user logged in and that setting. + /// + /// The issues. + private async Task> FilterIssuesAsync(IEnumerable issues, bool showResolved = false) + { + var settings = await PlexRequestSettings.GetSettingsAsync(); + IEnumerable myIssues; + + // Is the user an Admin? If so show everything + if (IsAdmin) + { + var issuesModels = issues as IssuesModel[] ?? issues.ToArray(); + myIssues = issuesModels.Where(x => x.Deleted == false); + if (!showResolved) + { + myIssues = issuesModels.Where(x => x.IssueStatus != IssueStatus.ResolvedIssue); + } + } + else if (settings.UsersCanViewOnlyOwnIssues) // The user is not an Admin, do we have the settings to hide them? + { + if (!showResolved) + { + myIssues = + issues.Where( + x => + x.Issues.Any(i => i.UserReported.Equals(Username, StringComparison.CurrentCultureIgnoreCase)) && x.Deleted == false + && x.IssueStatus != IssueStatus.ResolvedIssue); + } + else + { + myIssues = + issues.Where( + x => + x.Issues.Any(i => i.UserReported.Equals(Username, StringComparison.CurrentCultureIgnoreCase)) && x.Deleted == false); + } + } + else // Looks like the user is not an admin and there is no settings set. + { + var issuesModels = issues as IssuesModel[] ?? issues.ToArray(); + myIssues = issuesModels.Where(x => x.Deleted == false); + if (!showResolved) + { + myIssues = issuesModels.Where(x => x.IssueStatus != IssueStatus.ResolvedIssue); + } + } + + return myIssues; + } + private async Task RemoveIssue(int issueId) + { + try + { + this.RequiresClaims(UserClaims.Admin); + var issue = await IssuesService.GetAsync(issueId); + var request = await RequestService.GetAsync(issue.RequestId); + + request.IssueId = 0; // No issue; + + var result = await RequestService.UpdateRequestAsync(request); + if (result) + { + await IssuesService.DeleteIssueAsync(issueId); + } + + return View["Index"]; + + } + catch (Exception e) + { + Log.Error(e); + return View["Index"]; + } + + } + + private async Task ChangeStatus(int issueId, IssueStatus status) + { + try + { + this.RequiresClaims(UserClaims.Admin); + + var issue = await IssuesService.GetAsync(issueId); + issue.IssueStatus = status; + var result = await IssuesService.UpdateIssueAsync(issue); + return result ? await Details(issueId) : View["Index"]; + } + catch (Exception e) + { + Log.Error(e); + return View["Index"]; + } + + } + + + private async Task ClearIssue(int issueId, IssueState state) + { + this.RequiresClaims(UserClaims.Admin); + var issue = await IssuesService.GetAsync(issueId); + + var toRemove = issue.Issues.FirstOrDefault(x => x.Issue == state); + issue.Issues.Remove(toRemove); + + var result = await IssuesService.UpdateIssueAsync(issue); + + return result ? await Details(issueId) : View["Index"]; + } + + private async Task AddNote(int requestId, string noteArea, IssueState state) + { + this.RequiresClaims(UserClaims.Admin); + var issue = await IssuesService.GetAsync(requestId); + if (issue == null) + { + return Response.AsJson(new JsonResponseModel { Result = false, Message = "Issue does not exist to add a note!" }); + } + var toAddNote = issue.Issues.FirstOrDefault(x => x.Issue == state); + + if (toAddNote != null) + { + issue.Issues.Remove(toAddNote); + toAddNote.AdminNote = noteArea; + issue.Issues.Add(toAddNote); + } + + var result = await IssuesService.UpdateIssueAsync(issue); + return Response.AsJson(result + ? new JsonResponseModel { Result = true } + : new JsonResponseModel { Result = false, Message = "Could not update the notes, please try again or check the logs" }); + } + + /// + /// Orders the issues descending by the . + /// + /// The issues. + /// + private IssuesModel Order(IssuesModel issues) + { + issues.Issues = issues.Issues.OrderByDescending(x => x.Issue).ToList(); + return issues; + } + } +} diff --git a/PlexRequests.UI/Modules/LandingPageModule.cs b/PlexRequests.UI/Modules/LandingPageModule.cs new file mode 100644 index 000000000..4564e32ef --- /dev/null +++ b/PlexRequests.UI/Modules/LandingPageModule.cs @@ -0,0 +1,97 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: LandingPageModule.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 Nancy; +using Nancy.Responses.Negotiation; + +using PlexRequests.Api.Interfaces; +using PlexRequests.Core; +using PlexRequests.Core.SettingModels; +using PlexRequests.UI.Models; + +namespace PlexRequests.UI.Modules +{ + public class LandingPageModule : BaseModule + { + public LandingPageModule(ISettingsService settingsService, ISettingsService landing, + ISettingsService ps, IPlexApi pApi, ISettingsService auth) : base("landing", settingsService) + { + LandingSettings = landing; + PlexSettings = ps; + PlexApi = pApi; + AuthSettings = auth; + + Get["/", true] = async (x, ct) => await Index(); + Get["/status", true] = async (x, ct) => await CheckStatus(); + } + + private ISettingsService LandingSettings { get; } + private ISettingsService PlexSettings { get; } + private ISettingsService AuthSettings { get; } + private IPlexApi PlexApi { get; } + + private async Task Index() + { + var s = await LandingSettings.GetSettingsAsync(); + var model = new LandingPageViewModel + { + Enabled = s.Enabled, + Id = s.Id, + EnabledNoticeTime = s.EnabledNoticeTime, + NoticeEnable = s.NoticeEnable, + NoticeEnd = s.NoticeEnd, + NoticeMessage = s.NoticeMessage, + NoticeStart = s.NoticeStart, + ContinueUrl = s.BeforeLogin ? $"userlogin" : $"search" + }; + + return View["Landing/Index", model]; + } + + private async Task CheckStatus() + { + var auth = await AuthSettings.GetSettingsAsync(); + var plexSettings = await PlexSettings.GetSettingsAsync(); + if (string.IsNullOrEmpty(auth.PlexAuthToken) || string.IsNullOrEmpty(plexSettings.Ip)) + { + return Response.AsJson(false); + } + try + { + var status = PlexApi.GetStatus(auth.PlexAuthToken, plexSettings.FullUri); + return Response.AsJson(status != null); + } + catch (Exception) + { + return Response.AsJson(false); + } + + } + } +} \ No newline at end of file diff --git a/PlexRequests.UI/Modules/RequestsModule.cs b/PlexRequests.UI/Modules/RequestsModule.cs index 16bbd1cb2..8d73c0bb0 100644 --- a/PlexRequests.UI/Modules/RequestsModule.cs +++ b/PlexRequests.UI/Modules/RequestsModule.cs @@ -44,6 +44,8 @@ using System.Collections.Generic; using PlexRequests.Api.Interfaces; using System.Threading.Tasks; +using NLog; + namespace PlexRequests.UI.Modules { public class RequestsModule : BaseAuthModule @@ -73,10 +75,10 @@ namespace PlexRequests.UI.Modules CpApi = cpApi; Cache = cache; - Get["/"] = _ => LoadRequests(); + Get["/", true] = async (x, ct) => await LoadRequests(); Get["/movies", true] = async (x, ct) => await GetMovies(); Get["/tvshows", true] = async (c, ct) => await GetTvShows(); - Get["/albums", true] = async (x,ct) => await GetAlbumRequests(); + Get["/albums", true] = async (x, ct) => await GetAlbumRequests(); Post["/delete", true] = async (x, ct) => await DeleteRequest((int)Request.Form.id); Post["/reportissue", true] = async (x, ct) => await ReportIssue((int)Request.Form.requestId, (IssueState)(int)Request.Form.issue, null); Post["/reportissuecomment", true] = async (x, ct) => await ReportIssue((int)Request.Form.requestId, IssueState.Other, (string)Request.Form.commentArea); @@ -84,9 +86,9 @@ namespace PlexRequests.UI.Modules Post["/clearissues", true] = async (x, ct) => await ClearIssue((int)Request.Form.Id); Post["/changeavailability", true] = async (x, ct) => await ChangeRequestAvailability((int)Request.Form.Id, (bool)Request.Form.Available); - Post["/addnote", true] = async (x, ct) => await AddNote((int)Request.Form.requestId, (string)Request.Form.noteArea); } + private static Logger Log = LogManager.GetCurrentClassLogger(); private IRequestService Service { get; } private INotificationService NotificationService { get; } private ISettingsService PrSettings { get; } @@ -99,9 +101,9 @@ namespace PlexRequests.UI.Modules private ICouchPotatoApi CpApi { get; } private ICacheProvider Cache { get; } - private Negotiator LoadRequests() + private async Task LoadRequests() { - var settings = PrSettings.GetSettings(); + var settings = await PrSettings.GetSettingsAsync(); return View["Index", settings]; } @@ -126,13 +128,20 @@ namespace PlexRequests.UI.Modules var cpSettings = CpSettings.GetSettings(); if (cpSettings.Enabled) { - - var result = await Cache.GetOrSetAsync(CacheKeys.CouchPotatoQualityProfiles, async () => + try { - return await Task.Run(() => CpApi.GetProfiles(cpSettings.FullUri, cpSettings.ApiKey)).ConfigureAwait(false); - }); + var result = await Cache.GetOrSetAsync(CacheKeys.CouchPotatoQualityProfiles, async () => + { + return await Task.Run(() => CpApi.GetProfiles(cpSettings.FullUri, cpSettings.ApiKey)).ConfigureAwait(false); + }); - qualities = result.list.Select(x => new QualityModel() { Id = x._id, Name = x.label }).ToList(); + qualities = result.list.Select(x => new QualityModel() { Id = x._id, Name = x.label }).ToList(); + + } + catch (Exception e) + { + Log.Info(e); + } } } @@ -156,9 +165,7 @@ namespace PlexRequests.UI.Modules ReleaseYear = movie.ReleaseDate.Year.ToString(), Available = movie.Available, Admin = IsAdmin, - Issues = movie.Issues.ToString().CamelCaseToWords(), - OtherMessage = movie.OtherMessage, - AdminNotes = movie.AdminNote, + IssueId = movie.IssueId, Qualities = qualities.ToArray() }).ToList(); @@ -182,23 +189,31 @@ namespace PlexRequests.UI.Modules IEnumerable qualities = new List(); if (IsAdmin) { - var sonarrSettings = SonarrSettings.GetSettings(); - if (sonarrSettings.Enabled) + try { - var result = Cache.GetOrSetAsync(CacheKeys.SonarrQualityProfiles, async () => + var sonarrSettings = SonarrSettings.GetSettings(); + if (sonarrSettings.Enabled) { - return await Task.Run(() => SonarrApi.GetProfiles(sonarrSettings.ApiKey, sonarrSettings.FullUri)); - }); - qualities = result.Result.Select(x => new QualityModel() { Id = x.id.ToString(), Name = x.name }).ToList(); - } - else - { - var sickRageSettings = SickRageSettings.GetSettings(); - if (sickRageSettings.Enabled) + var result = Cache.GetOrSetAsync(CacheKeys.SonarrQualityProfiles, async () => + { + return await Task.Run(() => SonarrApi.GetProfiles(sonarrSettings.ApiKey, sonarrSettings.FullUri)); + }); + qualities = result.Result.Select(x => new QualityModel() { Id = x.id.ToString(), Name = x.name }).ToList(); + } + else { - qualities = sickRageSettings.Qualities.Select(x => new QualityModel() { Id = x.Key, Name = x.Value }).ToList(); + var sickRageSettings = SickRageSettings.GetSettings(); + if (sickRageSettings.Enabled) + { + qualities = sickRageSettings.Qualities.Select(x => new QualityModel() { Id = x.Key, Name = x.Value }).ToList(); + } } } + catch (Exception e) + { + Log.Info(e); + } + } var viewModel = dbTv.Select(tv => @@ -223,9 +238,7 @@ namespace PlexRequests.UI.Modules ReleaseYear = tv.ReleaseDate.Year.ToString(), Available = tv.Available, Admin = IsAdmin, - Issues = tv.Issues.ToString().CamelCaseToWords(), - OtherMessage = tv.OtherMessage, - AdminNotes = tv.AdminNote, + IssueId = tv.IssueId, TvSeriesRequestType = tv.SeasonsRequested, Qualities = qualities.ToArray() }; @@ -266,9 +279,7 @@ namespace PlexRequests.UI.Modules ReleaseYear = album.ReleaseDate.Year.ToString(), Available = album.Available, Admin = IsAdmin, - Issues = album.Issues.ToString().CamelCaseToWords(), - OtherMessage = album.OtherMessage, - AdminNotes = album.AdminNote, + IssueId = album.IssueId, TvSeriesRequestType = album.SeasonsRequested, MusicBrainzId = album.MusicBrainzId, ArtistName = album.ArtistName @@ -317,7 +328,7 @@ namespace PlexRequests.UI.Modules NotificationType = NotificationType.Issue, Title = originalRequest.Title, DateTime = DateTime.Now, - Body = issue == IssueState.Other ? comment : issue.ToString().CamelCaseToWords() + Body = issue == IssueState.Other ? comment : issue.ToString().ToCamelCaseWords() }; await NotificationService.Publish(model); @@ -361,21 +372,6 @@ namespace PlexRequests.UI.Modules : new { Result = false, Available = false, Message = "Could not update the availability, please try again or check the logs" }); } - private async Task AddNote(int requestId, string noteArea) - { - this.RequiresClaims(UserClaims.Admin); - var originalRequest = await Service.GetAsync(requestId); - if (originalRequest == null) - { - return Response.AsJson(new JsonResponseModel { Result = false, Message = "Request does not exist to add a note!" }); - } - - originalRequest.AdminNote = noteArea; - - var result = await Service.UpdateRequestAsync(originalRequest); - return Response.AsJson(result - ? new JsonResponseModel { Result = true } - : new JsonResponseModel { Result = false, Message = "Could not update the notes, please try again or check the logs" }); - } + } } diff --git a/PlexRequests.UI/Modules/SearchModule.cs b/PlexRequests.UI/Modules/SearchModule.cs index 0de1a23b9..380df096f 100644 --- a/PlexRequests.UI/Modules/SearchModule.cs +++ b/PlexRequests.UI/Modules/SearchModule.cs @@ -49,7 +49,10 @@ using PlexRequests.UI.Models; using System.Threading.Tasks; using Nancy.Extensions; +using Nancy.Responses; + using PlexRequests.Api.Models.Tv; +using PlexRequests.Core.Models; using PlexRequests.Store.Models; using PlexRequests.Store.Repository; @@ -65,7 +68,8 @@ namespace PlexRequests.UI.Modules ISettingsService sickRageService, ICouchPotatoApi cpApi, ISickRageApi srApi, INotificationService notify, IMusicBrainzApi mbApi, IHeadphonesApi hpApi, ISettingsService hpService, ICouchPotatoCacher cpCacher, ISonarrCacher sonarrCacher, ISickRageCacher sickRageCacher, IPlexApi plexApi, - ISettingsService plexService, ISettingsService auth, IRepository u, ISettingsService email) : base("search", prSettings) + ISettingsService plexService, ISettingsService auth, IRepository u, ISettingsService email, + IIssueService issue) : base("search", prSettings) { Auth = auth; PlexService = plexService; @@ -90,6 +94,7 @@ namespace PlexRequests.UI.Modules HeadphonesService = hpService; UsersToNotifyRepo = u; EmailNotificationSettings = email; + IssueService = issue; Get["/", true] = async (x, ct) => await RequestLoad(); @@ -134,6 +139,7 @@ namespace PlexRequests.UI.Modules private IMusicBrainzApi MusicBrainzApi { get; } private IHeadphonesApi HeadphonesApi { get; } private IRepository UsersToNotifyRepo { get; } + private IIssueService IssueService { get; } private static Logger Log = LogManager.GetCurrentClassLogger(); private async Task RequestLoad() @@ -265,7 +271,7 @@ namespace PlexRequests.UI.Modules if (usersCanViewOnlyOwnRequests) { var result = moviesInDb.FirstOrDefault(x => x.Value.ProviderId == movieId); - return result.Value != null && result.Value.UserHasRequested(Username); + return result.Value == null || result.Value.UserHasRequested(Username); } return true; @@ -273,6 +279,7 @@ namespace PlexRequests.UI.Modules private async Task SearchTvShow(string searchTerm) { + var plexSettings = await PlexService.GetSettingsAsync(); Log.Trace("Searching for TV Show {0}", searchTerm); var apiTv = new List(); @@ -299,11 +306,15 @@ namespace PlexRequests.UI.Modules var viewTv = new List(); foreach (var t in apiTv) { + var banner = t.show.image?.medium; + if (!string.IsNullOrEmpty(banner)) + { + banner = banner.Replace("http", "https"); + } + var viewT = new SearchTvShowViewModel { - // We are constructing the banner with the id: - // http://thetvdb.com/banners/_cache/posters/ID-1.jpg - Banner = t.show.image?.medium, + Banner = banner, FirstAired = t.show.premiered, Id = t.show.externals?.thetvdb ?? 0, ImdbId = t.show.externals?.imdb, @@ -317,7 +328,15 @@ namespace PlexRequests.UI.Modules Status = t.show.status }; - if (Checker.IsTvShowAvailable(plexTvShows.ToArray(), t.show.name, t.show.premiered?.Substring(0, 4))) + + var providerId = string.Empty; + + if (plexSettings.AdvancedSearch) + { + providerId = viewT.Id.ToString(); + } + + if (Checker.IsTvShowAvailable(plexTvShows.ToArray(), t.show.name, t.show.premiered?.Substring(0, 4), providerId)) { viewT.Available = true; } @@ -342,8 +361,6 @@ namespace PlexRequests.UI.Modules viewTv.Add(viewT); } - Log.Trace("Returning TV Show results: "); - Log.Trace(viewTv.DumpJson()); return Response.AsJson(viewTv); } @@ -400,11 +417,9 @@ namespace PlexRequests.UI.Modules private async Task RequestMovie(int movieId) { - var movieApi = new TheMovieDbApi(); - var movieInfo = movieApi.GetMovieInformation(movieId).Result; + var movieInfo = MovieApi.GetMovieInformation(movieId).Result; var fullMovieName = $"{movieInfo.Title}{(movieInfo.ReleaseDate.HasValue ? $" ({movieInfo.ReleaseDate.Value.Year})" : string.Empty)}"; Log.Trace("Getting movie info from TheMovieDb"); - //#if !DEBUG var settings = await PrService.GetSettingsAsync(); @@ -569,7 +584,13 @@ namespace PlexRequests.UI.Modules try { var shows = Checker.GetPlexTvShows(); - if (Checker.IsTvShowAvailable(shows.ToArray(), showInfo.name, showInfo.premiered?.Substring(0, 4))) + var providerId = string.Empty; + var plexSettings = await PlexService.GetSettingsAsync(); + if (plexSettings.AdvancedSearch) + { + providerId = showId.ToString(); + } + if (Checker.IsTvShowAvailable(shows.ToArray(), showInfo.name, showInfo.premiered?.Substring(0, 4), providerId)) { return Response.AsJson(new JsonResponseModel { Result = false, Message = $"{fullShowName} is already in Plex!" }); } @@ -596,6 +617,7 @@ namespace PlexRequests.UI.Modules Issues = IssueState.None, ImdbId = showInfo.externals?.imdb ?? string.Empty, SeasonCount = showInfo.seasonCount, + TvDbId = showId.ToString() }; var seasonsList = new List(); @@ -778,12 +800,6 @@ namespace PlexRequests.UI.Modules var img = GetMusicBrainzCoverArt(albumInfo.id); - Log.Trace("Album Details:"); - Log.Trace(albumInfo.DumpJson()); - Log.Trace("CoverArt Details:"); - Log.Trace(img.DumpJson()); - - var model = new RequestedModel { Title = albumInfo.title, @@ -806,9 +822,6 @@ namespace PlexRequests.UI.Modules Log.Debug("We don't require approval OR the user is in the whitelist"); var hpSettings = HeadphonesService.GetSettings(); - Log.Trace("Headphone Settings:"); - Log.Trace(hpSettings.DumpJson()); - if (!hpSettings.Enabled) { await RequestService.AddRequestAsync(model); @@ -964,5 +977,7 @@ namespace PlexRequests.UI.Modules var model = seasons.Select(x => x.number); return Response.AsJson(model); } + + } } diff --git a/PlexRequests.UI/Modules/UpdateCheckerModule.cs b/PlexRequests.UI/Modules/UpdateCheckerModule.cs index f1b46a805..216288c1a 100644 --- a/PlexRequests.UI/Modules/UpdateCheckerModule.cs +++ b/PlexRequests.UI/Modules/UpdateCheckerModule.cs @@ -25,6 +25,7 @@ // ************************************************************************/ #endregion using System; +using System.Threading.Tasks; using Nancy; @@ -43,14 +44,14 @@ namespace PlexRequests.UI.Modules { Cache = provider; - Get["/"] = _ => CheckLatestVersion(); + Get["/", true] = async (x,ct) => await CheckLatestVersion(); } private ICacheProvider Cache { get; } private static Logger Log = LogManager.GetCurrentClassLogger(); - private Response CheckLatestVersion() + private async Task CheckLatestVersion() { try { @@ -60,7 +61,7 @@ namespace PlexRequests.UI.Modules } var checker = new StatusChecker(); - var release = Cache.GetOrSet(CacheKeys.LastestProductVersion, () => checker.GetStatus(), 30); + var release = await Cache.GetOrSetAsync(CacheKeys.LastestProductVersion, async() => await checker.GetStatus(), 30); return Response.AsJson(release.UpdateAvailable ? new JsonUpdateAvailableModel { UpdateAvailable = true} diff --git a/PlexRequests.UI/Modules/UserLoginModule.cs b/PlexRequests.UI/Modules/UserLoginModule.cs index d6acb5343..cfa0c03a9 100644 --- a/PlexRequests.UI/Modules/UserLoginModule.cs +++ b/PlexRequests.UI/Modules/UserLoginModule.cs @@ -26,8 +26,8 @@ #endregion using System; -using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Nancy; using Nancy.Extensions; @@ -39,30 +39,57 @@ using PlexRequests.Api.Interfaces; using PlexRequests.Api.Models.Plex; using PlexRequests.Core; using PlexRequests.Core.SettingModels; -using PlexRequests.Helpers; using PlexRequests.UI.Models; namespace PlexRequests.UI.Modules { public class UserLoginModule : BaseModule { - public UserLoginModule(ISettingsService auth, IPlexApi api, ISettingsService pr) : base("userlogin", pr) + public UserLoginModule(ISettingsService auth, IPlexApi api, ISettingsService pr, ISettingsService lp) : base("userlogin", pr) { AuthService = auth; + LandingPageSettings = lp; Api = api; - Get["/"] = _ => Index(); + Get["/", true] = async (x, ct) => await Index(); Post["/"] = x => LoginUser(); Get["/logout"] = x => Logout(); } private ISettingsService AuthService { get; } + private ISettingsService LandingPageSettings { get; } private IPlexApi Api { get; } private static Logger Log = LogManager.GetCurrentClassLogger(); - public Negotiator Index() + public async Task Index() { - var settings = AuthService.GetSettings(); + var query = Request.Query["landing"]; + var landingCheck = (bool?)query ?? true; + if (landingCheck) + { + var landingSettings = await LandingPageSettings.GetSettingsAsync(); + + if (landingSettings.Enabled) + { + if (landingSettings.BeforeLogin) + { + var model = new LandingPageViewModel + { + Enabled = landingSettings.Enabled, + Id = landingSettings.Id, + EnabledNoticeTime = landingSettings.EnabledNoticeTime, + NoticeEnable = landingSettings.NoticeEnable, + NoticeEnd = landingSettings.NoticeEnd, + NoticeMessage = landingSettings.NoticeMessage, + NoticeStart = landingSettings.NoticeStart, + ContinueUrl = landingSettings.BeforeLogin ? $"userlogin" : $"search" + }; + + return View["Landing/Index", model]; + } + } + } + var settings = await AuthService.GetSettingsAsync(); return View["Index", settings]; } @@ -70,7 +97,7 @@ namespace PlexRequests.UI.Modules { var dateTimeOffset = Request.Form.DateTimeOffset; var username = Request.Form.username.Value; - Log.Debug("Username \"{0}\" attempting to login",username); + Log.Debug("Username \"{0}\" attempting to login", username); if (string.IsNullOrWhiteSpace(username)) { return Response.AsJson(new JsonResponseModel { Result = false, Message = "Incorrect User or Password" }); @@ -79,8 +106,6 @@ namespace PlexRequests.UI.Modules var authenticated = false; var settings = AuthService.GetSettings(); - Log.Debug("Settings: "); - Log.Debug(settings.DumpJson()); if (IsUserInDeniedList(username, settings)) { @@ -95,7 +120,7 @@ namespace PlexRequests.UI.Modules password = Request.Form.password.Value; } - + if (settings.UserAuthentication && settings.UsePassword) // Authenticate with Plex { Log.Debug("Need to auth and also provide pass"); @@ -115,7 +140,7 @@ namespace PlexRequests.UI.Modules } } } - else if(settings.UserAuthentication) // Check against the users in Plex + else if (settings.UserAuthentication) // Check against the users in Plex { Log.Debug("Need to auth"); authenticated = CheckIfUserIsInPlexFriends(username, settings.PlexAuthToken); @@ -126,7 +151,7 @@ namespace PlexRequests.UI.Modules } Log.Debug("Friends list result = {0}", authenticated); } - else if(!settings.UserAuthentication) // No auth, let them pass! + else if (!settings.UserAuthentication) // No auth, let them pass! { Log.Debug("No need to auth"); authenticated = true; @@ -141,12 +166,22 @@ namespace PlexRequests.UI.Modules Session[SessionKeys.ClientDateTimeOffsetKey] = (int)dateTimeOffset; - return Response.AsJson(authenticated - ? new JsonResponseModel { Result = true } - : new JsonResponseModel { Result = false, Message = "Incorrect User or Password"}); + if (!authenticated) + { + return Response.AsJson(new JsonResponseModel {Result = false, Message = "Incorrect User or Password"}); + } + + var landingSettings = LandingPageSettings.GetSettings(); + + if (landingSettings.Enabled) + { + if (!landingSettings.BeforeLogin) + return Response.AsJson(new JsonResponseModel { Result = true, Message = "landing" }); + } + return Response.AsJson(new JsonResponseModel {Result = true, Message = "search" }); } - + private Response Logout() { @@ -155,8 +190,8 @@ namespace PlexRequests.UI.Modules { Session.Delete(SessionKeys.UsernameKey); } - return Context.GetRedirect(!string.IsNullOrEmpty(BaseUrl) - ? $"~/{BaseUrl}/userlogin" + return Context.GetRedirect(!string.IsNullOrEmpty(BaseUrl) + ? $"~/{BaseUrl}/userlogin" : "~/userlogin"); } @@ -173,8 +208,6 @@ namespace PlexRequests.UI.Modules private bool CheckIfUserIsInPlexFriends(string username, string authToken) { var users = Api.GetUsers(authToken); - Log.Trace("Plex Users: "); - Log.Trace(users.DumpJson()); var allUsers = users?.User?.Where(x => !string.IsNullOrEmpty(x.Title)); return allUsers != null && allUsers.Any(x => x.Title.Equals(username, StringComparison.CurrentCultureIgnoreCase)); } diff --git a/PlexRequests.UI/PlexRequests.UI.csproj b/PlexRequests.UI/PlexRequests.UI.csproj index 2e226b54f..99b5f8641 100644 --- a/PlexRequests.UI/PlexRequests.UI.csproj +++ b/PlexRequests.UI/PlexRequests.UI.csproj @@ -65,6 +65,10 @@ ..\packages\Nancy.Swagger.0.1.0-alpha3\lib\net40\Nancy.Swagger.dll True + + ..\packages\NLog.4.3.4\lib\net45\NLog.dll + True + ..\packages\RestSharp.105.2.3\lib\net45\RestSharp.dll True @@ -139,9 +143,6 @@ ..\packages\Newtonsoft.Json.8.0.2\lib\net45\Newtonsoft.Json.dll - - ..\packages\NLog.4.2.3\lib\net45\NLog.dll - ..\packages\Owin.1.0\lib\net40\Owin.dll @@ -159,6 +160,7 @@ + @@ -175,9 +177,13 @@ + + + + @@ -190,6 +196,8 @@ + + @@ -240,6 +248,22 @@ + + datepicker.scss + + + datepicker.css + Always + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + PreserveNewest @@ -310,6 +334,16 @@ plex.css PreserveNewest + + PreserveNewest + + + PreserveNewest + + + + + PreserveNewest @@ -391,10 +425,11 @@ compilerconfig.json - + PreserveNewest + @@ -509,6 +544,24 @@ Always + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + web.config diff --git a/PlexRequests.UI/Program.cs b/PlexRequests.UI/Program.cs index 1bb0da24c..884dbcd59 100644 --- a/PlexRequests.UI/Program.cs +++ b/PlexRequests.UI/Program.cs @@ -59,7 +59,7 @@ namespace PlexRequests.UI var baseUrl = result.MapResult( o => o.BaseUrl, e => string.Empty); - + var port = result.MapResult( x => x.Port, e => -1); diff --git a/PlexRequests.UI/Scripts/bootstrap-datetimepicker.js b/PlexRequests.UI/Scripts/bootstrap-datetimepicker.js new file mode 100644 index 000000000..0e8e14104 --- /dev/null +++ b/PlexRequests.UI/Scripts/bootstrap-datetimepicker.js @@ -0,0 +1,2485 @@ +/*! version : 4.15.35 + ========================================================= + bootstrap-datetimejs + https://github.com/Eonasdan/bootstrap-datetimepicker + Copyright (c) 2015 Jonathan Peterson + ========================================================= + */ +/* + The MIT License (MIT) + + Copyright (c) 2015 Jonathan Peterson + + 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. + */ +/*global define:false */ +/*global exports:false */ +/*global require:false */ +/*global jQuery:false */ +/*global moment:false */ +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // AMD is used - Register as an anonymous module. + define(['jquery', 'moment'], factory); + } else if (typeof exports === 'object') { + factory(require('jquery'), require('moment')); + } else { + // Neither AMD nor CommonJS used. Use global variables. + if (typeof jQuery === 'undefined') { + throw 'bootstrap-datetimepicker requires jQuery to be loaded first'; + } + if (typeof moment === 'undefined') { + throw 'bootstrap-datetimepicker requires Moment.js to be loaded first'; + } + factory(jQuery, moment); + } +}(function ($, moment) { + 'use strict'; + if (!moment) { + throw new Error('bootstrap-datetimepicker requires Moment.js to be loaded first'); + } + + var dateTimePicker = function (element, options) { + var picker = {}, + date = moment().startOf('d'), + viewDate = date.clone(), + unset = true, + input, + component = false, + widget = false, + use24Hours, + minViewModeNumber = 0, + actualFormat, + parseFormats, + currentViewMode, + datePickerModes = [ + { + clsName: 'days', + navFnc: 'M', + navStep: 1 + }, + { + clsName: 'months', + navFnc: 'y', + navStep: 1 + }, + { + clsName: 'years', + navFnc: 'y', + navStep: 10 + }, + { + clsName: 'decades', + navFnc: 'y', + navStep: 100 + } + ], + viewModes = ['days', 'months', 'years', 'decades'], + verticalModes = ['top', 'bottom', 'auto'], + horizontalModes = ['left', 'right', 'auto'], + toolbarPlacements = ['default', 'top', 'bottom'], + keyMap = { + 'up': 38, + 38: 'up', + 'down': 40, + 40: 'down', + 'left': 37, + 37: 'left', + 'right': 39, + 39: 'right', + 'tab': 9, + 9: 'tab', + 'escape': 27, + 27: 'escape', + 'enter': 13, + 13: 'enter', + 'pageUp': 33, + 33: 'pageUp', + 'pageDown': 34, + 34: 'pageDown', + 'shift': 16, + 16: 'shift', + 'control': 17, + 17: 'control', + 'space': 32, + 32: 'space', + 't': 84, + 84: 't', + 'delete': 46, + 46: 'delete' + }, + keyState = {}, + + /******************************************************************************** + * + * Private functions + * + ********************************************************************************/ + isEnabled = function (granularity) { + if (typeof granularity !== 'string' || granularity.length > 1) { + throw new TypeError('isEnabled expects a single character string parameter'); + } + switch (granularity) { + case 'y': + return actualFormat.indexOf('Y') !== -1; + case 'M': + return actualFormat.indexOf('M') !== -1; + case 'd': + return actualFormat.toLowerCase().indexOf('d') !== -1; + case 'h': + case 'H': + return actualFormat.toLowerCase().indexOf('h') !== -1; + case 'm': + return actualFormat.indexOf('m') !== -1; + case 's': + return actualFormat.indexOf('s') !== -1; + default: + return false; + } + }, + hasTime = function () { + return (isEnabled('h') || isEnabled('m') || isEnabled('s')); + }, + + hasDate = function () { + return (isEnabled('y') || isEnabled('M') || isEnabled('d')); + }, + + getDatePickerTemplate = function () { + var headTemplate = $('') + .append($('') + .append($('') + .append($('') + .append($('
    ').addClass('cw').text('#')); + } + + while (currentDate.isBefore(viewDate.clone().endOf('w'))) { + row.append($('').addClass('dow').text(currentDate.format('dd'))); + currentDate.add(1, 'd'); + } + widget.find('.datepicker-days thead').append(row); + }, + + isInDisabledDates = function (testDate) { + return options.disabledDates[testDate.format('YYYY-MM-DD')] === true; + }, + + isInEnabledDates = function (testDate) { + return options.enabledDates[testDate.format('YYYY-MM-DD')] === true; + }, + + isInDisabledHours = function (testDate) { + return options.disabledHours[testDate.format('H')] === true; + }, + + isInEnabledHours = function (testDate) { + return options.enabledHours[testDate.format('H')] === true; + }, + + isValid = function (targetMoment, granularity) { + if (!targetMoment.isValid()) { + return false; + } + if (options.disabledDates && granularity === 'd' && isInDisabledDates(targetMoment)) { + return false; + } + if (options.enabledDates && granularity === 'd' && !isInEnabledDates(targetMoment)) { + return false; + } + if (options.minDate && targetMoment.isBefore(options.minDate, granularity)) { + return false; + } + if (options.maxDate && targetMoment.isAfter(options.maxDate, granularity)) { + return false; + } + if (options.daysOfWeekDisabled && granularity === 'd' && options.daysOfWeekDisabled.indexOf(targetMoment.day()) !== -1) { + return false; + } + if (options.disabledHours && (granularity === 'h' || granularity === 'm' || granularity === 's') && isInDisabledHours(targetMoment)) { + return false; + } + if (options.enabledHours && (granularity === 'h' || granularity === 'm' || granularity === 's') && !isInEnabledHours(targetMoment)) { + return false; + } + if (options.disabledTimeIntervals && (granularity === 'h' || granularity === 'm' || granularity === 's')) { + var found = false; + $.each(options.disabledTimeIntervals, function () { + if (targetMoment.isBetween(this[0], this[1])) { + found = true; + return false; + } + }); + if (found) { + return false; + } + } + return true; + }, + + fillMonths = function () { + var spans = [], + monthsShort = viewDate.clone().startOf('y').startOf('d'); + while (monthsShort.isSame(viewDate, 'y')) { + spans.push($('').attr('data-action', 'selectMonth').addClass('month').text(monthsShort.format('MMM'))); + monthsShort.add(1, 'M'); + } + widget.find('.datepicker-months td').empty().append(spans); + }, + + updateMonths = function () { + var monthsView = widget.find('.datepicker-months'), + monthsViewHeader = monthsView.find('th'), + months = monthsView.find('tbody').find('span'); + + monthsViewHeader.eq(0).find('span').attr('title', options.tooltips.prevYear); + monthsViewHeader.eq(1).attr('title', options.tooltips.selectYear); + monthsViewHeader.eq(2).find('span').attr('title', options.tooltips.nextYear); + + monthsView.find('.disabled').removeClass('disabled'); + + if (!isValid(viewDate.clone().subtract(1, 'y'), 'y')) { + monthsViewHeader.eq(0).addClass('disabled'); + } + + monthsViewHeader.eq(1).text(viewDate.year()); + + if (!isValid(viewDate.clone().add(1, 'y'), 'y')) { + monthsViewHeader.eq(2).addClass('disabled'); + } + + months.removeClass('active'); + if (date.isSame(viewDate, 'y') && !unset) { + months.eq(date.month()).addClass('active'); + } + + months.each(function (index) { + if (!isValid(viewDate.clone().month(index), 'M')) { + $(this).addClass('disabled'); + } + }); + }, + + updateYears = function () { + var yearsView = widget.find('.datepicker-years'), + yearsViewHeader = yearsView.find('th'), + startYear = viewDate.clone().subtract(5, 'y'), + endYear = viewDate.clone().add(6, 'y'), + html = ''; + + yearsViewHeader.eq(0).find('span').attr('title', options.tooltips.prevDecade); + yearsViewHeader.eq(1).attr('title', options.tooltips.selectDecade); + yearsViewHeader.eq(2).find('span').attr('title', options.tooltips.nextDecade); + + yearsView.find('.disabled').removeClass('disabled'); + + if (options.minDate && options.minDate.isAfter(startYear, 'y')) { + yearsViewHeader.eq(0).addClass('disabled'); + } + + yearsViewHeader.eq(1).text(startYear.year() + '-' + endYear.year()); + + if (options.maxDate && options.maxDate.isBefore(endYear, 'y')) { + yearsViewHeader.eq(2).addClass('disabled'); + } + + while (!startYear.isAfter(endYear, 'y')) { + html += '' + startYear.year() + ''; + startYear.add(1, 'y'); + } + + yearsView.find('td').html(html); + }, + + updateDecades = function () { + var decadesView = widget.find('.datepicker-decades'), + decadesViewHeader = decadesView.find('th'), + startDecade = moment({ y: viewDate.year() - (viewDate.year() % 100) - 1 }), + endDecade = startDecade.clone().add(100, 'y'), + startedAt = startDecade.clone(), + html = ''; + + decadesViewHeader.eq(0).find('span').attr('title', options.tooltips.prevCentury); + decadesViewHeader.eq(2).find('span').attr('title', options.tooltips.nextCentury); + + decadesView.find('.disabled').removeClass('disabled'); + + if (startDecade.isSame(moment({ y: 1900 })) || (options.minDate && options.minDate.isAfter(startDecade, 'y'))) { + decadesViewHeader.eq(0).addClass('disabled'); + } + + decadesViewHeader.eq(1).text(startDecade.year() + '-' + endDecade.year()); + + if (startDecade.isSame(moment({ y: 2000 })) || (options.maxDate && options.maxDate.isBefore(endDecade, 'y'))) { + decadesViewHeader.eq(2).addClass('disabled'); + } + + while (!startDecade.isAfter(endDecade, 'y')) { + html += '' + (startDecade.year() + 1) + ' - ' + (startDecade.year() + 12) + ''; + startDecade.add(12, 'y'); + } + html += ''; //push the dangling block over, at least this way it's even + + decadesView.find('td').html(html); + decadesViewHeader.eq(1).text((startedAt.year() + 1) + '-' + (startDecade.year())); + }, + + fillDate = function () { + var daysView = widget.find('.datepicker-days'), + daysViewHeader = daysView.find('th'), + currentDate, + html = [], + row, + clsName, + i; + + if (!hasDate()) { + return; + } + + daysViewHeader.eq(0).find('span').attr('title', options.tooltips.prevMonth); + daysViewHeader.eq(1).attr('title', options.tooltips.selectMonth); + daysViewHeader.eq(2).find('span').attr('title', options.tooltips.nextMonth); + + daysView.find('.disabled').removeClass('disabled'); + daysViewHeader.eq(1).text(viewDate.format(options.dayViewHeaderFormat)); + + if (!isValid(viewDate.clone().subtract(1, 'M'), 'M')) { + daysViewHeader.eq(0).addClass('disabled'); + } + if (!isValid(viewDate.clone().add(1, 'M'), 'M')) { + daysViewHeader.eq(2).addClass('disabled'); + } + + currentDate = viewDate.clone().startOf('M').startOf('w').startOf('d'); + + for (i = 0; i < 42; i++) { //always display 42 days (should show 6 weeks) + if (currentDate.weekday() === 0) { + row = $('
    ' + currentDate.week() + '' + currentDate.date() + '
    ' + currentHour.format(use24Hours ? 'HH' : 'hh') + '
    ' + currentMinute.format('mm') + '
    ' + currentSecond.format('ss') + '
    ').addClass('prev').attr('data-action', 'previous') + .append($('').addClass(options.icons.previous)) + ) + .append($('').addClass('picker-switch').attr('data-action', 'pickerSwitch').attr('colspan', (options.calendarWeeks ? '6' : '5'))) + .append($('').addClass('next').attr('data-action', 'next') + .append($('').addClass(options.icons.next)) + ) + ), + contTemplate = $('
    ').attr('colspan', (options.calendarWeeks ? '8' : '7'))) + ); + + return [ + $('
    ').addClass('datepicker-days') + .append($('').addClass('table-condensed') + .append(headTemplate) + .append($('')) + ), + $('
    ').addClass('datepicker-months') + .append($('
    ').addClass('table-condensed') + .append(headTemplate.clone()) + .append(contTemplate.clone()) + ), + $('
    ').addClass('datepicker-years') + .append($('
    ').addClass('table-condensed') + .append(headTemplate.clone()) + .append(contTemplate.clone()) + ), + $('
    ').addClass('datepicker-decades') + .append($('
    ').addClass('table-condensed') + .append(headTemplate.clone()) + .append(contTemplate.clone()) + ) + ]; + }, + + getTimePickerMainTemplate = function () { + var topRow = $(''), + middleRow = $(''), + bottomRow = $(''); + + if (isEnabled('h')) { + topRow.append($('
    ') + .append($('').attr({href: '#', tabindex: '-1', 'title':'Increment Hour'}).addClass('btn').attr('data-action', 'incrementHours') + .append($('').addClass(options.icons.up)))); + middleRow.append($('') + .append($('').addClass('timepicker-hour').attr({'data-time-component':'hours', 'title':'Pick Hour'}).attr('data-action', 'showHours'))); + bottomRow.append($('') + .append($('').attr({href: '#', tabindex: '-1', 'title':'Decrement Hour'}).addClass('btn').attr('data-action', 'decrementHours') + .append($('').addClass(options.icons.down)))); + } + if (isEnabled('m')) { + if (isEnabled('h')) { + topRow.append($('').addClass('separator')); + middleRow.append($('').addClass('separator').html(':')); + bottomRow.append($('').addClass('separator')); + } + topRow.append($('') + .append($('').attr({href: '#', tabindex: '-1', 'title':'Increment Minute'}).addClass('btn').attr('data-action', 'incrementMinutes') + .append($('').addClass(options.icons.up)))); + middleRow.append($('') + .append($('').addClass('timepicker-minute').attr({'data-time-component': 'minutes', 'title':'Pick Minute'}).attr('data-action', 'showMinutes'))); + bottomRow.append($('') + .append($('').attr({href: '#', tabindex: '-1', 'title':'Decrement Minute'}).addClass('btn').attr('data-action', 'decrementMinutes') + .append($('').addClass(options.icons.down)))); + } + if (isEnabled('s')) { + if (isEnabled('m')) { + topRow.append($('').addClass('separator')); + middleRow.append($('').addClass('separator').html(':')); + bottomRow.append($('').addClass('separator')); + } + topRow.append($('') + .append($('').attr({href: '#', tabindex: '-1', 'title':'Increment Second'}).addClass('btn').attr('data-action', 'incrementSeconds') + .append($('').addClass(options.icons.up)))); + middleRow.append($('') + .append($('').addClass('timepicker-second').attr({'data-time-component': 'seconds', 'title':'Pick Second'}).attr('data-action', 'showSeconds'))); + bottomRow.append($('') + .append($('').attr({href: '#', tabindex: '-1', 'title':'Decrement Second'}).addClass('btn').attr('data-action', 'decrementSeconds') + .append($('').addClass(options.icons.down)))); + } + + if (!use24Hours) { + topRow.append($('').addClass('separator')); + middleRow.append($('') + .append($('').addClass('separator')); + } + + return $('
    ').addClass('timepicker-picker') + .append($('').addClass('table-condensed') + .append([topRow, middleRow, bottomRow])); + }, + + getTimePickerTemplate = function () { + var hoursView = $('
    ').addClass('timepicker-hours') + .append($('
    ').addClass('table-condensed')), + minutesView = $('
    ').addClass('timepicker-minutes') + .append($('
    ').addClass('table-condensed')), + secondsView = $('
    ').addClass('timepicker-seconds') + .append($('
    ').addClass('table-condensed')), + ret = [getTimePickerMainTemplate()]; + + if (isEnabled('h')) { + ret.push(hoursView); + } + if (isEnabled('m')) { + ret.push(minutesView); + } + if (isEnabled('s')) { + ret.push(secondsView); + } + + return ret; + }, + + getToolbar = function () { + var row = []; + if (options.showTodayButton) { + row.push($('
    ').append($('').attr({'data-action':'today', 'title': options.tooltips.today}).append($('').addClass(options.icons.today)))); + } + if (!options.sideBySide && hasDate() && hasTime()) { + row.push($('').append($('').attr({'data-action':'togglePicker', 'title':'Select Time'}).append($('').addClass(options.icons.time)))); + } + if (options.showClear) { + row.push($('').append($('').attr({'data-action':'clear', 'title': options.tooltips.clear}).append($('').addClass(options.icons.clear)))); + } + if (options.showClose) { + row.push($('').append($('').attr({'data-action':'close', 'title': options.tooltips.close}).append($('').addClass(options.icons.close)))); + } + return $('').addClass('table-condensed').append($('').append($('').append(row))); + }, + + getTemplate = function () { + var template = $('
    ').addClass('bootstrap-datetimepicker-widget dropdown-menu'), + dateView = $('
    ').addClass('datepicker').append(getDatePickerTemplate()), + timeView = $('
    ').addClass('timepicker').append(getTimePickerTemplate()), + content = $('
      ').addClass('list-unstyled'), + toolbar = $('
    • ').addClass('picker-switch' + (options.collapse ? ' accordion-toggle' : '')).append(getToolbar()); + + if (options.inline) { + template.removeClass('dropdown-menu'); + } + + if (use24Hours) { + template.addClass('usetwentyfour'); + } + if (isEnabled('s') && !use24Hours) { + template.addClass('wider'); + } + + if (options.sideBySide && hasDate() && hasTime()) { + template.addClass('timepicker-sbs'); + if (options.toolbarPlacement === 'top') { + template.append(toolbar); + } + template.append( + $('
      ').addClass('row') + .append(dateView.addClass('col-md-6')) + .append(timeView.addClass('col-md-6')) + ); + if (options.toolbarPlacement === 'bottom') { + template.append(toolbar); + } + return template; + } + + if (options.toolbarPlacement === 'top') { + content.append(toolbar); + } + if (hasDate()) { + content.append($('
    • ').addClass((options.collapse && hasTime() ? 'collapse in' : '')).append(dateView)); + } + if (options.toolbarPlacement === 'default') { + content.append(toolbar); + } + if (hasTime()) { + content.append($('
    • ').addClass((options.collapse && hasDate() ? 'collapse' : '')).append(timeView)); + } + if (options.toolbarPlacement === 'bottom') { + content.append(toolbar); + } + return template.append(content); + }, + + dataToOptions = function () { + var eData, + dataOptions = {}; + + if (element.is('input') || options.inline) { + eData = element.data(); + } else { + eData = element.find('input').data(); + } + + if (eData.dateOptions && eData.dateOptions instanceof Object) { + dataOptions = $.extend(true, dataOptions, eData.dateOptions); + } + + $.each(options, function (key) { + var attributeName = 'date' + key.charAt(0).toUpperCase() + key.slice(1); + if (eData[attributeName] !== undefined) { + dataOptions[key] = eData[attributeName]; + } + }); + return dataOptions; + }, + + place = function () { + var position = (component || element).position(), + offset = (component || element).offset(), + vertical = options.widgetPositioning.vertical, + horizontal = options.widgetPositioning.horizontal, + parent; + + if (options.widgetParent) { + parent = options.widgetParent.append(widget); + } else if (element.is('input')) { + parent = element.after(widget).parent(); + } else if (options.inline) { + parent = element.append(widget); + return; + } else { + parent = element; + element.children().first().after(widget); + } + + // Top and bottom logic + if (vertical === 'auto') { + if (offset.top + widget.height() * 1.5 >= $(window).height() + $(window).scrollTop() && + widget.height() + element.outerHeight() < offset.top) { + vertical = 'top'; + } else { + vertical = 'bottom'; + } + } + + // Left and right logic + if (horizontal === 'auto') { + if (parent.width() < offset.left + widget.outerWidth() / 2 && + offset.left + widget.outerWidth() > $(window).width()) { + horizontal = 'right'; + } else { + horizontal = 'left'; + } + } + + if (vertical === 'top') { + widget.addClass('top').removeClass('bottom'); + } else { + widget.addClass('bottom').removeClass('top'); + } + + if (horizontal === 'right') { + widget.addClass('pull-right'); + } else { + widget.removeClass('pull-right'); + } + + // find the first parent element that has a relative css positioning + if (parent.css('position') !== 'relative') { + parent = parent.parents().filter(function () { + return $(this).css('position') === 'relative'; + }).first(); + } + + if (parent.length === 0) { + throw new Error('datetimepicker component should be placed within a relative positioned container'); + } + + widget.css({ + top: vertical === 'top' ? 'auto' : position.top + element.outerHeight(), + bottom: vertical === 'top' ? position.top + element.outerHeight() : 'auto', + left: horizontal === 'left' ? (parent === element ? 0 : position.left) : 'auto', + right: horizontal === 'left' ? 'auto' : parent.outerWidth() - element.outerWidth() - (parent === element ? 0 : position.left) + }); + }, + + notifyEvent = function (e) { + if (e.type === 'dp.change' && ((e.date && e.date.isSame(e.oldDate)) || (!e.date && !e.oldDate))) { + return; + } + element.trigger(e); + }, + + viewUpdate = function (e) { + if (e === 'y') { + e = 'YYYY'; + } + notifyEvent({ + type: 'dp.update', + change: e, + viewDate: viewDate.clone() + }); + }, + + showMode = function (dir) { + if (!widget) { + return; + } + if (dir) { + currentViewMode = Math.max(minViewModeNumber, Math.min(3, currentViewMode + dir)); + } + widget.find('.datepicker > div').hide().filter('.datepicker-' + datePickerModes[currentViewMode].clsName).show(); + }, + + fillDow = function () { + var row = $('
    '), + currentDate = viewDate.clone().startOf('w').startOf('d'); + + if (options.calendarWeeks === true) { + row.append($(''); + if (options.calendarWeeks) { + row.append(''); + } + html.push(row); + } + clsName = ''; + if (currentDate.isBefore(viewDate, 'M')) { + clsName += ' old'; + } + if (currentDate.isAfter(viewDate, 'M')) { + clsName += ' new'; + } + if (currentDate.isSame(date, 'd') && !unset) { + clsName += ' active'; + } + if (!isValid(currentDate, 'd')) { + clsName += ' disabled'; + } + if (currentDate.isSame(moment(), 'd')) { + clsName += ' today'; + } + if (currentDate.day() === 0 || currentDate.day() === 6) { + clsName += ' weekend'; + } + row.append(''); + currentDate.add(1, 'd'); + } + + daysView.find('tbody').empty().append(html); + + updateMonths(); + + updateYears(); + + updateDecades(); + }, + + fillHours = function () { + var table = widget.find('.timepicker-hours table'), + currentHour = viewDate.clone().startOf('d'), + html = [], + row = $(''); + + if (viewDate.hour() > 11 && !use24Hours) { + currentHour.hour(12); + } + while (currentHour.isSame(viewDate, 'd') && (use24Hours || (viewDate.hour() < 12 && currentHour.hour() < 12) || viewDate.hour() > 11)) { + if (currentHour.hour() % 4 === 0) { + row = $(''); + html.push(row); + } + row.append(''); + currentHour.add(1, 'h'); + } + table.empty().append(html); + }, + + fillMinutes = function () { + var table = widget.find('.timepicker-minutes table'), + currentMinute = viewDate.clone().startOf('h'), + html = [], + row = $(''), + step = options.stepping === 1 ? 5 : options.stepping; + + while (viewDate.isSame(currentMinute, 'h')) { + if (currentMinute.minute() % (step * 4) === 0) { + row = $(''); + html.push(row); + } + row.append(''); + currentMinute.add(step, 'm'); + } + table.empty().append(html); + }, + + fillSeconds = function () { + var table = widget.find('.timepicker-seconds table'), + currentSecond = viewDate.clone().startOf('m'), + html = [], + row = $(''); + + while (viewDate.isSame(currentSecond, 'm')) { + if (currentSecond.second() % 20 === 0) { + row = $(''); + html.push(row); + } + row.append(''); + currentSecond.add(5, 's'); + } + + table.empty().append(html); + }, + + fillTime = function () { + var toggle, newDate, timeComponents = widget.find('.timepicker span[data-time-component]'); + + if (!use24Hours) { + toggle = widget.find('.timepicker [data-action=togglePeriod]'); + newDate = date.clone().add((date.hours() >= 12) ? -12 : 12, 'h'); + + toggle.text(date.format('A')); + + if (isValid(newDate, 'h')) { + toggle.removeClass('disabled'); + } else { + toggle.addClass('disabled'); + } + } + timeComponents.filter('[data-time-component=hours]').text(date.format(use24Hours ? 'HH' : 'hh')); + timeComponents.filter('[data-time-component=minutes]').text(date.format('mm')); + timeComponents.filter('[data-time-component=seconds]').text(date.format('ss')); + + fillHours(); + fillMinutes(); + fillSeconds(); + }, + + update = function () { + if (!widget) { + return; + } + fillDate(); + fillTime(); + }, + + setValue = function (targetMoment) { + var oldDate = unset ? null : date; + + // case of calling setValue(null or false) + if (!targetMoment) { + unset = true; + input.val(''); + element.data('date', ''); + notifyEvent({ + type: 'dp.change', + date: false, + oldDate: oldDate + }); + update(); + return; + } + + targetMoment = targetMoment.clone().locale(options.locale); + + if (options.stepping !== 1) { + targetMoment.minutes((Math.round(targetMoment.minutes() / options.stepping) * options.stepping) % 60).seconds(0); + } + + if (isValid(targetMoment)) { + date = targetMoment; + viewDate = date.clone(); + input.val(date.format(actualFormat)); + element.data('date', date.format(actualFormat)); + unset = false; + update(); + notifyEvent({ + type: 'dp.change', + date: date.clone(), + oldDate: oldDate + }); + } else { + if (!options.keepInvalid) { + input.val(unset ? '' : date.format(actualFormat)); + } + notifyEvent({ + type: 'dp.error', + date: targetMoment + }); + } + }, + + hide = function () { + ///Hides the widget. Possibly will emit dp.hide + var transitioning = false; + if (!widget) { + return picker; + } + // Ignore event if in the middle of a picker transition + widget.find('.collapse').each(function () { + var collapseData = $(this).data('collapse'); + if (collapseData && collapseData.transitioning) { + transitioning = true; + return false; + } + return true; + }); + if (transitioning) { + return picker; + } + if (component && component.hasClass('btn')) { + component.toggleClass('active'); + } + widget.hide(); + + $(window).off('resize', place); + widget.off('click', '[data-action]'); + widget.off('mousedown', false); + + widget.remove(); + widget = false; + + notifyEvent({ + type: 'dp.hide', + date: date.clone() + }); + + input.blur(); + + return picker; + }, + + clear = function () { + setValue(null); + }, + + /******************************************************************************** + * + * Widget UI interaction functions + * + ********************************************************************************/ + actions = { + next: function () { + var navFnc = datePickerModes[currentViewMode].navFnc; + viewDate.add(datePickerModes[currentViewMode].navStep, navFnc); + fillDate(); + viewUpdate(navFnc); + }, + + previous: function () { + var navFnc = datePickerModes[currentViewMode].navFnc; + viewDate.subtract(datePickerModes[currentViewMode].navStep, navFnc); + fillDate(); + viewUpdate(navFnc); + }, + + pickerSwitch: function () { + showMode(1); + }, + + selectMonth: function (e) { + var month = $(e.target).closest('tbody').find('span').index($(e.target)); + viewDate.month(month); + if (currentViewMode === minViewModeNumber) { + setValue(date.clone().year(viewDate.year()).month(viewDate.month())); + if (!options.inline) { + hide(); + } + } else { + showMode(-1); + fillDate(); + } + viewUpdate('M'); + }, + + selectYear: function (e) { + var year = parseInt($(e.target).text(), 10) || 0; + viewDate.year(year); + if (currentViewMode === minViewModeNumber) { + setValue(date.clone().year(viewDate.year())); + if (!options.inline) { + hide(); + } + } else { + showMode(-1); + fillDate(); + } + viewUpdate('YYYY'); + }, + + selectDecade: function (e) { + var year = parseInt($(e.target).data('selection'), 10) || 0; + viewDate.year(year); + if (currentViewMode === minViewModeNumber) { + setValue(date.clone().year(viewDate.year())); + if (!options.inline) { + hide(); + } + } else { + showMode(-1); + fillDate(); + } + viewUpdate('YYYY'); + }, + + selectDay: function (e) { + var day = viewDate.clone(); + if ($(e.target).is('.old')) { + day.subtract(1, 'M'); + } + if ($(e.target).is('.new')) { + day.add(1, 'M'); + } + setValue(day.date(parseInt($(e.target).text(), 10))); + if (!hasTime() && !options.keepOpen && !options.inline) { + hide(); + } + }, + + incrementHours: function () { + var newDate = date.clone().add(1, 'h'); + if (isValid(newDate, 'h')) { + setValue(newDate); + } + }, + + incrementMinutes: function () { + var newDate = date.clone().add(options.stepping, 'm'); + if (isValid(newDate, 'm')) { + setValue(newDate); + } + }, + + incrementSeconds: function () { + var newDate = date.clone().add(1, 's'); + if (isValid(newDate, 's')) { + setValue(newDate); + } + }, + + decrementHours: function () { + var newDate = date.clone().subtract(1, 'h'); + if (isValid(newDate, 'h')) { + setValue(newDate); + } + }, + + decrementMinutes: function () { + var newDate = date.clone().subtract(options.stepping, 'm'); + if (isValid(newDate, 'm')) { + setValue(newDate); + } + }, + + decrementSeconds: function () { + var newDate = date.clone().subtract(1, 's'); + if (isValid(newDate, 's')) { + setValue(newDate); + } + }, + + togglePeriod: function () { + setValue(date.clone().add((date.hours() >= 12) ? -12 : 12, 'h')); + }, + + togglePicker: function (e) { + var $this = $(e.target), + $parent = $this.closest('ul'), + expanded = $parent.find('.in'), + closed = $parent.find('.collapse:not(.in)'), + collapseData; + + if (expanded && expanded.length) { + collapseData = expanded.data('collapse'); + if (collapseData && collapseData.transitioning) { + return; + } + if (expanded.collapse) { // if collapse plugin is available through bootstrap.js then use it + expanded.collapse('hide'); + closed.collapse('show'); + } else { // otherwise just toggle in class on the two views + expanded.removeClass('in'); + closed.addClass('in'); + } + if ($this.is('span')) { + $this.toggleClass(options.icons.time + ' ' + options.icons.date); + } else { + $this.find('span').toggleClass(options.icons.time + ' ' + options.icons.date); + } + + // NOTE: uncomment if toggled state will be restored in show() + //if (component) { + // component.find('span').toggleClass(options.icons.time + ' ' + options.icons.date); + //} + } + }, + + showPicker: function () { + widget.find('.timepicker > div:not(.timepicker-picker)').hide(); + widget.find('.timepicker .timepicker-picker').show(); + }, + + showHours: function () { + widget.find('.timepicker .timepicker-picker').hide(); + widget.find('.timepicker .timepicker-hours').show(); + }, + + showMinutes: function () { + widget.find('.timepicker .timepicker-picker').hide(); + widget.find('.timepicker .timepicker-minutes').show(); + }, + + showSeconds: function () { + widget.find('.timepicker .timepicker-picker').hide(); + widget.find('.timepicker .timepicker-seconds').show(); + }, + + selectHour: function (e) { + var hour = parseInt($(e.target).text(), 10); + + if (!use24Hours) { + if (date.hours() >= 12) { + if (hour !== 12) { + hour += 12; + } + } else { + if (hour === 12) { + hour = 0; + } + } + } + setValue(date.clone().hours(hour)); + actions.showPicker.call(picker); + }, + + selectMinute: function (e) { + setValue(date.clone().minutes(parseInt($(e.target).text(), 10))); + actions.showPicker.call(picker); + }, + + selectSecond: function (e) { + setValue(date.clone().seconds(parseInt($(e.target).text(), 10))); + actions.showPicker.call(picker); + }, + + clear: clear, + + today: function () { + if (isValid(moment(), 'd')) { + setValue(moment()); + } + }, + + close: hide + }, + + doAction = function (e) { + if ($(e.currentTarget).is('.disabled')) { + return false; + } + actions[$(e.currentTarget).data('action')].apply(picker, arguments); + return false; + }, + + show = function () { + ///Shows the widget. Possibly will emit dp.show and dp.change + var currentMoment, + useCurrentGranularity = { + 'year': function (m) { + return m.month(0).date(1).hours(0).seconds(0).minutes(0); + }, + 'month': function (m) { + return m.date(1).hours(0).seconds(0).minutes(0); + }, + 'day': function (m) { + return m.hours(0).seconds(0).minutes(0); + }, + 'hour': function (m) { + return m.seconds(0).minutes(0); + }, + 'minute': function (m) { + return m.seconds(0); + } + }; + + if (input.prop('disabled') || (!options.ignoreReadonly && input.prop('readonly')) || widget) { + return picker; + } + if (input.val() !== undefined && input.val().trim().length !== 0) { + setValue(parseInputDate(input.val().trim())); + } else if (options.useCurrent && unset && ((input.is('input') && input.val().trim().length === 0) || options.inline)) { + currentMoment = moment(); + if (typeof options.useCurrent === 'string') { + currentMoment = useCurrentGranularity[options.useCurrent](currentMoment); + } + setValue(currentMoment); + } + + widget = getTemplate(); + + fillDow(); + fillMonths(); + + widget.find('.timepicker-hours').hide(); + widget.find('.timepicker-minutes').hide(); + widget.find('.timepicker-seconds').hide(); + + update(); + showMode(); + + $(window).on('resize', place); + widget.on('click', '[data-action]', doAction); // this handles clicks on the widget + widget.on('mousedown', false); + + if (component && component.hasClass('btn')) { + component.toggleClass('active'); + } + widget.show(); + place(); + + if (options.focusOnShow && !input.is(':focus')) { + input.focus(); + } + + notifyEvent({ + type: 'dp.show' + }); + return picker; + }, + + toggle = function () { + /// Shows or hides the widget + return (widget ? hide() : show()); + }, + + parseInputDate = function (inputDate) { + if (options.parseInputDate === undefined) { + if (moment.isMoment(inputDate) || inputDate instanceof Date) { + inputDate = moment(inputDate); + } else { + inputDate = moment(inputDate, parseFormats, options.useStrict); + } + } else { + inputDate = options.parseInputDate(inputDate); + } + inputDate.locale(options.locale); + return inputDate; + }, + + keydown = function (e) { + var handler = null, + index, + index2, + pressedKeys = [], + pressedModifiers = {}, + currentKey = e.which, + keyBindKeys, + allModifiersPressed, + pressed = 'p'; + + keyState[currentKey] = pressed; + + for (index in keyState) { + if (keyState.hasOwnProperty(index) && keyState[index] === pressed) { + pressedKeys.push(index); + if (parseInt(index, 10) !== currentKey) { + pressedModifiers[index] = true; + } + } + } + + for (index in options.keyBinds) { + if (options.keyBinds.hasOwnProperty(index) && typeof (options.keyBinds[index]) === 'function') { + keyBindKeys = index.split(' '); + if (keyBindKeys.length === pressedKeys.length && keyMap[currentKey] === keyBindKeys[keyBindKeys.length - 1]) { + allModifiersPressed = true; + for (index2 = keyBindKeys.length - 2; index2 >= 0; index2--) { + if (!(keyMap[keyBindKeys[index2]] in pressedModifiers)) { + allModifiersPressed = false; + break; + } + } + if (allModifiersPressed) { + handler = options.keyBinds[index]; + break; + } + } + } + } + + if (handler) { + handler.call(picker, widget); + e.stopPropagation(); + e.preventDefault(); + } + }, + + keyup = function (e) { + keyState[e.which] = 'r'; + e.stopPropagation(); + e.preventDefault(); + }, + + change = function (e) { + var val = $(e.target).val().trim(), + parsedDate = val ? parseInputDate(val) : null; + setValue(parsedDate); + e.stopImmediatePropagation(); + return false; + }, + + attachDatePickerElementEvents = function () { + input.on({ + 'change': change, + 'blur': options.debug ? '' : hide, + 'keydown': keydown, + 'keyup': keyup, + 'focus': options.allowInputToggle ? show : '' + }); + + if (element.is('input')) { + input.on({ + 'focus': show + }); + } else if (component) { + component.on('click', toggle); + component.on('mousedown', false); + } + }, + + detachDatePickerElementEvents = function () { + input.off({ + 'change': change, + 'blur': blur, + 'keydown': keydown, + 'keyup': keyup, + 'focus': options.allowInputToggle ? hide : '' + }); + + if (element.is('input')) { + input.off({ + 'focus': show + }); + } else if (component) { + component.off('click', toggle); + component.off('mousedown', false); + } + }, + + indexGivenDates = function (givenDatesArray) { + // Store given enabledDates and disabledDates as keys. + // This way we can check their existence in O(1) time instead of looping through whole array. + // (for example: options.enabledDates['2014-02-27'] === true) + var givenDatesIndexed = {}; + $.each(givenDatesArray, function () { + var dDate = parseInputDate(this); + if (dDate.isValid()) { + givenDatesIndexed[dDate.format('YYYY-MM-DD')] = true; + } + }); + return (Object.keys(givenDatesIndexed).length) ? givenDatesIndexed : false; + }, + + indexGivenHours = function (givenHoursArray) { + // Store given enabledHours and disabledHours as keys. + // This way we can check their existence in O(1) time instead of looping through whole array. + // (for example: options.enabledHours['2014-02-27'] === true) + var givenHoursIndexed = {}; + $.each(givenHoursArray, function () { + givenHoursIndexed[this] = true; + }); + return (Object.keys(givenHoursIndexed).length) ? givenHoursIndexed : false; + }, + + initFormatting = function () { + var format = options.format || 'L LT'; + + actualFormat = format.replace(/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, function (formatInput) { + var newinput = date.localeData().longDateFormat(formatInput) || formatInput; + return newinput.replace(/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, function (formatInput2) { //temp fix for #740 + return date.localeData().longDateFormat(formatInput2) || formatInput2; + }); + }); + + + parseFormats = options.extraFormats ? options.extraFormats.slice() : []; + if (parseFormats.indexOf(format) < 0 && parseFormats.indexOf(actualFormat) < 0) { + parseFormats.push(actualFormat); + } + + use24Hours = (actualFormat.toLowerCase().indexOf('a') < 1 && actualFormat.replace(/\[.*?\]/g, '').indexOf('h') < 1); + + if (isEnabled('y')) { + minViewModeNumber = 2; + } + if (isEnabled('M')) { + minViewModeNumber = 1; + } + if (isEnabled('d')) { + minViewModeNumber = 0; + } + + currentViewMode = Math.max(minViewModeNumber, currentViewMode); + + if (!unset) { + setValue(date); + } + }; + + /******************************************************************************** + * + * Public API functions + * ===================== + * + * Important: Do not expose direct references to private objects or the options + * object to the outer world. Always return a clone when returning values or make + * a clone when setting a private variable. + * + ********************************************************************************/ + picker.destroy = function () { + ///Destroys the widget and removes all attached event listeners + hide(); + detachDatePickerElementEvents(); + element.removeData('DateTimePicker'); + element.removeData('date'); + }; + + picker.toggle = toggle; + + picker.show = show; + + picker.hide = hide; + + picker.disable = function () { + ///Disables the input element, the component is attached to, by adding a disabled="true" attribute to it. + ///If the widget was visible before that call it is hidden. Possibly emits dp.hide + hide(); + if (component && component.hasClass('btn')) { + component.addClass('disabled'); + } + input.prop('disabled', true); + return picker; + }; + + picker.enable = function () { + ///Enables the input element, the component is attached to, by removing disabled attribute from it. + if (component && component.hasClass('btn')) { + component.removeClass('disabled'); + } + input.prop('disabled', false); + return picker; + }; + + picker.ignoreReadonly = function (ignoreReadonly) { + if (arguments.length === 0) { + return options.ignoreReadonly; + } + if (typeof ignoreReadonly !== 'boolean') { + throw new TypeError('ignoreReadonly () expects a boolean parameter'); + } + options.ignoreReadonly = ignoreReadonly; + return picker; + }; + + picker.options = function (newOptions) { + if (arguments.length === 0) { + return $.extend(true, {}, options); + } + + if (!(newOptions instanceof Object)) { + throw new TypeError('options() options parameter should be an object'); + } + $.extend(true, options, newOptions); + $.each(options, function (key, value) { + if (picker[key] !== undefined) { + picker[key](value); + } else { + throw new TypeError('option ' + key + ' is not recognized!'); + } + }); + return picker; + }; + + picker.date = function (newDate) { + /// + ///Returns the component's model current date, a moment object or null if not set. + ///date.clone() + /// + /// + ///Sets the components model current moment to it. Passing a null value unsets the components model current moment. Parsing of the newDate parameter is made using moment library with the options.format and options.useStrict components configuration. + ///Takes string, Date, moment, null parameter. + /// + if (arguments.length === 0) { + if (unset) { + return null; + } + return date.clone(); + } + + if (newDate !== null && typeof newDate !== 'string' && !moment.isMoment(newDate) && !(newDate instanceof Date)) { + throw new TypeError('date() parameter must be one of [null, string, moment or Date]'); + } + + setValue(newDate === null ? null : parseInputDate(newDate)); + return picker; + }; + + picker.format = function (newFormat) { + ///test su + ///info about para + ///returns foo + if (arguments.length === 0) { + return options.format; + } + + if ((typeof newFormat !== 'string') && ((typeof newFormat !== 'boolean') || (newFormat !== false))) { + throw new TypeError('format() expects a sting or boolean:false parameter ' + newFormat); + } + + options.format = newFormat; + if (actualFormat) { + initFormatting(); // reinit formatting + } + return picker; + }; + + picker.dayViewHeaderFormat = function (newFormat) { + if (arguments.length === 0) { + return options.dayViewHeaderFormat; + } + + if (typeof newFormat !== 'string') { + throw new TypeError('dayViewHeaderFormat() expects a string parameter'); + } + + options.dayViewHeaderFormat = newFormat; + return picker; + }; + + picker.extraFormats = function (formats) { + if (arguments.length === 0) { + return options.extraFormats; + } + + if (formats !== false && !(formats instanceof Array)) { + throw new TypeError('extraFormats() expects an array or false parameter'); + } + + options.extraFormats = formats; + if (parseFormats) { + initFormatting(); // reinit formatting + } + return picker; + }; + + picker.disabledDates = function (dates) { + /// + ///Returns an array with the currently set disabled dates on the component. + ///options.disabledDates + /// + /// + ///Setting this takes precedence over options.minDate, options.maxDate configuration. Also calling this function removes the configuration of + ///options.enabledDates if such exist. + ///Takes an [ string or Date or moment ] of values and allows the user to select only from those days. + /// + if (arguments.length === 0) { + return (options.disabledDates ? $.extend({}, options.disabledDates) : options.disabledDates); + } + + if (!dates) { + options.disabledDates = false; + update(); + return picker; + } + if (!(dates instanceof Array)) { + throw new TypeError('disabledDates() expects an array parameter'); + } + options.disabledDates = indexGivenDates(dates); + options.enabledDates = false; + update(); + return picker; + }; + + picker.enabledDates = function (dates) { + /// + ///Returns an array with the currently set enabled dates on the component. + ///options.enabledDates + /// + /// + ///Setting this takes precedence over options.minDate, options.maxDate configuration. Also calling this function removes the configuration of options.disabledDates if such exist. + ///Takes an [ string or Date or moment ] of values and allows the user to select only from those days. + /// + if (arguments.length === 0) { + return (options.enabledDates ? $.extend({}, options.enabledDates) : options.enabledDates); + } + + if (!dates) { + options.enabledDates = false; + update(); + return picker; + } + if (!(dates instanceof Array)) { + throw new TypeError('enabledDates() expects an array parameter'); + } + options.enabledDates = indexGivenDates(dates); + options.disabledDates = false; + update(); + return picker; + }; + + picker.daysOfWeekDisabled = function (daysOfWeekDisabled) { + if (arguments.length === 0) { + return options.daysOfWeekDisabled.splice(0); + } + + if ((typeof daysOfWeekDisabled === 'boolean') && !daysOfWeekDisabled) { + options.daysOfWeekDisabled = false; + update(); + return picker; + } + + if (!(daysOfWeekDisabled instanceof Array)) { + throw new TypeError('daysOfWeekDisabled() expects an array parameter'); + } + options.daysOfWeekDisabled = daysOfWeekDisabled.reduce(function (previousValue, currentValue) { + currentValue = parseInt(currentValue, 10); + if (currentValue > 6 || currentValue < 0 || isNaN(currentValue)) { + return previousValue; + } + if (previousValue.indexOf(currentValue) === -1) { + previousValue.push(currentValue); + } + return previousValue; + }, []).sort(); + if (options.useCurrent && !options.keepInvalid) { + var tries = 0; + while (!isValid(date, 'd')) { + date.add(1, 'd'); + if (tries === 7) { + throw 'Tried 7 times to find a valid date'; + } + tries++; + } + setValue(date); + } + update(); + return picker; + }; + + picker.maxDate = function (maxDate) { + if (arguments.length === 0) { + return options.maxDate ? options.maxDate.clone() : options.maxDate; + } + + if ((typeof maxDate === 'boolean') && maxDate === false) { + options.maxDate = false; + update(); + return picker; + } + + if (typeof maxDate === 'string') { + if (maxDate === 'now' || maxDate === 'moment') { + maxDate = moment(); + } + } + + var parsedDate = parseInputDate(maxDate); + + if (!parsedDate.isValid()) { + throw new TypeError('maxDate() Could not parse date parameter: ' + maxDate); + } + if (options.minDate && parsedDate.isBefore(options.minDate)) { + throw new TypeError('maxDate() date parameter is before options.minDate: ' + parsedDate.format(actualFormat)); + } + options.maxDate = parsedDate; + if (options.useCurrent && !options.keepInvalid && date.isAfter(maxDate)) { + setValue(options.maxDate); + } + if (viewDate.isAfter(parsedDate)) { + viewDate = parsedDate.clone().subtract(options.stepping, 'm'); + } + update(); + return picker; + }; + + picker.minDate = function (minDate) { + if (arguments.length === 0) { + return options.minDate ? options.minDate.clone() : options.minDate; + } + + if ((typeof minDate === 'boolean') && minDate === false) { + options.minDate = false; + update(); + return picker; + } + + if (typeof minDate === 'string') { + if (minDate === 'now' || minDate === 'moment') { + minDate = moment(); + } + } + + var parsedDate = parseInputDate(minDate); + + if (!parsedDate.isValid()) { + throw new TypeError('minDate() Could not parse date parameter: ' + minDate); + } + if (options.maxDate && parsedDate.isAfter(options.maxDate)) { + throw new TypeError('minDate() date parameter is after options.maxDate: ' + parsedDate.format(actualFormat)); + } + options.minDate = parsedDate; + if (options.useCurrent && !options.keepInvalid && date.isBefore(minDate)) { + setValue(options.minDate); + } + if (viewDate.isBefore(parsedDate)) { + viewDate = parsedDate.clone().add(options.stepping, 'm'); + } + update(); + return picker; + }; + + picker.defaultDate = function (defaultDate) { + /// + ///Returns a moment with the options.defaultDate option configuration or false if not set + ///date.clone() + /// + /// + ///Will set the picker's inital date. If a boolean:false value is passed the options.defaultDate parameter is cleared. + ///Takes a string, Date, moment, boolean:false + /// + if (arguments.length === 0) { + return options.defaultDate ? options.defaultDate.clone() : options.defaultDate; + } + if (!defaultDate) { + options.defaultDate = false; + return picker; + } + + if (typeof defaultDate === 'string') { + if (defaultDate === 'now' || defaultDate === 'moment') { + defaultDate = moment(); + } + } + + var parsedDate = parseInputDate(defaultDate); + if (!parsedDate.isValid()) { + throw new TypeError('defaultDate() Could not parse date parameter: ' + defaultDate); + } + if (!isValid(parsedDate)) { + throw new TypeError('defaultDate() date passed is invalid according to component setup validations'); + } + + options.defaultDate = parsedDate; + + if (options.defaultDate && options.inline || (input.val().trim() === '' && input.attr('placeholder') === undefined)) { + setValue(options.defaultDate); + } + return picker; + }; + + picker.locale = function (locale) { + if (arguments.length === 0) { + return options.locale; + } + + if (!moment.localeData(locale)) { + throw new TypeError('locale() locale ' + locale + ' is not loaded from moment locales!'); + } + + options.locale = locale; + date.locale(options.locale); + viewDate.locale(options.locale); + + if (actualFormat) { + initFormatting(); // reinit formatting + } + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.stepping = function (stepping) { + if (arguments.length === 0) { + return options.stepping; + } + + stepping = parseInt(stepping, 10); + if (isNaN(stepping) || stepping < 1) { + stepping = 1; + } + options.stepping = stepping; + return picker; + }; + + picker.useCurrent = function (useCurrent) { + var useCurrentOptions = ['year', 'month', 'day', 'hour', 'minute']; + if (arguments.length === 0) { + return options.useCurrent; + } + + if ((typeof useCurrent !== 'boolean') && (typeof useCurrent !== 'string')) { + throw new TypeError('useCurrent() expects a boolean or string parameter'); + } + if (typeof useCurrent === 'string' && useCurrentOptions.indexOf(useCurrent.toLowerCase()) === -1) { + throw new TypeError('useCurrent() expects a string parameter of ' + useCurrentOptions.join(', ')); + } + options.useCurrent = useCurrent; + return picker; + }; + + picker.collapse = function (collapse) { + if (arguments.length === 0) { + return options.collapse; + } + + if (typeof collapse !== 'boolean') { + throw new TypeError('collapse() expects a boolean parameter'); + } + if (options.collapse === collapse) { + return picker; + } + options.collapse = collapse; + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.icons = function (icons) { + if (arguments.length === 0) { + return $.extend({}, options.icons); + } + + if (!(icons instanceof Object)) { + throw new TypeError('icons() expects parameter to be an Object'); + } + $.extend(options.icons, icons); + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.tooltips = function (tooltips) { + if (arguments.length === 0) { + return $.extend({}, options.tooltips); + } + + if (!(tooltips instanceof Object)) { + throw new TypeError('tooltips() expects parameter to be an Object'); + } + $.extend(options.tooltips, tooltips); + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.useStrict = function (useStrict) { + if (arguments.length === 0) { + return options.useStrict; + } + + if (typeof useStrict !== 'boolean') { + throw new TypeError('useStrict() expects a boolean parameter'); + } + options.useStrict = useStrict; + return picker; + }; + + picker.sideBySide = function (sideBySide) { + if (arguments.length === 0) { + return options.sideBySide; + } + + if (typeof sideBySide !== 'boolean') { + throw new TypeError('sideBySide() expects a boolean parameter'); + } + options.sideBySide = sideBySide; + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.viewMode = function (viewMode) { + if (arguments.length === 0) { + return options.viewMode; + } + + if (typeof viewMode !== 'string') { + throw new TypeError('viewMode() expects a string parameter'); + } + + if (viewModes.indexOf(viewMode) === -1) { + throw new TypeError('viewMode() parameter must be one of (' + viewModes.join(', ') + ') value'); + } + + options.viewMode = viewMode; + currentViewMode = Math.max(viewModes.indexOf(viewMode), minViewModeNumber); + + showMode(); + return picker; + }; + + picker.toolbarPlacement = function (toolbarPlacement) { + if (arguments.length === 0) { + return options.toolbarPlacement; + } + + if (typeof toolbarPlacement !== 'string') { + throw new TypeError('toolbarPlacement() expects a string parameter'); + } + if (toolbarPlacements.indexOf(toolbarPlacement) === -1) { + throw new TypeError('toolbarPlacement() parameter must be one of (' + toolbarPlacements.join(', ') + ') value'); + } + options.toolbarPlacement = toolbarPlacement; + + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.widgetPositioning = function (widgetPositioning) { + if (arguments.length === 0) { + return $.extend({}, options.widgetPositioning); + } + + if (({}).toString.call(widgetPositioning) !== '[object Object]') { + throw new TypeError('widgetPositioning() expects an object variable'); + } + if (widgetPositioning.horizontal) { + if (typeof widgetPositioning.horizontal !== 'string') { + throw new TypeError('widgetPositioning() horizontal variable must be a string'); + } + widgetPositioning.horizontal = widgetPositioning.horizontal.toLowerCase(); + if (horizontalModes.indexOf(widgetPositioning.horizontal) === -1) { + throw new TypeError('widgetPositioning() expects horizontal parameter to be one of (' + horizontalModes.join(', ') + ')'); + } + options.widgetPositioning.horizontal = widgetPositioning.horizontal; + } + if (widgetPositioning.vertical) { + if (typeof widgetPositioning.vertical !== 'string') { + throw new TypeError('widgetPositioning() vertical variable must be a string'); + } + widgetPositioning.vertical = widgetPositioning.vertical.toLowerCase(); + if (verticalModes.indexOf(widgetPositioning.vertical) === -1) { + throw new TypeError('widgetPositioning() expects vertical parameter to be one of (' + verticalModes.join(', ') + ')'); + } + options.widgetPositioning.vertical = widgetPositioning.vertical; + } + update(); + return picker; + }; + + picker.calendarWeeks = function (calendarWeeks) { + if (arguments.length === 0) { + return options.calendarWeeks; + } + + if (typeof calendarWeeks !== 'boolean') { + throw new TypeError('calendarWeeks() expects parameter to be a boolean value'); + } + + options.calendarWeeks = calendarWeeks; + update(); + return picker; + }; + + picker.showTodayButton = function (showTodayButton) { + if (arguments.length === 0) { + return options.showTodayButton; + } + + if (typeof showTodayButton !== 'boolean') { + throw new TypeError('showTodayButton() expects a boolean parameter'); + } + + options.showTodayButton = showTodayButton; + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.showClear = function (showClear) { + if (arguments.length === 0) { + return options.showClear; + } + + if (typeof showClear !== 'boolean') { + throw new TypeError('showClear() expects a boolean parameter'); + } + + options.showClear = showClear; + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.widgetParent = function (widgetParent) { + if (arguments.length === 0) { + return options.widgetParent; + } + + if (typeof widgetParent === 'string') { + widgetParent = $(widgetParent); + } + + if (widgetParent !== null && (typeof widgetParent !== 'string' && !(widgetParent instanceof $))) { + throw new TypeError('widgetParent() expects a string or a jQuery object parameter'); + } + + options.widgetParent = widgetParent; + if (widget) { + hide(); + show(); + } + return picker; + }; + + picker.keepOpen = function (keepOpen) { + if (arguments.length === 0) { + return options.keepOpen; + } + + if (typeof keepOpen !== 'boolean') { + throw new TypeError('keepOpen() expects a boolean parameter'); + } + + options.keepOpen = keepOpen; + return picker; + }; + + picker.focusOnShow = function (focusOnShow) { + if (arguments.length === 0) { + return options.focusOnShow; + } + + if (typeof focusOnShow !== 'boolean') { + throw new TypeError('focusOnShow() expects a boolean parameter'); + } + + options.focusOnShow = focusOnShow; + return picker; + }; + + picker.inline = function (inline) { + if (arguments.length === 0) { + return options.inline; + } + + if (typeof inline !== 'boolean') { + throw new TypeError('inline() expects a boolean parameter'); + } + + options.inline = inline; + return picker; + }; + + picker.clear = function () { + clear(); + return picker; + }; + + picker.keyBinds = function (keyBinds) { + options.keyBinds = keyBinds; + return picker; + }; + + picker.debug = function (debug) { + if (typeof debug !== 'boolean') { + throw new TypeError('debug() expects a boolean parameter'); + } + + options.debug = debug; + return picker; + }; + + picker.allowInputToggle = function (allowInputToggle) { + if (arguments.length === 0) { + return options.allowInputToggle; + } + + if (typeof allowInputToggle !== 'boolean') { + throw new TypeError('allowInputToggle() expects a boolean parameter'); + } + + options.allowInputToggle = allowInputToggle; + return picker; + }; + + picker.showClose = function (showClose) { + if (arguments.length === 0) { + return options.showClose; + } + + if (typeof showClose !== 'boolean') { + throw new TypeError('showClose() expects a boolean parameter'); + } + + options.showClose = showClose; + return picker; + }; + + picker.keepInvalid = function (keepInvalid) { + if (arguments.length === 0) { + return options.keepInvalid; + } + + if (typeof keepInvalid !== 'boolean') { + throw new TypeError('keepInvalid() expects a boolean parameter'); + } + options.keepInvalid = keepInvalid; + return picker; + }; + + picker.datepickerInput = function (datepickerInput) { + if (arguments.length === 0) { + return options.datepickerInput; + } + + if (typeof datepickerInput !== 'string') { + throw new TypeError('datepickerInput() expects a string parameter'); + } + + options.datepickerInput = datepickerInput; + return picker; + }; + + picker.parseInputDate = function (parseInputDate) { + if (arguments.length === 0) { + return options.parseInputDate; + } + + if (typeof parseInputDate !== 'function') { + throw new TypeError('parseInputDate() sholud be as function'); + } + + options.parseInputDate = parseInputDate; + + return picker; + }; + + picker.disabledTimeIntervals = function (disabledTimeIntervals) { + /// + ///Returns an array with the currently set disabled dates on the component. + ///options.disabledTimeIntervals + /// + /// + ///Setting this takes precedence over options.minDate, options.maxDate configuration. Also calling this function removes the configuration of + ///options.enabledDates if such exist. + ///Takes an [ string or Date or moment ] of values and allows the user to select only from those days. + /// + if (arguments.length === 0) { + return (options.disabledTimeIntervals ? $.extend({}, options.disabledTimeIntervals) : options.disabledTimeIntervals); + } + + if (!disabledTimeIntervals) { + options.disabledTimeIntervals = false; + update(); + return picker; + } + if (!(disabledTimeIntervals instanceof Array)) { + throw new TypeError('disabledTimeIntervals() expects an array parameter'); + } + options.disabledTimeIntervals = disabledTimeIntervals; + update(); + return picker; + }; + + picker.disabledHours = function (hours) { + /// + ///Returns an array with the currently set disabled hours on the component. + ///options.disabledHours + /// + /// + ///Setting this takes precedence over options.minDate, options.maxDate configuration. Also calling this function removes the configuration of + ///options.enabledHours if such exist. + ///Takes an [ int ] of values and disallows the user to select only from those hours. + /// + if (arguments.length === 0) { + return (options.disabledHours ? $.extend({}, options.disabledHours) : options.disabledHours); + } + + if (!hours) { + options.disabledHours = false; + update(); + return picker; + } + if (!(hours instanceof Array)) { + throw new TypeError('disabledHours() expects an array parameter'); + } + options.disabledHours = indexGivenHours(hours); + options.enabledHours = false; + if (options.useCurrent && !options.keepInvalid) { + var tries = 0; + while (!isValid(date, 'h')) { + date.add(1, 'h'); + if (tries === 24) { + throw 'Tried 24 times to find a valid date'; + } + tries++; + } + setValue(date); + } + update(); + return picker; + }; + + picker.enabledHours = function (hours) { + /// + ///Returns an array with the currently set enabled hours on the component. + ///options.enabledHours + /// + /// + ///Setting this takes precedence over options.minDate, options.maxDate configuration. Also calling this function removes the configuration of options.disabledHours if such exist. + ///Takes an [ int ] of values and allows the user to select only from those hours. + /// + if (arguments.length === 0) { + return (options.enabledHours ? $.extend({}, options.enabledHours) : options.enabledHours); + } + + if (!hours) { + options.enabledHours = false; + update(); + return picker; + } + if (!(hours instanceof Array)) { + throw new TypeError('enabledHours() expects an array parameter'); + } + options.enabledHours = indexGivenHours(hours); + options.disabledHours = false; + if (options.useCurrent && !options.keepInvalid) { + var tries = 0; + while (!isValid(date, 'h')) { + date.add(1, 'h'); + if (tries === 24) { + throw 'Tried 24 times to find a valid date'; + } + tries++; + } + setValue(date); + } + update(); + return picker; + }; + + picker.viewDate = function (newDate) { + /// + ///Returns the component's model current viewDate, a moment object or null if not set. + ///viewDate.clone() + /// + /// + ///Sets the components model current moment to it. Passing a null value unsets the components model current moment. Parsing of the newDate parameter is made using moment library with the options.format and options.useStrict components configuration. + ///Takes string, viewDate, moment, null parameter. + /// + if (arguments.length === 0) { + return viewDate.clone(); + } + + if (!newDate) { + viewDate = date.clone(); + return picker; + } + + if (typeof newDate !== 'string' && !moment.isMoment(newDate) && !(newDate instanceof Date)) { + throw new TypeError('viewDate() parameter must be one of [string, moment or Date]'); + } + + viewDate = parseInputDate(newDate); + viewUpdate(); + return picker; + }; + + // initializing element and component attributes + if (element.is('input')) { + input = element; + } else { + input = element.find(options.datepickerInput); + if (input.size() === 0) { + input = element.find('input'); + } else if (!input.is('input')) { + throw new Error('CSS class "' + options.datepickerInput + '" cannot be applied to non input element'); + } + } + + if (element.hasClass('input-group')) { + // in case there is more then one 'input-group-addon' Issue #48 + if (element.find('.datepickerbutton').size() === 0) { + component = element.find('.input-group-addon'); + } else { + component = element.find('.datepickerbutton'); + } + } + + if (!options.inline && !input.is('input')) { + throw new Error('Could not initialize DateTimePicker without an input element'); + } + + $.extend(true, options, dataToOptions()); + + picker.options(options); + + initFormatting(); + + attachDatePickerElementEvents(); + + if (input.prop('disabled')) { + picker.disable(); + } + if (input.is('input') && input.val().trim().length !== 0) { + setValue(parseInputDate(input.val().trim())); + } + else if (options.defaultDate && input.attr('placeholder') === undefined) { + setValue(options.defaultDate); + } + if (options.inline) { + show(); + } + return picker; + }; + + /******************************************************************************** + * + * jQuery plugin constructor and defaults object + * + ********************************************************************************/ + + $.fn.datetimepicker = function (options) { + return this.each(function () { + var $this = $(this); + if (!$this.data('DateTimePicker')) { + // create a private copy of the defaults object + options = $.extend(true, {}, $.fn.datetimepicker.defaults, options); + $this.data('DateTimePicker', dateTimePicker($this, options)); + } + }); + }; + + $.fn.datetimepicker.defaults = { + format: false, + dayViewHeaderFormat: 'MMMM YYYY', + extraFormats: false, + stepping: 1, + minDate: false, + maxDate: false, + useCurrent: true, + collapse: true, + locale: moment.locale(), + defaultDate: false, + disabledDates: false, + enabledDates: false, + icons: { + time: 'glyphicon glyphicon-time', + date: 'glyphicon glyphicon-calendar', + up: 'glyphicon glyphicon-chevron-up', + down: 'glyphicon glyphicon-chevron-down', + previous: 'glyphicon glyphicon-chevron-left', + next: 'glyphicon glyphicon-chevron-right', + today: 'glyphicon glyphicon-screenshot', + clear: 'glyphicon glyphicon-trash', + close: 'glyphicon glyphicon-remove' + }, + tooltips: { + today: 'Go to today', + clear: 'Clear selection', + close: 'Close the picker', + selectMonth: 'Select Month', + prevMonth: 'Previous Month', + nextMonth: 'Next Month', + selectYear: 'Select Year', + prevYear: 'Previous Year', + nextYear: 'Next Year', + selectDecade: 'Select Decade', + prevDecade: 'Previous Decade', + nextDecade: 'Next Decade', + prevCentury: 'Previous Century', + nextCentury: 'Next Century' + }, + useStrict: false, + sideBySide: false, + daysOfWeekDisabled: false, + calendarWeeks: false, + viewMode: 'days', + toolbarPlacement: 'default', + showTodayButton: false, + showClear: false, + showClose: false, + widgetPositioning: { + horizontal: 'auto', + vertical: 'auto' + }, + widgetParent: null, + ignoreReadonly: false, + keepOpen: false, + focusOnShow: true, + inline: false, + keepInvalid: false, + datepickerInput: '.datepickerinput', + keyBinds: { + up: function (widget) { + if (!widget) { + return; + } + var d = this.date() || moment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().subtract(7, 'd')); + } else { + this.date(d.clone().add(this.stepping(), 'm')); + } + }, + down: function (widget) { + if (!widget) { + this.show(); + return; + } + var d = this.date() || moment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().add(7, 'd')); + } else { + this.date(d.clone().subtract(this.stepping(), 'm')); + } + }, + 'control up': function (widget) { + if (!widget) { + return; + } + var d = this.date() || moment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().subtract(1, 'y')); + } else { + this.date(d.clone().add(1, 'h')); + } + }, + 'control down': function (widget) { + if (!widget) { + return; + } + var d = this.date() || moment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().add(1, 'y')); + } else { + this.date(d.clone().subtract(1, 'h')); + } + }, + left: function (widget) { + if (!widget) { + return; + } + var d = this.date() || moment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().subtract(1, 'd')); + } + }, + right: function (widget) { + if (!widget) { + return; + } + var d = this.date() || moment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().add(1, 'd')); + } + }, + pageUp: function (widget) { + if (!widget) { + return; + } + var d = this.date() || moment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().subtract(1, 'M')); + } + }, + pageDown: function (widget) { + if (!widget) { + return; + } + var d = this.date() || moment(); + if (widget.find('.datepicker').is(':visible')) { + this.date(d.clone().add(1, 'M')); + } + }, + enter: function () { + this.hide(); + }, + escape: function () { + this.hide(); + }, + //tab: function (widget) { //this break the flow of the form. disabling for now + // var toggle = widget.find('.picker-switch a[data-action="togglePicker"]'); + // if(toggle.length > 0) toggle.click(); + //}, + 'control space': function (widget) { + if (widget.find('.timepicker').is(':visible')) { + widget.find('.btn[data-action="togglePeriod"]').click(); + } + }, + t: function () { + this.date(moment()); + }, + 'delete': function () { + this.clear(); + } + }, + debug: false, + allowInputToggle: false, + disabledTimeIntervals: false, + disabledHours: false, + enabledHours: false, + viewDate: false + }; +})); diff --git a/PlexRequests.UI/Scripts/bootstrap-datetimepicker.min.js b/PlexRequests.UI/Scripts/bootstrap-datetimepicker.min.js new file mode 100644 index 000000000..bd6487093 --- /dev/null +++ b/PlexRequests.UI/Scripts/bootstrap-datetimepicker.min.js @@ -0,0 +1,9 @@ +/*! version : 4.15.35 + ========================================================= + bootstrap-datetimejs + https://github.com/Eonasdan/bootstrap-datetimepicker + Copyright (c) 2015 Jonathan Peterson + ========================================================= + */ +!function(a){"use strict";if("function"==typeof define&&define.amd)define(["jquery","moment"],a);else if("object"==typeof exports)a(require("jquery"),require("moment"));else{if("undefined"==typeof jQuery)throw"bootstrap-datetimepicker requires jQuery to be loaded first";if("undefined"==typeof moment)throw"bootstrap-datetimepicker requires Moment.js to be loaded first";a(jQuery,moment)}}(function(a,b){"use strict";if(!b)throw new Error("bootstrap-datetimepicker requires Moment.js to be loaded first");var c=function(c,d){var e,f,g,h,i,j={},k=b().startOf("d"),l=k.clone(),m=!0,n=!1,o=!1,p=0,q=[{clsName:"days",navFnc:"M",navStep:1},{clsName:"months",navFnc:"y",navStep:1},{clsName:"years",navFnc:"y",navStep:10},{clsName:"decades",navFnc:"y",navStep:100}],r=["days","months","years","decades"],s=["top","bottom","auto"],t=["left","right","auto"],u=["default","top","bottom"],v={up:38,38:"up",down:40,40:"down",left:37,37:"left",right:39,39:"right",tab:9,9:"tab",escape:27,27:"escape",enter:13,13:"enter",pageUp:33,33:"pageUp",pageDown:34,34:"pageDown",shift:16,16:"shift",control:17,17:"control",space:32,32:"space",t:84,84:"t","delete":46,46:"delete"},w={},x=function(a){if("string"!=typeof a||a.length>1)throw new TypeError("isEnabled expects a single character string parameter");switch(a){case"y":return-1!==g.indexOf("Y");case"M":return-1!==g.indexOf("M");case"d":return-1!==g.toLowerCase().indexOf("d");case"h":case"H":return-1!==g.toLowerCase().indexOf("h");case"m":return-1!==g.indexOf("m");case"s":return-1!==g.indexOf("s");default:return!1}},y=function(){return x("h")||x("m")||x("s")},z=function(){return x("y")||x("M")||x("d")},A=function(){var b=a("").append(a("").append(a("").append(a("").append(a("
    ').addClass('cw').text('#')); + } + + while (currentDate.isBefore(viewDate.clone().endOf('w'))) { + row.append($('').addClass('dow').text(currentDate.format('dd'))); + currentDate.add(1, 'd'); + } + widget.find('.datepicker-days thead').append(row); + }, + + isInDisabledDates = function (testDate) { + return options.disabledDates[testDate.format('YYYY-MM-DD')] === true; + }, + + isInEnabledDates = function (testDate) { + return options.enabledDates[testDate.format('YYYY-MM-DD')] === true; + }, + + isInDisabledHours = function (testDate) { + return options.disabledHours[testDate.format('H')] === true; + }, + + isInEnabledHours = function (testDate) { + return options.enabledHours[testDate.format('H')] === true; + }, + + isValid = function (targetMoment, granularity) { + if (!targetMoment.isValid()) { + return false; + } + if (options.disabledDates && granularity === 'd' && isInDisabledDates(targetMoment)) { + return false; + } + if (options.enabledDates && granularity === 'd' && !isInEnabledDates(targetMoment)) { + return false; + } + if (options.minDate && targetMoment.isBefore(options.minDate, granularity)) { + return false; + } + if (options.maxDate && targetMoment.isAfter(options.maxDate, granularity)) { + return false; + } + if (options.daysOfWeekDisabled && granularity === 'd' && options.daysOfWeekDisabled.indexOf(targetMoment.day()) !== -1) { + return false; + } + if (options.disabledHours && (granularity === 'h' || granularity === 'm' || granularity === 's') && isInDisabledHours(targetMoment)) { + return false; + } + if (options.enabledHours && (granularity === 'h' || granularity === 'm' || granularity === 's') && !isInEnabledHours(targetMoment)) { + return false; + } + if (options.disabledTimeIntervals && (granularity === 'h' || granularity === 'm' || granularity === 's')) { + var found = false; + $.each(options.disabledTimeIntervals, function () { + if (targetMoment.isBetween(this[0], this[1])) { + found = true; + return false; + } + }); + if (found) { + return false; + } + } + return true; + }, + + fillMonths = function () { + var spans = [], + monthsShort = viewDate.clone().startOf('y').startOf('d'); + while (monthsShort.isSame(viewDate, 'y')) { + spans.push($('').attr('data-action', 'selectMonth').addClass('month').text(monthsShort.format('MMM'))); + monthsShort.add(1, 'M'); + } + widget.find('.datepicker-months td').empty().append(spans); + }, + + updateMonths = function () { + var monthsView = widget.find('.datepicker-months'), + monthsViewHeader = monthsView.find('th'), + months = monthsView.find('tbody').find('span'); + + monthsViewHeader.eq(0).find('span').attr('title', options.tooltips.prevYear); + monthsViewHeader.eq(1).attr('title', options.tooltips.selectYear); + monthsViewHeader.eq(2).find('span').attr('title', options.tooltips.nextYear); + + monthsView.find('.disabled').removeClass('disabled'); + + if (!isValid(viewDate.clone().subtract(1, 'y'), 'y')) { + monthsViewHeader.eq(0).addClass('disabled'); + } + + monthsViewHeader.eq(1).text(viewDate.year()); + + if (!isValid(viewDate.clone().add(1, 'y'), 'y')) { + monthsViewHeader.eq(2).addClass('disabled'); + } + + months.removeClass('active'); + if (date.isSame(viewDate, 'y') && !unset) { + months.eq(date.month()).addClass('active'); + } + + months.each(function (index) { + if (!isValid(viewDate.clone().month(index), 'M')) { + $(this).addClass('disabled'); + } + }); + }, + + updateYears = function () { + var yearsView = widget.find('.datepicker-years'), + yearsViewHeader = yearsView.find('th'), + startYear = viewDate.clone().subtract(5, 'y'), + endYear = viewDate.clone().add(6, 'y'), + html = ''; + + yearsViewHeader.eq(0).find('span').attr('title', options.tooltips.nextDecade); + yearsViewHeader.eq(1).attr('title', options.tooltips.selectDecade); + yearsViewHeader.eq(2).find('span').attr('title', options.tooltips.prevDecade); + + yearsView.find('.disabled').removeClass('disabled'); + + if (options.minDate && options.minDate.isAfter(startYear, 'y')) { + yearsViewHeader.eq(0).addClass('disabled'); + } + + yearsViewHeader.eq(1).text(startYear.year() + '-' + endYear.year()); + + if (options.maxDate && options.maxDate.isBefore(endYear, 'y')) { + yearsViewHeader.eq(2).addClass('disabled'); + } + + while (!startYear.isAfter(endYear, 'y')) { + html += '' + startYear.year() + ''; + startYear.add(1, 'y'); + } + + yearsView.find('td').html(html); + }, + + updateDecades = function () { + var decadesView = widget.find('.datepicker-decades'), + decadesViewHeader = decadesView.find('th'), + startDecade = viewDate.isBefore(moment({y: 1999})) ? moment({y: 1899}) : moment({y: 1999}), + endDecade = startDecade.clone().add(100, 'y'), + html = ''; + + decadesViewHeader.eq(0).find('span').attr('title', options.tooltips.prevCentury); + decadesViewHeader.eq(2).find('span').attr('title', options.tooltips.nextCentury); + + decadesView.find('.disabled').removeClass('disabled'); + + if (startDecade.isSame(moment({y: 1900})) || (options.minDate && options.minDate.isAfter(startDecade, 'y'))) { + decadesViewHeader.eq(0).addClass('disabled'); + } + + decadesViewHeader.eq(1).text(startDecade.year() + '-' + endDecade.year()); + + if (startDecade.isSame(moment({y: 2000})) || (options.maxDate && options.maxDate.isBefore(endDecade, 'y'))) { + decadesViewHeader.eq(2).addClass('disabled'); + } + + while (!startDecade.isAfter(endDecade, 'y')) { + html += '' + (startDecade.year() + 1) + ' - ' + (startDecade.year() + 12) + ''; + startDecade.add(12, 'y'); + } + html += ''; //push the dangling block over, at least this way it's even + + decadesView.find('td').html(html); + }, + + fillDate = function () { + var daysView = widget.find('.datepicker-days'), + daysViewHeader = daysView.find('th'), + currentDate, + html = [], + row, + clsName, + i; + + if (!hasDate()) { + return; + } + + daysViewHeader.eq(0).find('span').attr('title', options.tooltips.prevMonth); + daysViewHeader.eq(1).attr('title', options.tooltips.selectMonth); + daysViewHeader.eq(2).find('span').attr('title', options.tooltips.nextMonth); + + daysView.find('.disabled').removeClass('disabled'); + daysViewHeader.eq(1).text(viewDate.format(options.dayViewHeaderFormat)); + + if (!isValid(viewDate.clone().subtract(1, 'M'), 'M')) { + daysViewHeader.eq(0).addClass('disabled'); + } + if (!isValid(viewDate.clone().add(1, 'M'), 'M')) { + daysViewHeader.eq(2).addClass('disabled'); + } + + currentDate = viewDate.clone().startOf('M').startOf('w').startOf('d'); + + for (i = 0; i < 42; i++) { //always display 42 days (should show 6 weeks) + if (currentDate.weekday() === 0) { + row = $('
    ' + currentDate.week() + '' + currentDate.date() + '
    ' + currentHour.format(use24Hours ? 'HH' : 'hh') + '
    ' + currentMinute.format('mm') + '
    ' + currentSecond.format('ss') + '
    ").addClass("prev").attr("data-action","previous").append(a("").addClass(d.icons.previous))).append(a("").addClass("picker-switch").attr("data-action","pickerSwitch").attr("colspan",d.calendarWeeks?"6":"5")).append(a("").addClass("next").attr("data-action","next").append(a("").addClass(d.icons.next)))),c=a("
    ").attr("colspan",d.calendarWeeks?"8":"7")));return[a("
    ").addClass("datepicker-days").append(a("").addClass("table-condensed").append(b).append(a(""))),a("
    ").addClass("datepicker-months").append(a("
    ").addClass("table-condensed").append(b.clone()).append(c.clone())),a("
    ").addClass("datepicker-years").append(a("
    ").addClass("table-condensed").append(b.clone()).append(c.clone())),a("
    ").addClass("datepicker-decades").append(a("
    ").addClass("table-condensed").append(b.clone()).append(c.clone()))]},B=function(){var b=a(""),c=a(""),e=a("");return x("h")&&(b.append(a("
    ").append(a("").attr({href:"#",tabindex:"-1",title:"Increment Hour"}).addClass("btn").attr("data-action","incrementHours").append(a("").addClass(d.icons.up)))),c.append(a("").append(a("").addClass("timepicker-hour").attr({"data-time-component":"hours",title:"Pick Hour"}).attr("data-action","showHours"))),e.append(a("").append(a("").attr({href:"#",tabindex:"-1",title:"Decrement Hour"}).addClass("btn").attr("data-action","decrementHours").append(a("").addClass(d.icons.down))))),x("m")&&(x("h")&&(b.append(a("").addClass("separator")),c.append(a("").addClass("separator").html(":")),e.append(a("").addClass("separator"))),b.append(a("").append(a("").attr({href:"#",tabindex:"-1",title:"Increment Minute"}).addClass("btn").attr("data-action","incrementMinutes").append(a("").addClass(d.icons.up)))),c.append(a("").append(a("").addClass("timepicker-minute").attr({"data-time-component":"minutes",title:"Pick Minute"}).attr("data-action","showMinutes"))),e.append(a("").append(a("").attr({href:"#",tabindex:"-1",title:"Decrement Minute"}).addClass("btn").attr("data-action","decrementMinutes").append(a("").addClass(d.icons.down))))),x("s")&&(x("m")&&(b.append(a("").addClass("separator")),c.append(a("").addClass("separator").html(":")),e.append(a("").addClass("separator"))),b.append(a("").append(a("").attr({href:"#",tabindex:"-1",title:"Increment Second"}).addClass("btn").attr("data-action","incrementSeconds").append(a("").addClass(d.icons.up)))),c.append(a("").append(a("").addClass("timepicker-second").attr({"data-time-component":"seconds",title:"Pick Second"}).attr("data-action","showSeconds"))),e.append(a("").append(a("").attr({href:"#",tabindex:"-1",title:"Decrement Second"}).addClass("btn").attr("data-action","decrementSeconds").append(a("").addClass(d.icons.down))))),f||(b.append(a("").addClass("separator")),c.append(a("").append(a("").addClass("separator"))),a("
    ").addClass("timepicker-picker").append(a("").addClass("table-condensed").append([b,c,e]))},C=function(){var b=a("
    ").addClass("timepicker-hours").append(a("
    ").addClass("table-condensed")),c=a("
    ").addClass("timepicker-minutes").append(a("
    ").addClass("table-condensed")),d=a("
    ").addClass("timepicker-seconds").append(a("
    ").addClass("table-condensed")),e=[B()];return x("h")&&e.push(b),x("m")&&e.push(c),x("s")&&e.push(d),e},D=function(){var b=[];return d.showTodayButton&&b.push(a("
    ").append(a("").attr({"data-action":"today",title:d.tooltips.today}).append(a("").addClass(d.icons.today)))),!d.sideBySide&&z()&&y()&&b.push(a("").append(a("").attr({"data-action":"togglePicker",title:"Select Time"}).append(a("").addClass(d.icons.time)))),d.showClear&&b.push(a("").append(a("").attr({"data-action":"clear",title:d.tooltips.clear}).append(a("").addClass(d.icons.clear)))),d.showClose&&b.push(a("").append(a("").attr({"data-action":"close",title:d.tooltips.close}).append(a("").addClass(d.icons.close)))),a("").addClass("table-condensed").append(a("").append(a("").append(b)))},E=function(){var b=a("
    ").addClass("bootstrap-datetimepicker-widget dropdown-menu"),c=a("
    ").addClass("datepicker").append(A()),e=a("
    ").addClass("timepicker").append(C()),g=a("
      ").addClass("list-unstyled"),h=a("
    • ").addClass("picker-switch"+(d.collapse?" accordion-toggle":"")).append(D());return d.inline&&b.removeClass("dropdown-menu"),f&&b.addClass("usetwentyfour"),x("s")&&!f&&b.addClass("wider"),d.sideBySide&&z()&&y()?(b.addClass("timepicker-sbs"),"top"===d.toolbarPlacement&&b.append(h),b.append(a("
      ").addClass("row").append(c.addClass("col-md-6")).append(e.addClass("col-md-6"))),"bottom"===d.toolbarPlacement&&b.append(h),b):("top"===d.toolbarPlacement&&g.append(h),z()&&g.append(a("
    • ").addClass(d.collapse&&y()?"collapse in":"").append(c)),"default"===d.toolbarPlacement&&g.append(h),y()&&g.append(a("
    • ").addClass(d.collapse&&z()?"collapse":"").append(e)),"bottom"===d.toolbarPlacement&&g.append(h),b.append(g))},F=function(){var b,e={};return b=c.is("input")||d.inline?c.data():c.find("input").data(),b.dateOptions&&b.dateOptions instanceof Object&&(e=a.extend(!0,e,b.dateOptions)),a.each(d,function(a){var c="date"+a.charAt(0).toUpperCase()+a.slice(1);void 0!==b[c]&&(e[a]=b[c])}),e},G=function(){var b,e=(n||c).position(),f=(n||c).offset(),g=d.widgetPositioning.vertical,h=d.widgetPositioning.horizontal;if(d.widgetParent)b=d.widgetParent.append(o);else if(c.is("input"))b=c.after(o).parent();else{if(d.inline)return void(b=c.append(o));b=c,c.children().first().after(o)}if("auto"===g&&(g=f.top+1.5*o.height()>=a(window).height()+a(window).scrollTop()&&o.height()+c.outerHeight()a(window).width()?"right":"left"),"top"===g?o.addClass("top").removeClass("bottom"):o.addClass("bottom").removeClass("top"),"right"===h?o.addClass("pull-right"):o.removeClass("pull-right"),"relative"!==b.css("position")&&(b=b.parents().filter(function(){return"relative"===a(this).css("position")}).first()),0===b.length)throw new Error("datetimepicker component should be placed within a relative positioned container");o.css({top:"top"===g?"auto":e.top+c.outerHeight(),bottom:"top"===g?e.top+c.outerHeight():"auto",left:"left"===h?b===c?0:e.left:"auto",right:"left"===h?"auto":b.outerWidth()-c.outerWidth()-(b===c?0:e.left)})},H=function(a){"dp.change"===a.type&&(a.date&&a.date.isSame(a.oldDate)||!a.date&&!a.oldDate)||c.trigger(a)},I=function(a){"y"===a&&(a="YYYY"),H({type:"dp.update",change:a,viewDate:l.clone()})},J=function(a){o&&(a&&(i=Math.max(p,Math.min(3,i+a))),o.find(".datepicker > div").hide().filter(".datepicker-"+q[i].clsName).show())},K=function(){var b=a("
    "),c=l.clone().startOf("w").startOf("d");for(d.calendarWeeks===!0&&b.append(a(""),d.calendarWeeks&&e.append('"),j.push(e)),f="",c.isBefore(l,"M")&&(f+=" old"),c.isAfter(l,"M")&&(f+=" new"),c.isSame(k,"d")&&!m&&(f+=" active"),P(c,"d")||(f+=" disabled"),c.isSame(b(),"d")&&(f+=" today"),(0===c.day()||6===c.day())&&(f+=" weekend"),e.append('"),c.add(1,"d");h.find("tbody").empty().append(j),R(),S(),T()}},V=function(){var b=o.find(".timepicker-hours table"),c=l.clone().startOf("d"),d=[],e=a("");for(l.hour()>11&&!f&&c.hour(12);c.isSame(l,"d")&&(f||l.hour()<12&&c.hour()<12||l.hour()>11);)c.hour()%4===0&&(e=a(""),d.push(e)),e.append('"),c.add(1,"h");b.empty().append(d)},W=function(){for(var b=o.find(".timepicker-minutes table"),c=l.clone().startOf("h"),e=[],f=a(""),g=1===d.stepping?5:d.stepping;l.isSame(c,"h");)c.minute()%(4*g)===0&&(f=a(""),e.push(f)),f.append('"),c.add(g,"m");b.empty().append(e)},X=function(){for(var b=o.find(".timepicker-seconds table"),c=l.clone().startOf("m"),d=[],e=a("");l.isSame(c,"m");)c.second()%20===0&&(e=a(""),d.push(e)),e.append('"),c.add(5,"s");b.empty().append(d)},Y=function(){var a,b,c=o.find(".timepicker span[data-time-component]");f||(a=o.find(".timepicker [data-action=togglePeriod]"),b=k.clone().add(k.hours()>=12?-12:12,"h"),a.text(k.format("A")),P(b,"h")?a.removeClass("disabled"):a.addClass("disabled")),c.filter("[data-time-component=hours]").text(k.format(f?"HH":"hh")),c.filter("[data-time-component=minutes]").text(k.format("mm")),c.filter("[data-time-component=seconds]").text(k.format("ss")),V(),W(),X()},Z=function(){o&&(U(),Y())},$=function(a){var b=m?null:k;return a?(a=a.clone().locale(d.locale),1!==d.stepping&&a.minutes(Math.round(a.minutes()/d.stepping)*d.stepping%60).seconds(0),void(P(a)?(k=a,l=k.clone(),e.val(k.format(g)),c.data("date",k.format(g)),m=!1,Z(),H({type:"dp.change",date:k.clone(),oldDate:b})):(d.keepInvalid||e.val(m?"":k.format(g)),H({type:"dp.error",date:a})))):(m=!0,e.val(""),c.data("date",""),H({type:"dp.change",date:!1,oldDate:b}),void Z())},_=function(){var b=!1;return o?(o.find(".collapse").each(function(){var c=a(this).data("collapse");return c&&c.transitioning?(b=!0,!1):!0}),b?j:(n&&n.hasClass("btn")&&n.toggleClass("active"),o.hide(),a(window).off("resize",G),o.off("click","[data-action]"),o.off("mousedown",!1),o.remove(),o=!1,H({type:"dp.hide",date:k.clone()}),e.blur(),j)):j},aa=function(){$(null)},ba={next:function(){var a=q[i].navFnc;l.add(q[i].navStep,a),U(),I(a)},previous:function(){var a=q[i].navFnc;l.subtract(q[i].navStep,a),U(),I(a)},pickerSwitch:function(){J(1)},selectMonth:function(b){var c=a(b.target).closest("tbody").find("span").index(a(b.target));l.month(c),i===p?($(k.clone().year(l.year()).month(l.month())),d.inline||_()):(J(-1),U()),I("M")},selectYear:function(b){var c=parseInt(a(b.target).text(),10)||0;l.year(c),i===p?($(k.clone().year(l.year())),d.inline||_()):(J(-1),U()),I("YYYY")},selectDecade:function(b){var c=parseInt(a(b.target).data("selection"),10)||0;l.year(c),i===p?($(k.clone().year(l.year())),d.inline||_()):(J(-1),U()),I("YYYY")},selectDay:function(b){var c=l.clone();a(b.target).is(".old")&&c.subtract(1,"M"),a(b.target).is(".new")&&c.add(1,"M"),$(c.date(parseInt(a(b.target).text(),10))),y()||d.keepOpen||d.inline||_()},incrementHours:function(){var a=k.clone().add(1,"h");P(a,"h")&&$(a)},incrementMinutes:function(){var a=k.clone().add(d.stepping,"m");P(a,"m")&&$(a)},incrementSeconds:function(){var a=k.clone().add(1,"s");P(a,"s")&&$(a)},decrementHours:function(){var a=k.clone().subtract(1,"h");P(a,"h")&&$(a)},decrementMinutes:function(){var a=k.clone().subtract(d.stepping,"m");P(a,"m")&&$(a)},decrementSeconds:function(){var a=k.clone().subtract(1,"s");P(a,"s")&&$(a)},togglePeriod:function(){$(k.clone().add(k.hours()>=12?-12:12,"h"))},togglePicker:function(b){var c,e=a(b.target),f=e.closest("ul"),g=f.find(".in"),h=f.find(".collapse:not(.in)");if(g&&g.length){if(c=g.data("collapse"),c&&c.transitioning)return;g.collapse?(g.collapse("hide"),h.collapse("show")):(g.removeClass("in"),h.addClass("in")),e.is("span")?e.toggleClass(d.icons.time+" "+d.icons.date):e.find("span").toggleClass(d.icons.time+" "+d.icons.date)}},showPicker:function(){o.find(".timepicker > div:not(.timepicker-picker)").hide(),o.find(".timepicker .timepicker-picker").show()},showHours:function(){o.find(".timepicker .timepicker-picker").hide(),o.find(".timepicker .timepicker-hours").show()},showMinutes:function(){o.find(".timepicker .timepicker-picker").hide(),o.find(".timepicker .timepicker-minutes").show()},showSeconds:function(){o.find(".timepicker .timepicker-picker").hide(),o.find(".timepicker .timepicker-seconds").show()},selectHour:function(b){var c=parseInt(a(b.target).text(),10);f||(k.hours()>=12?12!==c&&(c+=12):12===c&&(c=0)),$(k.clone().hours(c)),ba.showPicker.call(j)},selectMinute:function(b){$(k.clone().minutes(parseInt(a(b.target).text(),10))),ba.showPicker.call(j)},selectSecond:function(b){$(k.clone().seconds(parseInt(a(b.target).text(),10))),ba.showPicker.call(j)},clear:aa,today:function(){P(b(),"d")&&$(b())},close:_},ca=function(b){return a(b.currentTarget).is(".disabled")?!1:(ba[a(b.currentTarget).data("action")].apply(j,arguments),!1)},da=function(){var c,f={year:function(a){return a.month(0).date(1).hours(0).seconds(0).minutes(0)},month:function(a){return a.date(1).hours(0).seconds(0).minutes(0)},day:function(a){return a.hours(0).seconds(0).minutes(0)},hour:function(a){return a.seconds(0).minutes(0)},minute:function(a){return a.seconds(0)}};return e.prop("disabled")||!d.ignoreReadonly&&e.prop("readonly")||o?j:(void 0!==e.val()&&0!==e.val().trim().length?$(fa(e.val().trim())):d.useCurrent&&m&&(e.is("input")&&0===e.val().trim().length||d.inline)&&(c=b(),"string"==typeof d.useCurrent&&(c=f[d.useCurrent](c)),$(c)),o=E(),K(),Q(),o.find(".timepicker-hours").hide(),o.find(".timepicker-minutes").hide(),o.find(".timepicker-seconds").hide(),Z(),J(),a(window).on("resize",G),o.on("click","[data-action]",ca),o.on("mousedown",!1),n&&n.hasClass("btn")&&n.toggleClass("active"),o.show(),G(),d.focusOnShow&&!e.is(":focus")&&e.focus(),H({type:"dp.show"}),j)},ea=function(){return o?_():da()},fa=function(a){return a=void 0===d.parseInputDate?b.isMoment(a)||a instanceof Date?b(a):b(a,h,d.useStrict):d.parseInputDate(a),a.locale(d.locale),a},ga=function(a){var b,c,e,f,g=null,h=[],i={},k=a.which,l="p";w[k]=l;for(b in w)w.hasOwnProperty(b)&&w[b]===l&&(h.push(b),parseInt(b,10)!==k&&(i[b]=!0));for(b in d.keyBinds)if(d.keyBinds.hasOwnProperty(b)&&"function"==typeof d.keyBinds[b]&&(e=b.split(" "),e.length===h.length&&v[k]===e[e.length-1])){for(f=!0,c=e.length-2;c>=0;c--)if(!(v[e[c]]in i)){f=!1;break}if(f){g=d.keyBinds[b];break}}g&&(g.call(j,o),a.stopPropagation(),a.preventDefault())},ha=function(a){w[a.which]="r",a.stopPropagation(),a.preventDefault()},ia=function(b){var c=a(b.target).val().trim(),d=c?fa(c):null;return $(d),b.stopImmediatePropagation(),!1},ja=function(){e.on({change:ia,blur:d.debug?"":_,keydown:ga,keyup:ha,focus:d.allowInputToggle?da:""}),c.is("input")?e.on({focus:da}):n&&(n.on("click",ea),n.on("mousedown",!1))},ka=function(){e.off({change:ia,blur:blur,keydown:ga,keyup:ha,focus:d.allowInputToggle?_:""}),c.is("input")?e.off({focus:da}):n&&(n.off("click",ea),n.off("mousedown",!1))},la=function(b){var c={};return a.each(b,function(){var a=fa(this);a.isValid()&&(c[a.format("YYYY-MM-DD")]=!0)}),Object.keys(c).length?c:!1},ma=function(b){var c={};return a.each(b,function(){c[this]=!0}),Object.keys(c).length?c:!1},na=function(){var a=d.format||"L LT";g=a.replace(/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,function(a){var b=k.localeData().longDateFormat(a)||a;return b.replace(/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,function(a){return k.localeData().longDateFormat(a)||a})}),h=d.extraFormats?d.extraFormats.slice():[],h.indexOf(a)<0&&h.indexOf(g)<0&&h.push(g),f=g.toLowerCase().indexOf("a")<1&&g.replace(/\[.*?\]/g,"").indexOf("h")<1,x("y")&&(p=2),x("M")&&(p=1),x("d")&&(p=0),i=Math.max(p,i),m||$(k)};if(j.destroy=function(){_(),ka(),c.removeData("DateTimePicker"),c.removeData("date")},j.toggle=ea,j.show=da,j.hide=_,j.disable=function(){return _(),n&&n.hasClass("btn")&&n.addClass("disabled"),e.prop("disabled",!0),j},j.enable=function(){return n&&n.hasClass("btn")&&n.removeClass("disabled"),e.prop("disabled",!1),j},j.ignoreReadonly=function(a){if(0===arguments.length)return d.ignoreReadonly;if("boolean"!=typeof a)throw new TypeError("ignoreReadonly () expects a boolean parameter");return d.ignoreReadonly=a,j},j.options=function(b){if(0===arguments.length)return a.extend(!0,{},d);if(!(b instanceof Object))throw new TypeError("options() options parameter should be an object");return a.extend(!0,d,b),a.each(d,function(a,b){if(void 0===j[a])throw new TypeError("option "+a+" is not recognized!");j[a](b)}),j},j.date=function(a){if(0===arguments.length)return m?null:k.clone();if(!(null===a||"string"==typeof a||b.isMoment(a)||a instanceof Date))throw new TypeError("date() parameter must be one of [null, string, moment or Date]");return $(null===a?null:fa(a)),j},j.format=function(a){if(0===arguments.length)return d.format;if("string"!=typeof a&&("boolean"!=typeof a||a!==!1))throw new TypeError("format() expects a sting or boolean:false parameter "+a);return d.format=a,g&&na(),j},j.dayViewHeaderFormat=function(a){if(0===arguments.length)return d.dayViewHeaderFormat;if("string"!=typeof a)throw new TypeError("dayViewHeaderFormat() expects a string parameter");return d.dayViewHeaderFormat=a,j},j.extraFormats=function(a){if(0===arguments.length)return d.extraFormats;if(a!==!1&&!(a instanceof Array))throw new TypeError("extraFormats() expects an array or false parameter");return d.extraFormats=a,h&&na(),j},j.disabledDates=function(b){if(0===arguments.length)return d.disabledDates?a.extend({},d.disabledDates):d.disabledDates;if(!b)return d.disabledDates=!1,Z(),j;if(!(b instanceof Array))throw new TypeError("disabledDates() expects an array parameter");return d.disabledDates=la(b),d.enabledDates=!1,Z(),j},j.enabledDates=function(b){if(0===arguments.length)return d.enabledDates?a.extend({},d.enabledDates):d.enabledDates;if(!b)return d.enabledDates=!1,Z(),j;if(!(b instanceof Array))throw new TypeError("enabledDates() expects an array parameter");return d.enabledDates=la(b),d.disabledDates=!1,Z(),j},j.daysOfWeekDisabled=function(a){if(0===arguments.length)return d.daysOfWeekDisabled.splice(0);if("boolean"==typeof a&&!a)return d.daysOfWeekDisabled=!1,Z(),j;if(!(a instanceof Array))throw new TypeError("daysOfWeekDisabled() expects an array parameter");if(d.daysOfWeekDisabled=a.reduce(function(a,b){return b=parseInt(b,10),b>6||0>b||isNaN(b)?a:(-1===a.indexOf(b)&&a.push(b),a)},[]).sort(),d.useCurrent&&!d.keepInvalid){for(var b=0;!P(k,"d");){if(k.add(1,"d"),7===b)throw"Tried 7 times to find a valid date";b++}$(k)}return Z(),j},j.maxDate=function(a){if(0===arguments.length)return d.maxDate?d.maxDate.clone():d.maxDate;if("boolean"==typeof a&&a===!1)return d.maxDate=!1,Z(),j;"string"==typeof a&&("now"===a||"moment"===a)&&(a=b());var c=fa(a);if(!c.isValid())throw new TypeError("maxDate() Could not parse date parameter: "+a);if(d.minDate&&c.isBefore(d.minDate))throw new TypeError("maxDate() date parameter is before options.minDate: "+c.format(g));return d.maxDate=c,d.useCurrent&&!d.keepInvalid&&k.isAfter(a)&&$(d.maxDate),l.isAfter(c)&&(l=c.clone().subtract(d.stepping,"m")),Z(),j},j.minDate=function(a){if(0===arguments.length)return d.minDate?d.minDate.clone():d.minDate;if("boolean"==typeof a&&a===!1)return d.minDate=!1,Z(),j;"string"==typeof a&&("now"===a||"moment"===a)&&(a=b());var c=fa(a);if(!c.isValid())throw new TypeError("minDate() Could not parse date parameter: "+a);if(d.maxDate&&c.isAfter(d.maxDate))throw new TypeError("minDate() date parameter is after options.maxDate: "+c.format(g));return d.minDate=c,d.useCurrent&&!d.keepInvalid&&k.isBefore(a)&&$(d.minDate),l.isBefore(c)&&(l=c.clone().add(d.stepping,"m")),Z(),j},j.defaultDate=function(a){if(0===arguments.length)return d.defaultDate?d.defaultDate.clone():d.defaultDate;if(!a)return d.defaultDate=!1,j;"string"==typeof a&&("now"===a||"moment"===a)&&(a=b());var c=fa(a);if(!c.isValid())throw new TypeError("defaultDate() Could not parse date parameter: "+a);if(!P(c))throw new TypeError("defaultDate() date passed is invalid according to component setup validations");return d.defaultDate=c,(d.defaultDate&&d.inline||""===e.val().trim()&&void 0===e.attr("placeholder"))&&$(d.defaultDate),j},j.locale=function(a){if(0===arguments.length)return d.locale;if(!b.localeData(a))throw new TypeError("locale() locale "+a+" is not loaded from moment locales!");return d.locale=a,k.locale(d.locale),l.locale(d.locale),g&&na(),o&&(_(),da()),j},j.stepping=function(a){return 0===arguments.length?d.stepping:(a=parseInt(a,10),(isNaN(a)||1>a)&&(a=1),d.stepping=a,j)},j.useCurrent=function(a){var b=["year","month","day","hour","minute"];if(0===arguments.length)return d.useCurrent;if("boolean"!=typeof a&&"string"!=typeof a)throw new TypeError("useCurrent() expects a boolean or string parameter");if("string"==typeof a&&-1===b.indexOf(a.toLowerCase()))throw new TypeError("useCurrent() expects a string parameter of "+b.join(", "));return d.useCurrent=a,j},j.collapse=function(a){if(0===arguments.length)return d.collapse;if("boolean"!=typeof a)throw new TypeError("collapse() expects a boolean parameter");return d.collapse===a?j:(d.collapse=a,o&&(_(),da()),j)},j.icons=function(b){if(0===arguments.length)return a.extend({},d.icons);if(!(b instanceof Object))throw new TypeError("icons() expects parameter to be an Object");return a.extend(d.icons,b),o&&(_(),da()),j},j.tooltips=function(b){if(0===arguments.length)return a.extend({},d.tooltips);if(!(b instanceof Object))throw new TypeError("tooltips() expects parameter to be an Object");return a.extend(d.tooltips,b),o&&(_(),da()),j},j.useStrict=function(a){if(0===arguments.length)return d.useStrict;if("boolean"!=typeof a)throw new TypeError("useStrict() expects a boolean parameter");return d.useStrict=a,j},j.sideBySide=function(a){if(0===arguments.length)return d.sideBySide;if("boolean"!=typeof a)throw new TypeError("sideBySide() expects a boolean parameter");return d.sideBySide=a,o&&(_(),da()),j},j.viewMode=function(a){if(0===arguments.length)return d.viewMode;if("string"!=typeof a)throw new TypeError("viewMode() expects a string parameter");if(-1===r.indexOf(a))throw new TypeError("viewMode() parameter must be one of ("+r.join(", ")+") value");return d.viewMode=a,i=Math.max(r.indexOf(a),p),J(),j},j.toolbarPlacement=function(a){if(0===arguments.length)return d.toolbarPlacement;if("string"!=typeof a)throw new TypeError("toolbarPlacement() expects a string parameter");if(-1===u.indexOf(a))throw new TypeError("toolbarPlacement() parameter must be one of ("+u.join(", ")+") value");return d.toolbarPlacement=a,o&&(_(),da()),j},j.widgetPositioning=function(b){if(0===arguments.length)return a.extend({},d.widgetPositioning);if("[object Object]"!=={}.toString.call(b))throw new TypeError("widgetPositioning() expects an object variable");if(b.horizontal){if("string"!=typeof b.horizontal)throw new TypeError("widgetPositioning() horizontal variable must be a string");if(b.horizontal=b.horizontal.toLowerCase(),-1===t.indexOf(b.horizontal))throw new TypeError("widgetPositioning() expects horizontal parameter to be one of ("+t.join(", ")+")");d.widgetPositioning.horizontal=b.horizontal}if(b.vertical){if("string"!=typeof b.vertical)throw new TypeError("widgetPositioning() vertical variable must be a string");if(b.vertical=b.vertical.toLowerCase(),-1===s.indexOf(b.vertical))throw new TypeError("widgetPositioning() expects vertical parameter to be one of ("+s.join(", ")+")");d.widgetPositioning.vertical=b.vertical}return Z(),j},j.calendarWeeks=function(a){if(0===arguments.length)return d.calendarWeeks;if("boolean"!=typeof a)throw new TypeError("calendarWeeks() expects parameter to be a boolean value");return d.calendarWeeks=a,Z(),j},j.showTodayButton=function(a){if(0===arguments.length)return d.showTodayButton;if("boolean"!=typeof a)throw new TypeError("showTodayButton() expects a boolean parameter");return d.showTodayButton=a,o&&(_(),da()),j},j.showClear=function(a){if(0===arguments.length)return d.showClear;if("boolean"!=typeof a)throw new TypeError("showClear() expects a boolean parameter");return d.showClear=a,o&&(_(),da()),j},j.widgetParent=function(b){if(0===arguments.length)return d.widgetParent;if("string"==typeof b&&(b=a(b)),null!==b&&"string"!=typeof b&&!(b instanceof a))throw new TypeError("widgetParent() expects a string or a jQuery object parameter");return d.widgetParent=b,o&&(_(),da()),j},j.keepOpen=function(a){if(0===arguments.length)return d.keepOpen;if("boolean"!=typeof a)throw new TypeError("keepOpen() expects a boolean parameter");return d.keepOpen=a,j},j.focusOnShow=function(a){if(0===arguments.length)return d.focusOnShow;if("boolean"!=typeof a)throw new TypeError("focusOnShow() expects a boolean parameter");return d.focusOnShow=a,j},j.inline=function(a){if(0===arguments.length)return d.inline;if("boolean"!=typeof a)throw new TypeError("inline() expects a boolean parameter");return d.inline=a,j},j.clear=function(){return aa(),j},j.keyBinds=function(a){return d.keyBinds=a,j},j.debug=function(a){if("boolean"!=typeof a)throw new TypeError("debug() expects a boolean parameter");return d.debug=a,j},j.allowInputToggle=function(a){if(0===arguments.length)return d.allowInputToggle;if("boolean"!=typeof a)throw new TypeError("allowInputToggle() expects a boolean parameter");return d.allowInputToggle=a,j},j.showClose=function(a){if(0===arguments.length)return d.showClose;if("boolean"!=typeof a)throw new TypeError("showClose() expects a boolean parameter");return d.showClose=a,j},j.keepInvalid=function(a){if(0===arguments.length)return d.keepInvalid;if("boolean"!=typeof a)throw new TypeError("keepInvalid() expects a boolean parameter");return d.keepInvalid=a,j},j.datepickerInput=function(a){if(0===arguments.length)return d.datepickerInput;if("string"!=typeof a)throw new TypeError("datepickerInput() expects a string parameter");return d.datepickerInput=a,j},j.parseInputDate=function(a){if(0===arguments.length)return d.parseInputDate;if("function"!=typeof a)throw new TypeError("parseInputDate() sholud be as function");return d.parseInputDate=a,j},j.disabledTimeIntervals=function(b){if(0===arguments.length)return d.disabledTimeIntervals?a.extend({},d.disabledTimeIntervals):d.disabledTimeIntervals;if(!b)return d.disabledTimeIntervals=!1,Z(),j;if(!(b instanceof Array))throw new TypeError("disabledTimeIntervals() expects an array parameter");return d.disabledTimeIntervals=b,Z(),j},j.disabledHours=function(b){if(0===arguments.length)return d.disabledHours?a.extend({},d.disabledHours):d.disabledHours;if(!b)return d.disabledHours=!1,Z(),j;if(!(b instanceof Array))throw new TypeError("disabledHours() expects an array parameter"); +if(d.disabledHours=ma(b),d.enabledHours=!1,d.useCurrent&&!d.keepInvalid){for(var c=0;!P(k,"h");){if(k.add(1,"h"),24===c)throw"Tried 24 times to find a valid date";c++}$(k)}return Z(),j},j.enabledHours=function(b){if(0===arguments.length)return d.enabledHours?a.extend({},d.enabledHours):d.enabledHours;if(!b)return d.enabledHours=!1,Z(),j;if(!(b instanceof Array))throw new TypeError("enabledHours() expects an array parameter");if(d.enabledHours=ma(b),d.disabledHours=!1,d.useCurrent&&!d.keepInvalid){for(var c=0;!P(k,"h");){if(k.add(1,"h"),24===c)throw"Tried 24 times to find a valid date";c++}$(k)}return Z(),j},j.viewDate=function(a){if(0===arguments.length)return l.clone();if(!a)return l=k.clone(),j;if(!("string"==typeof a||b.isMoment(a)||a instanceof Date))throw new TypeError("viewDate() parameter must be one of [string, moment or Date]");return l=fa(a),I(),j},c.is("input"))e=c;else if(e=c.find(d.datepickerInput),0===e.size())e=c.find("input");else if(!e.is("input"))throw new Error('CSS class "'+d.datepickerInput+'" cannot be applied to non input element');if(c.hasClass("input-group")&&(n=0===c.find(".datepickerbutton").size()?c.find(".input-group-addon"):c.find(".datepickerbutton")),!d.inline&&!e.is("input"))throw new Error("Could not initialize DateTimePicker without an input element");return a.extend(!0,d,F()),j.options(d),na(),ja(),e.prop("disabled")&&j.disable(),e.is("input")&&0!==e.val().trim().length?$(fa(e.val().trim())):d.defaultDate&&void 0===e.attr("placeholder")&&$(d.defaultDate),d.inline&&da(),j};a.fn.datetimepicker=function(b){return this.each(function(){var d=a(this);d.data("DateTimePicker")||(b=a.extend(!0,{},a.fn.datetimepicker.defaults,b),d.data("DateTimePicker",c(d,b)))})},a.fn.datetimepicker.defaults={format:!1,dayViewHeaderFormat:"MMMM YYYY",extraFormats:!1,stepping:1,minDate:!1,maxDate:!1,useCurrent:!0,collapse:!0,locale:b.locale(),defaultDate:!1,disabledDates:!1,enabledDates:!1,icons:{time:"glyphicon glyphicon-time",date:"glyphicon glyphicon-calendar",up:"glyphicon glyphicon-chevron-up",down:"glyphicon glyphicon-chevron-down",previous:"glyphicon glyphicon-chevron-left",next:"glyphicon glyphicon-chevron-right",today:"glyphicon glyphicon-screenshot",clear:"glyphicon glyphicon-trash",close:"glyphicon glyphicon-remove"},tooltips:{today:"Go to today",clear:"Clear selection",close:"Close the picker",selectMonth:"Select Month",prevMonth:"Previous Month",nextMonth:"Next Month",selectYear:"Select Year",prevYear:"Previous Year",nextYear:"Next Year",selectDecade:"Select Decade",prevDecade:"Previous Decade",nextDecade:"Next Decade",prevCentury:"Previous Century",nextCentury:"Next Century"},useStrict:!1,sideBySide:!1,daysOfWeekDisabled:!1,calendarWeeks:!1,viewMode:"days",toolbarPlacement:"default",showTodayButton:!1,showClear:!1,showClose:!1,widgetPositioning:{horizontal:"auto",vertical:"auto"},widgetParent:null,ignoreReadonly:!1,keepOpen:!1,focusOnShow:!0,inline:!1,keepInvalid:!1,datepickerInput:".datepickerinput",keyBinds:{up:function(a){if(a){var c=this.date()||b();a.find(".datepicker").is(":visible")?this.date(c.clone().subtract(7,"d")):this.date(c.clone().add(this.stepping(),"m"))}},down:function(a){if(!a)return void this.show();var c=this.date()||b();a.find(".datepicker").is(":visible")?this.date(c.clone().add(7,"d")):this.date(c.clone().subtract(this.stepping(),"m"))},"control up":function(a){if(a){var c=this.date()||b();a.find(".datepicker").is(":visible")?this.date(c.clone().subtract(1,"y")):this.date(c.clone().add(1,"h"))}},"control down":function(a){if(a){var c=this.date()||b();a.find(".datepicker").is(":visible")?this.date(c.clone().add(1,"y")):this.date(c.clone().subtract(1,"h"))}},left:function(a){if(a){var c=this.date()||b();a.find(".datepicker").is(":visible")&&this.date(c.clone().subtract(1,"d"))}},right:function(a){if(a){var c=this.date()||b();a.find(".datepicker").is(":visible")&&this.date(c.clone().add(1,"d"))}},pageUp:function(a){if(a){var c=this.date()||b();a.find(".datepicker").is(":visible")&&this.date(c.clone().subtract(1,"M"))}},pageDown:function(a){if(a){var c=this.date()||b();a.find(".datepicker").is(":visible")&&this.date(c.clone().add(1,"M"))}},enter:function(){this.hide()},escape:function(){this.hide()},"control space":function(a){a.find(".timepicker").is(":visible")&&a.find('.btn[data-action="togglePeriod"]').click()},t:function(){this.date(b())},"delete":function(){this.clear()}},debug:!1,allowInputToggle:!1,disabledTimeIntervals:!1,disabledHours:!1,enabledHours:!1,viewDate:!1}}); \ No newline at end of file diff --git a/PlexRequests.UI/Scripts/bootstrap.js b/PlexRequests.UI/Scripts/bootstrap.js new file mode 100644 index 000000000..5debfd7de --- /dev/null +++ b/PlexRequests.UI/Scripts/bootstrap.js @@ -0,0 +1,2363 @@ +/*! + * Bootstrap v3.3.5 (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)) { + throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher') + } +}(jQuery); + +/* ======================================================================== + * Bootstrap: transition.js v3.3.5 + * 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.5 + * 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.5' + + 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.5 + * 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.5' + + 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.5 + * 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.5' + + 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.5 + * 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.5' + + 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.5 + * 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.5' + + 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('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('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.5 + * 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.5' + + 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.5 + * 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.5' + + 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.5 + * 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.5' + + 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.5 + * 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.5' + + 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.5 + * 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.5' + + 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.5 + * 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.5' + + 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); diff --git a/PlexRequests.UI/Scripts/bootstrap.min.js b/PlexRequests.UI/Scripts/bootstrap.min.js new file mode 100644 index 000000000..133aeecb9 --- /dev/null +++ b/PlexRequests.UI/Scripts/bootstrap.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under the MIT license + */ +if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){return a(b.target).is(this)?b.handleObj.handler.apply(this,arguments):void 0}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.5",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a(f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.5",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")?(c.prop("checked")&&(a=!1),b.find(".active").removeClass("active"),this.$element.addClass("active")):"checkbox"==c.prop("type")&&(c.prop("checked")!==this.$element.hasClass("active")&&(a=!1),this.$element.toggleClass("active")),c.prop("checked",this.$element.hasClass("active")),a&&c.trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active")),this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target);d.hasClass("btn")||(d=d.closest(".btn")),b.call(d,"toggle"),a(c.target).is('input[type="radio"]')||a(c.target).is('input[type="checkbox"]')||c.preventDefault()}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus",/^focus(in)?$/.test(b.type))})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=null,this.sliding=null,this.interval=null,this.$active=null,this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.5",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){if(!/input|textarea/i.test(a.target.tagName)){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()}},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c=this.getItemIndex(b),d="prev"==a&&0===c||"next"==a&&c==this.$items.length-1;if(d&&!this.options.wrap)return b;var e="prev"==a?-1:1,f=(c+e)%this.$items.length;return this.$items.eq(f)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));return a>this.$items.length-1||0>a?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){return this.sliding?void 0:this.slide("next")},c.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i=this;if(f.hasClass("active"))return this.sliding=!1;var j=f[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:h});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(f)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(m)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&/show|hide/.test(b)&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a('[data-toggle="collapse"][href="#'+b.id+'"],[data-toggle="collapse"][data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.5",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.children(".panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":e.data();c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function c(c){c&&3===c.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=b(d),f={relatedTarget:this};e.hasClass("open")&&(c&&"click"==c.type&&/input|textarea/i.test(c.target.tagName)&&a.contains(e[0],c.target)||(e.trigger(c=a.Event("hide.bs.dropdown",f)),c.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger("hidden.bs.dropdown",f))))}))}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.5",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=b(e),g=f.hasClass("open");if(c(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(document.createElement("div")).addClass("dropdown-backdrop").insertAfter(a(this)).on("click",c);var h={relatedTarget:this};if(f.trigger(d=a.Event("show.bs.dropdown",h)),d.isDefaultPrevented())return;e.trigger("focus").attr("aria-expanded","true"),f.toggleClass("open").trigger("shown.bs.dropdown",h)}return!1}},g.prototype.keydown=function(c){if(/(38|40|27|32)/.test(c.which)&&!/input|textarea/i.test(c.target.tagName)){var d=a(this);if(c.preventDefault(),c.stopPropagation(),!d.is(".disabled, :disabled")){var e=b(d),g=e.hasClass("open");if(!g&&27!=c.which||g&&27==c.which)return 27==c.which&&e.find(f).trigger("focus"),d.trigger("click");var h=" li:not(.disabled):visible a",i=e.find(".dropdown-menu"+h);if(i.length){var j=i.index(c.target);38==c.which&&j>0&&j--,40==c.which&&jdocument.documentElement.clientHeight;this.$element.css({paddingLeft:!this.bodyIsOverflowing&&a?this.scrollbarWidth:"",paddingRight:this.bodyIsOverflowing&&!a?this.scrollbarWidth:""})},c.prototype.resetAdjustments=function(){this.$element.css({paddingLeft:"",paddingRight:""})},c.prototype.checkScrollbar=function(){var a=window.innerWidth;if(!a){var b=document.documentElement.getBoundingClientRect();a=b.right-Math.abs(b.left)}this.bodyIsOverflowing=document.body.clientWidth
    ',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},c.prototype.init=function(b,c,d){if(this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d),this.$viewport=this.options.viewport&&a(a.isFunction(this.options.viewport)?this.options.viewport.call(this,this.$element):this.options.viewport.selector||this.options.viewport),this.inState={click:!1,hover:!1,focus:!1},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!");for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focusin",i="hover"==g?"mouseleave":"focusout";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},c.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},c.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusin"==b.type?"focus":"hover"]=!0),c.tip().hasClass("in")||"in"==c.hoverState?void(c.hoverState="in"):(clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?void(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show)):c.show())},c.prototype.isInStateTrue=function(){for(var a in this.inState)if(this.inState[a])return!0;return!1},c.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusout"==b.type?"focus":"hover"]=!1),c.isInStateTrue()?void 0:(clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?void(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide)):c.hide())},c.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(b);var d=a.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(b.isDefaultPrevented()||!d)return;var e=this,f=this.tip(),g=this.getUID(this.type);this.setContent(),f.attr("id",g),this.$element.attr("aria-describedby",g),this.options.animation&&f.addClass("fade");var h="function"==typeof this.options.placement?this.options.placement.call(this,f[0],this.$element[0]):this.options.placement,i=/\s?auto?\s?/i,j=i.test(h);j&&(h=h.replace(i,"")||"top"),f.detach().css({top:0,left:0,display:"block"}).addClass(h).data("bs."+this.type,this),this.options.container?f.appendTo(this.options.container):f.insertAfter(this.$element),this.$element.trigger("inserted.bs."+this.type);var k=this.getPosition(),l=f[0].offsetWidth,m=f[0].offsetHeight;if(j){var n=h,o=this.getPosition(this.$viewport);h="bottom"==h&&k.bottom+m>o.bottom?"top":"top"==h&&k.top-mo.width?"left":"left"==h&&k.left-lg.top+g.height&&(e.top=g.top+g.height-i)}else{var j=b.left-f,k=b.left+f+c;jg.right&&(e.left=g.left+g.width-k)}return e},c.prototype.getTitle=function(){var a,b=this.$element,c=this.options;return a=b.attr("data-original-title")||("function"==typeof c.title?c.title.call(b[0]):c.title)},c.prototype.getUID=function(a){do a+=~~(1e6*Math.random());while(document.getElementById(a));return a},c.prototype.tip=function(){if(!this.$tip&&(this.$tip=a(this.options.template),1!=this.$tip.length))throw new Error(this.type+" `template` option must consist of exactly 1 top-level element!");return this.$tip},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},c.prototype.enable=function(){this.enabled=!0},c.prototype.disable=function(){this.enabled=!1},c.prototype.toggleEnabled=function(){this.enabled=!this.enabled},c.prototype.toggle=function(b){var c=this;b&&(c=a(b.currentTarget).data("bs."+this.type),c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c))),b?(c.inState.click=!c.inState.click,c.isInStateTrue()?c.enter(c):c.leave(c)):c.tip().hasClass("in")?c.leave(c):c.enter(c)},c.prototype.destroy=function(){var a=this;clearTimeout(this.timeout),this.hide(function(){a.$element.off("."+a.type).removeData("bs."+a.type),a.$tip&&a.$tip.detach(),a.$tip=null,a.$arrow=null,a.$viewport=null})};var d=a.fn.tooltip;a.fn.tooltip=b,a.fn.tooltip.Constructor=c,a.fn.tooltip.noConflict=function(){return a.fn.tooltip=d,this}}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof b&&b;(e||!/destroy|hide/.test(b))&&(e||d.data("bs.popover",e=new c(this,f)),"string"==typeof b&&e[b]())})}var c=function(a,b){this.init("popover",a,b)};if(!a.fn.tooltip)throw new Error("Popover requires tooltip.js");c.VERSION="3.3.5",c.DEFAULTS=a.extend({},a.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),c.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),c.prototype.constructor=c,c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content").children().detach().end()[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},c.prototype.hasContent=function(){return this.getTitle()||this.getContent()},c.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")};var d=a.fn.popover;a.fn.popover=b,a.fn.popover.Constructor=c,a.fn.popover.noConflict=function(){return a.fn.popover=d,this}}(jQuery),+function(a){"use strict";function b(c,d){this.$body=a(document.body),this.$scrollElement=a(a(c).is(document.body)?window:c),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||"")+" .nav li > a",this.offsets=[],this.targets=[],this.activeTarget=null,this.scrollHeight=0,this.$scrollElement.on("scroll.bs.scrollspy",a.proxy(this.process,this)),this.refresh(),this.process()}function c(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})}b.VERSION="3.3.5",b.DEFAULTS={offset:10},b.prototype.getScrollHeight=function(){return this.$scrollElement[0].scrollHeight||Math.max(this.$body[0].scrollHeight,document.documentElement.scrollHeight)},b.prototype.refresh=function(){var b=this,c="offset",d=0;this.offsets=[],this.targets=[],this.scrollHeight=this.getScrollHeight(),a.isWindow(this.$scrollElement[0])||(c="position",d=this.$scrollElement.scrollTop()),this.$body.find(this.selector).map(function(){var b=a(this),e=b.data("target")||b.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[c]().top+d,e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){b.offsets.push(this[0]),b.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.getScrollHeight(),d=this.options.offset+c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(this.scrollHeight!=c&&this.refresh(),b>=d)return g!=(a=f[f.length-1])&&this.activate(a);if(g&&b=e[a]&&(void 0===e[a+1]||b .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.5",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return c>e?"top":!1;if("bottom"==this.affixed)return null!=c?e+this.unpin<=f.top?!1:"bottom":a-d>=e+g?!1:"bottom";var h=null==this.affixed,i=h?e:f.top,j=h?g:b;return null!=c&&c>=e?"top":null!=d&&i+j>=a-d?"bottom":!1},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=Math.max(a(document).height(),a(document.body).height());"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); \ No newline at end of file diff --git a/PlexRequests.UI/Startup.cs b/PlexRequests.UI/Startup.cs index a4a3c81cf..14aa38657 100644 --- a/PlexRequests.UI/Startup.cs +++ b/PlexRequests.UI/Startup.cs @@ -26,10 +26,13 @@ #endregion using System; +using Nancy.TinyIoc; + using NLog; using Owin; +using PlexRequests.UI.Helpers; using PlexRequests.UI.Jobs; namespace PlexRequests.UI @@ -43,7 +46,6 @@ namespace PlexRequests.UI try { app.UseNancy(); - var scheduler = new Scheduler(); scheduler.StartScheduler(); } diff --git a/PlexRequests.UI/Views/Admin/CouchPotato.cshtml b/PlexRequests.UI/Views/Admin/CouchPotato.cshtml index 8558534f2..413dc278f 100644 --- a/PlexRequests.UI/Views/Admin/CouchPotato.cshtml +++ b/PlexRequests.UI/Views/Admin/CouchPotato.cshtml @@ -1,4 +1,5 @@ @using PlexRequests.UI.Helpers +@inherits Nancy.ViewEngines.Razor.NancyRazorViewBase @Html.Partial("_Sidebar") @{ int port; diff --git a/PlexRequests.UI/Views/Admin/LandingPage.cshtml b/PlexRequests.UI/Views/Admin/LandingPage.cshtml new file mode 100644 index 000000000..9f6dcec6f --- /dev/null +++ b/PlexRequests.UI/Views/Admin/LandingPage.cshtml @@ -0,0 +1,160 @@ +@using PlexRequests.UI.Helpers +@Html.Partial("_Sidebar") +@inherits Nancy.ViewEngines.Razor.NancyRazorViewBase + +
    +
    +
    + Landing Page Settings + + +
    +
    + @if (Model.Enabled) + { + + } + else + { + + } +
    + If enabled then all users will be redirected to the landing page instead of the login page. +
    +
    +
    + @if (Model.BeforeLogin) + { + + } + else + { + + } +
    + If enabled then this will show the landing page before the login page, if this is disabled the user will log in first and then see the landing page. +
    + +
    +
    +
    + + @if (Model.NoticeEnable) + { + + } + else + { + + } + +
    +
    + +

    Notice Message

    +
    +
    + +
    +
    + +
    +
    + + @if (Model.EnabledNoticeTime) + { + + } + else + { + + } + +
    +
    + + + +
    +
    + + + + + +
    +
    + +
    +
    + + + + + +
    + +
    + + + +
    +
    + +
    +
    +
    + +
    + + \ No newline at end of file diff --git a/PlexRequests.UI/Views/Admin/Plex.cshtml b/PlexRequests.UI/Views/Admin/Plex.cshtml index d50364b02..430cb6b64 100644 --- a/PlexRequests.UI/Views/Admin/Plex.cshtml +++ b/PlexRequests.UI/Views/Admin/Plex.cshtml @@ -1,4 +1,5 @@ @using PlexRequests.UI.Helpers +@inherits Nancy.ViewEngines.Razor.NancyRazorViewBase @Html.Partial("_Sidebar") @{ int port; @@ -32,18 +33,35 @@
    - - @if (Model.Ssl) - { - - } - else - { - - } - + + @if (Model.Ssl) + { + + } + else + { + + } +
    + +
    +
    + + @if (Model.AdvancedSearch) + { + + } + else + { + + } + +
    + If enabled we will be able to have a 100% accurate match, but we will be querying Plex for every single item in your library. So if you have a big library then this could take some time. +
    +
    diff --git a/PlexRequests.UI/Views/Admin/SchedulerSettings.cshtml b/PlexRequests.UI/Views/Admin/SchedulerSettings.cshtml new file mode 100644 index 000000000..37e85e772 --- /dev/null +++ b/PlexRequests.UI/Views/Admin/SchedulerSettings.cshtml @@ -0,0 +1,109 @@ +@using PlexRequests.UI.Helpers +@inherits Nancy.ViewEngines.Razor.NancyRazorViewBase +@Html.Partial("_Sidebar") + +
    + + +
    +
    Job Name
    +
    Last Run
    +
    + @foreach (var record in Model.JobRecorder) + { +
    +
    @record.Key
    +
    @record.Value.ToString("O")
    +
    + } +
    +
    +
    +
    + Scheduler Settings + Please note, you will need to restart for these settings to take effect + +
    + + +
    + +
    + + +
    + + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    + \ No newline at end of file diff --git a/PlexRequests.UI/Views/Admin/Settings.cshtml b/PlexRequests.UI/Views/Admin/Settings.cshtml index 84f7233e1..b6ad5d09d 100644 --- a/PlexRequests.UI/Views/Admin/Settings.cshtml +++ b/PlexRequests.UI/Views/Admin/Settings.cshtml @@ -1,4 +1,5 @@ @using PlexRequests.UI.Helpers +@inherits Nancy.ViewEngines.Razor.NancyRazorViewBase @Html.Partial("_Sidebar") @{ int port; @@ -164,20 +165,52 @@
    - @if (Model.UsersCanViewOnlyOwnRequests) - { - - - } - else - { - - } - + @if (Model.UsersCanViewOnlyOwnRequests) + { + + + } + else + { + + } +
    +
    +
    + + @if (Model.UsersCanViewOnlyOwnIssues) + { + + + } + else + { + + } +
    +
    + +
    +
    + + @if (Model.CollectAnalyticData) + { + + + } + else + { + + } +
    +
    + + +

    A comma separated list of users whose requests do not require approval.

    diff --git a/PlexRequests.UI/Views/Admin/_Sidebar.cshtml b/PlexRequests.UI/Views/Admin/_Sidebar.cshtml index 9b7fee6cc..4791c9f19 100644 --- a/PlexRequests.UI/Views/Admin/_Sidebar.cshtml +++ b/PlexRequests.UI/Views/Admin/_Sidebar.cshtml @@ -2,6 +2,7 @@
    @Html.GetSidebarUrl(Context, "/admin", "Plex Request") + @Html.GetSidebarUrl(Context, "/admin/landingpage", "Landing Page") @Html.GetSidebarUrl(Context, "/admin/authentication", "Authentication") @Html.GetSidebarUrl(Context, "/admin/plex", "Plex") @Html.GetSidebarUrl(Context, "/admin/couchpotato", "CouchPotato") @@ -14,5 +15,6 @@ @Html.GetSidebarUrl(Context, "/admin/slacknotification", "Slack Notifications") @Html.GetSidebarUrl(Context, "/admin/logs", "Logs") @Html.GetSidebarUrl(Context, "/admin/status", "Status") + @Html.GetSidebarUrl(Context, "/admin/scheduledjobs", "Scheduled Jobs")
    \ No newline at end of file diff --git a/PlexRequests.UI/Views/Issues/Details.cshtml b/PlexRequests.UI/Views/Issues/Details.cshtml new file mode 100644 index 000000000..53a6c8994 --- /dev/null +++ b/PlexRequests.UI/Views/Issues/Details.cshtml @@ -0,0 +1,109 @@ +@using System.Linq +@using PlexRequests.Core.Models +@using PlexRequests.UI.Helpers +@{ + var baseUrl = Html.GetBaseUrl(); + var formAction = string.Empty; + if (!string.IsNullOrEmpty(baseUrl.ToHtmlString())) + { + formAction = "/" + baseUrl.ToHtmlString(); + } + + var isAdmin = false; + + if (Context.CurrentUser != null) + { + var claims = Context.CurrentUser.Claims.ToList(); + if (claims.Contains("Admin") || claims.Contains("PowerUser")) + { + isAdmin = true; + } + } +} +

    Details

    + +
    +
    + +
    +
    +

    Issues For "@Model.Title"

    +
    + @if (isAdmin) + { +
    + + @if (Model.IssueStatus == IssueStatus.PendingIssue) + { +
    + + + + } + @if (Model.IssueStatus == IssueStatus.ResolvedIssue) + { +
    + + + + } +
    + } +
    + + +
    + +
    + +@foreach (var issue in Model.Issues) +{ +
    +
    +
    Type: @StringHelper.ToCamelCaseWords(issue.Issue.ToString())
    +
    User Reported: @issue.UserReported
    +
    User Note: @issue.UserNote
    +
    Admin Note:@issue.AdminNote
    +
    + @if (isAdmin) + { +
    +
    + + + + + +
    +
    + +
    + } +
    +
    +
    +} + + + +@Html.LoadIssueDetailsAssets() \ No newline at end of file diff --git a/PlexRequests.UI/Views/Issues/Index.cshtml b/PlexRequests.UI/Views/Issues/Index.cshtml new file mode 100644 index 000000000..2c18ccf41 --- /dev/null +++ b/PlexRequests.UI/Views/Issues/Index.cshtml @@ -0,0 +1,96 @@ +@using PlexRequests.UI.Helpers +@{ + var baseUrl = Html.GetBaseUrl(); + var formAction = string.Empty; + if (!string.IsNullOrEmpty(baseUrl.ToHtmlString())) + { + formAction = "/" + baseUrl.ToHtmlString(); + } +} +

    Issues

    +

    Below you can see yours and all your current issues and their state.

    +
    +
    + + +
    + +
    +
    +

    Title

    +
    +
    +

    Type

    +
    +
    +

    Issue's

    +
    + +
    +

    Requested

    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    + +
    +
    + + +
    + +
    +
    + +
    + +
    +
    +
    + + + +@Html.LoadIssueAssets() diff --git a/PlexRequests.UI/Views/Landing/Index.cshtml b/PlexRequests.UI/Views/Landing/Index.cshtml new file mode 100644 index 000000000..8e0cd90a0 --- /dev/null +++ b/PlexRequests.UI/Views/Landing/Index.cshtml @@ -0,0 +1,78 @@ + +@using PlexRequests.UI.Helpers +@inherits PlexRequests.UI.Helpers.EmptyViewBase + + +
    + @if (Model.NoticeEnable && (!Model.EnabledNoticeTime || Model.NoticeActive)) + { +
    +
    + +
    +
    +

    Notice

    + @Model.NoticeMessage
    + @if (Model.EnabledNoticeTime) + { + + } +
    +
    + } + +
    +
    + +
    +
    +

    Checking...

    + The Plex server is Loading... (check this page for continuous status updates) +
    +
    +
    + + + \ No newline at end of file diff --git a/PlexRequests.UI/Views/Requests/Index.cshtml b/PlexRequests.UI/Views/Requests/Index.cshtml index 03722c18b..95db35a11 100644 --- a/PlexRequests.UI/Views/Requests/Index.cshtml +++ b/PlexRequests.UI/Views/Requests/Index.cshtml @@ -50,7 +50,7 @@ } @if (Model.SearchForMusic) { - + } } @@ -65,7 +65,7 @@
  • Approved
  • Not Approved
  • Available
  • -
  • Not Available
  • +
  • Not Available
  • Released
  • Not Released
  • @@ -146,7 +146,7 @@
    - +

    {{title}} ({{year}})

    {{status}} @@ -178,17 +178,13 @@
    Requested By: {{requestedUsers}}
    {{/if}}
    Requested Date: {{requestedDate}}
    -
    - {{#if otherMessage}} -
    Message: {{otherMessage}}
    +
    + Issue: + {{#if_eq issueId 0}} + {{else}} -
    Issue: {{issues}}
    - {{/if}} -
    -
    - {{#if adminNote}} -
    Note from Admin: {{adminNote}}
    - {{/if}} + + {{/if_eq}}
    @@ -197,20 +193,20 @@
    {{#if_eq hasQualities true}} -
    - - - -
    +
    + + + +
    {{else}} - + {{/if_eq}} {{/if_eq}} @@ -219,11 +215,6 @@ -
    - - - -
    {{#if_eq available true}} @@ -235,7 +226,7 @@ {{/if_eq}} - + @@ -277,6 +290,30 @@
    + + +
    ").addClass("cw").text("#"));c.isBefore(l.clone().endOf("w"));)b.append(a("").addClass("dow").text(c.format("dd"))),c.add(1,"d");o.find(".datepicker-days thead").append(b)},L=function(a){return d.disabledDates[a.format("YYYY-MM-DD")]===!0},M=function(a){return d.enabledDates[a.format("YYYY-MM-DD")]===!0},N=function(a){return d.disabledHours[a.format("H")]===!0},O=function(a){return d.enabledHours[a.format("H")]===!0},P=function(b,c){if(!b.isValid())return!1;if(d.disabledDates&&"d"===c&&L(b))return!1;if(d.enabledDates&&"d"===c&&!M(b))return!1;if(d.minDate&&b.isBefore(d.minDate,c))return!1;if(d.maxDate&&b.isAfter(d.maxDate,c))return!1;if(d.daysOfWeekDisabled&&"d"===c&&-1!==d.daysOfWeekDisabled.indexOf(b.day()))return!1;if(d.disabledHours&&("h"===c||"m"===c||"s"===c)&&N(b))return!1;if(d.enabledHours&&("h"===c||"m"===c||"s"===c)&&!O(b))return!1;if(d.disabledTimeIntervals&&("h"===c||"m"===c||"s"===c)){var e=!1;if(a.each(d.disabledTimeIntervals,function(){return b.isBetween(this[0],this[1])?(e=!0,!1):void 0}),e)return!1}return!0},Q=function(){for(var b=[],c=l.clone().startOf("y").startOf("d");c.isSame(l,"y");)b.push(a("").attr("data-action","selectMonth").addClass("month").text(c.format("MMM"))),c.add(1,"M");o.find(".datepicker-months td").empty().append(b)},R=function(){var b=o.find(".datepicker-months"),c=b.find("th"),e=b.find("tbody").find("span");c.eq(0).find("span").attr("title",d.tooltips.prevYear),c.eq(1).attr("title",d.tooltips.selectYear),c.eq(2).find("span").attr("title",d.tooltips.nextYear),b.find(".disabled").removeClass("disabled"),P(l.clone().subtract(1,"y"),"y")||c.eq(0).addClass("disabled"),c.eq(1).text(l.year()),P(l.clone().add(1,"y"),"y")||c.eq(2).addClass("disabled"),e.removeClass("active"),k.isSame(l,"y")&&!m&&e.eq(k.month()).addClass("active"),e.each(function(b){P(l.clone().month(b),"M")||a(this).addClass("disabled")})},S=function(){var a=o.find(".datepicker-years"),b=a.find("th"),c=l.clone().subtract(5,"y"),e=l.clone().add(6,"y"),f="";for(b.eq(0).find("span").attr("title",d.tooltips.nextDecade),b.eq(1).attr("title",d.tooltips.selectDecade),b.eq(2).find("span").attr("title",d.tooltips.prevDecade),a.find(".disabled").removeClass("disabled"),d.minDate&&d.minDate.isAfter(c,"y")&&b.eq(0).addClass("disabled"),b.eq(1).text(c.year()+"-"+e.year()),d.maxDate&&d.maxDate.isBefore(e,"y")&&b.eq(2).addClass("disabled");!c.isAfter(e,"y");)f+=''+c.year()+"",c.add(1,"y");a.find("td").html(f)},T=function(){var a=o.find(".datepicker-decades"),c=a.find("th"),e=b(l.isBefore(b({y:1999}))?{y:1899}:{y:1999}),f=e.clone().add(100,"y"),g="";for(c.eq(0).find("span").attr("title",d.tooltips.prevCentury),c.eq(2).find("span").attr("title",d.tooltips.nextCentury),a.find(".disabled").removeClass("disabled"),(e.isSame(b({y:1900}))||d.minDate&&d.minDate.isAfter(e,"y"))&&c.eq(0).addClass("disabled"),c.eq(1).text(e.year()+"-"+f.year()),(e.isSame(b({y:2e3}))||d.maxDate&&d.maxDate.isBefore(f,"y"))&&c.eq(2).addClass("disabled");!e.isAfter(f,"y");)g+=''+(e.year()+1)+" - "+(e.year()+12)+"",e.add(12,"y");g+="",a.find("td").html(g)},U=function(){var c,e,f,g,h=o.find(".datepicker-days"),i=h.find("th"),j=[];if(z()){for(i.eq(0).find("span").attr("title",d.tooltips.prevMonth),i.eq(1).attr("title",d.tooltips.selectMonth),i.eq(2).find("span").attr("title",d.tooltips.nextMonth),h.find(".disabled").removeClass("disabled"),i.eq(1).text(l.format(d.dayViewHeaderFormat)),P(l.clone().subtract(1,"M"),"M")||i.eq(0).addClass("disabled"),P(l.clone().add(1,"M"),"M")||i.eq(2).addClass("disabled"),c=l.clone().startOf("M").startOf("w").startOf("d"),g=0;42>g;g++)0===c.weekday()&&(e=a("
    '+c.week()+"'+c.date()+"
    '+c.format(f?"HH":"hh")+"
    '+c.format("mm")+"
    '+c.format("ss")+"