Initial check-in

This commit is contained in:
Robert McRackan 2019-10-04 16:14:04 -04:00
parent c080c9e51d
commit 2cc93078d2
282 changed files with 24387 additions and 0 deletions

View file

@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
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;
}
}
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 Description { get; private set; }
public int LengthInMinutes { get; private set; }
// mutable
public string PictureId { get; set; }
// book details
public bool HasBookDetails { get; private set; }
public bool IsAbridged { get; private set; }
public DateTime? DatePublished { get; private set; }
// non-null. use "empty pattern"
internal int CategoryId { get; private set; }
public Category Category { get; private set; }
public string[] CategoriesNames
=> Category == null ? new string[0]
: Category.ParentCategory == null ? new[] { Category.Name }
: new[] { Category.ParentCategory.Name, Category.Name };
public string[] CategoriesIds
=> Category == null ? null
: Category.ParentCategory == null ? new[] { Category.AudibleCategoryId }
: new[] { Category.ParentCategory.AudibleCategoryId, Category.AudibleCategoryId };
// is owned, not optional 1:1
public UserDefinedItem UserDefinedItem { get; private set; }
// is owned, not optional 1:1
/// <summary>The product's aggregate community rating</summary>
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
// ef-ctor
private Book() { }
// non-ef ctor
/// <param name="audibleProductId">special id class b/c it's too easy to get string order mixed up</param>
public Book(
AudibleProductId audibleProductId,
string title,
string description,
int lengthInMinutes,
IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators)
{
// validate
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
var productId = audibleProductId.Id;
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
ArgumentValidator.EnsureNotNullOrWhiteSpace(title, nameof(title));
// non-ef-ctor init.s
UserDefinedItem = new UserDefinedItem(this);
_contributorsLink = new HashSet<BookContributor>();
_seriesLink = new HashSet<SeriesBook>();
_supplements = new HashSet<Supplement>();
// since category/id is never null, nullity means it hasn't been loaded
CategoryId = Category.GetEmpty().CategoryId;
// simple assigns
AudibleProductId = productId;
Title = title;
Description = description;
LengthInMinutes = lengthInMinutes;
// assigns with biz logic
ReplaceAuthors(authors);
ReplaceNarrators(narrators);
// import previously saved tags
// do this immediately. any save occurs before reloading tags will overwrite persistent tags with new blank entries; all old persisted tags will be lost
// if refactoring, DO NOT use "ProductId" before it's assigned to. to be safe, just use "productId"
UserDefinedItem = new UserDefinedItem(this) { Tags = FileManager.TagsPersistence.GetTags(productId) };
}
#region contributors, authors, narrators
// use uninitialised backing fields - this means we can detect if the collection was loaded
private HashSet<BookContributor> _contributorsLink;
// i'd like this to be internal but migration throws this exception when i try:
// Value cannot be null.
// Parameter name: property
public IEnumerable<BookContributor> ContributorsLink
=> _contributorsLink?
.OrderBy(bc => bc.Order)
.ToList();
public IEnumerable<Contributor> Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList();
public string AuthorNames => string.Join(", ", Authors.Select(a => a.Name));
public IEnumerable<Contributor> Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList();
public string NarratorNames => string.Join(", ", Narrators.Select(n => n.Name));
public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name;
public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext context = null)
=> replaceContributors(authors, Role.Author, context);
public void ReplaceNarrators(IEnumerable<Contributor> narrators, DbContext context = null)
=> replaceContributors(narrators, Role.Narrator, context);
public void ReplacePublisher(Contributor publisher, DbContext context = null)
=> replaceContributors(new List<Contributor> { publisher }, Role.Publisher, context);
private void replaceContributors(IEnumerable<Contributor> 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 == null)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
if (!context.Entry(this).IsKeySet)
throw new InvalidOperationException("Could not add contributors");
context.Entry(this).Collection(s => s.ContributorsLink).Load();
}
var roleContributions = getContributions(role);
var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors);
if (isIdentical)
return;
_contributorsLink.RemoveWhere(bc => bc.Role == role);
addNewContributors(newContributors, role);
}
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
{
byte order = 0;
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
var newContributions = new HashSet<BookContributor>(newContributionsEnum);
_contributorsLink.UnionWith(newContributions);
}
private List<BookContributor> getContributions(Role role)
=> ContributorsLink
.Where(a => a.Role == role)
.OrderBy(a => a.Order)
.ToList();
#endregion
#region series
private HashSet<SeriesBook> _seriesLink;
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
public string SeriesNames
{
get
{
// first: alphabetical by name
var withNames = _seriesLink
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.Name)
.OrderBy(a => a)
.ToList();
// then un-named are alpha by series id
var nullNames = _seriesLink
.Where(s => string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.AudibleSeriesId)
.OrderBy(a => a)
.ToList();
var all = withNames.Union(nullNames).ToList();
return string.Join(", ", all);
}
}
public void UpsertSeries(Series series, float? index = null, 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 == null)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
if (!context.Entry(this).IsKeySet)
throw new InvalidOperationException("Could not add series");
context.Entry(this).Collection(s => s.SeriesLink).Load();
}
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
if (singleSeriesBook == null)
_seriesLink.Add(new SeriesBook(series, this, index));
else
singleSeriesBook.UpdateIndex(index);
}
#endregion
#region supplements
private HashSet<Supplement> _supplements;
public IEnumerable<Supplement> Supplements => _supplements?.ToList();
public bool HasPdfs => Supplements.Any();
public void AddSupplementDownloadUrl(string url)
{
// supplements are owned by Book, so no need to Load():
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
url = FileManager.FileUtility.RestoreDeclawed(url);
if (!_supplements.Any(s => url.EqualsInsensitive(url)))
_supplements.Add(new Supplement(this, url));
}
#endregion
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished)
{
// don't overwrite with default values
IsAbridged |= isAbridged;
DatePublished = datePublished ?? DatePublished;
HasBookDetails = true;
}
public void UpdateCategory(Category category, DbContext context = null)
{
// since category is never null, nullity means it hasn't been loaded
if (Category != null || CategoryId == Category.GetEmpty().CategoryId)
{
Category = category;
return;
}
if (context == null)
throw new Exception("need context");
context.Entry(this).Reference(s => s.Category).Load();
Category = category;
}
}
}

