using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using Dinah.Core; namespace DataLayer { /// /// Do not track in-process state. In-process state is determined by the presence of temp file. /// public enum LiberatedStatus { NotLiberated = 0, Liberated = 1, /// Error occurred during liberation. Don't retry Error = 2, /// Application-state only. Not a valid persistence state. PartialDownload = 0x1000 } public partial class UserDefinedItem { internal int BookId { get; private set; } public Book Book { get; private set; } public DateTime? LastDownloaded { get; private set; } public Version LastDownloadedVersion { get; private set; } public void SetLastDownloaded(Version version) { if (LastDownloadedVersion != version) { LastDownloadedVersion = version; OnItemChanged(nameof(LastDownloadedVersion)); } if (version is null) LastDownloaded = null; else { LastDownloaded = DateTime.Now; OnItemChanged(nameof(LastDownloaded)); } } private UserDefinedItem() { } internal UserDefinedItem(Book book) { ArgumentValidator.EnsureNotNull(book, nameof(book)); Book = book; } #region Tags private string _tags = ""; public string Tags { get => _tags; set { var newTags = sanitize(value); if (_tags != newTags) { _tags = newTags; OnItemChanged(nameof(Tags)); } } } public IEnumerable TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries); #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: /// + - && || ! ( ) { } [ ] ^ " ~ * ? : \ /// [GeneratedRegex(@"[^\w\d\s_]")] private static partial Regex IllegalCharacterRegex(); 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 = IllegalCharacterRegex() // 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(list); // de-dup, order doesn't matter but can seem random due to hashing algo // var isEqual = tagsNew.SetEquals(tagsOld); return string.Join(" ", unique); } #endregion #endregion #region Rating // owned: not an optional one-to-one /// The user's individual book rating public Rating Rating { get; private set; } = new Rating(0, 0, 0); public void UpdateRating(float overallRating, float performanceRating, float storyRating) { var changed = Rating.OverallRating != overallRating || Rating.PerformanceRating != performanceRating || Rating.StoryRating != storyRating; Rating.Update(overallRating, performanceRating, storyRating); if (changed) OnItemChanged(nameof(Rating)); } #endregion #region LiberatedStatuses /// /// Occurs when , , or values change. /// This signals the change of the in-memory value; it does not ensure that the new value has been persisted. /// public static event EventHandler ItemChanged; private void OnItemChanged(string e) { // HACK // must not fire during initial import. // // these checks are necessary because current architecture attaches to this instead of attaching to an event *after* fully committed to db. the attached delegate/action sometimes calls commit: // // desired: // - importing new book // - update pdf status // - initial book commit // // actual without these checks [BAD]: // - importing new book // - update pdf status // - invoke event // - commit UserDefinedItem // - initial book commit if (BookId > 0 && Book is not null && Book.BookId > 0) ItemChanged?.Invoke(this, e); } private LiberatedStatus _bookStatus; private LiberatedStatus? _pdfStatus; public LiberatedStatus BookStatus { get => _bookStatus; set { // PartialDownload is a live/ephemeral status, not a persistent one. Do not store var displayStatus = value == LiberatedStatus.PartialDownload ? LiberatedStatus.NotLiberated : value; if (_bookStatus != displayStatus) { _bookStatus = displayStatus; OnItemChanged(nameof(BookStatus)); } } } public void SetPdfStatus(LiberatedStatus? pdfStatus) { // don't change whether pdf is actually available. if null, leave as null. if not null, only assign non-null // null => non-null : only when adding a supplement if (pdfStatus.HasValue && PdfStatus.HasValue) PdfStatus = pdfStatus; } public LiberatedStatus? PdfStatus { get => _pdfStatus; internal set { if (_pdfStatus != value) { _pdfStatus = value; OnItemChanged(nameof(PdfStatus)); } } } #endregion #region IsFinished private bool _isFinished; public bool IsFinished { get => _isFinished; set { if (value != _isFinished) { _isFinished = value; OnItemChanged(nameof(IsFinished)); } } } #endregion public override string ToString() => $"{Book} {Rating} {Tags}"; } }