From 0b507b90a16693bc34023519206f85f5805c89d1 Mon Sep 17 00:00:00 2001 From: Harrison Deng Date: Mon, 9 Aug 2021 13:32:16 -0500 Subject: [PATCH] Added identifier and fetch time product listings; Added persistence to AdafruitShop. Implemented in AdafruitShop. AdafruitShop Product listing data now persisted. AdafruitShop configuration now persisted. --- .../Props.Shop/Adafruit/AdafruitShop.cs | 92 ++++++++++++++++-- .../Adafruit/Api/IProductListingManager.cs | 4 +- .../Props.Shop/Adafruit/Api/ListingsParser.cs | 3 + .../Adafruit/Api/LiveProductListingManager.cs | 19 ++-- .../Props.Shop/Adafruit/Api/SearchManager.cs | 4 +- .../Props.Shop/Adafruit/Configuration.cs | 15 --- .../Adafruit/Persistence/Configuration.cs | 19 ++++ .../Persistence/ProductListingCacheData.cs | 23 +++++ .../Props.Shop/Ebay/Actions/SearchRequest.cs | 51 ---------- .../Api/ItemSummary/SearchResultParser.cs | 29 ------ .../Ebay/Api/ItemSummary/SearchUriBuilder.cs | 57 ----------- .../Props.Shop/Ebay/Configuration.cs | 7 -- Props-Modules/Props.Shop/Ebay/EbayShop.cs | 74 -------------- .../Props.Shop/Ebay/Props.Shop.Ebay.csproj | 15 --- Props-Modules/Props.Shop/Framework/IShop.cs | 3 +- .../Props.Shop/Framework/ProductListing.cs | 38 ++++++++ .../Adafruit.Tests/AdafruitShopTest.cs | 2 - .../Api/FakeProductListingManager.cs | 28 ++---- Props/Data/ApplicationDbContext.cs | 72 ++++++++------ ... 20210809194646_InitialCreate.Designer.cs} | 9 +- ...ate.cs => 20210809194646_InitialCreate.cs} | 5 +- .../ApplicationDbContextModelSnapshot.cs | 7 +- Props/Extensions/ProductListingExtensions.cs | 3 + Props/Models/Search/ProductListingInfo.cs | 6 +- Props/Services/Modules/LiveMetricsManager.cs | 7 +- Props/Services/Modules/ModularShopManager.cs | 8 +- Props/shops/Props.Shop.Adafruit.dll | Bin 22016 -> 26624 bytes Props/shops/Props.Shop.Framework.dll | Bin 10752 -> 0 bytes 28 files changed, 251 insertions(+), 349 deletions(-) delete mode 100644 Props-Modules/Props.Shop/Adafruit/Configuration.cs create mode 100644 Props-Modules/Props.Shop/Adafruit/Persistence/Configuration.cs create mode 100644 Props-Modules/Props.Shop/Adafruit/Persistence/ProductListingCacheData.cs delete mode 100644 Props-Modules/Props.Shop/Ebay/Actions/SearchRequest.cs delete mode 100644 Props-Modules/Props.Shop/Ebay/Api/ItemSummary/SearchResultParser.cs delete mode 100644 Props-Modules/Props.Shop/Ebay/Api/ItemSummary/SearchUriBuilder.cs delete mode 100644 Props-Modules/Props.Shop/Ebay/Configuration.cs delete mode 100644 Props-Modules/Props.Shop/Ebay/EbayShop.cs delete mode 100644 Props-Modules/Props.Shop/Ebay/Props.Shop.Ebay.csproj rename Props/Data/Migrations/{20210805055109_InitialCreate.Designer.cs => 20210809194646_InitialCreate.Designer.cs} (98%) rename Props/Data/Migrations/{20210805055109_InitialCreate.cs => 20210809194646_InitialCreate.cs} (98%) delete mode 100644 Props/shops/Props.Shop.Framework.dll diff --git a/Props-Modules/Props.Shop/Adafruit/AdafruitShop.cs b/Props-Modules/Props.Shop/Adafruit/AdafruitShop.cs index 4a3bcfc..53c1897 100644 --- a/Props-Modules/Props.Shop/Adafruit/AdafruitShop.cs +++ b/Props-Modules/Props.Shop/Adafruit/AdafruitShop.cs @@ -1,15 +1,19 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net.Http; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Props.Shop.Adafruit.Api; +using Props.Shop.Adafruit.Persistence; using Props.Shop.Framework; namespace Props.Shop.Adafruit { public class AdafruitShop : IShop { + private string workspaceDir; private ILoggerFactory loggerFactory; private ILogger logger; private SearchManager searchManager; @@ -32,22 +36,80 @@ namespace Props.Shop.Adafruit ); public void Initialize(string workspaceDir, ILoggerFactory loggerFactory) { + this.workspaceDir = workspaceDir; this.loggerFactory = loggerFactory; + logger = loggerFactory.CreateLogger(); http = new HttpClient(); http.BaseAddress = new Uri("http://www.adafruit.com/api/"); - configuration = new Configuration(); - // TODO: Implement config persistence. - // TODO: Implement product listing persisted cache. - LiveProductListingManager productListingManager = new LiveProductListingManager(http, loggerFactory.CreateLogger(), configuration.MinDownloadInterval); - this.searchManager = new SearchManager(productListingManager, configuration.Similarity); - productListingManager.StartUpdateTimer(delay: 0); + try + { + configuration = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(workspaceDir, Configuration.FILE_NAME))); + } + catch (JsonException) + { + logger.LogWarning("Could not read JSON file."); + } + catch (ArgumentException) + { + logger.LogWarning("No working directory path provided."); + } + catch (DirectoryNotFoundException) + { + logger.LogWarning("Directory could not be found."); + } + catch (FileNotFoundException) + { + logger.LogWarning("File could not be found."); + } + finally + { + if (configuration == null) + { + configuration = new Configuration(); + } + } + + ProductListingCacheData listingData = null; + try + { + listingData = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(workspaceDir, ProductListingCacheData.FILE_NAME))); + if (listingData.LastUpdatedUtc - DateTime.UtcNow > TimeSpan.FromMilliseconds(configuration.CacheLifespan)) + { + listingData = null; + } + } + catch (JsonException) + { + logger.LogWarning("Could not read JSON file."); + } + catch (ArgumentException) + { + logger.LogWarning("No working directory path provided."); + } + catch (DirectoryNotFoundException) + { + logger.LogWarning("Directory could not be found."); + } + catch (FileNotFoundException) + { + logger.LogWarning("File could not be found."); + } + finally + { + if (configuration == null) + { + configuration = new Configuration(); + } + } + LiveProductListingManager productListingManager = new LiveProductListingManager(http, loggerFactory.CreateLogger(), listingData?.ProductListings, configuration.MinDownloadInterval); + this.searchManager = new SearchManager(productListingManager, configuration.Similarity); + productListingManager.StartUpdateTimer(delay: 0, configuration.CacheLifespan); - logger = loggerFactory.CreateLogger(); } - public Task GetProductListingFromUrl(string url) + public async Task GetProductFromIdentifier(string identifier) { - return searchManager.ProductListingManager.GetProductListingFromUrl(url); + return await searchManager.ProductListingManager.GetProductListingFromIdentifier(identifier); } public IAsyncEnumerable Search(string query, Filters filters) @@ -73,5 +135,17 @@ namespace Props.Shop.Adafruit Dispose(disposing: true); GC.SuppressFinalize(this); } + + public async ValueTask SaveData() + { + if (workspaceDir != null) + { + await File.WriteAllTextAsync(Path.Combine(workspaceDir, Configuration.FILE_NAME), JsonSerializer.Serialize(configuration)); + await File.WriteAllTextAsync( + Path.Combine(workspaceDir, configuration.ProductListingCacheFileName), + JsonSerializer.Serialize(new ProductListingCacheData(await searchManager.ProductListingManager.ProductListings)) + ); + } + } } } diff --git a/Props-Modules/Props.Shop/Adafruit/Api/IProductListingManager.cs b/Props-Modules/Props.Shop/Adafruit/Api/IProductListingManager.cs index 558e543..c2f7839 100644 --- a/Props-Modules/Props.Shop/Adafruit/Api/IProductListingManager.cs +++ b/Props-Modules/Props.Shop/Adafruit/Api/IProductListingManager.cs @@ -7,11 +7,11 @@ namespace Props.Shop.Adafruit.Api { public interface IProductListingManager : IDisposable { - public Task>> ProductListings { get; } + public Task>> ProductListings { get; } public void RefreshProductListings(); public void StartUpdateTimer(int delay = 1000 * 60 * 5, int period = 1000 * 60 * 5); public void StopUpdateTimer(); - public Task GetProductListingFromUrl(string url); + public Task GetProductListingFromIdentifier(string url); } } \ No newline at end of file diff --git a/Props-Modules/Props.Shop/Adafruit/Api/ListingsParser.cs b/Props-Modules/Props.Shop/Adafruit/Api/ListingsParser.cs index c454584..16064ea 100644 --- a/Props-Modules/Props.Shop/Adafruit/Api/ListingsParser.cs +++ b/Props-Modules/Props.Shop/Adafruit/Api/ListingsParser.cs @@ -16,6 +16,7 @@ namespace Props.Shop.Adafruit.Api { using (StreamReader streamReader = new StreamReader(stream)) { + DateTime startTime = DateTime.UtcNow; dynamic data = JArray.Load(new JsonTextReader(streamReader)); List parsed = new List(); foreach (dynamic item in data) @@ -23,6 +24,7 @@ namespace Props.Shop.Adafruit.Api if (item.products_discontinued == 0) { ProductListing res = new ProductListing(); + res.TimeFetchedUtc = startTime; res.Name = item.product_name; res.LowerPrice = item.product_price; res.UpperPrice = res.LowerPrice; @@ -40,6 +42,7 @@ namespace Props.Shop.Adafruit.Api res.URL = item.product_url; res.InStock = item.product_stock > 0; parsed.Add(res); + res.Identifier = res.URL; } } ProductListings = parsed; diff --git a/Props-Modules/Props.Shop/Adafruit/Api/LiveProductListingManager.cs b/Props-Modules/Props.Shop/Adafruit/Api/LiveProductListingManager.cs index 28e2fde..411c6f6 100644 --- a/Props-Modules/Props.Shop/Adafruit/Api/LiveProductListingManager.cs +++ b/Props-Modules/Props.Shop/Adafruit/Api/LiveProductListingManager.cs @@ -16,20 +16,21 @@ namespace Props.Shop.Adafruit.Api private int minDownloadInterval; private DateTime? lastDownload; private object refreshLock = new object(); - private volatile Task>> productListingsTask; + private volatile Task>> productListingsTask; - public Task>> ProductListings => productListingsTask; - private readonly ConcurrentDictionary activeProductListingUrls = new ConcurrentDictionary(); + public Task>> ProductListings => productListingsTask; + private readonly ConcurrentDictionary identifierMap = new ConcurrentDictionary(); private ProductListingsParser parser = new ProductListingsParser(); private HttpClient httpClient; private Timer updateTimer; - public LiveProductListingManager(HttpClient httpClient, ILogger logger, int minDownloadInterval = 5 * 60 * 1000) + public LiveProductListingManager(HttpClient httpClient, ILogger logger, IReadOnlyDictionary> productListings = null, int minDownloadInterval = 5 * 60 * 1000) { this.logger = logger; this.minDownloadInterval = minDownloadInterval; this.httpClient = httpClient; + productListingsTask = Task.FromResult(productListings); } public void RefreshProductListings() @@ -44,14 +45,14 @@ namespace Props.Shop.Adafruit.Api } } - public async Task GetProductListingFromUrl(string url) + public async Task GetProductListingFromIdentifier(string identifier) { if (disposedValue) throw new ObjectDisposedException("ProductListingManager"); await productListingsTask; - return activeProductListingUrls[url]; + return identifierMap[identifier]; } - private async Task>> DownloadListings() + private async Task>> DownloadListings() { if (disposedValue) throw new ObjectDisposedException("ProductListingManager"); logger.LogDebug("Beginning listing database download."); @@ -59,10 +60,10 @@ namespace Props.Shop.Adafruit.Api parser.BuildProductListings(responseMessage.Content.ReadAsStream()); logger.LogDebug("Listing database parsed."); Dictionary> listingNames = new Dictionary>(); - activeProductListingUrls.Clear(); + identifierMap.Clear(); foreach (ProductListing product in parser.ProductListings) { - activeProductListingUrls.TryAdd(product.URL, product); + identifierMap.TryAdd(product.Identifier, product); IList sameProducts = listingNames.GetValueOrDefault(product.Name); if (sameProducts == null) { diff --git a/Props-Modules/Props.Shop/Adafruit/Api/SearchManager.cs b/Props-Modules/Props.Shop/Adafruit/Api/SearchManager.cs index ae73b21..ede16c6 100644 --- a/Props-Modules/Props.Shop/Adafruit/Api/SearchManager.cs +++ b/Props-Modules/Props.Shop/Adafruit/Api/SearchManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using FuzzySharp; @@ -26,7 +27,8 @@ namespace Props.Shop.Adafruit.Api if (ProductListingManager.ProductListings == null) { ProductListingManager.RefreshProductListings(); } - IDictionary> productListings = await ProductListingManager.ProductListings; + IReadOnlyDictionary> productListings = await ProductListingManager.ProductListings; + if (productListings == null) throw new InvalidAsynchronousStateException("productListings can't be null"); foreach (ExtractedResult listingNames in Process.ExtractAll(query, productListings.Keys, cutoff: (int)(Similarity * 100))) { foreach (ProductListing same in productListings[listingNames.Value]) diff --git a/Props-Modules/Props.Shop/Adafruit/Configuration.cs b/Props-Modules/Props.Shop/Adafruit/Configuration.cs deleted file mode 100644 index 25a1832..0000000 --- a/Props-Modules/Props.Shop/Adafruit/Configuration.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Props.Shop.Adafruit -{ - public class Configuration - { - public int MinDownloadInterval { get; set; } - - public float Similarity { get; set; } - - public Configuration() - { - MinDownloadInterval = 5 * 60 * 1000; - Similarity = 0.8f; - } - } -} \ No newline at end of file diff --git a/Props-Modules/Props.Shop/Adafruit/Persistence/Configuration.cs b/Props-Modules/Props.Shop/Adafruit/Persistence/Configuration.cs new file mode 100644 index 0000000..f9358f0 --- /dev/null +++ b/Props-Modules/Props.Shop/Adafruit/Persistence/Configuration.cs @@ -0,0 +1,19 @@ +namespace Props.Shop.Adafruit.Persistence +{ + public class Configuration + { + public const string FILE_NAME = "config.json"; + public int MinDownloadInterval { get; set; } + public int CacheLifespan { get; set; } + public float Similarity { get; set; } + public string ProductListingCacheFileName { get; set; } + + public Configuration() + { + MinDownloadInterval = 5 * 60 * 1000; + Similarity = 0.8f; + CacheLifespan = 5 * 60 * 1000; + ProductListingCacheFileName = "ProductListings.json"; + } + } +} \ No newline at end of file diff --git a/Props-Modules/Props.Shop/Adafruit/Persistence/ProductListingCacheData.cs b/Props-Modules/Props.Shop/Adafruit/Persistence/ProductListingCacheData.cs new file mode 100644 index 0000000..a8e0654 --- /dev/null +++ b/Props-Modules/Props.Shop/Adafruit/Persistence/ProductListingCacheData.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using Props.Shop.Framework; + +namespace Props.Shop.Adafruit.Persistence +{ + public class ProductListingCacheData + { + public const string FILE_NAME = "Product-listing-cache.json"; + public DateTime LastUpdatedUtc { get; private set; } + public IReadOnlyDictionary> ProductListings { get; set; } + + public ProductListingCacheData(IReadOnlyDictionary> productListings) + { + this.ProductListings = productListings; + LastUpdatedUtc = DateTime.UtcNow; + } + + public ProductListingCacheData() + { + } + } +} \ No newline at end of file diff --git a/Props-Modules/Props.Shop/Ebay/Actions/SearchRequest.cs b/Props-Modules/Props.Shop/Ebay/Actions/SearchRequest.cs deleted file mode 100644 index 8a6b6c3..0000000 --- a/Props-Modules/Props.Shop/Ebay/Actions/SearchRequest.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Props.Shop.Framework; - -namespace Props.Shop.Ebay.Actions -{ - public class SearchRequest : IAsyncEnumerable - { - private HttpClient http; - private string[] query; - public SearchRequest(HttpClient http, string[] query) - { - this.http = http ?? throw new ArgumentNullException("http"); - this.query = query ?? throw new ArgumentNullException("query"); - } - - public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) - { - throw new System.NotImplementedException(); - } - - public class Enumerator : IAsyncEnumerator - { - private HttpClient http; - private string[] query; - - public Enumerator(HttpClient http, string[] query) - { - this.http = http; - this.query = query; - } - - public ProductListing Current { get; private set; } - - public ValueTask MoveNextAsync() - { - // TODO: Implement this. - throw new System.NotImplementedException(); - } - public ValueTask DisposeAsync() - { - // TODO: Implement this. - throw new System.NotImplementedException(); - } - - } - } -} \ No newline at end of file diff --git a/Props-Modules/Props.Shop/Ebay/Api/ItemSummary/SearchResultParser.cs b/Props-Modules/Props.Shop/Ebay/Api/ItemSummary/SearchResultParser.cs deleted file mode 100644 index 3ce4850..0000000 --- a/Props-Modules/Props.Shop/Ebay/Api/ItemSummary/SearchResultParser.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using Props.Shop.Framework; - -namespace Props.Shop.Ebay.Api.ItemSummary -{ - public class SearchResultParser - { - private dynamic data; - public int BeginIndex => data.offset; - public int EndIndex => BeginIndex + data.limit; - public IEnumerable ProductListings { get; private set; } - - public SearchResultParser(string result) - { - data = JObject.Parse(result); - - List parsed = new List(); - foreach (dynamic itemSummary in data.itemSummaries) - { - ProductListing listing = new ProductListing(); - // TODO: Finish parsing the data. - parsed.Add(listing); - } - ProductListings = parsed; - } - } -} \ No newline at end of file diff --git a/Props-Modules/Props.Shop/Ebay/Api/ItemSummary/SearchUriBuilder.cs b/Props-Modules/Props.Shop/Ebay/Api/ItemSummary/SearchUriBuilder.cs deleted file mode 100644 index 17904ee..0000000 --- a/Props-Modules/Props.Shop/Ebay/Api/ItemSummary/SearchUriBuilder.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Props.Shop.Ebay.Api.ItemSummary -{ - public class SearchUriBuilder - { - UriBuilder uriBuilder = new UriBuilder("/search"); - private HashSet queries = new HashSet(); - private bool autoCorrect = false; - private int? maxResults = 100; - private int? offset = 0; - public bool AutoCorrect - { - set - { - autoCorrect = value; - } - } - - public int? MaxResults - { - set - { - maxResults = value; - } - } - - public int? Offset - { - set - { - offset = value; - } - } - - public void AddSearchQuery(string query) - { - queries.Add(query); - } - - public Uri Build() - { - StringBuilder queryBuilder = new StringBuilder("q="); - queryBuilder.Append('('); - queryBuilder.AppendJoin(", ", queries); - queryBuilder.Append(')'); - uriBuilder.Query += queryBuilder.ToString(); - - if (autoCorrect) uriBuilder.Query += "&auto_correct=KEYWORD"; - if (maxResults.HasValue) uriBuilder.Query += "&limit=" + maxResults.Value; - if (offset.HasValue) uriBuilder.Query += "&offset=" + offset.Value; - return uriBuilder.Uri; - } - } -} \ No newline at end of file diff --git a/Props-Modules/Props.Shop/Ebay/Configuration.cs b/Props-Modules/Props.Shop/Ebay/Configuration.cs deleted file mode 100644 index 0ac57b4..0000000 --- a/Props-Modules/Props.Shop/Ebay/Configuration.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Props.Shop.Ebay -{ - public class Configuration - { - public bool Sandbox { get; set; } = true; - } -} \ No newline at end of file diff --git a/Props-Modules/Props.Shop/Ebay/EbayShop.cs b/Props-Modules/Props.Shop/Ebay/EbayShop.cs deleted file mode 100644 index ad3a5f0..0000000 --- a/Props-Modules/Props.Shop/Ebay/EbayShop.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Props.Shop.Framework; - -namespace Props.Shop.Ebay -{ - public class EbayShop : IShop - { - private bool disposedValue; - - public string ShopName => "Ebay"; - - public string ShopDescription => "A multi-national online store host to consumer-to-consumer and business-to-consumer sales."; - - public string ShopModuleAuthor => "Reslate"; - - public SupportedFeatures SupportedFeatures => new SupportedFeatures( - true, - true, - true, - true, - true - ); - - Configuration configuration; - - private HttpClient httpClient; - - public void Initialize(string workspaceDir) - { - httpClient = new HttpClient(); - configuration = new Configuration(); // TODO: Implement config persistence. - } - - - public Task InitializeAsync(string workspaceDir) - { - throw new NotImplementedException(); - } - - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - httpClient.Dispose(); - } - disposedValue = true; - } - } - - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - public byte[] GetDataForPersistence() - { - return JsonSerializer.SerializeToUtf8Bytes(configuration); - } - - public IEnumerable Search(string query, Filters filters) - { - // TODO: Implement the search system. - throw new NotImplementedException(); - } - } -} diff --git a/Props-Modules/Props.Shop/Ebay/Props.Shop.Ebay.csproj b/Props-Modules/Props.Shop/Ebay/Props.Shop.Ebay.csproj deleted file mode 100644 index aa7e6a8..0000000 --- a/Props-Modules/Props.Shop/Ebay/Props.Shop.Ebay.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - net5.0 - - - diff --git a/Props-Modules/Props.Shop/Framework/IShop.cs b/Props-Modules/Props.Shop/Framework/IShop.cs index 341ffd0..c4b3d70 100644 --- a/Props-Modules/Props.Shop/Framework/IShop.cs +++ b/Props-Modules/Props.Shop/Framework/IShop.cs @@ -15,9 +15,10 @@ namespace Props.Shop.Framework public IAsyncEnumerable Search(string query, Filters filters); - public Task GetProductListingFromUrl(string url); + public Task GetProductFromIdentifier(string identifier); void Initialize(string workspaceDir, ILoggerFactory loggerFactory); + ValueTask SaveData(); public SupportedFeatures SupportedFeatures { get; } } } \ No newline at end of file diff --git a/Props-Modules/Props.Shop/Framework/ProductListing.cs b/Props-Modules/Props.Shop/Framework/ProductListing.cs index bb9f356..a3d4754 100644 --- a/Props-Modules/Props.Shop/Framework/ProductListing.cs +++ b/Props-Modules/Props.Shop/Framework/ProductListing.cs @@ -1,3 +1,5 @@ +using System; + namespace Props.Shop.Framework { public class ProductListing @@ -13,5 +15,41 @@ namespace Props.Shop.Framework public int? ReviewCount { get; set; } public bool ConvertedPrices { get; set; } public bool? InStock { get; set; } + public string Identifier { get; set; } + public DateTime TimeFetchedUtc { get; set; } + + public override bool Equals(object obj) + { + if (obj == null || GetType() != obj.GetType()) + { + return false; + } + + ProductListing other = obj as ProductListing; + return + this.LowerPrice == other.LowerPrice && + this.UpperPrice == other.UpperPrice && + this.Shipping == other.Shipping && + this.Name == other.Name && + this.URL == other.URL && + this.ImageURL == other.ImageURL && + this.Rating == other.Rating && + this.PurchaseCount == other.PurchaseCount && + this.ReviewCount == other.ReviewCount && + this.ConvertedPrices == other.ConvertedPrices && + this.InStock == other.InStock && + this.Identifier == other.Identifier && + this.TimeFetchedUtc == other.TimeFetchedUtc; + } + + public override int GetHashCode() + { + return (Name, URL, UpperPrice, LowerPrice, ImageURL).GetHashCode(); + } + + public ProductListing Copy() + { + return MemberwiseClone() as ProductListing; + } } } \ No newline at end of file diff --git a/Props-Modules/test/Props.Shop/Adafruit.Tests/AdafruitShopTest.cs b/Props-Modules/test/Props.Shop/Adafruit.Tests/AdafruitShopTest.cs index cf37472..a81414d 100644 --- a/Props-Modules/test/Props.Shop/Adafruit.Tests/AdafruitShopTest.cs +++ b/Props-Modules/test/Props.Shop/Adafruit.Tests/AdafruitShopTest.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Props.Shop.Framework; using Xunit; diff --git a/Props-Modules/test/Props.Shop/Adafruit.Tests/Api/FakeProductListingManager.cs b/Props-Modules/test/Props.Shop/Adafruit.Tests/Api/FakeProductListingManager.cs index 61ad595..e08c814 100644 --- a/Props-Modules/test/Props.Shop/Adafruit.Tests/Api/FakeProductListingManager.cs +++ b/Props-Modules/test/Props.Shop/Adafruit.Tests/Api/FakeProductListingManager.cs @@ -11,31 +11,29 @@ namespace Props.Shop.Adafruit.Tests.Api { public class FakeProductListingManager : IProductListingManager { - private Timer refreshTimer; private bool disposedValue; - private volatile Task>> activeListings; private DateTime? lastDownload; private ProductListingsParser parser = new ProductListingsParser(); private readonly ConcurrentDictionary activeProductListingUrls = new ConcurrentDictionary(); - public Task>> ProductListings => activeListings; + public Task>> ProductListings { get; private set; } - public async Task GetProductListingFromUrl(string url) + public async Task GetProductListingFromIdentifier(string url) { if (disposedValue) throw new ObjectDisposedException("ProductListingManager"); - await activeListings; + await ProductListings; return activeProductListingUrls[url]; } public void RefreshProductListings() { if (disposedValue) throw new ObjectDisposedException("ProductListingManager"); - if ((lastDownload != null && DateTime.UtcNow - lastDownload <= TimeSpan.FromMilliseconds(5 * 60 * 1000)) || (activeListings != null && !activeListings.IsCompleted)) return; - activeListings = DownloadListings(); + if ((lastDownload != null && DateTime.UtcNow - lastDownload <= TimeSpan.FromMilliseconds(5 * 60 * 1000)) || (ProductListings != null && !ProductListings.IsCompleted)) return; + ProductListings = DownloadListings(); } - private Task>> DownloadListings() { + private Task>> DownloadListings() { if (disposedValue) throw new ObjectDisposedException("ProductListingManager"); lastDownload = DateTime.UtcNow; parser.BuildProductListings(File.OpenRead("./Assets/products.json")); @@ -52,24 +50,16 @@ namespace Props.Shop.Adafruit.Tests.Api sameProducts.Add(product); } - return Task.FromResult>>(listingNames); + return Task.FromResult>>(listingNames); } public void StartUpdateTimer(int delay = 300000, int period = 300000) { - if (disposedValue) throw new ObjectDisposedException("ProductListingManager"); - if (refreshTimer != null) throw new InvalidOperationException("Refresh timer already running."); - refreshTimer = new Timer((state) => { - RefreshProductListings(); - }, null, delay, period); + RefreshProductListings(); } public void StopUpdateTimer() { - if (disposedValue) throw new ObjectDisposedException("ProductListingManager"); - if (refreshTimer == null) throw new InvalidOperationException("Refresh timer not running."); - refreshTimer.Dispose(); - refreshTimer = null; } protected virtual void Dispose(bool disposing) @@ -78,8 +68,6 @@ namespace Props.Shop.Adafruit.Tests.Api { if (disposing) { - refreshTimer?.Dispose(); - refreshTimer = null; } disposedValue = true; diff --git a/Props/Data/ApplicationDbContext.cs b/Props/Data/ApplicationDbContext.cs index 339597c..d9febdc 100644 --- a/Props/Data/ApplicationDbContext.cs +++ b/Props/Data/ApplicationDbContext.cs @@ -31,40 +31,52 @@ namespace Props.Data base.OnModelCreating(modelBuilder); modelBuilder.Entity() - .Property(e => e.Order) - .HasConversion( - v => JsonSerializer.Serialize(v, null), - v => JsonSerializer.Deserialize>(v, null), - new ValueComparer>( - (a, b) => a.SequenceEqual(b), - c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), - c => (IList)c.ToList() - ) - ); + .Property(e => e.Order) + .HasConversion( + v => JsonSerializer.Serialize(v, null), + v => JsonSerializer.Deserialize>(v, null), + new ValueComparer>( + (a, b) => a.SequenceEqual(b), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => (IList)c.ToList() + ) + ); modelBuilder.Entity() - .Property(e => e.Enabled) - .HasConversion( - v => JsonSerializer.Serialize(v, null), - v => JsonSerializer.Deserialize(v, null), - new ValueComparer( - (a, b) => a.Equals(b), - c => c.GetHashCode(), - c => c.Copy() - ) - ); + .Property(e => e.Enabled) + .HasConversion( + v => JsonSerializer.Serialize(v, null), + v => JsonSerializer.Deserialize(v, null), + new ValueComparer( + (a, b) => a.Equals(b), + c => c.GetHashCode(), + c => c.Copy() + ) + ); modelBuilder.Entity() - .Property(e => e.Filters) - .HasConversion( - v => JsonSerializer.Serialize(v, null), - v => JsonSerializer.Deserialize(v, null), - new ValueComparer( - (a, b) => a.Equals(b), - c => c.GetHashCode(), - c => c.Copy() - ) - ); + .Property(e => e.Filters) + .HasConversion( + v => JsonSerializer.Serialize(v, null), + v => JsonSerializer.Deserialize(v, null), + new ValueComparer( + (a, b) => a.Equals(b), + c => c.GetHashCode(), + c => c.Copy() + ) + ); + + modelBuilder.Entity() + .Property(e => e.ProductListing) + .HasConversion( + v => JsonSerializer.Serialize(v, null), + v => JsonSerializer.Deserialize(v, null), + new ValueComparer( + (a, b) => a.Equals(b), + c => c.GetHashCode(), + c => c.Copy() + ) + ); } } } diff --git a/Props/Data/Migrations/20210805055109_InitialCreate.Designer.cs b/Props/Data/Migrations/20210809194646_InitialCreate.Designer.cs similarity index 98% rename from Props/Data/Migrations/20210805055109_InitialCreate.Designer.cs rename to Props/Data/Migrations/20210809194646_InitialCreate.Designer.cs index 989043f..e0c0b92 100644 --- a/Props/Data/Migrations/20210805055109_InitialCreate.Designer.cs +++ b/Props/Data/Migrations/20210809194646_InitialCreate.Designer.cs @@ -9,7 +9,7 @@ using Props.Data; namespace Props.Data.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20210805055109_InitialCreate")] + [Migration("20210809194646_InitialCreate")] partial class InitialCreate { protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -185,13 +185,10 @@ namespace Props.Data.Migrations b.Property("Hits") .HasColumnType("INTEGER"); - b.Property("LastUpdated") + b.Property("ProductListing") .HasColumnType("TEXT"); - b.Property("ProductName") - .HasColumnType("TEXT"); - - b.Property("ProductUrl") + b.Property("ProductListingIdentifier") .HasColumnType("TEXT"); b.Property("ShopName") diff --git a/Props/Data/Migrations/20210805055109_InitialCreate.cs b/Props/Data/Migrations/20210809194646_InitialCreate.cs similarity index 98% rename from Props/Data/Migrations/20210805055109_InitialCreate.cs rename to Props/Data/Migrations/20210809194646_InitialCreate.cs index ad3a704..a9b4715 100644 --- a/Props/Data/Migrations/20210805055109_InitialCreate.cs +++ b/Props/Data/Migrations/20210809194646_InitialCreate.cs @@ -68,9 +68,8 @@ namespace Props.Data.Migrations .Annotation("Sqlite:Autoincrement", true), ShopName = table.Column(type: "TEXT", nullable: true), Hits = table.Column(type: "INTEGER", nullable: false), - LastUpdated = table.Column(type: "TEXT", nullable: false), - ProductUrl = table.Column(type: "TEXT", nullable: true), - ProductName = table.Column(type: "TEXT", nullable: true) + ProductListing = table.Column(type: "TEXT", nullable: true), + ProductListingIdentifier = table.Column(type: "TEXT", nullable: true) }, constraints: table => { diff --git a/Props/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/Props/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 71e8ac7..af01399 100644 --- a/Props/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Props/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -183,13 +183,10 @@ namespace Props.Data.Migrations b.Property("Hits") .HasColumnType("INTEGER"); - b.Property("LastUpdated") + b.Property("ProductListing") .HasColumnType("TEXT"); - b.Property("ProductName") - .HasColumnType("TEXT"); - - b.Property("ProductUrl") + b.Property("ProductListingIdentifier") .HasColumnType("TEXT"); b.Property("ShopName") diff --git a/Props/Extensions/ProductListingExtensions.cs b/Props/Extensions/ProductListingExtensions.cs index da780a5..8462849 100644 --- a/Props/Extensions/ProductListingExtensions.cs +++ b/Props/Extensions/ProductListingExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Builder; using Props.Shop.Framework; namespace Props.Extensions @@ -10,5 +11,7 @@ namespace Props.Extensions int purchaseFactor = productListing.PurchaseCount.HasValue ? productListing.PurchaseCount.Value : 1; return (productListing.Rating * (reviewFactor > purchaseFactor ? reviewFactor : purchaseFactor)) / (productListing.LowerPrice * productListing.UpperPrice); } + + } } \ No newline at end of file diff --git a/Props/Models/Search/ProductListingInfo.cs b/Props/Models/Search/ProductListingInfo.cs index aad4dea..e49f0a3 100644 --- a/Props/Models/Search/ProductListingInfo.cs +++ b/Props/Models/Search/ProductListingInfo.cs @@ -11,10 +11,8 @@ namespace Props.Models.Search public uint Hits { get; set; } - public DateTime LastUpdated { get; set; } + public ProductListing ProductListing { get; set; } - public string ProductUrl { get; set; } - - public string ProductName { get; set; } + public string ProductListingIdentifier { get; set; } } } \ No newline at end of file diff --git a/Props/Services/Modules/LiveMetricsManager.cs b/Props/Services/Modules/LiveMetricsManager.cs index 19f6ded..ea2201c 100644 --- a/Props/Services/Modules/LiveMetricsManager.cs +++ b/Props/Services/Modules/LiveMetricsManager.cs @@ -68,16 +68,15 @@ namespace Props.Services.Modules { ProductListingInfo productListingInfo = (from info in dbContext.ProductListingInfos - where info.ProductUrl.Equals(productListing.URL) + where info.ProductListingIdentifier.Equals(productListing.Identifier) select info).SingleOrDefault() ?? new ProductListingInfo(); if (productListingInfo.Hits == 0) { dbContext.Add(productListingInfo); } productListingInfo.ShopName = shopName; - productListingInfo.ProductName = productListing.Name; - productListingInfo.ProductUrl = productListing.URL; - productListingInfo.LastUpdated = DateTime.UtcNow; + productListingInfo.ProductListing = productListing; + productListingInfo.ProductListingIdentifier = productListing.Identifier; productListingInfo.Hits += 1; dbContext.SaveChanges(); } diff --git a/Props/Services/Modules/ModularShopManager.cs b/Props/Services/Modules/ModularShopManager.cs index c9fbc5b..3242f9a 100644 --- a/Props/Services/Modules/ModularShopManager.cs +++ b/Props/Services/Modules/ModularShopManager.cs @@ -55,7 +55,6 @@ namespace Props.Services.Modules public void LoadShops() { - // TODO: Figure out how to best call this. string shopsDir = options.ModulesDir; string shopRegex = options.ShopRegex; bool recursiveLoad = options.RecursiveLoad; @@ -87,7 +86,8 @@ namespace Props.Services.Modules IShop shop = Activator.CreateInstance(type) as IShop; if (shop != null) { - DirectoryInfo dataDir = Directory.CreateDirectory(Path.Combine(options.ModuleDataDir, file)); + DirectoryInfo dataDir = Directory.CreateDirectory(Path.Combine(options.ModuleDataDir, file.Substring(file.IndexOf(Path.DirectorySeparatorChar) + 1))); + logger.LogDebug("Checking data directory for \"{0}\" at \"{1}\"", Path.GetFileName(file), dataDir.FullName); shop.Initialize(dataDir.FullName, loggerFactory); success += 1; if (!shops.TryAdd(shop.ShopName, shop)) @@ -105,8 +105,6 @@ namespace Props.Services.Modules } } } - logger.LogDebug("Waiting for all shops to finish asynchronous initialization."); - logger.LogDebug("All shops finished asynchronous initialization."); } protected virtual void Dispose(bool disposing) @@ -117,7 +115,7 @@ namespace Props.Services.Modules { foreach (string shopName in shops.Keys) { - // TODO: Get shop data to persist. + shops[shopName].SaveData().AsTask().Wait(); shops[shopName].Dispose(); } } diff --git a/Props/shops/Props.Shop.Adafruit.dll b/Props/shops/Props.Shop.Adafruit.dll index be966c005927aef7ea014c4d9928474b6eec39a1..04ed1cd9598096d679115b5e735cc9432fe32f30 100644 GIT binary patch literal 26624 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 literal 22016 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 ng9(hB3|b$Zf