Added primitive search mechanism in backend.

Began implementing search mechanism for frontend.
This commit is contained in:
Harrison Deng 2021-08-05 01:22:19 -05:00
parent f71758ca69
commit c94ea4a624
56 changed files with 1623 additions and 490 deletions

View File

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Props.Shop.Adafruit.Api;
using Props.Shop.Framework;
@ -9,7 +11,7 @@ namespace Props.Shop.Adafruit
{
public class AdafruitShop : IShop
{
private ProductListingManager productListingManager;
private SearchManager searchManager;
private Configuration configuration;
private HttpClient http;
private bool disposedValue;
@ -27,24 +29,24 @@ namespace Props.Shop.Adafruit
false,
true
);
public byte[] GetDataForPersistence()
{
return JsonSerializer.SerializeToUtf8Bytes(configuration);
}
public IEnumerable<IOption> Initialize(byte[] data)
public void Initialize(string workspaceDir)
{
http = new HttpClient();
http.BaseAddress = new Uri("http://www.adafruit.com/api");
configuration = JsonSerializer.Deserialize<Configuration>(data);
this.productListingManager = new ProductListingManager();
return null;
http.BaseAddress = new Uri("http://www.adafruit.com/api/");
configuration = new Configuration(); // TODO Implement config persistence.
}
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters)
public async Task InitializeAsync(string workspaceDir)
{
return productListingManager.Search(query, configuration.Similarity, http);
ProductListingManager productListingManager = new ProductListingManager(http);
this.searchManager = new SearchManager(productListingManager, configuration.Similarity);
await productListingManager.DownloadListings();
productListingManager.StartUpdateTimer();
}
public IEnumerable<ProductListing> Search(string query, Filters filters)
{
return searchManager.Search(query);
}
protected virtual void Dispose(bool disposing)
@ -54,6 +56,7 @@ namespace Props.Shop.Adafruit
if (disposing)
{
http.Dispose();
searchManager.Dispose();
}
disposedValue = true;
}

View File

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public interface IProductListingManager : IDisposable
{
public event EventHandler DataUpdateEvent;
public IDictionary<string, IList<ProductListing>> ActiveListings { get; }
public Task DownloadListings();
public void StartUpdateTimer(int delay = 1000 * 60 * 5, int period = 1000 * 60 * 5);
public void StopUpdateTimer();
}
}

View File

@ -1,44 +1,49 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public class ListingsParser
public class ProductListingsParser
{
public IEnumerable<ProductListing> ProductListings { get; private set; }
public ListingsParser(string json)
public void BuildProductListings(Stream stream)
{
dynamic data = JArray.Parse(json);
List<ProductListing> parsed = new List<ProductListing>();
foreach (dynamic item in data)
using (StreamReader streamReader = new StreamReader(stream))
{
if (item.products_discontinued == 0)
dynamic data = JArray.Load(new JsonTextReader(streamReader));
List<ProductListing> parsed = new List<ProductListing>();
foreach (dynamic item in data)
{
ProductListing res = new ProductListing();
res.Name = item.product_name;
res.LowerPrice = item.product_price;
res.UpperPrice = res.LowerPrice;
foreach (dynamic discount in item.discount_pricing)
if (item.products_discontinued == 0)
{
if (discount.discounted_price < res.LowerPrice)
ProductListing res = new ProductListing();
res.Name = item.product_name;
res.LowerPrice = item.product_price;
res.UpperPrice = res.LowerPrice;
foreach (dynamic discount in item.discount_pricing)
{
res.LowerPrice = discount.discounted_price;
}
if (discount.discounted_price > res.UpperPrice)
{
res.UpperPrice = discount.discounted_price;
if (discount.discounted_price < res.LowerPrice)
{
res.LowerPrice = discount.discounted_price;
}
if (discount.discounted_price > res.UpperPrice)
{
res.UpperPrice = discount.discounted_price;
}
}
res.URL = item.product_url;
res.InStock = item.product_stock > 0;
parsed.Add(res);
}
res.URL = item.product_url;
res.InStock = item.product_stock > 0;
parsed.Add(res);
}
ProductListings = parsed;
}
ProductListings = parsed;
}
}
}

View File

@ -0,0 +1,77 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public class ProductListingManager : IProductListingManager
{
public event EventHandler DataUpdateEvent;
private bool disposedValue;
private volatile Dictionary<string, IList<ProductListing>> activeListings;
public IDictionary<string, IList<ProductListing>> ActiveListings => activeListings;
private ProductListingsParser parser = new ProductListingsParser();
private HttpClient httpClient;
private Timer updateTimer;
public ProductListingManager(HttpClient httpClient)
{
this.httpClient = httpClient;
}
public async Task DownloadListings() {
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
HttpResponseMessage responseMessage = await httpClient.GetAsync("products");
parser.BuildProductListings(responseMessage.Content.ReadAsStream());
Dictionary<string, IList<ProductListing>> listings = new Dictionary<string, IList<ProductListing>>();
foreach (ProductListing product in parser.ProductListings)
{
IList<ProductListing> sameProducts = listings.GetValueOrDefault(product.Name);
if (sameProducts == null) {
sameProducts = new List<ProductListing>();
listings.Add(product.Name, sameProducts);
}
sameProducts.Add(product);
}
activeListings = listings;
DataUpdateEvent?.Invoke(this, null);
}
public void StartUpdateTimer(int delay = 1000 * 60 * 5, int period = 1000 * 60 * 5) {
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
if (updateTimer != null) throw new InvalidOperationException("Update timer already started.");
updateTimer = new Timer(async (state) => await DownloadListings(), null, delay, period);
}
public void StopUpdateTimer() {
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
if (updateTimer != null) throw new InvalidOperationException("Update timer not started.");
updateTimer.Dispose();
updateTimer = null;
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
updateTimer?.Dispose();
updateTimer = null;
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -1,58 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using FuzzySharp;
using FuzzySharp.Extractor;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public class ProductListingManager
{
private double minutesPerRequest;
private Dictionary<string, List<ProductListing>> listings = new Dictionary<string, List<ProductListing>>();
private bool requested = false;
public DateTime TimeOfLastRequest { get; private set; }
public bool RequestReady => !requested || DateTime.Now - TimeOfLastRequest > TimeSpan.FromMinutes(minutesPerRequest);
public ProductListingManager(int requestsPerMinute = 5)
{
this.minutesPerRequest = 1 / requestsPerMinute;
}
public async Task RefreshListings(HttpClient http)
{
requested = true;
TimeOfLastRequest = DateTime.Now;
HttpResponseMessage response = await http.GetAsync("/products");
SetListingsData(await response.Content.ReadAsStringAsync());
}
public void SetListingsData(string data)
{
ListingsParser listingsParser = new ListingsParser(data);
foreach (ProductListing listing in listingsParser.ProductListings)
{
List<ProductListing> similar = listings.GetValueOrDefault(listing.Name, new List<ProductListing>());
similar.Add(listing);
listings[listing.Name] = similar;
}
}
public async IAsyncEnumerable<ProductListing> Search(string query, float similarity, HttpClient httpClient = null)
{
if (RequestReady && httpClient != null) await RefreshListings(httpClient);
IEnumerable<ExtractedResult<string>> resultNames = Process.ExtractAll(query, listings.Keys, cutoff: (int)similarity * 100);
foreach (ExtractedResult<string> resultName in resultNames)
{
foreach (ProductListing product in listings[resultName.Value])
{
yield return product;
}
}
}
}
}

View File

@ -0,0 +1,72 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using FuzzySharp;
using FuzzySharp.Extractor;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public class SearchManager : IDisposable
{
public float Similarity { get; set; }
private readonly object searchLock = new object();
private IDictionary<string, IList<ProductListing>> searched;
private IProductListingManager listingManager;
private bool disposedValue;
public SearchManager(IProductListingManager productListingManager, float similarity = 0.8f)
{
this.listingManager = productListingManager ?? throw new ArgumentNullException("productListingManager");
this.Similarity = similarity;
listingManager.DataUpdateEvent += OnDataUpdate;
}
private void OnDataUpdate(object sender, EventArgs eventArgs)
{
BuildSearchIndex();
}
private void BuildSearchIndex()
{
lock (searchLock)
{
searched = new Dictionary<string, IList<ProductListing>>(listingManager.ActiveListings);
}
}
public IEnumerable<ProductListing> Search(string query)
{
lock (searchLock)
{
foreach (ExtractedResult<string> listingNames in Process.ExtractAll(query, searched.Keys, cutoff: (int)(Similarity * 100)))
{
foreach (ProductListing same in searched[listingNames.Value])
{
yield return same;
}
}
}
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
listingManager.Dispose();
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -3,5 +3,10 @@ namespace Props.Shop.Adafruit
public class Configuration
{
public float Similarity { get; set; }
public Configuration()
{
Similarity = 0.8f;
}
}
}

View File

@ -1,35 +0,0 @@
using System;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Options
{
public class SimilarityOption : IOption
{
private Configuration configuration;
public string Name => "Query Similarity";
public string Description => "The minimum level of similarity for a listing to be returned.";
public bool Required => true;
public Type Type => typeof(float);
internal SimilarityOption(Configuration configuration)
{
this.configuration = configuration;
}
public string GetValue()
{
return configuration.Similarity.ToString();
}
public bool SetValue(string value)
{
float parsed;
bool success = float.TryParse(value, out parsed);
configuration.Similarity = parsed;
return success;
}
}
}

View File

@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="System.Linq.Async" Version="5.0.0" />
</ItemGroup>
<ItemGroup>

View File

@ -3,6 +3,7 @@ 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
@ -29,13 +30,16 @@ namespace Props.Shop.Ebay
private HttpClient httpClient;
public IEnumerable<IOption> Initialize(byte[] data)
public void Initialize(string workspaceDir)
{
httpClient = new HttpClient();
configuration = JsonSerializer.Deserialize<Configuration>(data);
return new List<IOption>() {
new SandboxOption(configuration),
};
configuration = new Configuration(); // TODO: Implement config persistence.
}
public Task InitializeAsync(string workspaceDir)
{
throw new NotImplementedException();
}
protected virtual void Dispose(bool disposing)
@ -61,7 +65,7 @@ namespace Props.Shop.Ebay
return JsonSerializer.SerializeToUtf8Bytes(configuration);
}
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters)
public IEnumerable<ProductListing> Search(string query, Filters filters)
{
// TODO: Implement the search system.
throw new NotImplementedException();

View File

@ -1,35 +0,0 @@
using System;
using Props.Shop.Framework;
namespace Props.Shop.Ebay
{
public class SandboxOption : IOption
{
private Configuration configuration;
public string Name => "Ebay Sandbox";
public string Description => "For development purposes, Ebay Sandbox allows use of Ebay APIs (with exceptions) in a sandbox environment before applying for production use.";
public bool Required => true;
public Type Type => typeof(bool);
internal SandboxOption(Configuration configuration)
{
this.configuration = configuration;
}
public string GetValue()
{
return configuration.Sandbox.ToString();
}
public bool SetValue(string value)
{
bool sandbox = false;
bool res = bool.TryParse(value, out sandbox);
configuration.Sandbox = sandbox;
return res;
}
}
}

View File

@ -12,10 +12,10 @@ namespace Props.Shop.Framework
string ShopDescription { get; }
string ShopModuleAuthor { get; }
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters);
public IEnumerable<ProductListing> Search(string query, Filters filters);
IEnumerable<IOption> Initialize(byte[] data);
void Initialize(string workspaceDir);
Task InitializeAsync(string workspaceDir);
public SupportedFeatures SupportedFeatures { get; }
public byte[] GetDataForPersistence();
}
}