View file

@ -0,0 +1,27 @@
using Dinah.Core;
namespace DataLayer
{
public class BookContributor
{
internal int BookId { get; private set; }
internal int ContributorId { get; private set; }
public Role Role { get; private set; }
public byte Order { get; private set; }
public Book Book { get; private set; }
public Contributor Contributor { get; private set; }
private BookContributor() { }
internal BookContributor(Book book, Contributor contributor, Role role, byte order)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
ArgumentValidator.EnsureNotNull(contributor, nameof(contributor));
Book = book;
Contributor = contributor;
Role = role;
Order = order;
}
}
}

View file

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
public class AudibleCategoryId
{
public string Id { get; }
public AudibleCategoryId(string id)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id));
Id = id;
}
}
public class Category
{
// Empty is a special case. use private ctor w/o validation
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "", ParentCategory = null };
public bool IsEmpty() => string.IsNullOrWhiteSpace(AudibleCategoryId) || string.IsNullOrWhiteSpace(Name) || ParentCategory == null;
internal int CategoryId { get; private set; }
public string AudibleCategoryId { get; private set; }
public string Name { get; private set; }
public Category ParentCategory { get; private set; }
private Category() { }
/// <summary>special id class b/c it's too easy to get string order mixed up</summary>
public Category(AudibleCategoryId audibleSeriesId, string name, Category parentCategory = null)
{
ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId));
var id = audibleSeriesId.Id;
ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id));
ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name));
AudibleCategoryId = id;
Name = name;
UpdateParentCategory(parentCategory);
}
public void UpdateParentCategory(Category parentCategory)
{
// don't overwrite with null but not an error
if (parentCategory != null)
ParentCategory = parentCategory;
}
}
}

View file

