From e82b29e346ad691496f4eba1ac78fac2dffb212b Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Tue, 9 Sep 2014 17:02:55 -0700 Subject: [PATCH] Tags New: Ability to tag series New: Use tags to control which series use which notification channels --- .../Notifications/NotificationResource.cs | 2 + src/NzbDrone.Api/NzbDrone.Api.csproj | 2 + src/NzbDrone.Api/Series/SeriesResource.cs | 1 + src/NzbDrone.Api/Tags/TagModule.cs | 48 ++ src/NzbDrone.Api/Tags/TagResource.cs | 10 + .../Datastore/Migration/066_add_tags.cs | 21 + src/NzbDrone.Core/Datastore/TableMapping.cs | 11 +- .../Notifications/NotificationDefinition.cs | 7 + .../Notifications/NotificationService.cs | 38 +- src/NzbDrone.Core/NzbDrone.Core.csproj | 4 + src/NzbDrone.Core/Tags/Tag.cs | 10 + src/NzbDrone.Core/Tags/TagRepository.cs | 18 + src/NzbDrone.Core/Tags/TagService.cs | 50 ++ src/NzbDrone.Core/Tv/Series.cs | 3 +- .../Overrides/bootstrap.tagsinput.less | 35 + src/UI/Content/bootstrap.less | 3 +- src/UI/Content/bootstrap.tagsinput.less | 50 ++ src/UI/Content/overrides.less | 1 + src/UI/JsLibraries/bootstrap.tagsinput.js | 617 ++++++++++++++++++ src/UI/Mixins/TagInput.js | 110 ++++ src/UI/Series/Edit/EditSeriesView.js | 10 +- src/UI/Series/Edit/EditSeriesViewTemplate.hbs | 8 + .../Edit/NotificationEditView.js | 11 +- .../Edit/NotificationEditViewTemplate.hbs | 8 + src/UI/Tags/TagCollection.js | 14 + src/UI/Tags/TagHelpers.js | 20 + src/UI/Tags/TagInputPartial.hbs | 1 + src/UI/Tags/TagModel.js | 9 + src/UI/app.js | 8 + 29 files changed, 1116 insertions(+), 14 deletions(-) create mode 100644 src/NzbDrone.Api/Tags/TagModule.cs create mode 100644 src/NzbDrone.Api/Tags/TagResource.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/066_add_tags.cs create mode 100644 src/NzbDrone.Core/Tags/Tag.cs create mode 100644 src/NzbDrone.Core/Tags/TagRepository.cs create mode 100644 src/NzbDrone.Core/Tags/TagService.cs create mode 100644 src/UI/Content/Overrides/bootstrap.tagsinput.less create mode 100644 src/UI/Content/bootstrap.tagsinput.less create mode 100644 src/UI/JsLibraries/bootstrap.tagsinput.js create mode 100644 src/UI/Mixins/TagInput.js create mode 100644 src/UI/Tags/TagCollection.js create mode 100644 src/UI/Tags/TagHelpers.js create mode 100644 src/UI/Tags/TagInputPartial.hbs create mode 100644 src/UI/Tags/TagModel.js diff --git a/src/NzbDrone.Api/Notifications/NotificationResource.cs b/src/NzbDrone.Api/Notifications/NotificationResource.cs index 51c7fb7df..1c4bb34c5 100644 --- a/src/NzbDrone.Api/Notifications/NotificationResource.cs +++ b/src/NzbDrone.Api/Notifications/NotificationResource.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace NzbDrone.Api.Notifications { @@ -9,5 +10,6 @@ namespace NzbDrone.Api.Notifications public Boolean OnDownload { get; set; } public Boolean OnUpgrade { get; set; } public String TestCommand { get; set; } + public HashSet Tags { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index e57dfd6b6..36d1be281 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -212,6 +212,8 @@ + + diff --git a/src/NzbDrone.Api/Series/SeriesResource.cs b/src/NzbDrone.Api/Series/SeriesResource.cs index cc348e919..ce55f76da 100644 --- a/src/NzbDrone.Api/Series/SeriesResource.cs +++ b/src/NzbDrone.Api/Series/SeriesResource.cs @@ -65,6 +65,7 @@ namespace NzbDrone.Api.Series public String RootFolderPath { get; set; } public String Certification { get; set; } public List Genres { get; set; } + public HashSet Tags { get; set; } //Used to support legacy consumers public Int32 QualityProfileId diff --git a/src/NzbDrone.Api/Tags/TagModule.cs b/src/NzbDrone.Api/Tags/TagModule.cs new file mode 100644 index 000000000..b6cdb7431 --- /dev/null +++ b/src/NzbDrone.Api/Tags/TagModule.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Api.Mapping; +using NzbDrone.Core.Tags; + +namespace NzbDrone.Api.Tags +{ + public class TagModule : NzbDroneRestModule + { + private readonly ITagService _tagService; + + public TagModule(ITagService tagService) + { + _tagService = tagService; + + GetResourceById = Get; + GetResourceAll = GetAll; + CreateResource = Create; + UpdateResource = Update; + DeleteResource = Delete; + } + + private TagResource Get(Int32 id) + { + return _tagService.GetTag(id).InjectTo(); + } + + private List GetAll() + { + return ToListResource(_tagService.All); + } + + private Int32 Create(TagResource resource) + { + return _tagService.Add(resource.InjectTo()).Id; + } + + private void Update(TagResource resource) + { + _tagService.Update(resource.InjectTo()); + } + + private void Delete(Int32 id) + { + _tagService.Delete(id); + } + } +} diff --git a/src/NzbDrone.Api/Tags/TagResource.cs b/src/NzbDrone.Api/Tags/TagResource.cs new file mode 100644 index 000000000..7c3f0711c --- /dev/null +++ b/src/NzbDrone.Api/Tags/TagResource.cs @@ -0,0 +1,10 @@ +using System; +using NzbDrone.Api.REST; + +namespace NzbDrone.Api.Tags +{ + public class TagResource : RestResource + { + public String Label { get; set; } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/066_add_tags.cs b/src/NzbDrone.Core/Datastore/Migration/066_add_tags.cs new file mode 100644 index 000000000..dc8e94892 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/066_add_tags.cs @@ -0,0 +1,21 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(66)] + public class add_tags : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("Tags") + .WithColumn("Label").AsString().NotNullable(); + + Alter.Table("Series") + .AddColumn("Tags").AsString().Nullable(); + + Alter.Table("Notifications") + .AddColumn("Tags").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index e7a4be08f..b30b350ec 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -24,6 +24,7 @@ using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; using NzbDrone.Core.RootFolders; using NzbDrone.Core.SeriesStats; +using NzbDrone.Core.Tags; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Tv; @@ -90,6 +91,7 @@ namespace NzbDrone.Core.Datastore .Ignore(e => e.RemoteEpisode); Mapper.Entity().RegisterModel("RemotePathMappings"); + Mapper.Entity().RegisterModel("Tags"); } private static void RegisterMappers() @@ -104,11 +106,12 @@ namespace NzbDrone.Core.Datastore MapRepository.Instance.RegisterTypeConverter(typeof(Quality), new QualityIntConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new QualityIntConverter())); MapRepository.Instance.RegisterTypeConverter(typeof(QualityModel), new EmbeddedDocumentConverter(new QualityIntConverter())); - MapRepository.Instance.RegisterTypeConverter(typeof(Dictionary), new EmbeddedDocumentConverter()); - MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter()); - MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter()); + MapRepository.Instance.RegisterTypeConverter(typeof(Dictionary), new EmbeddedDocumentConverter()); + MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter()); + MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(ParsedEpisodeInfo), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(ReleaseInfo), new EmbeddedDocumentConverter()); + MapRepository.Instance.RegisterTypeConverter(typeof(HashSet), new EmbeddedDocumentConverter()); } private static void RegisterProviderSettingConverter() @@ -128,9 +131,11 @@ namespace NzbDrone.Core.Datastore var embeddedConvertor = new EmbeddedDocumentConverter(); var genericListDefinition = typeof(List<>).GetGenericTypeDefinition(); + foreach (var embeddedType in embeddedTypes) { var embeddedListType = genericListDefinition.MakeGenericType(embeddedType); + MapRepository.Instance.RegisterTypeConverter(embeddedType, embeddedConvertor); MapRepository.Instance.RegisterTypeConverter(embeddedListType, embeddedConvertor); } diff --git a/src/NzbDrone.Core/Notifications/NotificationDefinition.cs b/src/NzbDrone.Core/Notifications/NotificationDefinition.cs index 3ad406399..3c87bf2d7 100644 --- a/src/NzbDrone.Core/Notifications/NotificationDefinition.cs +++ b/src/NzbDrone.Core/Notifications/NotificationDefinition.cs @@ -1,13 +1,20 @@ using System; +using System.Collections.Generic; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Notifications { public class NotificationDefinition : ProviderDefinition { + public NotificationDefinition() + { + Tags = new HashSet(); + } + public Boolean OnGrab { get; set; } public Boolean OnDownload { get; set; } public Boolean OnUpgrade { get; set; } + public HashSet Tags { get; set; } public override Boolean Enable { diff --git a/src/NzbDrone.Core/Notifications/NotificationService.cs b/src/NzbDrone.Core/Notifications/NotificationService.cs index 21d4f2d0b..29b8e8c96 100644 --- a/src/NzbDrone.Core/Notifications/NotificationService.cs +++ b/src/NzbDrone.Core/Notifications/NotificationService.cs @@ -2,10 +2,12 @@ using System.Collections.Generic; using System.Linq; using NLog; +using NzbDrone.Common; using NzbDrone.Core.Download; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Qualities; +using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Notifications @@ -65,6 +67,27 @@ namespace NzbDrone.Core.Notifications qualityString); } + private bool ShouldHandleSeries(ProviderDefinition definition, Series series) + { + var notificationDefinition = (NotificationDefinition) definition; + + if (notificationDefinition.Tags.Empty()) + { + _logger.Debug("No tags set for this notification."); + return true; + } + + if (notificationDefinition.Tags.Intersect(series.Tags).Any()) + { + _logger.Debug("Notification and series have one or more matching tags."); + return true; + } + + //TODO: this message could be more clear + _logger.Debug("{0} does not have any tags that match {1}'s tags", notificationDefinition.Name, series.Title); + return false; + } + public void Handle(EpisodeGrabbedEvent message) { var messageBody = GetMessage(message.Episode.Series, message.Episode.Episodes, message.Episode.ParsedEpisodeInfo.Quality); @@ -73,6 +96,7 @@ namespace NzbDrone.Core.Notifications { try { + if (!ShouldHandleSeries(notification.Definition, message.Episode.Series)) continue; notification.OnGrab(messageBody); } @@ -95,12 +119,13 @@ namespace NzbDrone.Core.Notifications { try { - if (downloadMessage.OldFiles.Any() && !((NotificationDefinition) notification.Definition).OnUpgrade) + if (ShouldHandleSeries(notification.Definition, message.Episode.Series)) { - continue; + if (downloadMessage.OldFiles.Empty() || ((NotificationDefinition)notification.Definition).OnUpgrade) + { + notification.OnDownload(downloadMessage); + } } - - notification.OnDownload(downloadMessage); } catch (Exception ex) @@ -116,7 +141,10 @@ namespace NzbDrone.Core.Notifications { try { - notification.AfterRename(message.Series); + if (ShouldHandleSeries(notification.Definition, message.Series)) + { + notification.AfterRename(message.Series); + } } catch (Exception ex) diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 188ab2a4a..1a6b45ed4 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -230,6 +230,7 @@ + @@ -720,6 +721,9 @@ + + + diff --git a/src/NzbDrone.Core/Tags/Tag.cs b/src/NzbDrone.Core/Tags/Tag.cs new file mode 100644 index 000000000..824afa573 --- /dev/null +++ b/src/NzbDrone.Core/Tags/Tag.cs @@ -0,0 +1,10 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Tags +{ + public class Tag : ModelBase + { + public String Label { get; set; } + } +} diff --git a/src/NzbDrone.Core/Tags/TagRepository.cs b/src/NzbDrone.Core/Tags/TagRepository.cs new file mode 100644 index 000000000..3314141eb --- /dev/null +++ b/src/NzbDrone.Core/Tags/TagRepository.cs @@ -0,0 +1,18 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Tags +{ + public interface ITagRepository : IBasicRepository + { + + } + + public class TagRepository : BasicRepository, ITagRepository + { + public TagRepository(IDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs new file mode 100644 index 000000000..2cfe66281 --- /dev/null +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.Tags +{ + public interface ITagService + { + Tag GetTag(Int32 tagId); + List All(); + Tag Add(Tag tag); + Tag Update(Tag tag); + void Delete(Int32 tagId); + } + + public class TagService : ITagService + { + private readonly ITagRepository _tagRepository; + + public TagService(ITagRepository tagRepository) + { + _tagRepository = tagRepository; + } + + public Tag GetTag(Int32 tagId) + { + return _tagRepository.Get(tagId); + } + + public List All() + { + return _tagRepository.All().ToList(); + } + + public Tag Add(Tag tag) + { + return _tagRepository.Insert(tag); + } + + public Tag Update(Tag tag) + { + return _tagRepository.Update(tag); + } + + public void Delete(Int32 tagId) + { + _tagRepository.Delete(tagId); + } + } +} diff --git a/src/NzbDrone.Core/Tv/Series.cs b/src/NzbDrone.Core/Tv/Series.cs index 7baf9fb80..3717a36e7 100644 --- a/src/NzbDrone.Core/Tv/Series.cs +++ b/src/NzbDrone.Core/Tv/Series.cs @@ -5,7 +5,6 @@ using NzbDrone.Core.Datastore; using NzbDrone.Core.Profiles; using NzbDrone.Common; - namespace NzbDrone.Core.Tv { public class Series : ModelBase @@ -15,6 +14,7 @@ namespace NzbDrone.Core.Tv Images = new List(); Genres = new List(); Actors = new List(); + Tags = new HashSet(); } public int TvdbId { get; set; } @@ -49,6 +49,7 @@ namespace NzbDrone.Core.Tv public LazyLoaded Profile { get; set; } public List Seasons { get; set; } + public HashSet Tags { get; set; } public override string ToString() { diff --git a/src/UI/Content/Overrides/bootstrap.tagsinput.less b/src/UI/Content/Overrides/bootstrap.tagsinput.less new file mode 100644 index 000000000..85f726ae6 --- /dev/null +++ b/src/UI/Content/Overrides/bootstrap.tagsinput.less @@ -0,0 +1,35 @@ +@import "../Bootstrap/variables"; + +.bootstrap-tagsinput { + width : 100%; + + .twitter-typeahead { + width : auto; + } + + .tag { + margin-right: 0px; + + [data-role="remove"] { + &:hover { + color: @brand-danger; + } + } + } + + .tt-dropdown-menu { + + .opacity(0.95); + + .tt-suggestion { + color: #222222; + cursor: pointer; + + //selected item + &.tt-cursor { + background-color: @droneTeal; + color: #ffffff; + } + } + } +} \ No newline at end of file diff --git a/src/UI/Content/bootstrap.less b/src/UI/Content/bootstrap.less index d51ca8329..10e23ce63 100644 --- a/src/UI/Content/bootstrap.less +++ b/src/UI/Content/bootstrap.less @@ -1,2 +1,3 @@ @import "./Bootstrap/bootstrap"; -@import "./Overrides/bootstrap"; \ No newline at end of file +@import "./Overrides/bootstrap"; +@import "./bootstrap.tagsinput.less"; \ No newline at end of file diff --git a/src/UI/Content/bootstrap.tagsinput.less b/src/UI/Content/bootstrap.tagsinput.less new file mode 100644 index 000000000..face63f18 --- /dev/null +++ b/src/UI/Content/bootstrap.tagsinput.less @@ -0,0 +1,50 @@ +.bootstrap-tagsinput { + background-color: #fff; + border: 1px solid #ccc; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + display: inline-block; + padding: 4px 6px; + margin-bottom: 10px; + color: #555; + vertical-align: middle; + border-radius: 4px; + max-width: 100%; + line-height: 22px; + cursor: text; + + input { + border: none; + box-shadow: none; + outline: none; + background-color: transparent; + padding: 0; + margin: 0; + width: auto !important; + max-width: inherit; + + &:focus { + border: none; + box-shadow: none; + } + } + + .tag { + margin-right: 2px; + color: white; + + [data-role="remove"] { + margin-left:8px; + cursor:pointer; + &:after{ + content: "x"; + padding:0px 2px; + } + &:hover { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + &:active { + box-shadow: inset 0 3px 5px rgba(0,0,0,0.125); + } + } + } + } +} diff --git a/src/UI/Content/overrides.less b/src/UI/Content/overrides.less index 1c0ec7e73..abee53862 100644 --- a/src/UI/Content/overrides.less +++ b/src/UI/Content/overrides.less @@ -1,5 +1,6 @@ @import "Overrides/bootstrap"; @import "Overrides/browser"; @import "Overrides/bootstrap.toggle-switch"; +@import "Overrides/bootstrap.tagsinput.less"; @import "Overrides/fullcalendar"; @import "Overrides/messenger"; diff --git a/src/UI/JsLibraries/bootstrap.tagsinput.js b/src/UI/JsLibraries/bootstrap.tagsinput.js new file mode 100644 index 000000000..93e7548a4 --- /dev/null +++ b/src/UI/JsLibraries/bootstrap.tagsinput.js @@ -0,0 +1,617 @@ +(function ($) { + "use strict"; + + var defaultOptions = { + tagClass: function(item) { + return 'label label-info'; + }, + itemValue: function(item) { + return item ? item.toString() : item; + }, + itemText: function(item) { + return this.itemValue(item); + }, + freeInput: true, + addOnBlur: true, + maxTags: undefined, + maxChars: undefined, + confirmKeys: [13, 44], + onTagExists: function(item, $tag) { + $tag.hide().fadeIn(); + }, + trimValue: false, + allowDuplicates: false + }; + + /** + * Constructor function + */ + function TagsInput(element, options) { + this.itemsArray = []; + + this.$element = $(element); + this.$element.hide(); + + this.isSelect = (element.tagName === 'SELECT'); + this.multiple = (this.isSelect && element.hasAttribute('multiple')); + this.objectItems = options && options.itemValue; + this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : ''; + this.inputSize = Math.max(1, this.placeholderText.length); + + this.$container = $('
'); + this.$input = $('').appendTo(this.$container); + + this.$element.after(this.$container); + +// var inputWidth = (this.inputSize < 3 ? 3 : this.inputSize) + "em"; +// this.$input.get(0).style.cssText = "width: " + inputWidth + " !important;"; + this.build(options); + } + + TagsInput.prototype = { + constructor: TagsInput, + + /** + * Adds the given item as a new tag. Pass true to dontPushVal to prevent + * updating the elements val() + */ + add: function(item, dontPushVal) { + var self = this; + + if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags) + return; + + // Ignore falsey values, except false + if (item !== false && !item) + return; + + // Trim value + if (typeof item === "string" && self.options.trimValue) { + item = $.trim(item); + } + + // Throw an error when trying to add an object while the itemValue option was not set + if (typeof item === "object" && !self.objectItems) + throw("Can't add objects when itemValue option is not set"); + + // Ignore strings only containg whitespace + if (item.toString().match(/^\s*$/)) + return; + + // If SELECT but not multiple, remove current tag + if (self.isSelect && !self.multiple && self.itemsArray.length > 0) + self.remove(self.itemsArray[0]); + + if (typeof item === "string" && this.$element[0].tagName === 'INPUT') { + var items = item.split(','); + if (items.length > 1) { + for (var i = 0; i < items.length; i++) { + this.add(items[i], true); + } + + if (!dontPushVal) + self.pushVal(); + return; + } + } + + var itemValue = self.options.itemValue(item), + itemText = self.options.itemText(item), + tagClass = self.options.tagClass(item); + + // Ignore items allready added + var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0]; + if (existing && !self.options.allowDuplicates) { + // Invoke onTagExists + if (self.options.onTagExists) { + var $existingTag = $(".tag", self.$container).filter(function() { return $(this).data("item") === existing; }); + self.options.onTagExists(item, $existingTag); + } + return; + } + + // if length greater than limit + if (self.items().toString().length + item.length + 1 > self.options.maxInputLength) + return; + + // raise beforeItemAdd arg + var beforeItemAddEvent = $.Event('beforeItemAdd', { item: item, cancel: false }); + self.$element.trigger(beforeItemAddEvent); + if (beforeItemAddEvent.cancel) + return; + + // register item in internal array and map + self.itemsArray.push(item); + + // add a tag element + var $tag = $('' + htmlEncode(itemText) + ''); + $tag.data('item', item); + self.findInputWrapper().before($tag); + $tag.after(' '); + + // add