View File

@ -1,6 +1,6 @@
namespace Props.Shop.Framework
{
public struct ProductListing
public class ProductListing
{
public float LowerPrice { get; set; }
public float UpperPrice { get; set; }

View File

@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Props.Shop.Framework;
using Xunit;
namespace Props.Shop.Adafruit.Tests
{
public class AdafruitShopTest
{
[Fact]
public async Task TestSearch() {
AdafruitShop mockAdafruitShop = new AdafruitShop();
mockAdafruitShop.Initialize(null);
await mockAdafruitShop.InitializeAsync(null);
int count = 0;
foreach (ProductListing listing in mockAdafruitShop.Search("raspberry pi", new Filters()))
{
count += 1;
}
Assert.True(count > 0);
}
}
}

View File

@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Props.Shop.Adafruit.Api;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Tests.Api
{
public class FakeProductListingManager : IProductListingManager
{
private ProductListingsParser parser;
private Timer updateTimer;
private bool disposedValue;
private volatile Dictionary<string, IList<ProductListing>> activeListings;
public IDictionary<string, IList<ProductListing>> ActiveListings => activeListings;
public event EventHandler DataUpdateEvent;
public FakeProductListingManager()
{
parser = new ProductListingsParser();
}
public Task DownloadListings()
{
if (disposedValue) throw new ObjectDisposedException("FakeProductListingManager");
using (Stream stream = File.OpenRead("./Assets/products.json"))
{
parser.BuildProductListings(stream);
}
Dictionary<string, IList<ProductListing>> results = new Dictionary<string, IList<ProductListing>>();
foreach (ProductListing product in parser.ProductListings)
{
IList<ProductListing> sameProducts = results.GetValueOrDefault(product.Name);
if (sameProducts == null) {
sameProducts = new List<ProductListing>();
results.Add(product.Name, sameProducts);
}
sameProducts.Add(product);
}
activeListings = results;
DataUpdateEvent?.Invoke(this, null);
return Task.CompletedTask;
}
public void StartUpdateTimer(int delay = 300000, int period = 300000)
{
if (disposedValue) throw new ObjectDisposedException("FakeProductListingManager");
if (updateTimer != null) throw new InvalidOperationException("Update timer already started.");
updateTimer = new Timer((state) => DownloadListings(), null, delay, period);
}
public void StopUpdateTimer()
{
if (disposedValue) throw new ObjectDisposedException("FakeProductListingManager");
if (updateTimer == null) throw new InvalidOperationException("Update timer not started.");
updateTimer.Dispose();
updateTimer = null;
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
updateTimer?.Dispose();
updateTimer = null;
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -10,7 +10,11 @@ namespace Props.Shop.Adafruit.Tests
[Fact]
public void TestParsing()
{
ListingsParser mockParser = new ListingsParser(File.ReadAllText("./Assets/products.json"));
ProductListingsParser mockParser = new ProductListingsParser();
using (Stream stream = File.OpenRead("./Assets/products.json"))
{
mockParser.BuildProductListings(stream);
}
Assert.NotEmpty(mockParser.ProductListings);
}
}

View File

@ -1,25 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Props.Shop.Adafruit.Api;
using Props.Shop.Framework;
using Xunit;
namespace Props.Shop.Adafruit.Tests.Api
{
public class ProductListingManagerTest
{
[Fact]
public async Task TestSearch()
{
ProductListingManager mockProductListingManager = new ProductListingManager();
mockProductListingManager.SetListingsData(File.ReadAllText("./Assets/products.json"));
List<ProductListing> results = new List<ProductListing>();
await foreach (ProductListing item in mockProductListingManager.Search("arduino", 0.5f))
{
results.Add(item);
}
Assert.NotEmpty(results);
}
}
}

View File

@ -0,0 +1,20 @@
using System.Threading.Tasks;
using Props.Shop.Adafruit.Api;
using Xunit;
namespace Props.Shop.Adafruit.Tests.Api
{
public class SearchManagerTest
{
[Fact]
public async Task SearchTest()
{
FakeProductListingManager stubProductListingManager = new FakeProductListingManager();
SearchManager searchManager = new SearchManager(stubProductListingManager);
await stubProductListingManager.DownloadListings();
searchManager.Similarity = 0.8f;
Assert.NotEmpty(searchManager.Search("Raspberry Pi"));
searchManager.Dispose();
}
}
}

View File

@ -5,4 +5,5 @@
],
"todo-tree.regex.regex": "((//|#|<!--|;|@\\*|/\\*|^)\\s*($TAGS)|^\\s*- \\[ \\])",
"todo-tree.regex.subTagRegex": "(\\*@)",
"editor.formatOnSave": true,
}

View File

@ -6,7 +6,8 @@
}
<div class="jumbotron text-center">
<img alt="Props logo" src="~/images/logo-simplified.svg" class="img-fluid" style="max-height: 150px;" asp-append-version="true" />
<img alt="Props logo" src="~/images/logo-simplified.svg" class="img-fluid" style="max-height: 150px;"
asp-append-version="true" />
<h1 class="mt-3 mb-4 display-1">@ViewData["Title"]</h1>
<p>Welcome back!</p>
</div>
@ -40,13 +41,16 @@
</div>
<div class="row">
<div class="col-lg">
<a class="link-secondary" id="forgot-password" asp-page="./ForgotPassword">Forgot your password?</a>
<a class="link-secondary" id="forgot-password" asp-page="./ForgotPassword">Forgot your
password?</a>
</div>
<div class="col-lg">
<a class="link-secondary" asp-page="./Register" asp-route-returnUrl="@Model.ReturnUrl">Register as a new user</a>
<a class="link-secondary" asp-page="./Register" asp-route-returnUrl="@Model.ReturnUrl">Register
as a new user</a>
</div>
<div class="col-lg">
<a class="link-secondary" id="resend-confirmation" asp-page="./ResendEmailConfirmation">Resend email confirmation</a>
<a class="link-secondary" id="resend-confirmation" asp-page="./ResendEmailConfirmation">Resend
email confirmation</a>
</div>
</div>
</form>
@ -56,12 +60,14 @@
<div class="col-md-6 md-offset-2">
<h4>Use another service to log in.</h4>
<hr />
<form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post" class="form-horizontal">
<form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post"
class="form-horizontal">
<div>
<p>
@foreach (var provider in Model.ExternalLogins)
{
<button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
<button type="submit" class="btn btn-primary" name="provider" value="@provider.Name"
title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
}
</p>
</div>

View File

@ -3,15 +3,22 @@
var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
}
<ul class="nav nav-pills flex-column">
<li class="nav-item"><a class="nav-link @ManageNavPages.IndexNavClass(ViewContext)" id="profile" asp-page="./Index">Profile</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.EmailNavClass(ViewContext)" id="email" asp-page="./Email">Email</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.ChangePasswordNavClass(ViewContext)" id="change-password" asp-page="./ChangePassword">Password</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.IndexNavClass(ViewContext)" id="profile"
asp-page="./Index">Profile</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.EmailNavClass(ViewContext)" id="email"
asp-page="./Email">Email</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.ChangePasswordNavClass(ViewContext)" id="change-password"
asp-page="./ChangePassword">Password</a></li>
@if (hasExternalLogins)
{
<li id="external-logins" class="nav-item"><a id="external-login" class="nav-link @ManageNavPages.ExternalLoginsNavClass(ViewContext)" asp-page="./ExternalLogins">External logins</a></li>
<li id="external-logins" class="nav-item"><a id="external-login"
class="nav-link @ManageNavPages.ExternalLoginsNavClass(ViewContext)" asp-page="./ExternalLogins">External
logins</a></li>
}
<li class="nav-item"><a class="nav-link @ManageNavPages.TwoFactorAuthenticationNavClass(ViewContext)" id="two-factor" asp-page="./TwoFactorAuthentication">Two-factor authentication</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.PersonalDataNavClass(ViewContext)" id="personal-data" asp-page="./PersonalData">Personal data</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.TwoFactorAuthenticationNavClass(ViewContext)"
id="two-factor" asp-page="./TwoFactorAuthentication">Two-factor authentication</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.PersonalDataNavClass(ViewContext)" id="personal-data"
asp-page="./PersonalData">Personal data</a></li>
</ul>
@* TODO: Finish styling this page. *@
@* TODO: Finish styling account page. *@

