Made progress on implementing some shops.
Performed some folder restructuring as well.
							
								
								
									
										27
									
								
								Props-Modules/.vscode/launch.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,27 @@ | ||||
| { | ||||
|     "version": "0.2.0", | ||||
|     "configurations": [ | ||||
|         { | ||||
|             // Use IntelliSense to find out which attributes exist for C# debugging | ||||
|             // Use hover for the description of the existing attributes | ||||
|             // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md | ||||
|             "name": ".NET Core Launch (console)", | ||||
|             "type": "coreclr", | ||||
|             "request": "launch", | ||||
|             "preLaunchTask": "build", | ||||
|             // If you have changed target frameworks, make sure to update the program path. | ||||
|             "program": "${workspaceFolder}/test/Props.Shop/Adafruit.Tests/bin/Debug/net5.0/Props.Shop.Adafruit.Tests.dll", | ||||
|             "args": [], | ||||
|             "cwd": "${workspaceFolder}/test/Props.Shop/Adafruit.Tests", | ||||
|             // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console | ||||
|             "console": "internalConsole", | ||||
|             "stopAtEntry": false | ||||
|         }, | ||||
|         { | ||||
|             "name": ".NET Core Attach", | ||||
|             "type": "coreclr", | ||||
|             "request": "attach", | ||||
|             "processId": "${command:pickProcess}" | ||||
|         } | ||||
|     ] | ||||
| } | ||||
							
								
								
									
										42
									
								
								Props-Modules/.vscode/tasks.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,42 @@ | ||||
| { | ||||
|     "version": "2.0.0", | ||||
|     "tasks": [ | ||||
|         { | ||||
|             "label": "build", | ||||
|             "command": "dotnet", | ||||
|             "type": "process", | ||||
|             "args": [ | ||||
|                 "build", | ||||
|                 "${workspaceFolder}/test/Props.Shop/Adafruit.Tests/Props.Shop.Adafruit.Tests.csproj", | ||||
|                 "/property:GenerateFullPaths=true", | ||||
|                 "/consoleloggerparameters:NoSummary" | ||||
|             ], | ||||
|             "problemMatcher": "$msCompile" | ||||
|         }, | ||||
|         { | ||||
|             "label": "publish", | ||||
|             "command": "dotnet", | ||||
|             "type": "process", | ||||
|             "args": [ | ||||
|                 "publish", | ||||
|                 "${workspaceFolder}/test/Props.Shop/Adafruit.Tests/Props.Shop.Adafruit.Tests.csproj", | ||||
|                 "/property:GenerateFullPaths=true", | ||||
|                 "/consoleloggerparameters:NoSummary" | ||||
|             ], | ||||
|             "problemMatcher": "$msCompile" | ||||
|         }, | ||||
|         { | ||||
|             "label": "watch", | ||||
|             "command": "dotnet", | ||||
|             "type": "process", | ||||
|             "args": [ | ||||
|                 "watch", | ||||
|                 "run", | ||||
|                 "${workspaceFolder}/test/Props.Shop/Adafruit.Tests/Props.Shop.Adafruit.Tests.csproj", | ||||
|                 "/property:GenerateFullPaths=true", | ||||
|                 "/consoleloggerparameters:NoSummary" | ||||
|             ], | ||||
|             "problemMatcher": "$msCompile" | ||||
|         } | ||||
|     ] | ||||
| } | ||||
							
								
								
									
										68
									
								
								Props-Modules/Props.Shop/Adafruit/AdafruitShop.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,68 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Net.Http; | ||||