@ -0,0 +1,82 @@
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
namespace DataLayer
{
public class Contributor
{
// contributors search links are just name with url-encoding. space can be + or %20
// author search link: /search?searchAuthor=Robert+Bevan
// narrator search link: /search?searchNarrator=Robert+Bevan
// can also search multiples. concat with comma before url encode
// id.s
// ----
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
// goes to summary page
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
internal int ContributorId { get; private set; }
public string Name { get; private set; }
private HashSet<BookContributor> _booksLink;
public IEnumerable<BookContributor> BooksLink => _booksLink?.ToList();
private Contributor() { }
public Contributor(string name)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name));
_booksLink = new HashSet<BookContributor>();
Name = name;
}
public string AudibleAuthorId { get; private set; }
public void UpdateAudibleAuthorId(string authorId)
{
// don't overwrite with null or whitespace but not an error
if (!string.IsNullOrWhiteSpace(authorId))
AudibleAuthorId = authorId;
}
#region // AudibleAuthorId refactor: separate author-specific info. overkill for a single optional string
///// <summary>Most authors in Audible have a unique id</summary>
//public AudibleAuthorProperty AudibleAuthorProperty { get; private set; }
//public void UpdateAuthorId(string authorId, LibationContext context = null)
//{
// if (authorId == null)
// return;
// if (AudibleAuthorProperty != null)
// {
// AudibleAuthorProperty.UpdateAudibleAuthorId(authorId);
// return;
// }
// if (context == null)
// throw new ArgumentNullException(nameof(context), "You must provide a context");
// if (context.Contributors.Find(ContributorId) == null)
// throw new InvalidOperationException("Could not update audible author id.");
// var audibleAuthorProperty = new AudibleAuthorProperty();
// audibleAuthorProperty.UpdateAudibleAuthorId(authorId);
// context.AuthorProperties.Add(audibleAuthorProperty);
//}
//public class AudibleAuthorProperty
//{
// public int ContributorId { get; private set; }
// public Contributor Contributor { get; set; }
// public string AudibleAuthorId { get; private set; }
// public void UpdateAudibleAuthorId(string authorId)
// {
// if (!string.IsNullOrWhiteSpace(authorId))
// AudibleAuthorId = authorId;
// }
//}
//// ...and create EF table config
#endregion
}
}

View file

@ -0,0 +1,25 @@
using System;
using Dinah.Core;
namespace DataLayer
{
public class LibraryBook
{
internal int BookId { get; private set; }
public Book Book { get; private set; }
public DateTime DateAdded { get; private set; }
/// <summary>For downloading AAX file</summary>
public string DownloadBookLink { get; private set; }
private LibraryBook() { }
public LibraryBook(Book book, DateTime dateAdded, string downloadBookLink)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
Book = book;
DateAdded = dateAdded;
DownloadBookLink = downloadBookLink;
}
}
}

View file

@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using Dinah.Core;
namespace DataLayer
{
/// <summary>Parameterless ctor and setters should be used by EF only. Everything else should treat it as immutable</summary>
public class Rating : ValueObject_Static<Rating>
{
public float OverallRating { get; private set; }
public float PerformanceRating { get; private set; }
public float StoryRating { get; private set; }
private Rating() { }
internal Rating(float overallRating, float performanceRating, float storyRating)
{
OverallRating = overallRating;
PerformanceRating = performanceRating;
StoryRating = storyRating;
}
// EF magically tracks this owned object. by replacing it with a new() immutable object, stuff gets weird. update instead
internal void Update(float overallRating, float performanceRating, float storyRating)
{
// don't overwrite with all 0
if (overallRating + performanceRating + storyRating == 0)
return;
OverallRating = overallRating;
PerformanceRating = performanceRating;
StoryRating = storyRating;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return OverallRating;
yield return PerformanceRating;
yield return StoryRating;
}
public float FirstScore
=> OverallRating > 0 ? OverallRating
: PerformanceRating > 0 ? PerformanceRating
: StoryRating;
/// <summary>character: ★</summary>
const char STAR = '\u2605';
/// <summary>character: ½</summary>
const char HALF = '\u00BD';
string getStars(float score)
{
var fullStars = (int)Math.Floor(score);
var starString = "".PadLeft(fullStars, STAR);
if (score - fullStars == 0.5f)
starString += HALF;
return starString;
}
public string ToStarString()
{
var items = new List<string>();
if (OverallRating > 0)
items.Add($"Overall: {getStars(OverallRating)}");
if (PerformanceRating > 0)
items.Add($"Perform: {getStars(PerformanceRating)}");
if (StoryRating > 0)
items.Add($"Story: {getStars(StoryRating)}");
return string.Join("\r\n", items);
}
}
}

View file

@ -0,0 +1,4 @@
namespace DataLayer
{
public enum Role { Author = 1, Narrator = 2, Publisher = 3 }
}

View file