View File

@ -9,8 +9,10 @@
if (@Model.DisplayConfirmAccountLink)
{
<p>
This app does not currently have a real email sender registered, see <a href="https://aka.ms/aspaccountconf">these docs</a> for how to configure a real email sender.
Normally this would be emailed: <a id="confirm-link" href="@Model.EmailConfirmationUrl">Click here to confirm your account</a>
This app does not currently have a real email sender registered, see <a href="https://aka.ms/aspaccountconf">these
docs</a> for how to configure a real email sender.
Normally this would be emailed: <a id="confirm-link" href="@Model.EmailConfirmationUrl">Click here to confirm your
account</a>
</p>
}
else
@ -20,4 +22,4 @@
</p>
}
}
@* TODO: Do something about this. *@
@* TODO: implement account confirmation. *@

16
Props/Content/search.json Normal file
View File

@ -0,0 +1,16 @@
{
"instructions": [
"Type in the electronic component your looking for in the search bar above.",
"Optionally, hit the configure button to modify your search criteria. With an account, you can even create and save multiple search profiles known as \"search outlines\".",
"That's it! Hit search and we'll look far and wide for what you need!"
],
"quickPicks": {
"searched": "To save you some time, these are some of the better options we found!",
"prompt": "This is where we'll show you top listings so you can get back to working on your project!"
},
"results": {
"searched": "We searched far and wide. Here's what we found!",
"prompt": "This is were we'll display all the results we went through to show you the top. This is a good place to check if none of our quick picks cater to your needs."
},
"notSearched": "Nothing to show yet!"
}

View File

@ -15,24 +15,10 @@ namespace Props.Controllers
}
[HttpGet]
[Route("Shops/Available")]
[Route("Available")]
public IActionResult GetAvailableShops()
{
return Ok(shopManager.AvailableShops());
}
[HttpGet]
[Route("Default/Filters")]
public IActionResult GetDefaultFilters()
{
return Ok(defaultOutline.Filters);
}
[HttpGet]
[Route("Default/DisabledShops")]
public IActionResult GetDefaultDisabledShops()
{
return Ok(defaultOutline.Disabled);
return Ok(shopManager.GetAllShopNames());
}
}
}

View File

@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc;
using Props.Models.Search;
using Props.Services.Modules;
namespace Props.Controllers
{
public class SearchOutlineController : ApiControllerBase
{
private SearchOutline defaultOutline = new SearchOutline();
public SearchOutlineController()
{
}
[HttpGet]
[Route("Filters")]
public IActionResult GetFilters()
{
return Ok(defaultOutline.Filters);
}
[HttpGet]
[Route("DisabledShops")]
public IActionResult GetDisabledShops()
{
return Ok(defaultOutline.Enabled);
}
[HttpGet]
[Route("SearchOutlineName")]
public IActionResult GetSearchOutlineName()
{
return Ok(defaultOutline.Name);
}
}
}

View File

@ -3,10 +3,11 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.Localization;
using Props.Models;
using Props.Models.Search;
using Props.Models.User;
@ -16,8 +17,10 @@ namespace Props.Data
{
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
DbSet<SearchOutline> SearchOutlines { get; set; }
DbSet<ProductListingInfo> TrackedListings { get; set; }
public DbSet<QueryWordInfo> Keywords { get; set; }
public DbSet<ProductListingInfo> ProductListingInfos { get; set; }
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
@ -40,7 +43,7 @@ namespace Props.Data
);
modelBuilder.Entity<SearchOutline>()
.Property(e => e.Disabled)
.Property(e => e.Enabled)
.HasConversion(
v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<SearchOutline.ShopsDisabled>(v, null),
@ -50,6 +53,7 @@ namespace Props.Data
c => c.Copy()
)
);
modelBuilder.Entity<SearchOutline>()
.Property(e => e.Filters)
.HasConversion(

View File

@ -9,7 +9,7 @@ using Props.Data;
namespace Props.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20210724073427_InitialCreate")]
[Migration("20210805055109_InitialCreate")]
partial class InitialCreate
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@ -188,18 +188,36 @@ namespace Props.Data.Migrations
b.Property<DateTime>("LastUpdated")
.HasColumnType("TEXT");
b.Property<string>("OriginName")
.HasColumnType("TEXT");
b.Property<string>("ProductName")
.HasColumnType("TEXT");
b.Property<string>("ProductUrl")
.HasColumnType("TEXT");
b.Property<string>("ShopName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("TrackedListings");
b.ToTable("ProductListingInfos");
});
modelBuilder.Entity("Props.Models.Search.QueryWordInfo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("Hits")
.HasColumnType("INTEGER");
b.Property<string>("Word")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Keywords");
});
modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
@ -212,18 +230,27 @@ namespace Props.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Disabled")
b.Property<string>("Enabled")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Filters")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SearchOutlinePreferencesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.ToTable("SearchOutlines");
b.HasIndex("SearchOutlinePreferencesId");
b.ToTable("SearchOutline");
});
modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
@ -290,6 +317,29 @@ namespace Props.Data.Migrations
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ActiveSearchOutlineId")
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ActiveSearchOutlineId");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("SearchOutlinePreferences");
});
modelBuilder.Entity("Props.Shared.Models.User.ApplicationPreferences", b =>
{
b.Property<int>("Id")
@ -314,6 +364,21 @@ namespace Props.Data.Migrations
b.ToTable("ApplicationPreferences");
});
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.Property<int>("FollowingId")
.HasColumnType("INTEGER");
b.Property<int>("PrecedingId")
.HasColumnType("INTEGER");
b.HasKey("FollowingId", "PrecedingId");
b.HasIndex("PrecedingId");
b.ToTable("QueryWordInfoQueryWordInfo");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -379,11 +444,32 @@ namespace Props.Data.Migrations
modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithMany("SearchOutlines")
.WithMany()
.HasForeignKey("ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Props.Models.User.SearchOutlinePreferences", null)
.WithMany("SearchOutlines")
.HasForeignKey("SearchOutlinePreferencesId");
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.HasOne("Props.Models.Search.SearchOutline", "ActiveSearchOutline")
.WithMany()
.HasForeignKey("ActiveSearchOutlineId");
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("searchOutlinePreferences")
.HasForeignKey("Props.Models.User.SearchOutlinePreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ActiveSearchOutline");
b.Navigation("ApplicationUser");
});
@ -398,6 +484,21 @@ namespace Props.Data.Migrations
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("FollowingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("PrecedingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
{
b.Navigation("ApplicationPreferences")
@ -406,6 +507,12 @@ namespace Props.Data.Migrations
b.Navigation("ResultsPreferences")
.IsRequired();
b.Navigation("searchOutlinePreferences")
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Navigation("SearchOutlines");
});
#pragma warning restore 612, 618

View File

