Added identifier and fetch time product listings; Added persistence to AdafruitShop.
Implemented in AdafruitShop. AdafruitShop Product listing data now persisted. AdafruitShop configuration now persisted.
This commit is contained in:
parent
38ffb3c7e1
commit
0b507b90a1
@ -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<AdafruitShop> 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<AdafruitShop>();
|
||||
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<LiveProductListingManager>(), configuration.MinDownloadInterval);
|
||||
this.searchManager = new SearchManager(productListingManager, configuration.Similarity);
|
||||
productListingManager.StartUpdateTimer(delay: 0);
|
||||
try
|
||||
{
|
||||
configuration = JsonSerializer.Deserialize<Configuration>(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<ProductListingCacheData>(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<LiveProductListingManager>(), listingData?.ProductListings, configuration.MinDownloadInterval);
|
||||
this.searchManager = new SearchManager(productListingManager, configuration.Similarity);
|
||||
productListingManager.StartUpdateTimer(delay: 0, configuration.CacheLifespan);
|
||||
|
||||
logger = loggerFactory.CreateLogger<AdafruitShop>();
|
||||
}
|
||||
|
||||
public Task<ProductListing> GetProductListingFromUrl(string url)
|
||||
public async Task<ProductListing> GetProductFromIdentifier(string identifier)
|
||||
{
|
||||
return searchManager.ProductListingManager.GetProductListingFromUrl(url);
|
||||
return await searchManager.ProductListingManager.GetProductListingFromIdentifier(identifier);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<ProductListing> 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,11 +7,11 @@ namespace Props.Shop.Adafruit.Api
|
||||
{
|
||||
public interface IProductListingManager : IDisposable
|
||||
{
|
||||
public Task<IDictionary<string, IList<ProductListing>>> ProductListings { get; }
|
||||
public Task<IReadOnlyDictionary<string, IList<ProductListing>>> ProductListings { get; }
|
||||
public void RefreshProductListings();
|
||||
public void StartUpdateTimer(int delay = 1000 * 60 * 5, int period = 1000 * 60 * 5);
|
||||
public void StopUpdateTimer();
|
||||
|
||||
public Task<ProductListing> GetProductListingFromUrl(string url);
|
||||
public Task<ProductListing> GetProductListingFromIdentifier(string url);
|
||||
}
|
||||
}
|
@ -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<ProductListing> parsed = new List<ProductListing>();
|
||||
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;
|
||||
|
@ -16,20 +16,21 @@ namespace Props.Shop.Adafruit.Api
|
||||
private int minDownloadInterval;
|
||||
private DateTime? lastDownload;
|
||||
private object refreshLock = new object();
|
||||
private volatile Task<IDictionary<string, IList<ProductListing>>> productListingsTask;
|
||||
private volatile Task<IReadOnlyDictionary<string, IList<ProductListing>>> productListingsTask;
|
||||
|
||||
public Task<IDictionary<string, IList<ProductListing>>> ProductListings => productListingsTask;
|
||||
private readonly ConcurrentDictionary<string, ProductListing> activeProductListingUrls = new ConcurrentDictionary<string, ProductListing>();
|
||||
public Task<IReadOnlyDictionary<string, IList<ProductListing>>> ProductListings => productListingsTask;
|
||||
private readonly ConcurrentDictionary<string, ProductListing> identifierMap = new ConcurrentDictionary<string, ProductListing>();
|
||||
|
||||
private ProductListingsParser parser = new ProductListingsParser();
|
||||
private HttpClient httpClient;
|
||||
private Timer updateTimer;
|
||||
|
||||
public LiveProductListingManager(HttpClient httpClient, ILogger<LiveProductListingManager> logger, int minDownloadInterval = 5 * 60 * 1000)
|
||||
public LiveProductListingManager(HttpClient httpClient, ILogger<LiveProductListingManager> logger, IReadOnlyDictionary<string, IList<ProductListing>> 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<ProductListing> GetProductListingFromUrl(string url)
|
||||
public async Task<ProductListing> GetProductListingFromIdentifier(string identifier)
|
||||
{
|
||||
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
|
||||
await productListingsTask;
|
||||
return activeProductListingUrls[url];
|
||||
return identifierMap[identifier];
|
||||
}
|
||||
|
||||
private async Task<IDictionary<string, IList<ProductListing>>> DownloadListings()
|
||||
private async Task<IReadOnlyDictionary<string, IList<ProductListing>>> 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<string, IList<ProductListing>> listingNames = new Dictionary<string, IList<ProductListing>>();
|
||||
activeProductListingUrls.Clear();
|
||||
identifierMap.Clear();
|
||||
foreach (ProductListing product in parser.ProductListings)
|
||||
{
|
||||
activeProductListingUrls.TryAdd(product.URL, product);
|
||||
identifierMap.TryAdd(product.Identifier, product);
|
||||
IList<ProductListing> sameProducts = listingNames.GetValueOrDefault(product.Name);
|
||||
if (sameProducts == null)
|
||||
{
|
||||
|
@ -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<string, IList<ProductListing>> productListings = await ProductListingManager.ProductListings;
|
||||
IReadOnlyDictionary<string, IList<ProductListing>> productListings = await ProductListingManager.ProductListings;
|
||||
if (productListings == null) throw new InvalidAsynchronousStateException("productListings can't be null");
|
||||
foreach (ExtractedResult<string> listingNames in Process.ExtractAll(query, productListings.Keys, cutoff: (int)(Similarity * 100)))
|
||||
{
|
||||
foreach (ProductListing same in productListings[listingNames.Value])
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
@ -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<string, IList<ProductListing>> ProductListings { get; set; }
|
||||
|
||||
public ProductListingCacheData(IReadOnlyDictionary<string, IList<ProductListing>> productListings)
|
||||
{
|
||||
this.ProductListings = productListings;
|
||||
LastUpdatedUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public ProductListingCacheData()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
@ -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<ProductListing>
|
||||
{
|
||||
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<ProductListing> GetAsyncEnumerator(CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new System.NotImplementedException();
|
||||
}
|
||||
|
||||
public class Enumerator : IAsyncEnumerator<ProductListing>
|
||||
{
|
||||
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<bool> MoveNextAsync()
|
||||
{
|
||||
// TODO: Implement this.
|
||||
throw new System.NotImplementedException();
|
||||
}
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// TODO: Implement this.
|
||||
throw new System.NotImplementedException();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@ -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<ProductListing> ProductListings { get; private set; }
|
||||
|
||||
public SearchResultParser(string result)
|
||||
{
|
||||
data = JObject.Parse(result);
|
||||
|
||||
List<ProductListing> parsed = new List<ProductListing>();
|
||||
foreach (dynamic itemSummary in data.itemSummaries)
|
||||
{
|
||||
ProductListing listing = new ProductListing();
|
||||
// TODO: Finish parsing the data.
|
||||
parsed.Add(listing);
|
||||
}
|
||||
ProductListings = parsed;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<string> queries = new HashSet<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
namespace Props.Shop.Ebay
|
||||
{
|
||||
public class Configuration
|
||||
{
|
||||
public bool Sandbox { get; set; } = true;
|
||||
}
|
||||
}
|
@ -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<ProductListing> Search(string query, Filters filters)
|
||||
{
|
||||
// TODO: Implement the search system.
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Framework\Props.Shop.Framework.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
@ -15,9 +15,10 @@ namespace Props.Shop.Framework
|
||||
|
||||
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters);
|
||||
|
||||
public Task<ProductListing> GetProductListingFromUrl(string url);
|
||||
public Task<ProductListing> GetProductFromIdentifier(string identifier);
|
||||
|
||||
void Initialize(string workspaceDir, ILoggerFactory loggerFactory);
|
||||
ValueTask SaveData();
|
||||
public SupportedFeatures SupportedFeatures { get; }
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -11,31 +11,29 @@ namespace Props.Shop.Adafruit.Tests.Api
|
||||
{
|
||||
public class FakeProductListingManager : IProductListingManager
|
||||
{
|
||||
private Timer refreshTimer;
|
||||
private bool disposedValue;
|
||||
private volatile Task<IDictionary<string, IList<ProductListing>>> activeListings;
|
||||
private DateTime? lastDownload;
|
||||
private ProductListingsParser parser = new ProductListingsParser();
|
||||
private readonly ConcurrentDictionary<string, ProductListing> activeProductListingUrls = new ConcurrentDictionary<string, ProductListing>();
|
||||
|
||||
|
||||
public Task<IDictionary<string, IList<ProductListing>>> ProductListings => activeListings;
|
||||
public Task<IReadOnlyDictionary<string, IList<ProductListing>>> ProductListings { get; private set; }
|
||||
|
||||
public async Task<ProductListing> GetProductListingFromUrl(string url)
|
||||
public async Task<ProductListing> 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<IDictionary<string, IList<ProductListing>>> DownloadListings() {
|
||||
private Task<IReadOnlyDictionary<string, IList<ProductListing>>> 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<IDictionary<string, IList<ProductListing>>>(listingNames);
|
||||
return Task.FromResult<IReadOnlyDictionary<string, IList<ProductListing>>>(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;
|
||||
|
@ -31,40 +31,52 @@ namespace Props.Data
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<ResultsPreferences>()
|
||||
.Property(e => e.Order)
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, null),
|
||||
v => JsonSerializer.Deserialize<List<ResultsPreferences.Category>>(v, null),
|
||||
new ValueComparer<IList<ResultsPreferences.Category>>(
|
||||
(a, b) => a.SequenceEqual(b),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => (IList<ResultsPreferences.Category>)c.ToList()
|
||||
)
|
||||
);
|
||||
.Property(e => e.Order)
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, null),
|
||||
v => JsonSerializer.Deserialize<List<ResultsPreferences.Category>>(v, null),
|
||||
new ValueComparer<IList<ResultsPreferences.Category>>(
|
||||
(a, b) => a.SequenceEqual(b),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => (IList<ResultsPreferences.Category>)c.ToList()
|
||||
)
|
||||
);
|
||||
|
||||
modelBuilder.Entity<SearchOutline>()
|
||||
.Property(e => e.Enabled)
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, null),
|
||||
v => JsonSerializer.Deserialize<SearchOutline.ShopsDisabled>(v, null),
|
||||
new ValueComparer<SearchOutline.ShopsDisabled>(
|
||||
(a, b) => a.Equals(b),
|
||||
c => c.GetHashCode(),
|
||||
c => c.Copy()
|
||||
)
|
||||
);
|
||||
.Property(e => e.Enabled)
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, null),
|
||||
v => JsonSerializer.Deserialize<SearchOutline.ShopsDisabled>(v, null),
|
||||
new ValueComparer<SearchOutline.ShopsDisabled>(
|
||||
(a, b) => a.Equals(b),
|
||||
c => c.GetHashCode(),
|
||||
c => c.Copy()
|
||||
)
|
||||
);
|
||||
|
||||
modelBuilder.Entity<SearchOutline>()
|
||||
.Property(e => e.Filters)
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, null),
|
||||
v => JsonSerializer.Deserialize<Filters>(v, null),
|
||||
new ValueComparer<Filters>(
|
||||
(a, b) => a.Equals(b),
|
||||
c => c.GetHashCode(),
|
||||
c => c.Copy()
|
||||
)
|
||||
);
|
||||
.Property(e => e.Filters)
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, null),
|
||||
v => JsonSerializer.Deserialize<Filters>(v, null),
|
||||
new ValueComparer<Filters>(
|
||||
(a, b) => a.Equals(b),
|
||||
c => c.GetHashCode(),
|
||||
c => c.Copy()
|
||||
)
|
||||
);
|
||||
|
||||
modelBuilder.Entity<ProductListingInfo>()
|
||||
.Property(e => e.ProductListing)
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, null),
|
||||
v => JsonSerializer.Deserialize<ProductListing>(v, null),
|
||||
new ValueComparer<ProductListing>(
|
||||
(a, b) => a.Equals(b),
|
||||
c => c.GetHashCode(),
|
||||
c => c.Copy()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<uint>("Hits")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastUpdated")
|
||||
b.Property<string>("ProductListing")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProductName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProductUrl")
|
||||
b.Property<string>("ProductListingIdentifier")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ShopName")
|
@ -68,9 +68,8 @@ namespace Props.Data.Migrations
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
ShopName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Hits = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||
LastUpdated = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
ProductUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ProductName = table.Column<string>(type: "TEXT", nullable: true)
|
||||
ProductListing = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ProductListingIdentifier = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
@ -183,13 +183,10 @@ namespace Props.Data.Migrations
|
||||
b.Property<uint>("Hits")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastUpdated")
|
||||
b.Property<string>("ProductListing")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProductName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProductUrl")
|
||||
b.Property<string>("ProductListingIdentifier")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ShopName")
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user