@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
public class AudibleSeriesId
{
public string Id { get; }
public AudibleSeriesId(string id)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id));
Id = id;
}
}
public class Series
{
internal int SeriesId { get; private set; }
public string AudibleSeriesId { get; private set; }
/// <summary>optional</summary>
public string Name { get; private set; }
private HashSet<SeriesBook> _booksLink;
public IEnumerable<SeriesBook> BooksLink
=> _booksLink?
.OrderBy(sb => sb.Index)
.ToList();
private Series() { }
/// <summary>special id class b/c it's too easy to get string order mixed up</summary>
public Series(AudibleSeriesId audibleSeriesId, string name = null)
{
ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId));
var id = audibleSeriesId.Id;
ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id));
AudibleSeriesId = id;
_booksLink = new HashSet<SeriesBook>();
UpdateName(name);
}
public void UpdateName(string name)
{
// don't overwrite with null or whitespace but not an error
if (!string.IsNullOrWhiteSpace(name))
Name = name;
}
public void AddBook(Book book, float? index = null, DbContext context = null)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
// 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 (_booksLink == null)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
if (!context.Entry(this).IsKeySet)
throw new InvalidOperationException("Could not add series");
context.Entry(this).Collection(s => s.BooksLink).Load();
}
if (_booksLink.SingleOrDefault(sb => sb.Book == book) == null)
_booksLink.Add(new SeriesBook(this, book, index));
}
}
}

View file

@ -0,0 +1,38 @@
using Dinah.Core;
namespace DataLayer
{
public class SeriesBook
{
internal int SeriesId { get; private set; }
internal int BookId { get; private set; }
/// <summary>
/// <para>"index" not "order". This is both for sequence and display</para>
/// <para>Float allows for in-between books. eg: 2.5</para>
/// <para>To show 2 editions as the same book in a series, give them the same index</para>
/// <para>null IS NOT the same as 0. Some series call a book "book 0"</para>
/// </summary>
public float? Index { get; private set; }
public Series Series { get; private set; }
public Book Book { get; private set; }
private SeriesBook() { }
internal SeriesBook(Series series, Book book, float? index = null)
{
ArgumentValidator.EnsureNotNull(series, nameof(series));
ArgumentValidator.EnsureNotNull(book, nameof(book));
Series = series;
Book = book;
Index = index;
}
public void UpdateIndex(float? index)
{
if (index.HasValue)
Index = index.Value;
}
}
}

View file

@ -0,0 +1,24 @@
using Dinah.Core;
namespace DataLayer
{
/// <summary>PDF/ZIP files only. Although book download info could be the same format, they're substantially different and subject to change</summary>
public class Supplement
{
internal int SupplementId { get; private set; }
internal int BookId { get; private set; }
public Book Book { get; private set; }
public string Url { get; private set; }
private Supplement() { }
public Supplement(Book book, string url)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
Book = book;
Url = url;
}
}
}

View file

@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Dinah.Core;
namespace DataLayer
{
public class UserDefinedItem
{
internal int BookId { get; private set; }
public Book Book { get; private set; }
private UserDefinedItem() { }
internal UserDefinedItem(Book book)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
Book = book;
}
private string _tags = "";
public string Tags
{
get => _tags;
set => _tags = sanitize(value);
}
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
// only legal chars are letters numbers underscores and separating whitespace
//
// technically, the only char.s which aren't easily supported are \ [ ]
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
// it's easy to expand whitelist as needed
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
//
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
// full list of characters which must be escaped:
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
static Regex regex = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
private static string sanitize(string input)
{
if (string.IsNullOrWhiteSpace(input))
return "";
var str = input
.Trim()
.ToLowerInvariant()
// assume a hyphen is supposed to be an underscore
.Replace("-", "_");
var unique = regex
// turn illegal characters into a space. this will also take care of turning new lines into spaces
.Replace(str, " ")
// split and remove excess spaces
.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
// de-dup
.Distinct()
// this will prevent order from being relevant
.OrderBy(a => a);
// currently, the string is the canonical set. if we later make the collection into the canonical set:
// var tags = new Hashset<string>(list); // de-dup, order doesn't matter but can seem random due to hashing algo
// var isEqual = tagsNew.SetEquals(tagsOld);
return string.Join(" ", unique);
}
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
#endregion
// owned: not an optional one-to-one
/// <summary>The user's individual book rating</summary>
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
}
}