@ -47,12 +47,26 @@ namespace Props.Data.Migrations
});
migrationBuilder.CreateTable(
name: "TrackedListings",
name: "Keywords",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
OriginName = table.Column<string>(type: "TEXT", nullable: true),
Word = table.Column<string>(type: "TEXT", nullable: false),
Hits = table.Column<uint>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Keywords", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ProductListingInfos",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.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),
@ -60,7 +74,7 @@ namespace Props.Data.Migrations
},
constraints: table =>
{
table.PrimaryKey("PK_TrackedListings", x => x.Id);
table.PrimaryKey("PK_ProductListingInfos", x => x.Id);
});
migrationBuilder.CreateTable(
@ -212,26 +226,78 @@ namespace Props.Data.Migrations
});
migrationBuilder.CreateTable(
name: "SearchOutlines",
name: "QueryWordInfoQueryWordInfo",
columns: table => new
{
FollowingId = table.Column<int>(type: "INTEGER", nullable: false),
PrecedingId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_QueryWordInfoQueryWordInfo", x => new { x.FollowingId, x.PrecedingId });
table.ForeignKey(
name: "FK_QueryWordInfoQueryWordInfo_Keywords_FollowingId",
column: x => x.FollowingId,
principalTable: "Keywords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_QueryWordInfoQueryWordInfo_Keywords_PrecedingId",
column: x => x.PrecedingId,
principalTable: "Keywords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SearchOutline",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Filters = table.Column<string>(type: "TEXT", nullable: true),
Disabled = table.Column<string>(type: "TEXT", nullable: false)
Enabled = table.Column<string>(type: "TEXT", nullable: false),
SearchOutlinePreferencesId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SearchOutlines", x => x.Id);
table.PrimaryKey("PK_SearchOutline", x => x.Id);
table.ForeignKey(
name: "FK_SearchOutlines_AspNetUsers_ApplicationUserId",
name: "FK_SearchOutline_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SearchOutlinePreferences",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: false),
ActiveSearchOutlineId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SearchOutlinePreferences", x => x.Id);
table.ForeignKey(
name: "FK_SearchOutlinePreferences_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SearchOutlinePreferences_SearchOutline_ActiveSearchOutlineId",
column: x => x.ActiveSearchOutlineId,
principalTable: "SearchOutline",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_ApplicationPreferences_ApplicationUserId",
table: "ApplicationPreferences",
@ -275,6 +341,11 @@ namespace Props.Data.Migrations
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_QueryWordInfoQueryWordInfo_PrecedingId",
table: "QueryWordInfoQueryWordInfo",
column: "PrecedingId");
migrationBuilder.CreateIndex(
name: "IX_ResultsPreferences_ApplicationUserId",
table: "ResultsPreferences",
@ -282,13 +353,49 @@ namespace Props.Data.Migrations
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SearchOutlines_ApplicationUserId",
table: "SearchOutlines",
name: "IX_SearchOutline_ApplicationUserId",
table: "SearchOutline",
column: "ApplicationUserId");
migrationBuilder.CreateIndex(
name: "IX_SearchOutline_SearchOutlinePreferencesId",
table: "SearchOutline",
column: "SearchOutlinePreferencesId");
migrationBuilder.CreateIndex(
name: "IX_SearchOutlinePreferences_ActiveSearchOutlineId",
table: "SearchOutlinePreferences",
column: "ActiveSearchOutlineId");
migrationBuilder.CreateIndex(
name: "IX_SearchOutlinePreferences_ApplicationUserId",
table: "SearchOutlinePreferences",
column: "ApplicationUserId",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_SearchOutline_SearchOutlinePreferences_SearchOutlinePreferencesId",
table: "SearchOutline",
column: "SearchOutlinePreferencesId",
principalTable: "SearchOutlinePreferences",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_SearchOutline_AspNetUsers_ApplicationUserId",
table: "SearchOutline");
migrationBuilder.DropForeignKey(
name: "FK_SearchOutlinePreferences_AspNetUsers_ApplicationUserId",
table: "SearchOutlinePreferences");
migrationBuilder.DropForeignKey(
name: "FK_SearchOutline_SearchOutlinePreferences_SearchOutlinePreferencesId",
table: "SearchOutline");
migrationBuilder.DropTable(
name: "ApplicationPreferences");
@ -307,20 +414,29 @@ namespace Props.Data.Migrations
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "ProductListingInfos");
migrationBuilder.DropTable(
name: "QueryWordInfoQueryWordInfo");
migrationBuilder.DropTable(
name: "ResultsPreferences");
migrationBuilder.DropTable(
name: "SearchOutlines");
migrationBuilder.DropTable(
name: "TrackedListings");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "Keywords");
migrationBuilder.DropTable(
name: "AspNetUsers");
migrationBuilder.DropTable(
name: "SearchOutlinePreferences");
migrationBuilder.DropTable(
name: "SearchOutline");
}
}
}

View File

@ -186,18 +186,36 @@ namespace Props.Data.Migrations
b.Property<DateTime>("LastUpdated")
.HasColumnType("TEXT");
b.Property<string>("OriginName")
.HasColumnType("TEXT");
b.Property<string>("ProductName")
.HasColumnType("TEXT");
b.Property<string>("ProductUrl")
.HasColumnType("TEXT");
b.Property<string>("ShopName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("TrackedListings");
b.ToTable("ProductListingInfos");
});
modelBuilder.Entity("Props.Models.Search.QueryWordInfo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("Hits")
.HasColumnType("INTEGER");
b.Property<string>("Word")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Keywords");
});
modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
@ -210,18 +228,27 @@ namespace Props.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Disabled")
b.Property<string>("Enabled")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Filters")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SearchOutlinePreferencesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.ToTable("SearchOutlines");
b.HasIndex("SearchOutlinePreferencesId");
b.ToTable("SearchOutline");
});
modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
@ -288,6 +315,29 @@ namespace Props.Data.Migrations
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ActiveSearchOutlineId")
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ActiveSearchOutlineId");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("SearchOutlinePreferences");
});
modelBuilder.Entity("Props.Shared.Models.User.ApplicationPreferences", b =>
{
b.Property<int>("Id")
@ -312,6 +362,21 @@ namespace Props.Data.Migrations
b.ToTable("ApplicationPreferences");
});
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.Property<int>("FollowingId")
.HasColumnType("INTEGER");
b.Property<int>("PrecedingId")
.HasColumnType("INTEGER");
b.HasKey("FollowingId", "PrecedingId");
b.HasIndex("PrecedingId");
b.ToTable("QueryWordInfoQueryWordInfo");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -377,11 +442,32 @@ namespace Props.Data.Migrations
modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithMany("SearchOutlines")
.WithMany()
.HasForeignKey("ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Props.Models.User.SearchOutlinePreferences", null)
.WithMany("SearchOutlines")
.HasForeignKey("SearchOutlinePreferencesId");
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.HasOne("Props.Models.Search.SearchOutline", "ActiveSearchOutline")
.WithMany()
.HasForeignKey("ActiveSearchOutlineId");
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("searchOutlinePreferences")
.HasForeignKey("Props.Models.User.SearchOutlinePreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ActiveSearchOutline");
b.Navigation("ApplicationUser");
});
@ -396,6 +482,21 @@ namespace Props.Data.Migrations
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("FollowingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("PrecedingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
{
b.Navigation("ApplicationPreferences")
@ -404,6 +505,12 @@ namespace Props.Data.Migrations
b.Navigation("ResultsPreferences")
.IsRequired();
b.Navigation("searchOutlinePreferences")
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Navigation("SearchOutlines");
});
#pragma warning restore 612, 618

View File

@ -0,0 +1,14 @@
using Props.Shop.Framework;
namespace Props.Extensions
{
public static class ProductListingExtensions
{
public static float? GetRatingToPriceRatio(this ProductListing productListing)
{
int reviewFactor = productListing.ReviewCount.HasValue ? productListing.ReviewCount.Value : 1;
int purchaseFactor = productListing.PurchaseCount.HasValue ? productListing.PurchaseCount.Value : 1;
return (productListing.Rating * (reviewFactor > purchaseFactor ? reviewFactor : purchaseFactor)) / (productListing.LowerPrice * productListing.UpperPrice);
}
}
}

View File

@ -7,7 +7,7 @@ namespace Props.Models.Search
{
public int Id { get; set; }
public string OriginName { get; set; }
public string ShopName { get; set; }
public uint Hits { get; set; }

View File

@ -0,0 +1,34 @@
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Props.Models.Search
{
public class QueryWordInfo
{
public int Id { get; set; }
[Required]
public string Word { get; set; }
public uint Hits { get; set; }
[Required]
public virtual ISet<QueryWordInfo> Preceding { get; set; }
[Required]
public virtual ISet<QueryWordInfo> Following { get; set; }
public QueryWordInfo()
{
this.Preceding = new HashSet<QueryWordInfo>();
this.Following = new HashSet<QueryWordInfo>();
}
public QueryWordInfo(ISet<QueryWordInfo> preceding, ISet<QueryWordInfo> following)
{
this.Preceding = preceding;
this.Following = following;
}
}
}

View File

@ -17,10 +17,13 @@ namespace Props.Models.Search
[Required]
public virtual ApplicationUser ApplicationUser { get; set; }
[Required]
public string Name { get; set; } = "Default";
public Filters Filters { get; set; } = new Filters();
[Required]
public ShopsDisabled Disabled { get; set; } = new ShopsDisabled();
public ShopsDisabled Enabled { get; set; } = new ShopsDisabled();
public sealed class ShopsDisabled : HashSet<string>
{
@ -68,12 +71,26 @@ namespace Props.Models.Search
return
Id == other.Id &&
Filters.Equals(other.Filters) &&
Disabled.Equals(other.Disabled);
Enabled.Equals(other.Enabled);
}
public override int GetHashCode()
{
return Id;
return HashCode.Combine(Id, Name);
}
public SearchOutline()
{
this.Name = "Default";
this.Filters = new Filters();
this.Enabled = new ShopsDisabled();
}
public SearchOutline(string name, Filters filters, ShopsDisabled disabled)
{
this.Name = name;
this.Filters = filters;
this.Enabled = disabled;
}
}
}

View File