| using System.Text.Json; | ||||
| using Props.Shop.Adafruit.Api; | ||||
| using Props.Shop.Framework; | ||||
|  | ||||
| namespace Props.Shop.Adafruit | ||||
| { | ||||
|     public class AdafruitShop : IShop | ||||
|     { | ||||
|         private ProductListingManager productListingManager; | ||||
|         private Configuration configuration; | ||||
|         private HttpClient http; | ||||
|         private bool disposedValue; | ||||
|  | ||||
|         public string ShopName => "Adafruit"; | ||||
|  | ||||
|         public string ShopDescription => "A electronic component online hardware company."; | ||||
|  | ||||
|         public string ShopModuleAuthor => "Reslate"; | ||||
|  | ||||
|         public SupportedFeatures SupportedFeatures => new SupportedFeatures( | ||||
|             false, | ||||
|             false, | ||||
|             false, | ||||
|             false, | ||||
|             true | ||||
|         ); | ||||
|  | ||||
|         public byte[] GetDataForPersistence() | ||||
|         { | ||||
|             return JsonSerializer.SerializeToUtf8Bytes(configuration); | ||||
|         } | ||||
|  | ||||
|         public IEnumerable<IOption> Initialize(byte[] data) | ||||
|         { | ||||
|             http = new HttpClient(); | ||||
|             http.BaseAddress = new Uri("http://www.adafruit.com/api"); | ||||
|             configuration = JsonSerializer.Deserialize<Configuration>(data); | ||||
|             this.productListingManager = new ProductListingManager(); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         public IAsyncEnumerable<ProductListing> Search(string query, Filters filters) | ||||
|         { | ||||
|             return productListingManager.Search(query, configuration.Similarity, http); | ||||
|         } | ||||
|  | ||||
|         protected virtual void Dispose(bool disposing) | ||||
|         { | ||||
|             if (!disposedValue) | ||||
|             { | ||||
|                 if (disposing) | ||||
|                 { | ||||
|                     http.Dispose(); | ||||
|                 } | ||||
|                 disposedValue = true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public void Dispose() | ||||
|         { | ||||
|             Dispose(disposing: true); | ||||
|             GC.SuppressFinalize(this); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										44
									
								
								Props-Modules/Props.Shop/Adafruit/Api/ListingsParser.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | ||||
| using System; | ||||
| using System.Collections; | ||||
| using System.Collections.Generic; | ||||
| using System.Net.Http; | ||||
| using Newtonsoft.Json.Linq; | ||||
| using Props.Shop.Framework; | ||||
|  | ||||
| namespace Props.Shop.Adafruit.Api | ||||
| { | ||||
|     public class ListingsParser | ||||
|     { | ||||
|         public IEnumerable<ProductListing> ProductListings { get; private set; } | ||||
|         public ListingsParser(string json) | ||||
|         { | ||||
|             dynamic data = JArray.Parse(json); | ||||
|             List<ProductListing> parsed = new List<ProductListing>(); | ||||
|             foreach (dynamic item in data) | ||||
|             { | ||||
|                 if (item.products_discontinued == 0) | ||||
|                 { | ||||
|                     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 (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); | ||||
|                 } | ||||
|             } | ||||
|             ProductListings = parsed; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,58 @@ | ||||
| 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 => 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"); | ||||
|             SetListings(await response.Content.ReadAsStringAsync()); | ||||
|         } | ||||
|  | ||||
|         public void SetListings(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; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     } | ||||
| } | ||||
							
								
								
									
										7
									
								
								Props-Modules/Props.Shop/Adafruit/Configuration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| namespace Props.Shop.Adafruit | ||||
| { | ||||
|     public class Configuration | ||||
|     { | ||||
|         public float Similarity { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,35 @@ | ||||
| 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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,16 +1,16 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\Framework\Props.Shop.Framework.csproj" /> | ||||
|     <ProjectReference Include="..\..\..\Libraries\SimpleLogger\SimpleLogger.csproj" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="HtmlAgilityPack" Version="1.11.33" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net5.0</TargetFramework> | ||||
|   </PropertyGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="FuzzySharp" Version="2.0.2" /> | ||||
|     <PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\Framework\Props.Shop.Framework.csproj" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
| </Project> | ||||
| @@ -1,108 +0,0 @@ | ||||
| using System; | ||||
| using System.Collections; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace GameServiceWarden.Core.Collection | ||||
| { | ||||
|     public class LRUCache<K, V> : IEnumerable<V> | ||||
|     { | ||||
|         private class Node | ||||
|         { | ||||
|             public K key; | ||||
|             public V value; | ||||
|             public Node front; | ||||
|             public Node back; | ||||
|         } | ||||
|  | ||||
|         public int Size {get { return size; } } | ||||
|         public int Count {get { return valueDictionary.Count; } } | ||||
|         private readonly int size; | ||||
|         private Node top; | ||||
|         private Node bottom; | ||||
|         private Dictionary<K, Node> valueDictionary; | ||||
|         private Action<V> cleanupAction; | ||||
|  | ||||
|         public LRUCache(int size = 100, Action<V> cleanup = null) | ||||
|         { | ||||
|             this.size = size; | ||||
|             valueDictionary = new Dictionary<K, Node>(size); | ||||
|             this.cleanupAction = cleanup; | ||||
|         } | ||||
|  | ||||
|         private void MoveToTop(K key) { | ||||
|             Node node = valueDictionary[key]; | ||||
|             if (node != null && top != node) { | ||||
|                 node.front.back = node.back; | ||||
|                 node.back = top; | ||||
|                 node.front = null; | ||||
|                 top = node; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private Node AddToTop(K key, V value) { | ||||
|             Node node = new Node(); | ||||
|             node.front = null; | ||||
|             node.back = top; | ||||
|             node.value = value; | ||||
|             node.key = key; | ||||
|             top = node; | ||||
|             if (bottom == null) { | ||||
|                 bottom = node; | ||||
|             } else if (valueDictionary.Count == Size) { | ||||
|                 valueDictionary.Remove(bottom.key); | ||||
|                 cleanupAction?.Invoke(bottom.value); | ||||
|                 bottom = bottom.front; | ||||
|             } | ||||
|             valueDictionary[key] = node; | ||||
|             return node; | ||||
|         } | ||||
|  | ||||
|         public V Use(K key, Func<V> generate) { | ||||
|             if (generate == null) throw new ArgumentNullException("generate"); | ||||
|             Node value = null; | ||||
|             if (valueDictionary.ContainsKey(key)) { | ||||
|                 value = valueDictionary[key]; | ||||
|                 MoveToTop(key); | ||||
|             } else { | ||||
|                 value = AddToTop(key, generate()); | ||||
|             } | ||||
|             return value.value; | ||||
|         } | ||||
|  | ||||
|         public async Task<V> UseAsync(K key, Func<Task<V>> generate) { | ||||
|             if (generate == null) throw new ArgumentNullException("generate"); | ||||
|             Node value = null; | ||||
|             if (valueDictionary.ContainsKey(key)) { | ||||
|                 value = valueDictionary[key]; | ||||
|                 MoveToTop(key); | ||||
|             } else { | ||||
|                 value = AddToTop(key, await generate()); | ||||
|             } | ||||
|             return value.value; | ||||
|         } | ||||
|  | ||||
|         public bool IsCached(K key) { | ||||
|             return valueDictionary.ContainsKey(key); | ||||
|         } | ||||
|  | ||||
|         public void Clear() { | ||||
|             top = null; | ||||
|             bottom = null; | ||||
|             valueDictionary.Clear(); | ||||
|         } | ||||
|  | ||||
|         public IEnumerator<V> GetEnumerator() | ||||
|         { | ||||
|             foreach (Node node in valueDictionary.Values) | ||||
|             { | ||||
|                 yield return node.value; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         IEnumerator IEnumerable.GetEnumerator() | ||||
|         { | ||||
|             throw new NotImplementedException(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,70 +0,0 @@ | ||||
| using System; | ||||
| using System.Collections; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Text.RegularExpressions; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using GameServiceWarden.Core.Collection; | ||||
| using Props.Shop.Framework; | ||||
| using SimpleLogger; | ||||
|  | ||||
| namespace Props.Shop.AliExpressModule | ||||
| { | ||||
|     public class Shop : IShop | ||||
|     { | ||||
|         public string ShopName => "AliExpress"; | ||||
|  | ||||
|         public string ShopDescription => "A China based online store."; | ||||
|  | ||||
|         public string ShopModuleAuthor => "Reslate"; | ||||
|  | ||||
|         public bool UseProxy { get; set; } = true; | ||||
|  | ||||
|         private HttpClient http; | ||||
|         private string query; | ||||
|         private Currency currency; | ||||
|         private bool disposedValue; | ||||
|  | ||||
|  | ||||
|         public void Initialize() | ||||
|         { | ||||
|             if (http != null) throw new InvalidOperationException("HttpClient already instantiated."); | ||||
|             this.http = new HttpClient(); | ||||
|         } | ||||
|  | ||||
|         protected virtual void Dispose(bool disposing) | ||||
|         { | ||||
|             if (!disposedValue) | ||||
|             { | ||||
|                 if (disposing) | ||||
|                 { | ||||
|                     if (http == null) throw new InvalidOperationException("HttpClient not instantiated."); | ||||
|                     http.Dispose(); | ||||
|                 } | ||||
|                 disposedValue = true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public void Dispose() | ||||
|         { | ||||
|             Dispose(disposing: true); | ||||
|             GC.SuppressFinalize(this); | ||||
|         } | ||||
|  | ||||
|         public void SetupSession(string query, Currency currency) | ||||
|         { | ||||
|             this.query = query; | ||||
|             this.currency = currency; | ||||
|         } | ||||
|  | ||||
|         public IAsyncEnumerator<ProductListing> GetAsyncEnumerator(CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             return new ShopEnumerator(cancellationToken, query, currency, http, UseProxy); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,285 +0,0 @@ | ||||
| using System; | ||||
| using System.Collections; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Net.Http; | ||||
| using System.Text.RegularExpressions; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using GameServiceWarden.Core.Collection; | ||||
| using Props.Shop.Framework; | ||||
| using SimpleLogger; | ||||
|  | ||||
| namespace Props.Shop.AliExpressModule | ||||
| { | ||||
|     class ShopEnumerator : IAsyncEnumerator<ProductListing> | ||||
|     { | ||||
|         private CancellationToken cancellationToken; | ||||
|         private LRUCache<(string, Currency), float> conversionCache = new LRUCache<(string, Currency), float>(); | ||||
|         private string query; | ||||
|         private Currency currency; | ||||
|         private HttpClient http; | ||||
|         bool useProxy; | ||||
|         int currentPage = 0; | ||||
|         IEnumerator<ProductListing> pageListings; | ||||
|         private bool disposedValue; | ||||
|  | ||||
|         public ProductListing Current {get; private set;} | ||||
|  | ||||
|         public ShopEnumerator(CancellationToken cancellationToken, string query, Currency currency, HttpClient http, bool useProxy = true) | ||||
|         { | ||||
|             this.cancellationToken = cancellationToken; | ||||
|             this.query = query; | ||||
|             this.currency = currency; | ||||
|             this.http = http; | ||||
|             this.useProxy = useProxy; | ||||
|         } | ||||
|  | ||||
|  | ||||
|         private async Task<IEnumerable<ProductListing>> ScrapePage(int page) | ||||
|         { | ||||
|             const string ALIEXPRESS_QUERY_FORMAT = "https://www.aliexpress.com/wholesale?trafficChannel=main&d=y&CatId=0&SearchText={0}<ype=wholesale&SortType=default&page={1}"; | ||||
|             const char SPACE_CHAR = '+'; | ||||
|             const string PROXY_FORMAT = "https://cors.bridged.cc/{0}"; | ||||
|             const int DELAY = 1000/5; | ||||
|             Regex dataLineRegex = new Regex("^ +window.runParams = .+\"items\":.+;$"); | ||||
|             Regex pageCountRegex = new Regex("\"maxPage\":(\\d+)"); | ||||
|             const string ITEM_LIST_SEQ = "\"items\":"; | ||||
|  | ||||
|  | ||||
|             if (http == null) throw new InvalidOperationException("HttpClient is not initiated."); | ||||
|             List<ProductListing> listings = new List<ProductListing>(); | ||||
|  | ||||
|             string modifiedQuery = query.Replace(' ', SPACE_CHAR); | ||||
|             Logger.Log($"Searching with query \"{query}\".", LogLevel.Info); | ||||
|  | ||||
|             DateTime start = DateTime.Now; | ||||
|             //Set up request. We need to use the Cors Proxy. | ||||
|             string url = string.Format(ALIEXPRESS_QUERY_FORMAT, modifiedQuery, page); | ||||
|             HttpRequestMessage request = null; | ||||
|             if (useProxy) { | ||||
|                 request = new HttpRequestMessage(HttpMethod.Get, string.Format(PROXY_FORMAT, url)); | ||||
|             } else { | ||||
|                 request = new HttpRequestMessage(HttpMethod.Get, url); | ||||
|             } | ||||
|  | ||||
|             //Delay for Cors proxy. | ||||
|             double waitTime = DELAY - (DateTime.Now - start).TotalMilliseconds; | ||||
|             if (waitTime > 0) { | ||||
|                 Logger.Log($"Delaying next page by {waitTime}ms.", LogLevel.Debug); | ||||
|                 await Task.Delay((int)Math.Ceiling(waitTime), cancellationToken); | ||||
|             } | ||||
|  | ||||
|             Logger.Log($"Sending GET request with uri: {request.RequestUri}", LogLevel.Debug); | ||||
|             HttpResponseMessage response = await http.SendAsync(request, cancellationToken); | ||||
|             start = DateTime.Now; | ||||
|              | ||||
|             string data = null; | ||||
|             using (StreamReader reader = new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken))) | ||||
|             { | ||||
|                 string line = null; | ||||
|                 while ((line = await reader.ReadLineAsync()) != null && data == null) | ||||
|                 { | ||||
|                     if (cancellationToken.IsCancellationRequested) throw new OperationCanceledException(); | ||||
|                     if (dataLineRegex.IsMatch(line)) { | ||||
|                         data = line.Trim(); | ||||
|                         Logger.Log($"Found line with listing data.", LogLevel.Debug); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             if (data == null) { | ||||
|                 Logger.Log($"Completed search prematurely with status {response.StatusCode} ({(int)response.StatusCode})."); | ||||
|                 return null; | ||||
|             } | ||||
|             string itemsString = GetBracketSet(data, data.IndexOf(ITEM_LIST_SEQ) + ITEM_LIST_SEQ.Length, '[', ']'); | ||||
|             IEnumerable<string> listingsStrs = GetItemsFromString(itemsString); | ||||
|             foreach (string listingStr in listingsStrs) | ||||
|             { | ||||
|                 listings.Add(await GenerateListingFromString(listingStr, currency)); | ||||
|             } | ||||
|             return listings; | ||||
|         } | ||||
|  | ||||
|         private async Task<ProductListing> GenerateListingFromString(string str, Currency currency) { | ||||
|             Regex itemRatingRegex = new Regex("\"starRating\":\"(\\d*.\\d*)\""); | ||||
|             Regex itemsSoldRegex = new Regex("\"tradeDesc\":\"(\\d+) sold\""); | ||||
|             Regex shippingPriceRegex = new Regex("Shipping: \\w+ ?\\$ ?(\\d*.\\d*)"); | ||||
|             Regex itemPriceRegex = new Regex("\"price\":\"\\w+ ?\\$ ?(\\d*.\\d*)( - (\\d+.\\d+))?\","); | ||||
|  | ||||
|             const string FREE_SHIPPING_STR = "\"logisticsDesc\":\"Free Shipping\""; | ||||
|             const string TITLE_SEQ = "\"title\":"; | ||||
|             const string IMAGE_URL_SEQ = "\"imageUrl\":"; | ||||
|             const string PRODUCT_URL_SEQ = "\"productDetailUrl\":"; | ||||
|              | ||||
|             ProductListing listing = new ProductListing(); | ||||
|  | ||||
|             string name = GetQuoteSet(str, str.IndexOf(TITLE_SEQ) + TITLE_SEQ.Length); | ||||
|             if (name != null) { | ||||
|                 Logger.Log($"Found name: {name}", LogLevel.Debug); | ||||
|                 listing.Name = name; | ||||
|             } else { | ||||
|                 Logger.Log($"Unable to get listing name from: \n {str}", LogLevel.Warning); | ||||
|             } | ||||
|             Match ratingMatch = itemRatingRegex.Match(str); | ||||
|             if (ratingMatch.Success) { | ||||
|                 Logger.Log($"Found rating: {ratingMatch.Groups[1].Value}", LogLevel.Debug); | ||||
|                 listing.Rating = float.Parse(ratingMatch.Groups[1].Value) / 5f; | ||||
|             } | ||||
|             Match numberSoldMatch = itemsSoldRegex.Match(str); | ||||
|             if (numberSoldMatch.Success) { | ||||
|                 Logger.Log($"Found quantity sold: {numberSoldMatch.Groups[1].Value}", LogLevel.Debug); | ||||
|                 listing.PurchaseCount = int.Parse(numberSoldMatch.Groups[1].Value); | ||||
|             } | ||||
|  | ||||
|             Match priceMatch = itemPriceRegex.Match(str); | ||||
|             if (priceMatch.Success) { | ||||
|                 listing.LowerPrice = (float)Math.Round(float.Parse(priceMatch.Groups[1].Value) * await conversionCache.UseAsync(("USD", currency), () => FetchConversion("USD", currency)), 2); | ||||
|                 Logger.Log($"Found price: {listing.LowerPrice}", LogLevel.Debug); | ||||
|                 if (priceMatch.Groups[3].Success) { | ||||
|                     listing.UpperPrice = (float)Math.Round(float.Parse(priceMatch.Groups[3].Value) * await conversionCache.UseAsync(("USD", currency), () => FetchConversion("USD", currency)), 2); | ||||
|                     Logger.Log($"Found a price range with upper bound: {listing.UpperPrice}", LogLevel.Debug); | ||||
|                 } else { | ||||
|                     listing.UpperPrice = (float)Math.Round(listing.LowerPrice * await conversionCache.UseAsync(("USD", currency), () => FetchConversion("USD", currency)), 2); | ||||
|                 } | ||||
|             } else { | ||||
|                 Logger.Log($"Unable to get listing price from: \n {str}", LogLevel.Warning); | ||||
|             } | ||||
|  | ||||
|             string prodUrl = GetQuoteSet(str, str.IndexOf(PRODUCT_URL_SEQ) + PRODUCT_URL_SEQ.Length).Substring(2); | ||||
|             if (prodUrl != null) { | ||||
|                 Logger.Log($"Found URL: {prodUrl}", LogLevel.Debug); | ||||
|                 listing.URL = "https://" + prodUrl; | ||||
|             } else { | ||||
|                 Logger.Log($"Unable to get item URL from: \n {str}", LogLevel.Warning); | ||||
|             } | ||||
|             string imageUrl = GetQuoteSet(str, str.IndexOf(IMAGE_URL_SEQ) + IMAGE_URL_SEQ.Length).Substring(2); | ||||
|             if (imageUrl != null) { | ||||
|                 Logger.Log($"Found image URL: {imageUrl}", LogLevel.Debug); | ||||
|                 listing.ImageURL = "https://" + imageUrl; | ||||
|             } | ||||
|             Match shippingMatch = shippingPriceRegex.Match(str); | ||||
|             if (shippingMatch.Success) { | ||||
|                 listing.Shipping = (float)Math.Round(float.Parse(shippingMatch.Groups[1].Value) * await conversionCache.UseAsync(("USD", currency), () => FetchConversion("USD", currency)), 2); | ||||
|                 Logger.Log($"Found shipping price: {listing.Shipping}", LogLevel.Debug); | ||||
|             } else if (str.Contains(FREE_SHIPPING_STR)) { | ||||
|                 listing.Shipping = 0; | ||||
|             } else { | ||||
|                 listing.Shipping = null; | ||||
|             } | ||||
|             listing.ConvertedPrices = true; | ||||
|             return listing; | ||||
|         } | ||||
|  | ||||
|         private string GetQuoteSet(string str, int start) { | ||||
|             char[] cs = str.ToCharArray(); | ||||
|             int quoteCount = 0; | ||||
|             int a = -1; | ||||
|             if (start < 0) return null; | ||||
|             for (int b = start; b < cs.Length; b++) | ||||
|             { | ||||
|                 if (cs[b] == '"' && !(b >= 1 && cs[b - 1] == '\\')) { | ||||
|                     if (a == -1) { | ||||
|                         a = b + 1; | ||||
|                     } | ||||
|                     quoteCount += 1; | ||||
|  | ||||
|                     if (quoteCount >= 2) { | ||||
|                         return str.Substring(a, b - a);  | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         private string GetBracketSet(string str, int start, char open = '{', char close = '}') { | ||||
|             if (start < 0) return null; | ||||
|  | ||||
|             char[] cs = str.ToCharArray(); | ||||
|             int bracketDepth = 0; | ||||
|             int a = -1; | ||||
|             for (int i = start; i < cs.Length; i++) | ||||
|             { | ||||
|                 char c = cs[i]; | ||||
|                 if (c == open) { | ||||
|                     if (a < 0) { | ||||
|                         a = i; | ||||
|                     } | ||||
|                     bracketDepth += 1; | ||||
|                 } else if (c == close) { | ||||
|                     bracketDepth -= 1; | ||||
|                     if (bracketDepth == 0) { | ||||
|                         if (i + 1 >= cs.Length) { | ||||
|                             return str.Substring(a); | ||||
|                         } | ||||
|                         return str.Substring(a, i - a + 1); | ||||
|                     } else if (bracketDepth < 0) { | ||||
|                         return null; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         private IEnumerable<string> GetItemsFromString(string str) { | ||||
|             int startPos = 0; | ||||
|             string itemString = null; | ||||
|             while ((itemString = GetBracketSet(str, startPos)) != null) | ||||
|             { | ||||
|                 startPos += itemString.Length + 1; | ||||
|                 yield return itemString; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task<float> FetchConversion(string from, Currency to) { | ||||
|             if (from.Equals(to.ToString())) return 1; | ||||
|             HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, string.Format("https://api.exchangerate.host/convert?from={0}&to={1}", from, to)); | ||||
|             HttpResponseMessage response = await http.SendAsync(request, cancellationToken); | ||||
|             string results = null; | ||||
|             using (StreamReader reader = new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken))) | ||||
|             { | ||||
|                 results = await reader.ReadToEndAsync(); | ||||
|             } | ||||
|             Match match = Regex.Match(results, "\"result\":(\\d*.\\d*)"); | ||||
|             return float.Parse(match.Groups[1].Value); | ||||
|         } | ||||
|  | ||||
|         protected virtual void Dispose(bool disposing) | ||||
|         { | ||||
|             if (!disposedValue) | ||||
|             { | ||||
|                 if (disposing) | ||||
|                 { | ||||
|                 } | ||||
|                 disposedValue = true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public void Dispose() | ||||
|         { | ||||
|             Dispose(disposing: true); | ||||
|             System.GC.SuppressFinalize(this); | ||||
|         } | ||||
|  | ||||
|         public async ValueTask<bool> MoveNextAsync() | ||||
|         { | ||||
|             if (pageListings == null || !pageListings.MoveNext()) { | ||||
|                 pageListings?.Dispose(); | ||||
|                 currentPage += 1; | ||||
|                 IEnumerable<ProductListing> currentListings = await ScrapePage(currentPage); | ||||
|                 if (currentListings == null) { | ||||
|                     return false; | ||||
|                 } | ||||
|                 pageListings = currentListings.GetEnumerator(); | ||||
|                 pageListings.MoveNext(); | ||||
|             } | ||||
|             Current = pageListings.Current; | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         public ValueTask DisposeAsync() | ||||
|         { | ||||
|             Dispose(); | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,59 +0,0 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Net.Http; | ||||
| using System.Runtime.CompilerServices; | ||||
| using System.Threading; | ||||
| using Props.Shop.Framework; | ||||
|  | ||||
| namespace Props.Shop.BanggoodModule | ||||
| { | ||||
|     public class Shop : IShop | ||||
|     { | ||||
|         public bool UseProxy { get; set; } = true; | ||||
|         private bool disposedValue; | ||||
|  | ||||
|         public string ShopName => "Banggood"; | ||||
|  | ||||
|         public string ShopDescription => "A online retailer based in China."; | ||||
|  | ||||
|         public string ShopModuleAuthor => "Reslate"; | ||||
|  | ||||
|         private HttpClient http; | ||||
|         private string query; | ||||
|         private Currency currency; | ||||
|  | ||||
|         public IAsyncEnumerator<ProductListing> GetAsyncEnumerator(CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             return new ShopEnumerator(query, currency, http, UseProxy, cancellationToken); | ||||
|         } | ||||
|  | ||||
|         public void Initialize() | ||||
|         { | ||||
|             this.http = new HttpClient(); | ||||
|         } | ||||
|  | ||||
|         public void SetupSession(string query, Currency currency) | ||||
|         { | ||||
|             this.query = query; | ||||
|             this.currency = currency; | ||||
|         } | ||||
|  | ||||
|         protected virtual void Dispose(bool disposing) | ||||
|         { | ||||
|             if (!disposedValue) | ||||
|             { | ||||
|                 if (disposing) | ||||
|                 { | ||||
|                     http.Dispose(); | ||||
|                 } | ||||
|                 disposedValue = true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public void Dispose() | ||||
|         { | ||||
|             Dispose(disposing: true); | ||||
|             GC.SuppressFinalize(this); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,98 +0,0 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Net.Http; | ||||
| using System.Text.RegularExpressions; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using System.Xml; | ||||
| using HtmlAgilityPack; | ||||
| using Props.Shop.Framework; | ||||
| using SimpleLogger; | ||||
|  | ||||
| namespace Props.Shop.BanggoodModule | ||||
| { | ||||
|     class ShopEnumerator : IAsyncEnumerator<ProductListing> | ||||
|     { | ||||
|         const string PROXY_FORMAT = "https://cors.bridged.cc/{0}"; | ||||
|         private const string QUERY_FORMAT = "https://www.banggood.com/search/{0}/0-0-0-1-1-60-0-price-0-0_p-{1}.html?DCC=CA¤cy={2}"; | ||||
|         HttpClient http; | ||||
|         private string query; | ||||
|         private Currency currency; | ||||
|         private bool useProxy; | ||||
|         private CancellationToken cancellationToken; | ||||
|  | ||||
|         private IEnumerator<ProductListing> pageListings; | ||||
|         private int currentPage; | ||||
|         private DateTime lastScrape; | ||||
|  | ||||
|         public ProductListing Current { get; private set; } | ||||
|  | ||||
|         public ShopEnumerator(string query, Currency currency, HttpClient http, bool useProxy, CancellationToken cancellationToken) | ||||
|         { | ||||
|             query = query.Replace(' ', '-'); | ||||
|             this.query = query; | ||||
|             this.currency = currency; | ||||
|             this.http = http; | ||||
|             this.useProxy = useProxy; | ||||
|             this.cancellationToken = cancellationToken; | ||||
|         } | ||||
|  | ||||
|         private async Task<IEnumerable<ProductListing>> ScrapePage(int page) | ||||
|         { | ||||
|             string requestUrl = string.Format(QUERY_FORMAT, query, page, currency.ToString()); | ||||
|             if (useProxy) requestUrl = string.Format(PROXY_FORMAT, requestUrl); | ||||
|             TimeSpan difference = DateTime.Now - lastScrape; | ||||
|             if (difference.TotalMilliseconds < 200) { | ||||
|                 await Task.Delay((int)Math.Ceiling(200 - difference.TotalMilliseconds)); | ||||
|             } | ||||
|             HttpResponseMessage response = await http.GetAsync(requestUrl); | ||||
|             lastScrape = DateTime.Now; | ||||
|             HtmlDocument html = new HtmlDocument(); | ||||
|             html.Load(await response.Content.ReadAsStreamAsync()); | ||||
|             HtmlNodeCollection collection = html.DocumentNode.SelectNodes(@"//div[@class='product-list']/ul[@class='goodlist cf']/li"); | ||||
|             if (collection == null) return null; | ||||
|             List<ProductListing> results = new List<ProductListing>(); | ||||
|             foreach (HtmlNode node in collection) | ||||
|             { | ||||
|                 ProductListing listing = new ProductListing(); | ||||
|                 HtmlNode productNode = node.SelectSingleNode(@"div/a[1]"); | ||||
|                 listing.Name = productNode.InnerText; | ||||
|                 Logger.Log($"Found name: {listing.Name}", LogLevel.Debug); | ||||
|                 listing.URL = productNode.GetAttributeValue("href", null); | ||||
|                 Logger.Log($"Found URL: {listing.URL}", LogLevel.Debug); | ||||
|                 listing.ImageURL = node.SelectSingleNode(@"div/span[@class='img notranslate']/a/img").GetAttributeValue("data-src", null); | ||||
|                 Logger.Log($"Found image URL: {listing.ImageURL}", LogLevel.Debug); | ||||
|                 listing.LowerPrice = float.Parse(Regex.Match(node.SelectSingleNode(@"div/span[@class='price-box']/span").InnerText, @"(\d*\.\d*)").Groups[1].Value); | ||||
|                 Logger.Log($"Found price: {listing.LowerPrice}", LogLevel.Debug); | ||||
|                 listing.UpperPrice = listing.LowerPrice; | ||||
|                 listing.ReviewCount = int.Parse(Regex.Match(node.SelectSingleNode(@"div/a[2]").InnerText, @"(\d+) reviews?").Groups[1].Value); | ||||
|                 Logger.Log($"Found reviews: {listing.ReviewCount}", LogLevel.Debug); | ||||
|                 results.Add(listing); | ||||
|             } | ||||
|             return results; | ||||
|         } | ||||
|  | ||||
|         public ValueTask DisposeAsync() | ||||
|         { | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         public async ValueTask<bool> MoveNextAsync() | ||||
|         { | ||||
|             if (pageListings == null || !pageListings.MoveNext()) | ||||
|             { | ||||
|                 currentPage += 1; | ||||
|                 pageListings?.Dispose(); | ||||
|                 IEnumerable<ProductListing> pageEnumerable = await ScrapePage(currentPage); | ||||
|                 if (pageEnumerable == null) return false; | ||||
|                 pageListings = pageEnumerable.GetEnumerator(); | ||||
|                 pageListings.MoveNext(); | ||||
|             } | ||||
|             Current = pageListings.Current; | ||||
|             return true; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										51
									
								
								Props-Modules/Props.Shop/Ebay/Actions/SearchRequest.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,51 @@ | ||||
| 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(); | ||||
|             } | ||||
|  | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,29 @@ | ||||
| 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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,57 @@ | ||||
| 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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										7
									
								
								Props-Modules/Props.Shop/Ebay/Configuration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| namespace Props.Shop.Ebay | ||||
| { | ||||
|     public class Configuration | ||||
|     { | ||||
|         public bool Sandbox { get; set; } = true; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										70
									
								
								Props-Modules/Props.Shop/Ebay/EbayShop.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,70 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Net.Http; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| 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 IEnumerable<IOption> Initialize(byte[] data) | ||||
|         { | ||||
|             httpClient = new HttpClient(); | ||||
|             configuration = JsonSerializer.Deserialize<Configuration>(data); | ||||
|             return new List<IOption>() { | ||||
|                 new SandboxOption(configuration), | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         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 IAsyncEnumerable<ProductListing> Search(string query, Filters filters) | ||||
|         { | ||||
|             // TODO: Implement the search system. | ||||
|             throw new NotImplementedException(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										35
									
								
								Props-Modules/Props.Shop/Ebay/Options/SandboxOption.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,35 @@ | ||||
| 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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -2,7 +2,10 @@ | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\Framework\Props.Shop.Framework.csproj" /> | ||||
|     <ProjectReference Include="..\..\..\Libraries\SimpleLogger\SimpleLogger.csproj" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <PropertyGroup> | ||||
							
								
								
									
										85
									
								
								Props-Modules/Props.Shop/Framework/Filters.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,85 @@ | ||||
| using System; | ||||
|  | ||||
| namespace Props.Shop.Framework | ||||
| { | ||||
|     public class Filters | ||||
|     { | ||||
|         public Currency Currency { get; set; } = Currency.CAD; | ||||
|         public float MinRating { get; set; } = 0.8f; | ||||
|         public bool KeepUnrated { get; set; } = true; | ||||
|         public bool EnableUpperPrice { get; set; } = false; | ||||
|         private int upperPrice; | ||||
|  | ||||
|         public int UpperPrice | ||||
|         { | ||||
|             get | ||||
|             { | ||||
|                 return upperPrice; | ||||
|             } | ||||
|             set | ||||
|             { | ||||
|                 if (EnableUpperPrice) upperPrice = value; | ||||
|             } | ||||
|         } | ||||
|         public int LowerPrice { get; set; } | ||||
|         public int MinPurchases { get; set; } | ||||
|         public bool KeepUnknownPurchaseCount { get; set; } = true; | ||||
|         public int MinReviews { get; set; } | ||||
|         public bool KeepUnknownRatingCount { get; set; } = true; | ||||
|         public bool EnableMaxShippingFee { get; set; } | ||||
|         private int maxShippingFee; | ||||
|  | ||||
|         public int MaxShippingFee | ||||
|         { | ||||
|             get | ||||
|             { | ||||
|                 return maxShippingFee; | ||||
|             } | ||||
|             set | ||||
|             { | ||||
|                 if (EnableMaxShippingFee) maxShippingFee = value; | ||||
|             } | ||||
|         } | ||||
|         public bool KeepUnknownShipping { get; set; } = true; | ||||
|  | ||||
|         public override bool Equals(object obj) | ||||
|         { | ||||
|             if (obj == null || GetType() != obj.GetType()) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|             Filters other = (Filters)obj; | ||||
|             return | ||||
|                 Currency == other.Currency && | ||||
|                 MinRating == other.MinRating && | ||||
|                 KeepUnrated == other.KeepUnrated && | ||||
|                 EnableUpperPrice == other.EnableUpperPrice && | ||||
|                 UpperPrice == other.UpperPrice && | ||||
|                 LowerPrice == other.LowerPrice && | ||||
|                 MinPurchases == other.MinPurchases && | ||||
|                 KeepUnknownPurchaseCount == other.KeepUnknownPurchaseCount && | ||||
|                 MinReviews == other.MinReviews && | ||||
|                 KeepUnknownRatingCount == other.KeepUnknownRatingCount && | ||||
|                 EnableMaxShippingFee == other.EnableMaxShippingFee && | ||||
|                 MaxShippingFee == other.MaxShippingFee && | ||||
|                 KeepUnknownShipping == other.KeepUnknownShipping; | ||||
|  | ||||
|         } | ||||
|  | ||||
|         public override int GetHashCode() | ||||
|         { | ||||
|             return HashCode.Combine( | ||||
|                 Currency, | ||||
|                 MinRating, | ||||
|                 UpperPrice, | ||||
|                 LowerPrice, | ||||
|                 MinPurchases, | ||||
|                 MinReviews, | ||||
|                 MaxShippingFee); | ||||
|         } | ||||
|         public Filters Copy() | ||||
|         { | ||||
|             return (Filters)this.MemberwiseClone(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										14
									
								
								Props-Modules/Props.Shop/Framework/IOption.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,14 @@ | ||||
| using System; | ||||
|  | ||||
| namespace Props.Shop.Framework | ||||
| { | ||||
|     public interface IOption | ||||
|     { | ||||
|         public string Name { get; } | ||||
|         public string Description { get; } | ||||
|         public bool Required { get; } | ||||
|         public string GetValue(); | ||||
|         public bool SetValue(string value); | ||||
|         public Type Type { get; } | ||||
|     } | ||||
| } | ||||
| @@ -6,14 +6,16 @@ using System.Threading.Tasks; | ||||
|  | ||||
| namespace Props.Shop.Framework | ||||
| { | ||||
|     public interface IShop : IAsyncEnumerable<ProductListing>, IDisposable | ||||
|     public interface IShop : IDisposable | ||||
|     { | ||||
|         string ShopName { get; } | ||||
|         string ShopDescription { get; } | ||||
|         string ShopModuleAuthor { get; } | ||||
|  | ||||
|         public void SetupSession(string query, Currency currency); | ||||
|         public IAsyncEnumerable<ProductListing> Search(string query, Filters filters); | ||||
|  | ||||
|         void Initialize(); | ||||
|         IEnumerable<IOption> Initialize(byte[] data); | ||||
|         public SupportedFeatures SupportedFeatures { get; } | ||||
|         public byte[] GetDataForPersistence(); | ||||
|     } | ||||
| } | ||||
| @@ -12,5 +12,6 @@ namespace Props.Shop.Framework | ||||
|         public int? PurchaseCount { get; set; } | ||||
|         public int? ReviewCount { get; set; } | ||||
|         public bool ConvertedPrices { get; set; } | ||||
|         public bool? InStock { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										20
									
								
								Props-Modules/Props.Shop/Framework/SupportedFeatures.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,20 @@ | ||||
| namespace Props.Shop.Framework | ||||
| { | ||||
|     public class SupportedFeatures | ||||
|     { | ||||
|         bool Shipping { get; } | ||||
|         bool Rating { get; } | ||||
|         bool ReviewCount { get; } | ||||
|         bool PurchaseCount { get; } | ||||
|         bool InStock { get; } | ||||
|  | ||||
|         public SupportedFeatures(bool shipping, bool rating, bool reviewCount, bool purchaseCount, bool inStock) | ||||
|         { | ||||
|             this.Shipping = shipping; | ||||
|             this.Rating = rating; | ||||
|             this.ReviewCount = reviewCount; | ||||
|             this.PurchaseCount = purchaseCount; | ||||
|             this.InStock = inStock; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using Props.Shop.Adafruit.Api; | ||||
| using Xunit; | ||||
|  | ||||
| namespace Props.Shop.Adafruit.Tests | ||||
| { | ||||
|     public class ListingParserTest | ||||
|     { | ||||
|         [Fact] | ||||
|         public void TestParsing() | ||||
|         { | ||||
|             ListingsParser mockParser = new ListingsParser(File.ReadAllText("./Assets/products.json")); | ||||
|             Assert.NotEmpty(mockParser.ProductListings); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,25 @@ | ||||
| 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.SetListings(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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										158548
									
								
								Props-Modules/test/Props.Shop/Adafruit.Tests/Assets/products.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -20,8 +20,13 @@ | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\..\..\..\Libraries\SimpleLogger\SimpleLogger.csproj" /> | ||||
|     <ProjectReference Include="..\..\..\Props.Shop\AliExpressModule\Props.Shop.AliExpressModule.csproj" /> | ||||
|     <ProjectReference Include="..\..\..\Props.Shop\Adafruit\Props.Shop.Adafruit.csproj" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <Content Include="Assets\**"> | ||||
|       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> | ||||
|     </Content> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
| </Project> | ||||
| @@ -1,59 +0,0 @@ | ||||
| using Props.Shop.Framework; | ||||
| using SimpleLogger; | ||||
| using Xunit; | ||||
| using Xunit.Abstractions; | ||||
|  | ||||
| namespace Props.Shop.AliExpressModule.Tests | ||||
| { | ||||
|     public class ShopTest | ||||
|     { | ||||
|         public ShopTest(ITestOutputHelper output) | ||||
|         { | ||||
|             Logger.AddLogListener(new XUnitLogger(output)); | ||||
|         } | ||||
|  | ||||
|         [Fact] | ||||
|         public async void Search_SearchForItem_MultiplePages() | ||||
|         { | ||||
|             //Given | ||||
|             const int MAX_RESULTS = 120; | ||||
|             Shop shop = new Shop(); | ||||
|             shop.UseProxy = false; | ||||
|             shop.Initialize(); | ||||
|             //When | ||||
|             shop.SetupSession("mpu6050", Currency.CAD); | ||||
|             //Then | ||||
|             int count = 0; | ||||
|             await foreach (ProductListing listing in shop) | ||||
|             { | ||||
|                 Assert.False(string.IsNullOrWhiteSpace(listing.Name)); | ||||
|                 count += 1; | ||||
|                 if (count > MAX_RESULTS) return; | ||||
|             } | ||||
|             shop.Dispose(); | ||||
|         } | ||||
|  | ||||
|         [Fact] | ||||
|         public async void Search_USD_ResultsFound() | ||||
|         { | ||||
|             //Given | ||||
|             const int MAX_RESULTS = 120; | ||||
|             Shop shop = new Shop(); | ||||
|             shop.UseProxy = false; | ||||
|             shop.Initialize(); | ||||
|             //When | ||||
|             shop.SetupSession("mpu6050", Currency.USD); | ||||
|             //Then | ||||
|  | ||||
|             int count = 0; | ||||
|             await foreach (ProductListing listing in shop) | ||||
|             { | ||||
|                 Assert.False(string.IsNullOrWhiteSpace(listing.Name)); | ||||
|                 Assert.True(listing.LowerPrice != 0); | ||||
|                 count += 1; | ||||
|                 if (count > MAX_RESULTS) return; | ||||
|             } | ||||
|             shop.Dispose(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,33 +0,0 @@ | ||||
| using System; | ||||
| using SimpleLogger; | ||||
| using Xunit.Abstractions; | ||||
|  | ||||
| namespace Props.Shop.AliExpressModule | ||||
| { | ||||
|     public class XUnitLogger : ILogReceiver | ||||
|     { | ||||
|         public LogLevel Level => LogLevel.Debug; | ||||
|  | ||||
|         public string Identifier => GetType().Name; | ||||
|  | ||||
|         private ITestOutputHelper outputHelper; | ||||
|  | ||||
|         public XUnitLogger(ITestOutputHelper output) | ||||
|         { | ||||
|             this.outputHelper = output; | ||||
|         } | ||||
|  | ||||
|         public void Flush() | ||||
|         { | ||||
|         } | ||||
|  | ||||
|         public void LogMessage(string message, DateTime time, LogLevel level) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 outputHelper.WriteLine($"[{time.ToShortTimeString()}][{level.ToString()}]: {message}"); | ||||
|             } | ||||
|             catch (InvalidOperationException) { }; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,27 +0,0 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|  | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net5.0</TargetFramework> | ||||
|  | ||||
|     <IsPackable>false</IsPackable> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> | ||||
|     <PackageReference Include="xunit" Version="2.4.1" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> | ||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|       <PrivateAssets>all</PrivateAssets> | ||||
|     </PackageReference> | ||||
|     <PackageReference Include="coverlet.collector" Version="1.3.0"> | ||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|       <PrivateAssets>all</PrivateAssets> | ||||
|     </PackageReference> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\..\..\Props.Shop\BanggoodModule\Props.Shop.BanggoodModule.csproj" /> | ||||
|     <ProjectReference Include="..\..\..\..\Libraries\SimpleLogger\SimpleLogger.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
| @@ -1,35 +0,0 @@ | ||||
| using Props.Shop.Framework; | ||||
| using SimpleLogger; | ||||
| using Xunit; | ||||
| using Xunit.Abstractions; | ||||
|  | ||||
| namespace Props.Shop.BanggoodModule.Tests | ||||
| { | ||||
|     public class ShopTest | ||||
|     { | ||||
|         public ShopTest(ITestOutputHelper output) | ||||
|         { | ||||
|             Logger.AddLogListener(new XUnitLogger(output)); | ||||
|         } | ||||
|  | ||||
|         [Fact] | ||||
|         public async void Search_CAD_ResultsFound() | ||||
|         { | ||||
|             //Given | ||||
|             const int MAX_RESULTS = 100; | ||||
|             Shop shop = new Shop(); | ||||
|             shop.UseProxy = false; | ||||
|             //When | ||||
|             shop.Initialize(); | ||||
|             shop.SetupSession("samsung galaxy 20 case", Currency.CAD); | ||||
|             //Then | ||||
|             int count = 0; | ||||
|             await foreach (ProductListing listing in shop) | ||||
|             { | ||||
|                 count += 1; | ||||
|                 Assert.False(string.IsNullOrWhiteSpace(listing.Name)); | ||||
|                 if (count >= MAX_RESULTS) return; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,33 +0,0 @@ | ||||
| using System; | ||||
| using SimpleLogger; | ||||
| using Xunit.Abstractions; | ||||
|  | ||||
| namespace Props.Shop.BanggoodModule | ||||
| { | ||||
|     public class XUnitLogger : ILogReceiver | ||||
|     { | ||||
|         public LogLevel Level => LogLevel.Debug; | ||||
|  | ||||
|         public string Identifier => GetType().Name; | ||||
|  | ||||
|         private ITestOutputHelper outputHelper; | ||||
|  | ||||
|         public XUnitLogger(ITestOutputHelper output) | ||||
|         { | ||||
|             this.outputHelper = output; | ||||
|         } | ||||
|  | ||||
|         public void Flush() | ||||
|         { | ||||
|         } | ||||
|  | ||||
|         public void LogMessage(string message, DateTime time, LogLevel level) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 outputHelper.WriteLine($"[{time.ToShortTimeString()}][{level.ToString()}]: {message}"); | ||||
|             } | ||||
|             catch (InvalidOperationException) { }; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,3 +0,0 @@ | ||||
| $themes: ( | ||||
|     "light": ("background": #F4F4F4, "navbar": #FFF7F7, "main": #BDF2D5, "sub": #F2FCFC, "bold": #1E56A0, "text": #1A1A1A), | ||||
| ); | ||||
| @@ -2,7 +2,7 @@ import os | ||||
| import asyncio | ||||
|  | ||||
| SERVER_CSPROJ_DIR = "server" | ||||
| CLIENT_PACKAGE_DIR = "client" | ||||
| CLIENT_PACKAGE_DIR = "spa" | ||||
|  | ||||
|  | ||||
| async def exec(cmd, path, silent=False): | ||||
|   | ||||
							
								
								
									
										22
									
								
								Props/server/Areas/Identity/IdentityHostingStartup.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | ||||
| using System; | ||||
| using Microsoft.AspNetCore.Hosting; | ||||
| using Microsoft.AspNetCore.Identity; | ||||
| using Microsoft.AspNetCore.Identity.UI; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Props.Data; | ||||
| using Props.Models; | ||||
|  | ||||
| [assembly: HostingStartup(typeof(Props.Areas.Identity.IdentityHostingStartup))] | ||||
| namespace Props.Areas.Identity | ||||
| { | ||||
|     public class IdentityHostingStartup : IHostingStartup | ||||
|     { | ||||
|         public void Configure(IWebHostBuilder builder) | ||||
|         { | ||||
|             builder.ConfigureServices((context, services) => { | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										85
									
								
								Props/server/Areas/Identity/Pages/Account/Login.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,85 @@ | ||||
| @page | ||||
| @model LoginModel | ||||
|  | ||||
| @{ | ||||
|     ViewData["Title"] = "Log in"; | ||||
| } | ||||
|  | ||||
| <h1>@ViewData["Title"]</h1> | ||||
| <div class="row"> | ||||
|     <div class="col-md-4"> | ||||
|         <section> | ||||
|             <form id="account" method="post"> | ||||
|                 <h4>Use a local account to log in.</h4> | ||||
|                 <hr /> | ||||
|                 <div asp-validation-summary="All" class="text-danger"></div> | ||||
|                 <div class="form-group"> | ||||
|                     <label asp-for="Input.Email"></label> | ||||
|                     <input asp-for="Input.Email" class="form-control" /> | ||||
|                     <span asp-validation-for="Input.Email" class="text-danger"></span> | ||||
|                 </div> | ||||
|                 <div class="form-group"> | ||||
|                     <label asp-for="Input.Password"></label> | ||||
|                     <input asp-for="Input.Password" class="form-control" /> | ||||
|                     <span asp-validation-for="Input.Password" class="text-danger"></span> | ||||
|                 </div> | ||||
|                 <div class="form-group"> | ||||
|                     <div class="checkbox"> | ||||
|                         <label asp-for="Input.RememberMe"> | ||||
|                             <input asp-for="Input.RememberMe" /> | ||||
|                             @Html.DisplayNameFor(m => m.Input.RememberMe) | ||||
|                         </label> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="form-group"> | ||||
|                     <button type="submit" class="btn btn-primary">Log in</button> | ||||
|                 </div> | ||||
|                 <div class="form-group"> | ||||
|                     <p> | ||||
|                         <a id="forgot-password" asp-page="./ForgotPassword">Forgot your password?</a> | ||||
|                     </p> | ||||
|                     <p> | ||||
|                         <a asp-page="./Register" asp-route-returnUrl="@Model.ReturnUrl">Register as a new user</a> | ||||
|                     </p> | ||||
|                     <p> | ||||
|                         <a id="resend-confirmation" asp-page="./ResendEmailConfirmation">Resend email confirmation</a> | ||||
|                     </p> | ||||
|                 </div> | ||||
|             </form> | ||||
|         </section> | ||||
|     </div> | ||||
|     <div class="col-md-6 col-md-offset-2"> | ||||
|         <section> | ||||
|             <h4>Use another service to log in.</h4> | ||||
|             <hr /> | ||||
|             @{ | ||||
|                 if ((Model.ExternalLogins?.Count ?? 0) == 0) | ||||
|                 { | ||||
|                     <div> | ||||
|                         <p> | ||||
|                             There are no external authentication services configured. See <a href="https://go.microsoft.com/fwlink/?LinkID=532715">this article</a> | ||||
|                             for details on setting up this ASP.NET application to support logging in via external services. | ||||
|                         </p> | ||||
|                     </div> | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     <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> | ||||
|                                 } | ||||
|                             </p> | ||||
|                         </div> | ||||
|                     </form> | ||||
|                 } | ||||
|             } | ||||
|         </section> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| @section Scripts { | ||||
|     <partial name="_ValidationScriptsPartial" /> | ||||
| } | ||||
							
								
								
									
										111
									
								
								Props/server/Areas/Identity/Pages/Account/Login.cshtml.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,111 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.Linq; | ||||
| using System.Text.Encodings.Web; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Authentication; | ||||
| using Microsoft.AspNetCore.Identity; | ||||
| using Microsoft.AspNetCore.Identity.UI.Services; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.AspNetCore.Mvc.RazorPages; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Props.Models; | ||||
|  | ||||
| namespace Props.Areas.Identity.Pages.Account | ||||
| { | ||||
|     [AllowAnonymous] | ||||
|     public class LoginModel : PageModel | ||||
|     { | ||||
|         private readonly UserManager<ApplicationUser> _userManager; | ||||
|         private readonly SignInManager<ApplicationUser> _signInManager; | ||||
|         private readonly ILogger<LoginModel> _logger; | ||||
|  | ||||
|         public LoginModel(SignInManager<ApplicationUser> signInManager,  | ||||
|             ILogger<LoginModel> logger, | ||||
|             UserManager<ApplicationUser> userManager) | ||||
|         { | ||||
|             _userManager = userManager; | ||||
|             _signInManager = signInManager; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         [BindProperty] | ||||
|         public InputModel Input { get; set; } | ||||
|  | ||||
|         public IList<AuthenticationScheme> ExternalLogins { get; set; } | ||||
|  | ||||
|         public string ReturnUrl { get; set; } | ||||
|  | ||||
|         [TempData] | ||||
|         public string ErrorMessage { get; set; } | ||||
|  | ||||
|         public class InputModel | ||||
|         { | ||||
|             [Required] | ||||
|             [EmailAddress] | ||||
|             public string Email { get; set; } | ||||
|  | ||||
|             [Required] | ||||
|             [DataType(DataType.Password)] | ||||
|             public string Password { get; set; } | ||||
|  | ||||
|             [Display(Name = "Remember me?")] | ||||
|             public bool RememberMe { get; set; } | ||||
|         } | ||||
|  | ||||
|         public async Task OnGetAsync(string returnUrl = null) | ||||
|         { | ||||
|             if (!string.IsNullOrEmpty(ErrorMessage)) | ||||
|             { | ||||
|                 ModelState.AddModelError(string.Empty, ErrorMessage); | ||||
|             } | ||||
|  | ||||
|             returnUrl ??= Url.Content("~/"); | ||||
|  | ||||
|             // Clear the existing external cookie to ensure a clean login process | ||||
|             await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); | ||||
|  | ||||
|             ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); | ||||
|  | ||||
|             ReturnUrl = returnUrl; | ||||
|         } | ||||
|  | ||||
|         public async Task<IActionResult> OnPostAsync(string returnUrl = null) | ||||
|         { | ||||
|             returnUrl ??= Url.Content("~/"); | ||||
|  | ||||
|             ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); | ||||
|          | ||||
|             if (ModelState.IsValid) | ||||
|             { | ||||
|                 // This doesn't count login failures towards account lockout | ||||
|                 // To enable password failures to trigger account lockout, set lockoutOnFailure: true | ||||
|                 var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); | ||||
|                 if (result.Succeeded) | ||||
|                 { | ||||
|                     _logger.LogInformation("User logged in."); | ||||
|                     return LocalRedirect(returnUrl); | ||||
|                 } | ||||
|                 if (result.RequiresTwoFactor) | ||||
|                 { | ||||
|                     return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe }); | ||||
|                 } | ||||
|                 if (result.IsLockedOut) | ||||
|                 { | ||||
|                     _logger.LogWarning("User account locked out."); | ||||
|                     return RedirectToPage("./Lockout"); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     ModelState.AddModelError(string.Empty, "Invalid login attempt."); | ||||
|                     return Page(); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // If we got this far, something failed, redisplay form | ||||
|             return Page(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										21
									
								
								Props/server/Areas/Identity/Pages/Account/Logout.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| @page | ||||
| @model LogoutModel | ||||
| @{ | ||||
|     ViewData["Title"] = "Log out"; | ||||
| } | ||||
|  | ||||
| <header> | ||||
|     <h1>@ViewData["Title"]</h1> | ||||
|     @{ | ||||
|         if (User.Identity.IsAuthenticated) | ||||
|         { | ||||
|             <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 text-dark">Click here to Logout</button> | ||||
|             </form> | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             <p>You have successfully logged out of the application.</p> | ||||
|         } | ||||
|     } | ||||
| </header> | ||||
							
								
								
									
										44
									
								
								Props/server/Areas/Identity/Pages/Account/Logout.cshtml.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Identity; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.AspNetCore.Mvc.RazorPages; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Props.Models; | ||||
|  | ||||
| namespace Props.Areas.Identity.Pages.Account | ||||
| { | ||||
|     [AllowAnonymous] | ||||
|     public class LogoutModel : PageModel | ||||
|     { | ||||
|         private readonly SignInManager<ApplicationUser> _signInManager; | ||||
|         private readonly ILogger<LogoutModel> _logger; | ||||
|  | ||||
|         public LogoutModel(SignInManager<ApplicationUser> signInManager, ILogger<LogoutModel> logger) | ||||
|         { | ||||
|             _signInManager = signInManager; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public void OnGet() | ||||
|         { | ||||
|         } | ||||
|  | ||||
|         public async Task<IActionResult> OnPost(string returnUrl = null) | ||||
|         { | ||||
|             await _signInManager.SignOutAsync(); | ||||
|             _logger.LogInformation("User logged out."); | ||||
|             if (returnUrl != null) | ||||
|             { | ||||
|                 return LocalRedirect(returnUrl); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 return RedirectToPage(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										67
									
								
								Props/server/Areas/Identity/Pages/Account/Register.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,67 @@ | ||||
| @page | ||||
| @model RegisterModel | ||||
| @{ | ||||
|     ViewData["Title"] = "Register"; | ||||
| } | ||||
|  | ||||
| <h1>@ViewData["Title"]</h1> | ||||
|  | ||||
| <div class="row"> | ||||
|     <div class="col-md-4"> | ||||
|         <form asp-route-returnUrl="@Model.ReturnUrl" method="post"> | ||||
|             <h4>Create a new account.</h4> | ||||
|             <hr /> | ||||
|             <div asp-validation-summary="All" class="text-danger"></div> | ||||
|             <div class="form-group"> | ||||
|                 <label asp-for="Input.Email"></label> | ||||
|                 <input asp-for="Input.Email" class="form-control" /> | ||||
|                 <span asp-validation-for="Input.Email" class="text-danger"></span> | ||||
|             </div> | ||||
|             <div class="form-group"> | ||||
|                 <label asp-for="Input.Password"></label> | ||||
|                 <input asp-for="Input.Password" class="form-control" /> | ||||
|                 <span asp-validation-for="Input.Password" class="text-danger"></span> | ||||
|             </div> | ||||
|             <div class="form-group"> | ||||
|                 <label asp-for="Input.ConfirmPassword"></label> | ||||
|                 <input asp-for="Input.ConfirmPassword" class="form-control" /> | ||||
|                 <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span> | ||||
|             </div> | ||||
|             <button type="submit" class="btn btn-primary">Register</button> | ||||
|         </form> | ||||
|     </div> | ||||
|     <div class="col-md-6 col-md-offset-2"> | ||||
|         <section> | ||||
|             <h4>Use another service to register.</h4> | ||||
|             <hr /> | ||||
|             @{ | ||||
|                 if ((Model.ExternalLogins?.Count ?? 0) == 0) | ||||
|                 { | ||||
|                     <div> | ||||
|                         <p> | ||||
|                             There are no external authentication services configured. See <a href="https://go.microsoft.com/fwlink/?LinkID=532715">this article</a> | ||||
|                             for details on setting up this ASP.NET application to support logging in via external services. | ||||
|                         </p> | ||||
|                     </div> | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     <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> | ||||
|                                 } | ||||
|                             </p> | ||||
|                         </div> | ||||
|                     </form> | ||||
|                 } | ||||
|             } | ||||
|         </section> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| @section Scripts { | ||||
|     <partial name="_ValidationScriptsPartial" /> | ||||
| } | ||||
							
								
								
									
										115
									
								
								Props/server/Areas/Identity/Pages/Account/Register.cshtml.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,115 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Text.Encodings.Web; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.AspNetCore.Authentication; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Identity; | ||||
| using Microsoft.AspNetCore.Identity.UI.Services; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.AspNetCore.Mvc.RazorPages; | ||||
| using Microsoft.AspNetCore.WebUtilities; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Props.Models; | ||||
|  | ||||
| namespace Props.Areas.Identity.Pages.Account | ||||
| { | ||||
|     [AllowAnonymous] | ||||
|     public class RegisterModel : PageModel | ||||
|     { | ||||
|         private readonly SignInManager<ApplicationUser> _signInManager; | ||||
|         private readonly UserManager<ApplicationUser> _userManager; | ||||
|         private readonly ILogger<RegisterModel> _logger; | ||||
|         private readonly IEmailSender _emailSender; | ||||
|  | ||||
|         public RegisterModel( | ||||
|             UserManager<ApplicationUser> userManager, | ||||
|             SignInManager<ApplicationUser> signInManager, | ||||
|             ILogger<RegisterModel> logger, | ||||
|             IEmailSender emailSender) | ||||
|         { | ||||
|             _userManager = userManager; | ||||
|             _signInManager = signInManager; | ||||
|             _logger = logger; | ||||
|             _emailSender = emailSender; | ||||
|         } | ||||
|  | ||||
|         [BindProperty] | ||||
|         public InputModel Input { get; set; } | ||||
|  | ||||
|         public string ReturnUrl { get; set; } | ||||
|  | ||||
|         public IList<AuthenticationScheme> ExternalLogins { get; set; } | ||||
|  | ||||
|         public class InputModel | ||||
|         { | ||||
|             [Required] | ||||
|             [EmailAddress] | ||||
|             [Display(Name = "Email")] | ||||
|             public string Email { get; set; } | ||||
|  | ||||
|             [Required] | ||||
|             [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] | ||||
|             [DataType(DataType.Password)] | ||||
|             [Display(Name = "Password")] | ||||
|             public string Password { get; set; } | ||||
|  | ||||
|             [DataType(DataType.Password)] | ||||
|             [Display(Name = "Confirm password")] | ||||
|             [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] | ||||
|             public string ConfirmPassword { get; set; } | ||||
|         } | ||||
|  | ||||
|         public async Task OnGetAsync(string returnUrl = null) | ||||
|         { | ||||
|             ReturnUrl = returnUrl; | ||||
|             ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); | ||||
|         } | ||||
|  | ||||
|         public async Task<IActionResult> OnPostAsync(string returnUrl = null) | ||||
|         { | ||||
|             returnUrl ??= Url.Content("~/"); | ||||
|             ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); | ||||
|             if (ModelState.IsValid) | ||||
|             { | ||||
|                 var user = new ApplicationUser { UserName = Input.Email, Email = Input.Email }; | ||||
|                 var result = await _userManager.CreateAsync(user, Input.Password); | ||||
|                 if (result.Succeeded) | ||||
|                 { | ||||
|                     _logger.LogInformation("User created a new account with password."); | ||||
|  | ||||
|                     var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); | ||||
|                     code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); | ||||
|                     var callbackUrl = Url.Page( | ||||
|                         "/Account/ConfirmEmail", | ||||
|                         pageHandler: null, | ||||
|                         values: new { area = "Identity", userId = user.Id, code = code, returnUrl = returnUrl }, | ||||
|                         protocol: Request.Scheme); | ||||
|  | ||||
|                     await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", | ||||
|                         $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>."); | ||||
|  | ||||
|                     if (_userManager.Options.SignIn.RequireConfirmedAccount) | ||||
|                     { | ||||
|                         return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl }); | ||||
|                     } | ||||
|                     else | ||||
|                     { | ||||
|                         await _signInManager.SignInAsync(user, isPersistent: false); | ||||
|                         return LocalRedirect(returnUrl); | ||||
|                     } | ||||
|                 } | ||||
|                 foreach (var error in result.Errors) | ||||
|                 { | ||||
|                     ModelState.AddModelError(string.Empty, error.Description); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // If we got this far, something failed, redisplay form | ||||
|             return Page(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| @page | ||||
| @model RegisterConfirmationModel | ||||
| @{ | ||||
|     ViewData["Title"] = "Register confirmation"; | ||||
| } | ||||
|  | ||||
| <h1>@ViewData["Title"]</h1> | ||||
| @{ | ||||
|     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> | ||||
| </p> | ||||
|     } | ||||
|     else | ||||
|     { | ||||
| <p> | ||||
|         Please check your email to confirm your account. | ||||
| </p> | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,62 @@ | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.AspNetCore.Identity; | ||||
| using Microsoft.AspNetCore.Identity.UI.Services; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.AspNetCore.Mvc.RazorPages; | ||||
| using Microsoft.AspNetCore.WebUtilities; | ||||
| using Props.Models; | ||||
|  | ||||
| namespace Props.Areas.Identity.Pages.Account | ||||
| { | ||||
|     [AllowAnonymous] | ||||
|     public class RegisterConfirmationModel : PageModel | ||||
|     { | ||||
|         private readonly UserManager<ApplicationUser> _userManager; | ||||
|         private readonly IEmailSender _sender; | ||||
|  | ||||
|         public RegisterConfirmationModel(UserManager<ApplicationUser> userManager, IEmailSender sender) | ||||
|         { | ||||
|             _userManager = userManager; | ||||
|             _sender = sender; | ||||
|         } | ||||
|  | ||||
|         public string Email { get; set; } | ||||
|  | ||||
|         public bool DisplayConfirmAccountLink { get; set; } | ||||
|  | ||||
|         public string EmailConfirmationUrl { get; set; } | ||||
|  | ||||
|         public async Task<IActionResult> OnGetAsync(string email, string returnUrl = null) | ||||
|         { | ||||
|             if (email == null) | ||||
|             { | ||||
|                 return RedirectToPage("/Index"); | ||||
|             } | ||||
|  | ||||
|             var user = await _userManager.FindByEmailAsync(email); | ||||
|             if (user == null) | ||||
|             { | ||||
|                 return NotFound($"Unable to load user with email '{email}'."); | ||||
|             } | ||||
|  | ||||
|             Email = email; | ||||
|             // Once you add a real email sender, you should remove this code that lets you confirm the account | ||||
|             DisplayConfirmAccountLink = true; | ||||
|             if (DisplayConfirmAccountLink) | ||||
|             { | ||||
|                 var userId = await _userManager.GetUserIdAsync(user); | ||||
|                 var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); | ||||
|                 code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); | ||||
|                 EmailConfirmationUrl = Url.Page( | ||||
|                     "/Account/ConfirmEmail", | ||||
|                     pageHandler: null, | ||||
|                     values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl }, | ||||
|                     protocol: Request.Scheme); | ||||
|             } | ||||
|  | ||||
|             return Page(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1 @@ | ||||
| @using Props.Areas.Identity.Pages.Account | ||||
							
								
								
									
										5
									
								
								Props/server/Areas/Identity/Pages/_ViewImports.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | ||||
| @using Microsoft.AspNetCore.Identity | ||||
| @using Props.Areas.Identity | ||||
| @using Props.Areas.Identity.Pages | ||||
| @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers | ||||
| @using Props.Models | ||||
							
								
								
									
										4
									
								
								Props/server/Areas/Identity/Pages/_ViewStart.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | ||||
|  | ||||
| @{ | ||||
|     Layout = "/Pages/Shared/_Layout.cshtml"; | ||||
| } | ||||
| @@ -1,42 +0,0 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.Extensions.Logging; | ||||
|  | ||||
| namespace Props.Controllers | ||||
| { | ||||
|     // TODO: Create new shop search controller. | ||||
|     [Authorize] | ||||
|     [ApiController] | ||||
|     [Route("api/[controller]")] | ||||
|     public class WeatherForecastController : ControllerBase | ||||
|     { | ||||
|         private static readonly string[] Summaries = new[] | ||||
|         { | ||||
|             "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" | ||||
|         }; | ||||
|  | ||||
|         private readonly ILogger<WeatherForecastController> _logger; | ||||
|  | ||||
|         public WeatherForecastController(ILogger<WeatherForecastController> logger) | ||||
|         { | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         [HttpGet] | ||||
|         public IEnumerable<WeatherForecast> Get() | ||||
|         { | ||||
|             var rng = new Random(); | ||||
|             return Enumerable.Range(1, 5).Select(index => new WeatherForecast | ||||
|             { | ||||
|                 Date = DateTime.Now.AddDays(index), | ||||
|                 TemperatureC = rng.Next(-20, 55), | ||||
|                 Summary = Summaries[rng.Next(Summaries.Length)] | ||||
|             }) | ||||
|             .ToArray(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -9,7 +9,7 @@ using Props.Data; | ||||
| namespace Props.Data.Migrations | ||||
| { | ||||
|     [DbContext(typeof(ApplicationDbContext))] | ||||
|     [Migration("20210714061021_InitialCreate")] | ||||
|     [Migration("20210714061654_InitialCreate")] | ||||
|     partial class InitialCreate | ||||
|     { | ||||
|         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||
| @@ -9,10 +9,6 @@ namespace Props.Shared.Models | ||||
|         [Required] | ||||
|         public string ApplicationUserId { get; set; } | ||||
|  | ||||
|         public bool DarkMode { get; set; } | ||||
|  | ||||
|         public bool CacheCommonSearches { get; set; } = true; | ||||
|  | ||||
|         public bool EnableSearchHistory { get; set; } = true; | ||||
|     } | ||||
| } | ||||
| @@ -12,7 +12,6 @@ namespace Props.Models | ||||
|     { | ||||
|         [Required] | ||||
|         public virtual SearchOutline SearchOutline { get; private set; } | ||||
|  | ||||
|         [Required] | ||||
|         public virtual ResultsPreferences ResultsPreferences { get; private set; } | ||||
|  | ||||
|   | ||||
							
								
								
									
										57
									
								
								Props/server/Pages/Shared/_Layout.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,57 @@ | ||||
| @using Microsoft.AspNetCore.Hosting | ||||
| @using Microsoft.AspNetCore.Mvc.ViewEngines | ||||
| @inject IWebHostEnvironment Environment | ||||
| @inject ICompositeViewEngine Engine | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title>@ViewData["Title"] - Props</title> | ||||
|     <link rel="stylesheet" href="~/Identity/lib/bootstrap/dist/css/bootstrap.min.css" /> | ||||
|     <link rel="stylesheet" href="~/Identity/css/site.css" /> | ||||
| </head> | ||||
| <body> | ||||
|     <header> | ||||
|         <nav class="navbar navbar-expand-sm navbar-light navbar-toggleable-sm bg-white border-bottom box-shadow mb-3"> | ||||
|             <div class="container"> | ||||
|                 <a class="navbar-brand" href="~/">Props</a> | ||||
|                 <button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent" | ||||
|                         aria-expanded="false" aria-label="Toggle navigation"> | ||||
|                     <span class="navbar-toggler-icon"></span> | ||||
|                 </button> | ||||
|                 <div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse"> | ||||
|                     @{ | ||||
|                         var result = Engine.FindView(ViewContext, "_LoginPartial", isMainPage: false); | ||||
|                     } | ||||
|                     @if (result.Success) | ||||
|                     { | ||||
|                         await Html.RenderPartialAsync("_LoginPartial"); | ||||
|                     } | ||||
|                     else | ||||
|                     { | ||||
|                         throw new InvalidOperationException("The default Identity UI layout requires a partial view '_LoginPartial' " + | ||||
|                             "usually located at '/Pages/_LoginPartial' or at '/Views/Shared/_LoginPartial' to work. Based on your configuration " + | ||||
|                             $"we have looked at it in the following locations: {System.Environment.NewLine}{string.Join(System.Environment.NewLine, result.SearchedLocations)}."); | ||||
|                     } | ||||
|                 </div> | ||||
|             </div> | ||||
|         </nav> | ||||
|     </header> | ||||
|  | ||||
|     <div class="container"> | ||||
|         <main role="main" class="pb-3"> | ||||
|             @RenderBody() | ||||
|         </main> | ||||
|     </div> | ||||
|     <footer class="footer border-top text-muted"> | ||||
|         <div class="container"> | ||||
|             © 2021 - Props - <a asp-area="" asp-page="Privacy">Privacy</a> | ||||
|         </div> | ||||
|     </footer> | ||||
|     <script src="~/Identity/lib/jquery/dist/jquery.min.js"></script> | ||||
|     <script src="~/Identity/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script> | ||||
|     <script src="~/Identity/js/site.js" asp-append-version="true"></script> | ||||
|     @RenderSection("Scripts", required: false) | ||||
| </body> | ||||
| </html> | ||||
							
								
								
									
										18
									
								
								Props/server/Pages/Shared/_ValidationScriptsPartial.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | ||||
| <environment include="Development"> | ||||
|     <script src="~/Identity/lib/jquery-validation/dist/jquery.validate.js"></script> | ||||
|     <script src="~/Identity/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js"></script> | ||||
| </environment> | ||||
| <environment exclude="Development"> | ||||
|     <script src="https://ajax.aspnetcdn.com/ajax/jquery.validate/1.17.0/jquery.validate.min.js" | ||||
|             asp-fallback-src="~/Identity/lib/jquery-validation/dist/jquery.validate.min.js" | ||||
|             asp-fallback-test="window.jQuery && window.jQuery.validator" | ||||
|             crossorigin="anonymous" | ||||
|             integrity="sha384-rZfj/ogBloos6wzLGpPkkOr/gpkBNLZ6b6yLy4o+ok+t/SAKlL5mvXLr0OXNi1Hp"> | ||||
|     </script> | ||||
|     <script src="https://ajax.aspnetcdn.com/ajax/jquery.validation.unobtrusive/3.2.9/jquery.validate.unobtrusive.min.js" | ||||
|             asp-fallback-src="~/Identity/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js" | ||||
|             asp-fallback-test="window.jQuery && window.jQuery.validator && window.jQuery.validator.unobtrusive" | ||||
|             crossorigin="anonymous" | ||||
|             integrity="sha384-ifv0TYDWxBHzvAk2Z0n8R434FL1Rlv/Av18DXE43N/1rvHyOG4izKst0f2iSLdds"> | ||||
|     </script> | ||||
| </environment> | ||||
							
								
								
									
										3
									
								
								Props/server/Pages/_ViewStart.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| @{ | ||||
|     Layout = "_Layout"; | ||||
| } | ||||
| @@ -5,7 +5,7 @@ | ||||
|     <TypeScriptCompileBlocked>true</TypeScriptCompileBlocked> | ||||
|     <TypeScriptToolsVersion>Latest</TypeScriptToolsVersion> | ||||
|     <IsPackable>false</IsPackable> | ||||
|     <SpaRoot>../client/</SpaRoot> | ||||
|     <SpaRoot>../spa/</SpaRoot> | ||||
|     <DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes> | ||||
|  | ||||
|     <!-- Set this to true if you enable server-side prerendering --> | ||||
| @@ -16,12 +16,21 @@ | ||||
|     <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="5.0.5" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.ApiAuthorization.IdentityServer" Version="5.0.5" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="5.0.5" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="5.0.5" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="5.0.5" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="5.0.8" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="5.0.8" /> | ||||
|     <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.8"> | ||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|       <PrivateAssets>all</PrivateAssets> | ||||
|     </PackageReference> | ||||
|     <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="5.0.8" /> | ||||
|     <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.5" /> | ||||
|     <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.8" /> | ||||
|     <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.5" /> | ||||
|     <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.5" /> | ||||
|     <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="5.0.8" /> | ||||
|     <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.8"> | ||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|       <PrivateAssets>all</PrivateAssets> | ||||
|     </PackageReference> | ||||
|     <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="5.0.2" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|   | ||||
							
								
								
									
										3
									
								
								Props/server/ScaffoldingReadMe.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| Support for ASP.NET Core Identity was added to your project. | ||||
|  | ||||
| For setup and configuration information, see https://go.microsoft.com/fwlink/?linkid=2116645. | ||||
| @@ -53,7 +53,7 @@ namespace Props | ||||
|             services.AddRazorPages(); | ||||
|             services.AddSpaStaticFiles(configuration => | ||||
|             { | ||||
|                 configuration.RootPath = "client/dist"; | ||||
|                 configuration.RootPath = "spa/dist"; | ||||
|             }); | ||||
|         } | ||||
|  | ||||
| @@ -91,7 +91,7 @@ namespace Props | ||||
|  | ||||
|             app.UseSpa(spa => | ||||
|             { | ||||
|                 spa.Options.SourcePath = "../client"; // "May not exist in published applications" - https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.spaservices.spaoptions.sourcepath?view=aspnetcore-5.0#Microsoft_AspNetCore_SpaServices_SpaOptions_SourcePath  | ||||
|                 spa.Options.SourcePath = "../spa"; // "May not exist in published applications" - https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.spaservices.spaoptions.sourcepath?view=aspnetcore-5.0#Microsoft_AspNetCore_SpaServices_SpaOptions_SourcePath  | ||||
|  | ||||
|                 if (env.IsDevelopment()) | ||||
|                 { | ||||
|   | ||||
| @@ -1233,11 +1233,6 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "@juggle/resize-observer": { | ||||
|       "version": "3.3.1", | ||||
|       "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.3.1.tgz", | ||||
|       "integrity": "sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==" | ||||
|     }, | ||||
|     "@mrmlnc/readdir-enhanced": { | ||||
|       "version": "2.2.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", | ||||
| @@ -4682,11 +4677,6 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "can-use-dom": { | ||||
|       "version": "0.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/can-use-dom/-/can-use-dom-0.1.0.tgz", | ||||
|       "integrity": "sha1-IsxKNKCrxDlQ9CxkEQJKP2NmtFo=" | ||||
|     }, | ||||
|     "caniuse-api": { | ||||
|       "version": "3.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", | ||||
| @@ -11163,7 +11153,8 @@ | ||||
|     "lodash.debounce": { | ||||
|       "version": "4.0.8", | ||||
|       "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", | ||||
|       "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" | ||||
|       "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "lodash.defaults": { | ||||
|       "version": "4.2.0", | ||||
| @@ -11222,7 +11213,8 @@ | ||||
|     "lodash.memoize": { | ||||
|       "version": "4.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", | ||||
|       "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=" | ||||
|       "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "lodash.merge": { | ||||
|       "version": "4.6.2", | ||||
| @@ -11261,11 +11253,6 @@ | ||||
|         "lodash._reinterpolate": "^3.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "lodash.throttle": { | ||||
|       "version": "4.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", | ||||
|       "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" | ||||
|     }, | ||||
|     "lodash.transform": { | ||||
|       "version": "4.6.0", | ||||
|       "resolved": "https://registry.npmjs.org/lodash.transform/-/lodash.transform-4.6.0.tgz", | ||||
| @@ -15309,28 +15296,6 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "simplebar": { | ||||
|       "version": "5.3.4", | ||||
|       "resolved": "https://registry.npmjs.org/simplebar/-/simplebar-5.3.4.tgz", | ||||
|       "integrity": "sha512-2mCaVdiroCKmXuD+Qfy+QSE32m5BMuZ4ssHvRD1QEPYH95Re/kox7j/Wy0Hje8Uo7LY7O6JK3XSNJmesGlsP8Q==", | ||||
|       "requires": { | ||||
|         "@juggle/resize-observer": "^3.3.1", | ||||
|         "can-use-dom": "^0.1.0", | ||||
|         "core-js": "^3.0.1", | ||||
|         "lodash.debounce": "^4.0.8", | ||||
|         "lodash.memoize": "^4.1.2", | ||||
|         "lodash.throttle": "^4.1.1" | ||||
|       } | ||||
|     }, | ||||
|     "simplebar-vue": { | ||||
|       "version": "1.6.6", | ||||
|       "resolved": "https://registry.npmjs.org/simplebar-vue/-/simplebar-vue-1.6.6.tgz", | ||||
|       "integrity": "sha512-rtS9HC5KSVj8eMp37DxCldtihf7gECvLD3iE/iHfnG34I5kU1puERE51cpLJZV3PxcsKg5AIMdd5irGRCb88Qw==", | ||||
|       "requires": { | ||||
|         "core-js": "^3.0.1", | ||||
|         "simplebar": "^5.3.4" | ||||
|       } | ||||
|     }, | ||||
|     "slash": { | ||||
|       "version": "3.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", | ||||
| Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB | 
| Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.2 KiB | 
| Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB | 
| Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB | 
| Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB | 
| Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB | 
| Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB | 
| Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB | 
| Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB | 
| Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB | 
| Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB | 
| Before Width: | Height: | Size: 799 B After Width: | Height: | Size: 799 B | 
| Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB | 
| Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB | 
| Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB | 
| Before Width: | Height: | Size: 215 B After Width: | Height: | Size: 215 B | 
| Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB | 
| Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB | 
| @@ -1,9 +1,13 @@ | ||||
| // Used to provide inial layout for application containing elements. | ||||
| html, body, #app, #app-content { | ||||
|     min-height: 100vh; | ||||
| html { | ||||
|     min-height: 100%; | ||||
|     height: 100%; | ||||
| } | ||||
| html, body, #app, #content { | ||||
|     min-height: 100%; | ||||
| } | ||||
| 
 | ||||
| #app-content { | ||||
| #content { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
							
								
								
									
										3
									
								
								Props/spa/src/assets/scss/_variables.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| $themes: ( | ||||
|     "light": ("background": #F4F4F4, "navbar": #FFF7F7, "main": #BDF2D5, "sub": #F2FCFC, "bold": #1E56A0, "footer": #F4F4F4, "text": #1A1A1A, "muted": #797a7e), | ||||
| ); | ||||
| @@ -1,19 +1,42 @@ | ||||
| @use "app-layout"; | ||||
| @use "base"; | ||||
| @use "../../../node_modules/bootstrap/scss/bootstrap"; | ||||
| 
 | ||||
| #app-content { | ||||
| #nav { | ||||
|     @extend .navbar; | ||||
|     @extend .navbar-expand-lg; | ||||
|     @extend .sticky-top; | ||||
|     @include themer.themed { | ||||
|         background-color: themer.color-of("navbar"); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| .nav-link, .navbar-brand { | ||||
|     @include themer.themed { | ||||
|         color: themer.color-of("bold"); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #content { | ||||
|     @include themer.themed { | ||||
|         background-color: themer.color-of("background"); | ||||
|         color: themer.color-of("text"); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| nav.navbar { | ||||
|     @extend .navbar-expand-lg; | ||||
|     @extend .sticky-top; | ||||
| #footer { | ||||
|     @extend .py-2; | ||||
|     @extend .text-center; | ||||
|     @extend .border-top; | ||||
|     @include themer.themed { | ||||
|         @extend .navbar-light; | ||||
|         background-color: themer.color-of("navbar"); | ||||
|         background-color: themer.color-of("footer"); | ||||
|         color: themer.color-of("muted"); | ||||
|     } | ||||
| 
 | ||||
|     a { | ||||
|         text-decoration: none; | ||||
|         @include themer.themed { | ||||
|             color: themer.color-of("muted"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
|     <div id="app-content" class="theme-light"> | ||||
|     <div id="content" class="theme-light"> | ||||
|         <nav class="navbar" id="nav"> | ||||
|             <div class="container-fluid"> | ||||
|                 <router-link class="navbar-brand" to="/">Props</router-link> | ||||
| @@ -32,26 +32,25 @@ | ||||
|                     </ul> | ||||
|                     <ul class="navbar-nav ms-auto mb-2 mb-lg-0"> | ||||
|                         <li class="nav-item"> | ||||
|                             <ProfileDisplay> | ||||
|                             </ProfileDisplay> | ||||
|                             <ProfileDisplay> </ProfileDisplay> | ||||
|                         </li> | ||||
|                         <li class="nav-item"> | ||||
|                             <ProfileSignUp> | ||||
|                             </ProfileSignUp> | ||||
|                             <ProfileSignUp> </ProfileSignUp> | ||||
|                         </li> | ||||
|                         <li class="nav-item"> | ||||
|                             <ProfileLogIn> | ||||
|                             </ProfileLogIn> | ||||
|                             <ProfileLogIn> </ProfileLogIn> | ||||
|                         </li> | ||||
|                         <li class="nav-item"> | ||||
|                             <ProfileLogOut> | ||||
|                             </ProfileLogOut> | ||||
|                             <ProfileLogOut> </ProfileLogOut> | ||||
|                         </li> | ||||
|                     </ul> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </nav> | ||||
|         <router-view /> | ||||
|         <footer id="footer"> | ||||
|             © 2021 - Props - <a href="">Privacy</a> | ||||
|         </footer> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||