using Dinah.Core; using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; 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 #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. private Book() { } #pragma warning restore CS8618 // 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 Title = title?.Trim() ?? ""; Subtitle = subtitle?.Trim() ?? ""; 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) { if (context is null) throw new ArgumentNullException(nameof(context), "A DbContext is required to load the ContributorsLink collection"); 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 readonly 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) { if (context is null) throw new ArgumentNullException(nameof(context), "A DbContext is required to load the SeriesLink collection"); 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 readonly 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)) is true) 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 ?? IsSpatial; DatePublished = datePublished ?? DatePublished; Language = language?.FirstCharToUpper() ?? Language; } public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}"; }