using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using Dinah.Core; using Microsoft.EntityFrameworkCore; namespace DataLayer { public class AudibleProductId { public string Id { get; } public AudibleProductId(string id) { ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id)); Id = id; } } // enum will be easier than bool to extend later. public enum ContentType { Unknown = 0, Product = 1, Episode = 2, Parent = 4, } public class Book { // implementation detail. set by db only. only used by data layer internal int BookId { get; private set; } // immutable public string AudibleProductId { get; private set; } public string Title { get; private set; } public string Subtitle { get; private set; } private string _titleWithSubtitle; public string TitleWithSubtitle => _titleWithSubtitle ??= string.IsNullOrEmpty(Subtitle) ? Title : $"{Title}: {Subtitle}"; public string Description { get; private set; } public int LengthInMinutes { get; private set; } public ContentType ContentType { get; private set; } public string Locale { get; private set; } // mutable public string PictureId { get; set; } public string PictureLarge { get; set; } // book details public bool IsAbridged { get; private set; } public bool IsSpatial { get; private set; } public DateTime? DatePublished { get; private set; } public string Language { get; private set; } // is owned, not optional 1:1 public UserDefinedItem UserDefinedItem { get; private set; } // is owned, not optional 1:1 /// The product's aggregate community rating public Rating Rating { get; private set; } = new Rating(0, 0, 0); // ef-ctor private Book() { } // non-ef ctor /// special id class b/c it's too easy to get string order mixed up public Book( AudibleProductId audibleProductId, string title, string subtitle, string description, int lengthInMinutes, ContentType contentType, IEnumerable authors, IEnumerable narrators, string localeName) { // validate ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId)); var productId = audibleProductId.Id; ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId)); // assign as soon as possible. stuff below relies on this AudibleProductId = productId; Locale = localeName; ArgumentValidator.EnsureNotNullOrWhiteSpace(title, nameof(title)); // non-ef-ctor init.s UserDefinedItem = new UserDefinedItem(this); ContributorsLink = new HashSet(); CategoriesLink = new HashSet(); _seriesLink = new HashSet(); _supplements = new HashSet(); // simple assigns UpdateTitle(title, subtitle); Description = description?.Trim() ?? ""; LengthInMinutes = lengthInMinutes; ContentType = contentType; // assigns with biz logic ReplaceAuthors(authors); ReplaceNarrators(narrators); } public void UpdateTitle(string title, string subtitle) { Title = title?.Trim() ?? ""; Subtitle = subtitle?.Trim() ?? ""; _titleWithSubtitle = null; } public void UpdateLengthInMinutes(int lengthInMinutes) => LengthInMinutes = lengthInMinutes; #region contributors, authors, narrators internal HashSet ContributorsLink { get; private set; } public IEnumerable Authors => ContributorsLink.ByRole(Role.Author).Select(bc => bc.Contributor).ToList(); public IEnumerable Narrators => ContributorsLink.ByRole(Role.Narrator).Select(bc => bc.Contributor).ToList(); public string Publisher => ContributorsLink.ByRole(Role.Publisher).SingleOrDefault()?.Contributor.Name; public void ReplaceAuthors(IEnumerable authors, DbContext context = null) => replaceContributors(authors, Role.Author, context); public void ReplaceNarrators(IEnumerable narrators, DbContext context = null) => replaceContributors(narrators, Role.Narrator, context); public void ReplacePublisher(Contributor publisher, DbContext context = null) => replaceContributors(new List { publisher }, Role.Publisher, context); private void replaceContributors(IEnumerable newContributors, Role role, DbContext context = null) { ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors)); // the edge cases of doing local-loaded vs remote-only got weird. just load it if (ContributorsLink is null) getEntry(context).Collection(s => s.ContributorsLink).Load(); var isIdentical = ContributorsLink .ByRole(role) .Select(c => c.Contributor) .SequenceEqual(newContributors); if (isIdentical) return; ContributorsLink.RemoveWhere(bc => bc.Role == role); addNewContributors(newContributors, role); } private void addNewContributors(IEnumerable newContributors, Role role) { byte order = 0; var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++)); var newContributions = new HashSet(newContributionsEnum); ContributorsLink.UnionWith(newContributions); } #endregion private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry getEntry(DbContext context) { ArgumentValidator.EnsureNotNull(context, nameof(context)); var entry = context.Entry(this); if (!entry.IsKeySet) throw new InvalidOperationException("Could not load a valid Book from database"); return entry; } #region categories internal HashSet CategoriesLink { get; private set; } private ReadOnlyCollection _categoriesReadOnly; public ReadOnlyCollection Categories { get { if (_categoriesReadOnly?.SequenceEqual(CategoriesLink) is not true) _categoriesReadOnly = CategoriesLink.ToList().AsReadOnly(); return _categoriesReadOnly; } } public void SetCategoryLadders(IEnumerable ladders) { ArgumentValidator.EnsureNotNull(ladders, nameof(ladders)); //Replace all existing category ladders. //Some books make have duplicate ladders CategoriesLink.Clear(); CategoriesLink.UnionWith(ladders.Distinct().Select(l => new BookCategory(this, l))); } #endregion #region series private HashSet _seriesLink; public IEnumerable SeriesLink => _seriesLink?.ToList(); public void UpsertSeries(Series series, string order, DbContext context = null) { ArgumentValidator.EnsureNotNull(series, nameof(series)); // our add() is conditional upon what's already included in the collection. // therefore if not loaded, a trip is required. might as well just load it if (_seriesLink is null) getEntry(context).Collection(s => s.SeriesLink).Load(); var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series); if (singleSeriesBook is null) _seriesLink.Add(new SeriesBook(series, this, order)); else singleSeriesBook.UpdateOrder(order); } #endregion #region supplements private HashSet _supplements; public IEnumerable Supplements => _supplements?.ToList(); public void AddSupplementDownloadUrl(string url) { // supplements are owned by Book, so no need to Load(): // Are automatically loaded, and can only be tracked by a DbContext alongside their owner. ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url)); if (_supplements.Any(s => url.EqualsInsensitive(url))) return; _supplements.Add(new Supplement(this, url)); UserDefinedItem.PdfStatus ??= LiberatedStatus.NotLiberated; } #endregion public void UpdateProductRating(float overallRating, float performanceRating, float storyRating) => Rating.Update(overallRating, performanceRating, storyRating); public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string language) { // don't overwrite with default values IsAbridged |= isAbridged; IsSpatial |= isSpatial ?? false; DatePublished = datePublished ?? DatePublished; Language = language?.FirstCharToUpper() ?? Language; } public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}"; } }