@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Props.Models.Search;
using Props.Shared.Models.User;
@ -11,7 +12,8 @@ namespace Props.Models.User
{
public class ApplicationUser : IdentityUser
{
public virtual ISet<SearchOutline> SearchOutlines { get; set; }
[Required]
public virtual SearchOutlinePreferences searchOutlinePreferences { get; set; }
[Required]
public virtual ResultsPreferences ResultsPreferences { get; private set; }
@ -19,15 +21,16 @@ namespace Props.Models.User
[Required]
public virtual ApplicationPreferences ApplicationPreferences { get; private set; }
// TODO: Write project system.
public ApplicationUser()
{
searchOutlinePreferences = new SearchOutlinePreferences();
ResultsPreferences = new ResultsPreferences();
ApplicationPreferences = new ApplicationPreferences();
}
public ApplicationUser(ResultsPreferences resultsPreferences, ApplicationPreferences applicationPreferences)
public ApplicationUser(SearchOutlinePreferences searchOutlinePreferences, ResultsPreferences resultsPreferences, ApplicationPreferences applicationPreferences)
{
this.searchOutlinePreferences = searchOutlinePreferences;
this.ResultsPreferences = resultsPreferences;
this.ApplicationPreferences = applicationPreferences;
}

View File

@ -0,0 +1,37 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Props.Models.Search;
using Props.Models.User;
namespace Props.Models.User
{
public class SearchOutlinePreferences
{
public int Id { get; set; }
[Required]
public string ApplicationUserId { get; set; }
[Required]
public virtual ApplicationUser ApplicationUser { get; set; }
[Required]
public virtual ISet<SearchOutline> SearchOutlines { get; set; }
[Required]
public virtual SearchOutline ActiveSearchOutline { get; set; }
public SearchOutlinePreferences()
{
SearchOutlines = new HashSet<SearchOutline>();
ActiveSearchOutline = new SearchOutline();
SearchOutlines.Add(ActiveSearchOutline);
}
public SearchOutlinePreferences(ISet<SearchOutline> searchOutlines, SearchOutline activeSearchOutline)
{
this.SearchOutlines = searchOutlines;
this.ActiveSearchOutline = activeSearchOutline;
}
}
}

View File

@ -5,7 +5,6 @@ namespace Props.Options
public const string Modules = "Modules";
public string ShopsDir { get; set; }
public bool RecursiveLoad { get; set; }
public int MaxResults { get; set; }
public string ShopRegex { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace Props.Options
{
public class SearchOptions
{
public const string Search = "Search";
public int MaxResults { get; set; }
}
}

View File

@ -1,25 +1,32 @@
@page
@using Props.Services.Content
@model SearchModel
@inject IContentManager<SearchModel> ContentManager
@{
ViewData["Title"] = "Search";
ViewData["Specific"] = "Search";
}
<div>
<div class="my-4 less-concise mx-auto">
<div class="mt-4 mb-3">
<div class="less-concise mx-auto">
<div class="input-group">
<input type="text" class="form-control bg-transparent border-primary" placeholder="What are you looking for?" aria-label="Search" aria-describedby="search-btn" id="search-bar">
<button class="btn btn-outline-secondary" type="button" id="configuration-toggle" data-bs-toggle="collapse" data-bs-target="#configuration"><i class="bi bi-sliders"></i></button>
<button class="btn btn-outline-primary" type="button" id="search-btn">Search</button>
<input type="text" class="form-control border-primary" placeholder="What are you looking for?"
aria-label="Search" aria-describedby="search-btn" id="search-bar" value="@Model.SearchQuery">
<button class="btn btn-outline-secondary" type="button" id="configuration-toggle" data-bs-toggle="collapse"
data-bs-target="#configuration"><i class="bi bi-sliders"></i></button>
<button class="btn btn-primary" type="button" id="search-btn">Search</button>
</div>
</div>
</div>
<div class="collapse tear" id="configuration">
<div class="collapse tear" id="configuration" x-data="configuration">
<div class="p-3">
<div class="container invisible">
<div class="container">
<div class="d-flex">
<h1 class="my-2 display-2 me-auto">Configuration</h1>
<button class="btn align-self-start" type="button" id="configuration-close" data-bs-toggle="collapse" data-bs-target="#configuration"><i class="bi bi-x-lg"></i></button>
<button class="btn align-self-start" type="button" id="configuration-close" data-bs-toggle="collapse"
data-bs-target="#configuration"><i class="bi bi-x-lg"></i></button>
</div>
<div class="row justify-content-md-center">
<section class="col-lg px-4">
@ -28,10 +35,11 @@
<label for="max-price" class="form-label">Maximum Price</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input mt-0" type="checkbox" id="max-price-enabled">
<input class="form-check-input mt-0" type="checkbox" id="max-price-enabled"
x-model="maxPriceEnabled">
</div>
<span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="max-price">
<input type="number" class="form-control" min="0" id="max-price" x-model="maxPrice">
<span class="input-group-text">.00</span>
</div>
</div>
@ -39,7 +47,7 @@
<label for="min-price" class="form-label">Minimum Price</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="min-price">
<input type="number" class="form-control" min="0" id="min-price" x-model="minPrice">
<span class="input-group-text">.00</span>
</div>
</div>
@ -50,13 +58,14 @@
<input class="form-check-input mt-0" type="checkbox" id="max-shipping-enabled">
</div>
<span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="max-shipping">
<input type="number" class="form-control" min="0" id="max-shipping" x-model="maxShipping">
<span class="input-group-text">.00</span>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-shipping">
<input class="form-check-input" type="checkbox" id="keep-unknown-shipping"
x-model="keepUnknownShipping">
<label class="form-check-label" for="keep-unknown-shipping">Keep Unknown Shipping</label>
</div>
</div>
@ -66,37 +75,41 @@
<div class="mb-3">
<label for="min-purchases" class="form-label">Minimum Purchases</label>
<div class="input-group">
<input type="number" class="form-control" min="0" id="min-purchases">
<input type="number" class="form-control" min="0" id="min-purchases" x-model="minPurchases">
<span class="input-group-text">Purchases</span>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-purchases">
<input class="form-check-input" type="checkbox" id="keep-unknown-purchases"
x-model="keepUnknownPurchases">
<label class="form-check-label" for="keep-unknown-purchases">Keep Unknown Purchases</label>
</div>
</div>
<div class="mb-3">
<label for="min-reviews" class="form-label">Minimum Reviews</label>
<div class="input-group">
<input type="number" class="form-control" min="0" id="min-reviews">
<input type="number" class="form-control" min="0" id="min-reviews" x-model="minReviews">
<span class="input-group-text">Reviews</span>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-reviews">
<label class="form-check-label" for="keep-unknown-reviews">Keep Unknown Number of Reviews</label>
<input class="form-check-input" type="checkbox" id="keep-unknown-reviews"
x-model="keepUnknownReviews">
<label class="form-check-label" for="keep-unknown-reviews">Keep Unknown Number of
Reviews</label>
</div>
</div>
<div class="mb-1">
<label for="min-rating" class="form-label">Minimum Rating</label>
<input type="range" class="form-range" id="min-rating" min="0" max="100" step="1">
<div id="min-rating-display" class="form-text"></div>
<input type="range" class="form-range" id="min-rating" min="0" max="100" step="1"
x-model="minRating">
<div id="min-rating-display" class="form-text">Minimum rating: <b x-text="minRating"></b>%</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unrated">
<input class="form-check-input" type="checkbox" id="keep-unrated" x-model="keepUnrated">
<label class="form-check-label" for="keep-unrated">Keep Unrated Items</label>
</div>
</div>
@ -104,6 +117,14 @@
<section class="col-lg px-4">
<h3>Shops Enabled</h3>
<div class="mb-3 px-3" id="shop-checkboxes">
<template x-for="shop in Object.keys(shops)">
<div class="form-check">
<input class="form-check-input" type="checkbox" :id="`${shop}-enabled`"
x-model="shops[shop]">
<label class="form-check-label" :for="`${shop}-enabled`"><span
x-text="shop"></span></label>
</div>
</template>
</div>
</section>
</div>
@ -111,4 +132,111 @@
</div>
</div>
@* TODO: Add results display and default results display *@
<div id="content-pages" class="multipage mt-3 invisible">
<ul class="nav nav-pills selectors">
<li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#quick-picks-slide"><i
class="bi bi-stopwatch"></i></button>
</li>
<li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#results-slide"><i
class="bi bi-view-list"></i></button>
</li>
<li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#info-slide"><i
class="bi bi-info-lg"></i></button>
</li>
</ul>
<div class="multipage-slides tab-content">
<div class="multipage-slide tab-pane fade" id="quick-picks-slide">
<div class="multipage-title">
<h1 class="display-2"><i class="bi bi-stopwatch"></i> Quick Picks</h1>
@if (Model.SearchResults != null)
{
<p>@ContentManager.Json.quickPicks.searched</p>
}
else
{
<p>@ContentManager.Json.quickPicks.prompt</p>
}
<hr class="less-concise">
</div>
<div class="multipage-content">
@if (Model.SearchResults != null)
{
@if (Model.BestRatingPriceRatio != null)
{
<p>We found this product to have the best rating to price ratio.</p>
}
@if (Model.TopRated != null)
{
<p>This listing was the one that had the highest rating.</p>
}
@if (Model.MostPurchases != null)
{
<p>This listing has the most purchases.</p>
}
@if (Model.MostReviews != null)
{
<p>This listing had the most reviews.</p>
}
@if (Model.BestPrice != null)
{
<p>Looking for the lowest price? Well here it is.</p>
}
@* TODO: Add display for top results. *@
}
else
{
<div class="text-center less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column">
<h2>@ContentManager.Json.notSearched</h2>
</div>
}
</div>
</div>
<div class="multipage-slide tab-pane fade" id="results-slide" x-data>
<div class="multipage-title">
<h2><i class="bi bi-view-list"></i> Results</h2>
@if (Model.SearchResults != null)
{
<p>@ContentManager.Json.results.searched</p>
}
else
{
<p>@ContentManager.Json.results.prompt</p>
}
<hr class="less-concise">
</div>
<div class="multipage-content">
@if (Model.SearchResults != null)
{
@* TODO: Display results with UI for sorting and changing views. *@
}
else
{
<div class="text-center less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column">
<h2>@ContentManager.Json.notSearched</h2>
</div>
}
</div>
</div>
<div class="multipage-slide tab-pane fade" id="info-slide">
<div class="multipage-content">
<div class="less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column">
<h1 class="display-3"><i class="bi bi-info-circle"></i> Get Started!</h1>
<ol>
@foreach (string instruction in ContentManager.Json.instructions)
{
<li>@instruction</li>
}
</ol>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,9 +1,54 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Castle.Core.Internal;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Props.Data;
using Props.Extensions;
using Props.Models.Search;
using Props.Models.User;
using Props.Services.Modules;
using Props.Shop.Framework;
namespace Props.Pages
{
public class SearchModel : PageModel
{
// TODO: Complete the search model.
[BindProperty(Name = "q", SupportsGet = true)]
public string SearchQuery { get; set; }
public IEnumerable<ProductListing> SearchResults { get; private set; }
public ProductListing BestRatingPriceRatio { get; private set; }
public ProductListing TopRated { get; private set; }
public ProductListing MostPurchases { get; private set; }
public ProductListing MostReviews { get; private set; }
public ProductListing BestPrice { get; private set; }
private ISearchManager searchManager;
private UserManager<ApplicationUser> userManager;
private IMetricsManager analytics;
public SearchModel(ISearchManager searchManager, UserManager<ApplicationUser> userManager, IMetricsManager analyticsManager)
{
this.searchManager = searchManager;
this.userManager = userManager;
this.analytics = analyticsManager;
}
public async Task OnGet()
{
if (string.IsNullOrWhiteSpace(SearchQuery)) return;
SearchOutline activeSearchOutline = User.Identity.IsAuthenticated ? (await userManager.GetUserAsync(User)).searchOutlinePreferences.ActiveSearchOutline : new SearchOutline();
this.SearchResults = searchManager.Search(SearchQuery, activeSearchOutline);
BestRatingPriceRatio = (from result in SearchResults orderby result.GetRatingToPriceRatio() descending select result).FirstOrDefault((listing) => listing.GetRatingToPriceRatio() >= 0.5f);
TopRated = (from result in SearchResults orderby result.Rating descending select result).FirstOrDefault();
MostPurchases = (from result in SearchResults orderby result.PurchaseCount descending select result).FirstOrDefault();
MostReviews = (from result in SearchResults orderby result.ReviewCount descending select result).FirstOrDefault();
BestPrice = (from result in SearchResults orderby result.UpperPrice descending select result).FirstOrDefault();
}
}
}

View File

@ -11,6 +11,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Props</title>
<script src="~/js/site.js" asp-append-version="true"></script>
@if (!string.IsNullOrEmpty((ViewData["Specific"] as string)))
{
<script defer src="@($"~/js/specific/{(ViewData["Specific"])}.js")" asp-append-version="true"></script>
}
</head>
<body class="theme-light">
@ -34,10 +38,12 @@
@if (SignInManager.IsSignedIn(User))
{
<li class="nav-item">
<nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Manage/Index" title="Manage">Hello @User.Identity.Name!</nav-link>
<nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Manage/Index"
title="Manage">Hello @User.Identity.Name!</nav-link>
</li>
<li class="nav-item">
<form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post">
<form class="form-inline" asp-area="Identity" asp-page="/Account/Logout"
asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post">
<button type="submit" class="nav-link btn btn-link">Logout</button>
</form>
</li>
@ -45,7 +51,8 @@
else
{
<li class="nav-item">
<nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Register">Register</nav-link>
<nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Register">Register
</nav-link>
</li>
<li class="nav-item">
<nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Login">Login</nav-link>
@ -62,11 +69,6 @@
<footer id="footer">
&copy; 2021 - Props - <a asp-area="" asp-page="/Privacy">Privacy</a>
</footer>
@if (!string.IsNullOrEmpty((ViewData["Specific"] as string)))
{
<script src="@($"~/js/specific/{(ViewData["Specific"])}.js")" asp-append-version="true"></script>
}
@await RenderSectionAsync("Scripts", required: false)
</body>

View File

@ -35,6 +35,9 @@
<Content Include=".\content\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include=".\shops\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>

View File

@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Props.Models.Search;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public interface IMetricsManager
{
public IEnumerable<ProductListingInfo> RetrieveTopListings(int max = 10);
public IEnumerable<string> RetrieveCommonKeywords(int max = 50);
public void RegisterSearchQuery(string query);
public void RegisterListing(ProductListing productListing, string shopName);
}
}

View File

@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Props.Models.Search;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public interface ISearchManager
{
public IEnumerable<ProductListing> Search(string query, SearchOutline searchOutline);
}
}

View File

@ -1,3 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
@ -6,9 +7,10 @@ using Props.Shop.Framework;
namespace Props.Services.Modules
{
public interface IShopManager
public interface IShopManager : IDisposable
{
public IEnumerable<string> AvailableShops();
public Task<IList<ProductListing>> Search(string query, SearchOutline searchOutline);
public IEnumerable<string> GetAllShopNames();
public IShop GetShop(string name);
public IEnumerable<IShop> GetAllShops();
}
}

View File

@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Props.Data;
using Props.Models.Search;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public class LiveMetricsManager : IMetricsManager
{
private ILogger<LiveMetricsManager> logger;
ApplicationDbContext dbContext;
public LiveMetricsManager(ApplicationDbContext dbContext, ILogger<LiveMetricsManager> logger)
{
this.logger = logger;
this.dbContext = dbContext;
}
public IEnumerable<ProductListingInfo> RetrieveTopListings(int max)
{
if (dbContext.ProductListingInfos == null) return null;
return (from l in dbContext.ProductListingInfos
orderby l.Hits descending
select l).Take(max);
}
public IEnumerable<string> RetrieveCommonKeywords(int max)
{
if (dbContext.Keywords == null) return null;
return (from k in dbContext.Keywords
orderby k.Hits descending
select k.Word).Take(max);
}
public void RegisterSearchQuery(string query)
{
query = query.ToLower();
string[] tokens = query.Split(' ');
QueryWordInfo[] wordInfos = new QueryWordInfo[tokens.Length];
for (int wordIndex = 0; wordIndex < tokens.Length; wordIndex++)
{
QueryWordInfo queryWordInfo = dbContext.Keywords.Where((k) => k.Word.ToLower().Equals(tokens[wordIndex])).SingleOrDefault() ?? new QueryWordInfo();
if (queryWordInfo.Hits == 0)
{
queryWordInfo.Word = tokens[wordIndex];
dbContext.Keywords.Add(queryWordInfo);
}
queryWordInfo.Hits += 1;
wordInfos[wordIndex] = queryWordInfo;
}
for (int wordIndex = 0; wordIndex < tokens.Length; wordIndex++)
{
for (int beforeIndex = 0; beforeIndex < wordIndex; beforeIndex++)
{
wordInfos[wordIndex].Preceding.Add(wordInfos[beforeIndex]);
}
for (int afterIndex = wordIndex; afterIndex < tokens.Length; afterIndex++)
{
wordInfos[wordIndex].Following.Add(wordInfos[afterIndex]);
}
}
dbContext.SaveChanges();
}
public void RegisterListing(ProductListing productListing, string shopName)
{
ProductListingInfo productListingInfo =
(from info in dbContext.ProductListingInfos
where info.ProductUrl.Equals(productListing.URL)
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.Hits += 1;
dbContext.SaveChanges();
}
}
}

View File

@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Castle.Core.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Props.Models.Search;
using Props.Options;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public class LiveSearchManager : ISearchManager
{
private ILogger<LiveSearchManager> logger;
private SearchOptions searchOptions;
private IShopManager shopManager;
private IMetricsManager metricsManager;
public LiveSearchManager(IMetricsManager metricsManager, IShopManager shopManager, IConfiguration configuration, ILogger<LiveSearchManager> logger)
{
this.logger = logger;
this.metricsManager = metricsManager;
this.shopManager = shopManager;
this.searchOptions = configuration.GetSection(SearchOptions.Search).Get<SearchOptions>();
}
public IEnumerable<ProductListing> Search(string query, SearchOutline searchOutline)
{
if (string.IsNullOrWhiteSpace(query)) throw new ArgumentException($"Query \"{query}\" is null or whitepsace.");
if (searchOutline == null) throw new ArgumentNullException("searchOutline");
List<ProductListing> results = new List<ProductListing>();
metricsManager.RegisterSearchQuery(query);
logger.LogDebug("Searching for \"{0}\".", query);
foreach (string shopName in shopManager.GetAllShopNames())
{
if (searchOutline.Enabled[shopName])
{
logger.LogDebug("Checking \"{0}\".", shopName);
int amount = 0;
foreach (ProductListing product in shopManager.GetShop(shopName).Search(query, searchOutline.Filters))
{
if (searchOutline.Filters.Validate(product))
{
amount += 1;
metricsManager.RegisterListing(product, shopName);
results.Add(product);
}
if (amount >= searchOptions.MaxResults) break;
}
logger.LogDebug("Found {0} listings that satisfy the search filters from {1}.", amount, shopName);
}
}
return results;
}
}
}

View File

@ -1,24 +1,30 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Props.Data;
using Props.Models.Search;
using Props.Options;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public class LocalShopManager : IShopManager
public class ModularShopManager : IShopManager
{
private ILogger<LocalShopManager> logger;
private ILogger<ModularShopManager> logger;
private Dictionary<string, IShop> shops;
private ModulesOptions options;
private IConfiguration configuration;
public LocalShopManager(IConfiguration configuration, ILogger<LocalShopManager> logger)
private bool disposedValue;
public ModularShopManager(IConfiguration configuration, ILogger<ModularShopManager> logger)
{
this.configuration = configuration;
this.logger = logger;
@ -34,35 +40,25 @@ namespace Props.Services.Modules
}
}
public IEnumerable<string> AvailableShops()
public IEnumerable<string> GetAllShopNames()
{
return shops.Keys;
}
public async Task<IList<ProductListing>> Search(string query, SearchOutline searchOutline)
public IShop GetShop(string name)
{
List<ProductListing> results = new List<ProductListing>();
foreach (string shopName in shops.Keys)
{
if (!searchOutline.Disabled[shopName])
{
int amount = 0;
await foreach (ProductListing product in shops[shopName].Search(query, searchOutline.Filters))
{
if (searchOutline.Filters.Validate(product))
{
amount += 1;
results.Add(product);
}
if (amount >= options.MaxResults) break;
}
}
}
return results;
return shops[name];
}
public IEnumerable<IShop> GetAllShops()
{
return shops.Values;
}
private IEnumerable<IShop> LoadShops(string shopsDir, string shopRegex, bool recursiveLoad)
{
Stack<Task> asyncInitTasks = new Stack<Task>();
Stack<string> directories = new Stack<string>();
directories.Push(shopsDir);
string currentDirectory = null;
@ -90,7 +86,11 @@ namespace Props.Services.Modules
IShop shop = Activator.CreateInstance(type) as IShop;
if (shop != null)
{
// TODO: load persisted shop data.
shop.Initialize(null);
asyncInitTasks.Push(shop.InitializeAsync(null));
success += 1;
logger.LogDebug("Loaded \"{0}\".", shop.ShopName);
yield return shop;
}
}
@ -102,6 +102,32 @@ namespace Props.Services.Modules
}
}
}
logger.LogDebug("Waiting for all shops to finish asynchronous initialization.");
Task.WaitAll(asyncInitTasks.ToArray());
logger.LogDebug("All shops finished asynchronous initialization.");
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
foreach (string shopName in shops.Keys)
{
// TODO: Get shop data to persist.
shops[shopName].Dispose();
}
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -55,7 +55,9 @@ namespace Props
services.AddRazorPages();
services.AddSingleton(typeof(IContentManager<>), typeof(CachedContentManager<>));
services.AddSingleton<IShopManager, LocalShopManager>();
services.AddSingleton<IShopManager, ModularShopManager>();
services.AddScoped<IMetricsManager, LiveMetricsManager>();
services.AddScoped<ISearchManager, LiveSearchManager>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.

View File

@ -2,16 +2,9 @@
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Default": "Debug",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"webOptimizer": {
"enableCaching": false,
"enableMemoryCache": false,
"enableDiskCache": false,
"enableTagHelperBundling": false,
"allowEmptyBundle": false
}
}

View File

@ -12,9 +12,11 @@
"Modules": {
"ShopsDir": "./shops",
"RecursiveLoad": "false",
"MaxResults": "100",
"ShopRegex": "Props\\.Shop\\.."
},
"Search": {
"MaxResults": 100
},
"Content": {
"Dir": "./content"
},

View File

@ -1,117 +1,76 @@
import Alpine from "alpinejs";
import { apiHttp } from "~/assets/js/services/http.js";
// All input fields.
let inputs = {
maxPriceEnabled: document.getElementById("max-price-enabled"),
maxShippingEnabled: document.getElementById("max-shipping-enabled"),
minRating: document.getElementById("min-rating"),
maxPrice: document.getElementById("max-price"),
maxShipping: document.getElementById("max-shipping"),
keepUnknownPurchases: document.getElementById("keep-unknown-purchases"),
keepUnknownReviews: document.getElementById("keep-unknown-reviews"),
keepUnknownShipping: document.getElementById("keep-unknown-shipping"),
keepUnrated: document.getElementById("keep-unrated"),
minPrice: document.getElementById("min-price"),
minPurchases: document.getElementById("min-purchases"),
minReviews: document.getElementById("min-reviews"),
shopToggles: {}
};
const startingSlide = "#quick-picks-slide";
async function main() {
setupInteractiveBehavior();
await setupInitialValues((await apiHttp.get("/Search/Default/Filters")).data);
await setupShopToggles((await apiHttp.get("/Search/Shops/Available")).data);
document.querySelector("#configuration .invisible").classList.remove("invisible"); // Load completed, show the UI.
}
function setupInteractiveBehavior() {
let configurationElem = document.getElementById("configuration");
function initInteractiveElements() {
let configurationToggle = document.getElementById("configuration-toggle");
let configurationElem = document.getElementById("configuration");
configurationElem.addEventListener("show.bs.collapse", function () {
configurationToggle.classList.add("active");
});
configurationElem.addEventListener("hidden.bs.collapse", function () {
configurationToggle.classList.remove("active");
});
}
inputs.maxPriceEnabled.addEventListener("change", function () {
inputs.maxPrice.disabled = !this.checked;
});
inputs.maxShippingEnabled.addEventListener("change", function () {
inputs.maxShipping.disabled = !this.checked;
});
inputs.minRating.addEventListener("input", function () {
document.getElementById("min-rating-display").innerHTML = `Minimum rating: ${this.value}%`;
async function initConfigurationData() {
const givenConfig = (await apiHttp.get("/SearchOutline/Filters")).data;
const disabledShops = (await apiHttp.get("/SearchOutline/DisabledShops")).data;
const availableShops = (await apiHttp.get("/Search/Available")).data;
document.addEventListener("alpine:init", () => {
Alpine.data("configuration", () => {
const configuration = {
maxPriceEnabled: givenConfig.enableUpperPrice,
maxPrice: givenConfig.upperPrice,
minPrice: givenConfig.lowerPrice,
maxShippingEnabled: givenConfig.enableMaxShippingFee,
maxShipping: givenConfig.maxShippingFee,
keepUnknownShipping: givenConfig.keepUnknownShipping,
minRating: givenConfig.minRating * 100,
keepUnrated: givenConfig.keepUnrated,
minReviews: givenConfig.minReviews,
keepUnknownReviews: givenConfig.keepUnknownReviewCount,
keepUnknownPurchases: givenConfig.keepUnknownPurchaseCount,
minPurchases: givenConfig.minPurchases,
shops: {},
};
availableShops.forEach(shop => {
configuration.shops[shop] = !disabledShops.includes(shop);
});
return configuration;
});
});
}
async function setupInitialValues(filters) {
inputs.maxShippingEnabled.checked = filters.enableMaxShippingFee;
inputs.maxShippingEnabled.dispatchEvent(new Event("change"));
function initSlides() {
document.querySelectorAll("#content-pages > .selectors > .nav-item > button").forEach(tabElem => {
tabElem.addEventListener("click", () => {
const destUrl = new URL(tabElem.getAttribute("data-bs-target"), window.location.href);
if (location.href === destUrl.href) return;
history.pushState({}, document.title, destUrl);
});
});
const goTo = () => {
const match = location.href.match("(#[\\w-]+)");
const idAnchor = match && match[1] ? match[1] : startingSlide;
document.querySelector("#content-pages > .selectors > .nav-item > .active")?.classList.remove("active");
document.querySelector("#content-pages > .multipage-slides > .active.show")?.classList.remove("active", "show");
document.querySelector(`#content-pages > .selectors > .nav-item > [data-bs-target="${idAnchor}"]`).classList.add("active");
document.querySelector(`#content-pages > .multipage-slides > ${idAnchor}`).classList.add("active", "show");
};
window.addEventListener("popstate", goTo);
goTo();
inputs.maxPriceEnabled.checked = filters.enableUpperPrice;
inputs.maxPriceEnabled.dispatchEvent(new Event("change"));
inputs.keepUnknownPurchases.checked = filters.keepUnknownPurchaseCount;
inputs.keepUnknownPurchases.dispatchEvent(new Event("change"));
inputs.keepUnknownReviews.checked = filters.keepUnknownReviewCount;
inputs.keepUnknownReviews.dispatchEvent(new Event("change"));
inputs.keepUnknownShipping.checked = filters.keepUnknownShipping;
inputs.keepUnknownShipping.dispatchEvent(new Event("change"));
inputs.keepUnrated.checked = filters.keepUnrated;
inputs.keepUnrated.dispatchEvent(new Event("change"));
inputs.minPrice.value = filters.lowerPrice;
inputs.minPrice.dispatchEvent(new Event("change"));
inputs.maxShipping.value = filters.maxShippingFee;
inputs.maxShipping.dispatchEvent(new Event("change"));
inputs.minPurchases.value = filters.minPurchases;
inputs.minPurchases.dispatchEvent(new Event("change"));
inputs.minRating.value = filters.minRating * 100;
inputs.minRating.dispatchEvent(new Event("input"));
inputs.minReviews.value = filters.minReviews;
inputs.minReviews.dispatchEvent(new Event("change"));
inputs.maxPrice.value = filters.upperPrice;
inputs.maxPrice.dispatchEvent(new Event("change"));
require("bootstrap/js/dist/tab.js");
document.querySelector("#content-pages").classList.remove("invisible");
}
async function setupShopToggles(availableShops) {
let disabledShops = (await apiHttp.get("/Search/Default/DisabledShops")).data;
let shopsElem = document.getElementById("shop-checkboxes");
availableShops.forEach(shopName => {
let id = `${shopName}-enabled`;
let shopLabelElem = document.createElement("label");
shopLabelElem.classList.add("form-check-label");
shopLabelElem.htmlFor = id;
shopLabelElem.innerHTML = `Enable ${shopName}`;
let shopCheckboxElem = document.createElement("input");
shopCheckboxElem.classList.add("form-check-input");
shopCheckboxElem.type = "checkbox";
shopCheckboxElem.id = id;
shopCheckboxElem.checked = !disabledShops.includes(shopName);
inputs.shopToggles[shopName] = shopCheckboxElem;
let shopToggleElem = document.createElement("div");
shopToggleElem.classList.add("form-check");
shopToggleElem.appendChild(shopCheckboxElem);
shopToggleElem.appendChild(shopLabelElem);
shopsElem.appendChild(shopToggleElem);
});
async function main() {
initInteractiveElements();
await initConfigurationData();
initSlides();
Alpine.start();
}
main();
// TODO: Implement search.

View File

@ -17,10 +17,11 @@ header > nav {
&.active {
@include themer.themed {
color: themer.color-of("navbar-active");
border-color: themer.color-of("navbar-active");
border-bottom-color: themer.color-of("navbar-active");
}
padding-bottom: 2px;
border-bottom-style: solid;
border-bottom-width: 1px;
border-bottom-width: 2px;
}
}
@ -82,26 +83,32 @@ footer {
}
.concise {
@extend .container;
max-width: 630px;
width: inherit;
}
.less-concise {
@extend .container;
max-width: 720px;
width: inherit;
}
hr.concise {
@extend .my-3;
@include themer.themed {
color: themer.color-of("special");
hr {
&.concise {
@extend .my-2;
width: 15%;
max-width: 160px;
min-width: 32px;
height: 2px;
}
&.less-concise {
@extend .my-2;
width: 30%;
max-width: 270px;
min-width: 32px;
height: 2px;
}
width: 15%;
max-width: 160px;
min-width: 32px;
margin-left: auto;
margin-right: auto;
height: 2px;
}
html {
@ -119,3 +126,61 @@ body {
color: themer.color-of("text");
}
}
.text-muted {
@include themer.themed {
color: themer.color-of("muted") !important;
}
}
.multipage {
display: flex;
flex-direction: column;
flex-grow: 1;
.multipage-slides, .multipage-slides > .multipage-slide.active {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.multipage-slide {
.multipage-content {
@extend .container;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.multipage-title {
@extend .less-concise;
text-align: center;
}
}
.nav-pills.selectors {
display: flex;
justify-content: center;
margin-bottom: 1rem;
button[type="button"] {
margin-right: 5px;
margin-left: 5px;
opacity: 0.4;
border-style: none;
font-size: 1.5rem;
min-width: 30px;
width: auto;
height: auto;
background-color: transparent;
background-clip: border-box;
@include themer.themed {
border-bottom: 2px solid themer.color-of("text");
}
border-bottom-style: none;
&.active {
opacity: 1;
}
}
}
}

View File

@ -1349,6 +1349,19 @@
"integrity": "sha512-8h7k1YgQKxKXWckzFCMfsIwn0Y61UK6tlD6y2lOb3hTOIMlK3t9/QwHOhc81TwU+RMf0As5fj7NPjroERCnejQ==",
"dev": true
},
"@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"requires": {
"@vue/shared": "3.1.5"
}
},
"@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="
},
"@webassemblyjs/ast": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@ -1558,6 +1571,14 @@
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true
},
"alpinejs": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.2.2.tgz",
"integrity": "sha512-XkQKikB4tJTLIQuRUeF86CZnvmAKhjGzw5lmMri+7MTQzz77DTetuOqldBWjEgdJ/DOExXuiM57rQwWfBPdMPA==",
"requires": {
"@vue/reactivity": "^3.0.2"
}
},
"ansi-colors": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
@ -2411,14 +2432,6 @@
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz",
"integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg=="
},
"framesync": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/framesync/-/framesync-5.3.0.tgz",
"integrity": "sha512-oc5m68HDO/tuK2blj7ZcdEBRx3p1PjrgHazL8GYEpvULhrtGIFbQArN6cQS2QhW8mitffaB+VYzMjDqBxxQeoA==",
"requires": {
"tslib": "^2.1.0"
}
},
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -2554,11 +2567,6 @@
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
"dev": true
},
"hey-listen": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="
},
"human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@ -3125,17 +3133,6 @@
"find-up": "^4.0.0"
}
},
"popmotion": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.4.0.tgz",
"integrity": "sha512-FGnHjc8iDMrAwuZhka8eNx0yzcaufDqyZzW9vjJebRuC6BryR5ICyBmUH+wCgUuuaFSSU4r6oT2WtnbnDGcr3g==",
"requires": {
"framesync": "5.3.0",
"hey-listen": "^1.0.8",
"style-value-types": "4.1.4",
"tslib": "^2.1.0"
}
},
"postcss": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.3.6.tgz",
@ -3588,15 +3585,6 @@
"integrity": "sha512-1k9ZosJCRFaRbY6hH49JFlRB0fVSbmnyq1iTPjNxUmGVjBNEmwrrHPenhlp+Lgo51BojHSf6pl2FcqYaN3PfVg==",
"dev": true
},
"style-value-types": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-4.1.4.tgz",
"integrity": "sha512-LCJL6tB+vPSUoxgUBt9juXIlNJHtBMy8jkXzUJSBzeHWdBu6lhzHqCvLVkXFGsFIlNa2ln1sQHya/gzaFmB2Lg==",
"requires": {
"hey-listen": "^1.0.8",
"tslib": "^2.1.0"
}
},
"supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@ -3700,11 +3688,6 @@
"is-number": "^7.0.0"
}
},
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -30,10 +30,10 @@
},
"dependencies": {
"@babel/runtime": "^7.14.8",
"alpinejs": "^3.2.2",
"axios": "^0.21.1",
"bootstrap": "^5.0.2",
"bootstrap-icons": "^1.5.0",
"popmotion": "^9.4.0",
"simplebar": "^5.3.5"
}
}

View File

@ -0,0 +1,84 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v5.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v5.0": {
"Props.Shop.Adafruit/1.0.0": {
"dependencies": {
"FuzzySharp": "2.0.2",
"Newtonsoft.Json": "13.0.1",
"Props.Shop.Framework": "1.0.0",
"System.Linq.Async": "5.0.0"
},
"runtime": {
"Props.Shop.Adafruit.dll": {}
}
},
"FuzzySharp/2.0.2": {
"runtime": {
"lib/netcoreapp2.1/FuzzySharp.dll": {
"assemblyVersion": "1.0.4.0",
"fileVersion": "1.0.4.0"
}
}
},
"Newtonsoft.Json/13.0.1": {
"runtime": {
"lib/netstandard2.0/Newtonsoft.Json.dll": {
"assemblyVersion": "13.0.0.0",
"fileVersion": "13.0.1.25517"
}
}
},
"System.Linq.Async/5.0.0": {
"runtime": {
"lib/netcoreapp3.1/System.Linq.Async.dll": {
"assemblyVersion": "5.0.0.0",
"fileVersion": "5.0.0.1"
}
}
},
"Props.Shop.Framework/1.0.0": {
"runtime": {
"Props.Shop.Framework.dll": {}
}
}
}
},
"libraries": {
"Props.Shop.Adafruit/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"FuzzySharp/2.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-sBKqWxw3g//peYxDZ8JipRlyPbIyBtgzqBVA5GqwHVeqtIrw75maGXAllztf+1aJhchD+drcQIgf2mFho8ZV8A==",
"path": "fuzzysharp/2.0.2",
"hashPath": "fuzzysharp.2.0.2.nupkg.sha512"
},
"Newtonsoft.Json/13.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==",
"path": "newtonsoft.json/13.0.1",
"hashPath": "newtonsoft.json.13.0.1.nupkg.sha512"
},
"System.Linq.Async/5.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-cPtIuuH8TIjVHSi2ewwReWGW1PfChPE0LxPIDlfwVcLuTM9GANFTXiMB7k3aC4sk3f0cQU25LNKzx+jZMxijqw==",
"path": "system.linq.async/5.0.0",
"hashPath": "system.linq.async.5.0.0.nupkg.sha512"
},
"Props.Shop.Framework/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
}
}
}

Binary file not shown.