Merge branch 'develop'
All checks were successful
ydeng/props/pipeline/head This commit looks good

This commit is contained in:
Harrison Deng 2024-07-20 14:26:54 +00:00
commit a92762b85b
137 changed files with 175884 additions and 8 deletions

11
.devcontainer/Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM mcr.microsoft.com/devcontainers/anaconda:1-3
# Copy environment.yml (if found) to a temp location so we update the environment. Also
# copy "noop.txt" so the COPY instruction does not fail if no environment.yml exists.
COPY environment.yml* .devcontainer/noop.txt /tmp/conda-tmp/
RUN if [ -f "/tmp/conda-tmp/environment.yml" ]; then umask 0002 && /opt/conda/bin/conda env update -n base -f /tmp/conda-tmp/environment.yml; fi \
&& rm -rf /tmp/conda-tmp
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>

View File

@ -0,0 +1,31 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/debian
{
"name": "Debian",
"build": {
"context": "..",
"dockerfile": "Dockerfile"
},
"customizations": {
"vscode": {
"extensions": [
"ms-dotnettools.vscode-dotnet-runtime",
"svelte.svelte-vscode",
"syler.sass-indented",
"dbaeumer.vscode-eslint",
"ms-dotnettools.csharp"
],
"settings": {
"python.defaultInterpreterPath": "/opt/conda/bin/python",
"dotnet.dotnetPath": "/opt/conda/lib/dotnet/",
"omnisharp.dotNetCliPaths": [
"/opt/conda/lib/dotnet/dotnet"
]
}
}
},
"postCreateCommand": "bash -i -c 'conda init'"
}

3
.devcontainer/noop.txt Normal file
View File

@ -0,0 +1,3 @@
This file is copied into the container along with environment.yml* from the
parent folder. This is done to prevent the Dockerfile COPY instruction from
failing if no environment.yml is found.

307
.gitignore vendored
View File

@ -1,6 +1,5 @@
# Created by https://www.toptal.com/developers/gitignore/api/vscode,aspnetcore
# Edit at https://www.toptal.com/developers/gitignore?templates=vscode,aspnetcore
# Created by https://www.toptal.com/developers/gitignore/api/aspnetcore,visualstudiocode,dotnetcore,python,database,node
# Edit at https://www.toptal.com/developers/gitignore?templates=aspnetcore,visualstudiocode,dotnetcore,python,database,node
### ASPNETCore ###
## Ignore Visual Studio temporary files, build results, and
@ -30,7 +29,7 @@ bld/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
@ -272,7 +271,287 @@ __pycache__/
# Cake - Uncomment if you are using it
# tools/
### vscode ###
### Database ###
*.accdb
*.db
*.dbf
*.mdb
*.sqlite3
### DotnetCore ###
# .NET Core build folders
bin/
obj/
# Common node modules locations
/node_modules
/wwwroot/node_modules
### Node ###
# Logs
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.production
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
.stylelintcache
# SvelteKit build / generate output
.svelte-kit
### Python ###
# Byte-compiled / optimized / DLL files
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
@ -280,4 +559,20 @@ __pycache__/
!.vscode/extensions.json
*.code-workspace
# End of https://www.toptal.com/developers/gitignore/api/vscode,aspnetcore
# Local History for Visual Studio Code
.history/
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# Support for Project snippet scope
!.vscode/*.code-snippets
# End of https://www.toptal.com/developers/gitignore/api/aspnetcore,visualstudiocode,dotnetcore,python,database,node
# Props
shop-data
Props/shops
output

0
.gitmodules vendored Normal file
View File

26
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,26 @@
{
"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/dotnet/vscode-csharp/blob/main/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}/Props.Shop/Adafruit.Tests/bin/Debug/net8.0/Props.Shop.Adafruit.Tests.dll",
"args": [],
"cwd": "${workspaceFolder}/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"
}
]
}

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"python.defaultInterpreterPath": "/opt/conda/bin/python",
"dotnet.dotnetPath": "/opt/conda/lib/dotnet/",
"omnisharp.dotNetCliPaths": [
"/opt/conda/lib/dotnet/dotnet"
]
}

41
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/Props.Shop/Props.Shop.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/Props.Shop/Props.Shop.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/Props.Shop/Props.Shop.sln"
],
"problemMatcher": "$msCompile"
}
]
}

9
.woodpecker.yaml Normal file
View File

@ -0,0 +1,9 @@
steps:
- name: build
image: debian
commands:
- echo "This is the build step"
- name: a-test-step
image: debian
commands:
- echo "Testing.."

56
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,56 @@
pipeline {
agent {
kubernetes {
cloud 'Reslate Systems'
defaultContainer 'conda'
}
}
environment {
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT = "1"
}
stages {
stage("Install") {
steps {
sh 'conda update conda -y -q'
sh 'conda env update -n base --file environment.yml'
sh 'conda run -n base dotnet restore props.sln'
sh 'npm install --prefix ./Props'
}
}
stage("Test Props.Shop") {
steps {
sh returnStatus: true, script: 'conda run -n base dotnet test --logger xunit --no-restore Props.Shop/**/*.Tests.csproj'
xunit([xUnitDotNet(excludesPattern: '', pattern: 'Props.Shop/*.Tests/TestResults/*.xml', stopProcessingIfError: true)])
}
}
stage("Publish Props.Shop") {
steps {
sh '''#!/bin/bash
for file in Props.Shop/**/*.csproj
do
conda run -n base dotnet publish --configuration Release --output output/shop-modules $file
done
'''
fingerprint 'output/shop-modules/**/Props.Shop.*'
sh 'mkdir -p ./Props/shops/'
sh 'cp ./output/shop-modules/*.dll ./output/shop-modules/*.deps.json ./Props/shops/.'
}
}
stage("Test Props") {
steps {
sh returnStatus: true, script: 'conda run -n base dotnet test --logger xunit --no-restore Props.Tests/Props.Tests.csproj'
xunit([xUnitDotNet(excludesPattern: '', pattern: 'Props.Tests/TestResults/*.xml', stopProcessingIfError: true)])
}
}
stage("Publish Props") {
steps {
sh 'conda run -n base dotnet publish --configuration Release --output output/props/props-linux-x64 --runtime linux-x64 --self-contained Props'
sh 'conda run -n base dotnet publish --configuration Release --output output/props/props-win-x64 --runtime win-x64 --self-contained Props'
fingerprint 'output/props/**/Props*'
sh 'conda run -n base tar -czf output/props-linux-x64.tar.gz output/props/props-linux-x64'
sh 'conda run -n base zip -r output/props-win-x64.zip output/props/props-win-x64'
archiveArtifacts artifacts: 'output/*.tar.gz,output/*.zip', followSymlinks: false
}
}
}
}

View File

@ -0,0 +1,21 @@
using System.Linq;
using Microsoft.Extensions.Logging;
using Props.Shop.Framework;
using Xunit;
namespace Props.Shop.Adafruit.Tests
{
public class AdafruitShopTest
{
[Fact]
public async void TestSearch()
{
AdafruitShop mockAdafruitShop = new AdafruitShop();
await mockAdafruitShop.Initialize(null, LoggerFactory.Create(builder =>
{
builder.AddXUnit();
}));
Assert.NotEmpty(mockAdafruitShop.Search("raspberry pi", new Filters()).ToEnumerable());
}
}
}

View File

@ -0,0 +1,89 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Props.Shop.Adafruit.Api;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Tests.Api
{
public class FakeProductListingManager : IProductListingManager
{
private bool disposedValue;
public DateTime? LastDownload { get; private set; }
private ProductListingsParser parser = new ProductListingsParser();
private readonly ConcurrentDictionary<string, ProductListing> activeProductListingUrls = new ConcurrentDictionary<string, ProductListing>();
public Task<IReadOnlyDictionary<string, IList<ProductListing>>> ProductListings { get; private set; }
public async Task<ProductListing> GetProductListingFromIdentifier(string url)
{
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
await ProductListings;
return activeProductListingUrls[url];
}
public void RefreshProductListings()
{
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
if ((LastDownload != null && DateTime.UtcNow - LastDownload <= TimeSpan.FromMilliseconds(5 * 60 * 1000)) || (ProductListings != null && !ProductListings.IsCompleted)) return;
ProductListings = DownloadListings();
}
private Task<IReadOnlyDictionary<string, IList<ProductListing>>> DownloadListings()
{
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
LastDownload = DateTime.UtcNow;
using (Stream stream = File.OpenRead("./Assets/products.json"))
{
parser.BuildProductListings(stream);
}
Dictionary<string, IList<ProductListing>> listingNames = new Dictionary<string, IList<ProductListing>>();
activeProductListingUrls.Clear();
foreach (ProductListing product in parser.ProductListings)
{
activeProductListingUrls.TryAdd(product.URL, product);
IList<ProductListing> sameProducts = listingNames.GetValueOrDefault(product.Name);
if (sameProducts == null)
{
sameProducts = new List<ProductListing>();
listingNames.Add(product.Name, sameProducts);
}
sameProducts.Add(product);
}
return Task.FromResult<IReadOnlyDictionary<string, IList<ProductListing>>>(listingNames);
}
public void StartUpdateTimer(int delay = 300000, int period = 300000)
{
RefreshProductListings();
}
public void StopUpdateTimer()
{
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,21 @@
using System;
using System.IO;
using Props.Shop.Adafruit.Api;
using Xunit;
namespace Props.Shop.Adafruit.Tests
{
public class ListingParserTest
{
[Fact]
public void TestParsing()
{
ProductListingsParser mockParser = new ProductListingsParser();
using (Stream stream = File.OpenRead("./Assets/products.json"))
{
mockParser.BuildProductListings(stream);
}
Assert.NotEmpty(mockParser.ProductListings);
}
}
}

View File

@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Props.Shop.Adafruit.Api;
using Props.Shop.Adafruit.Persistence;
using Props.Shop.Framework;
using Xunit;
namespace Props.Shop.Adafruit.Tests.Api
{
public class LiveProductListingManagerTest
{
[Fact]
public async Task CacheTest()
{
// TODO: Improve testability of caching system, IProductListingManager, and implement here.
//Given
ProductListingsParser parser = new ProductListingsParser();
using (Stream stream = File.OpenRead("./Assets/products.json"))
{
parser.BuildProductListings(stream);
}
Dictionary<string, IList<ProductListing>> listingNames = new Dictionary<string, IList<ProductListing>>();
foreach (ProductListing product in parser.ProductListings)
{
IList<ProductListing> sameProducts = listingNames.GetValueOrDefault(product.Name);
if (sameProducts == null)
{
sameProducts = new List<ProductListing>();
listingNames.Add(product.Name, sameProducts);
}
sameProducts.Add(product);
}
ProductListingCacheData cache = new ProductListingCacheData(listingNames);
await Task.Delay(500);
LiveProductListingManager mockLiveProductListingManager = new LiveProductListingManager(null, new Logger<LiveProductListingManager>(LoggerFactory.Create((builder) => builder.AddXUnit())), cache);
//When
mockLiveProductListingManager.RefreshProductListings();
//Then
Assert.True(cache.LastUpdatedUtc.Equals(mockLiveProductListingManager.LastDownload));
Assert.NotEmpty(await mockLiveProductListingManager.ProductListings);
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<InvariantGlobalization>true</InvariantGlobalization>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MartinCostello.Logging.XUnit" Version="0.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="System.Linq.Async" Version="5.1.0" />
<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="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="XunitXml.TestLogger" Version="3.0.70" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Adafruit\Props.Shop.Adafruit.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Assets\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Props.Shop.Adafruit.Api;
using Props.Shop.Adafruit.Persistence;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit
{
public class AdafruitShop : IShop, IDisposable
{
private string workspaceDir;
private ILoggerFactory loggerFactory;
private ILogger<AdafruitShop> logger;
private SearchManager searchManager;
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 async ValueTask Initialize(string workspaceDir, ILoggerFactory loggerFactory)
{
workspaceDir = workspaceDir ?? "";
this.workspaceDir = workspaceDir;
this.loggerFactory = loggerFactory;
logger = loggerFactory.CreateLogger<AdafruitShop>();
http = new HttpClient();
http.BaseAddress = new Uri("http://www.adafruit.com/api/");
string configPath = Path.Combine(workspaceDir, Configuration.FILE_NAME);
try
{
configuration = JsonSerializer.Deserialize<Configuration>(File.ReadAllText(configPath));
}
catch (JsonException e)
{
logger.LogWarning("Could not read JSON file \"{0}\": {1}", configPath, e.Message);
}
catch (ArgumentException)
{
logger.LogWarning("No working directory path provided.");
}
catch (DirectoryNotFoundException)
{
logger.LogWarning("Directory \"{0}\" could not be found.", Path.GetDirectoryName(configPath));
}
catch (FileNotFoundException)
{
logger.LogWarning("File \"{0}\" could not be found.", configPath);
}
finally
{
if (configuration == null)
{
configuration = new Configuration();
}
}
ProductListingCacheData listingData = null;
string cachePath = Path.Combine(workspaceDir, ProductListingCacheData.FILE_NAME);
try
{
using (Stream fileStream = File.OpenRead(cachePath))
{
listingData = await JsonSerializer.DeserializeAsync<ProductListingCacheData>(fileStream);
}
}
catch (JsonException e)
{
logger.LogWarning("Could not read JSON file \"{0}\": {1}", cachePath, e.Message);
}
catch (ArgumentException)
{
logger.LogWarning("No working directory path provided.");
}
catch (DirectoryNotFoundException)
{
logger.LogWarning("Directory \"{0}\" could not be found.", Path.GetDirectoryName(cachePath));
}
catch (FileNotFoundException)
{
logger.LogWarning("File \"{0}\" could not be found.", cachePath);
}
finally
{
if (configuration == null)
{
configuration = new Configuration();
}
}
LiveProductListingManager productListingManager = new LiveProductListingManager(http, loggerFactory.CreateLogger<LiveProductListingManager>(), listingData, configuration.MinDownloadInterval);
this.searchManager = new SearchManager(productListingManager, configuration.Similarity);
productListingManager.StartUpdateTimer(delay: 0, configuration.CacheLifespan);
}
public async Task<ProductListing> GetProductFromIdentifier(string identifier)
{
return await searchManager.ProductListingManager.GetProductListingFromIdentifier(identifier);
}
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters)
{
return searchManager.Search(query);
}
public async ValueTask SaveData()
{
if (workspaceDir != null)
{
logger.LogDebug("Saving data in \"{0}\"...", workspaceDir);
string configurationPath = Path.Combine(workspaceDir, Configuration.FILE_NAME);
File.Delete(configurationPath);
await File.WriteAllTextAsync(Path.Combine(workspaceDir, Configuration.FILE_NAME), JsonSerializer.Serialize(configuration));
string productListingCachePath = Path.Combine(workspaceDir, ProductListingCacheData.FILE_NAME);
File.Delete(productListingCachePath);
using (Stream fileStream = File.OpenWrite(productListingCachePath))
{
await JsonSerializer.SerializeAsync(fileStream, new ProductListingCacheData(await searchManager.ProductListingManager.ProductListings));
}
logger.LogDebug("Completed saving data.");
}
}
public async ValueTask DisposeAsync()
{
Dispose(true);
await DisposeAsyncCore();
}
protected virtual async ValueTask DisposeAsyncCore()
{
await SaveData();
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
http.Dispose();
searchManager.Dispose();
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public interface IProductListingManager : IDisposable
{
public Task<IReadOnlyDictionary<string, IList<ProductListing>>> ProductListings { get; }
public void RefreshProductListings();
public void StartUpdateTimer(int delay = 1000 * 60 * 5, int period = 1000 * 60 * 5);
public void StopUpdateTimer();
public DateTime? LastDownload { get; }
public Task<ProductListing> GetProductListingFromIdentifier(string url);
}
}

View File

@ -0,0 +1,52 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public class ProductListingsParser
{
public IEnumerable<ProductListing> ProductListings { get; private set; }
public void BuildProductListings(Stream stream)
{
using (StreamReader streamReader = new StreamReader(stream))
{
DateTime startTime = DateTime.UtcNow;
dynamic data = JArray.Load(new JsonTextReader(streamReader));
List<ProductListing> parsed = new List<ProductListing>();
foreach (dynamic item in data)
{
if (item.products_discontinued == 0)
{
ProductListing res = new ProductListing();
res.TimeFetchedUtc = startTime;
res.Name = item.product_name;
res.LowerPrice = item.product_price;
res.UpperPrice = res.LowerPrice;
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);
res.Identifier = res.URL;
}
}
ProductListings = parsed;
}
}
}
}

View File

@ -0,0 +1,125 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Props.Shop.Adafruit.Persistence;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public class LiveProductListingManager : IProductListingManager
{
private ILogger<LiveProductListingManager> logger;
private bool disposedValue;
private int minDownloadInterval;
public DateTime? LastDownload { get; private set; }
private object refreshLock = new object();
private volatile Task<IReadOnlyDictionary<string, IList<ProductListing>>> productListingsTask;
public Task<IReadOnlyDictionary<string, IList<ProductListing>>> ProductListings => productListingsTask;
private readonly ConcurrentDictionary<string, ProductListing> identifierMap = new ConcurrentDictionary<string, ProductListing>();
private ProductListingsParser parser = new ProductListingsParser();
private HttpClient httpClient;
private Timer updateTimer;
public LiveProductListingManager(HttpClient httpClient, ILogger<LiveProductListingManager> logger, ProductListingCacheData productListingCacheData = null, int minDownloadInterval = 5 * 60 * 1000)
{
this.logger = logger;
this.minDownloadInterval = minDownloadInterval;
this.httpClient = httpClient;
if (productListingCacheData != null)
{
productListingsTask = Task.FromResult(productListingCacheData.ProductListings);
LastDownload = productListingCacheData.LastUpdatedUtc;
logger.LogInformation("{0} Cached product listings loaded. Listing saved at {1}", productListingCacheData.ProductListings.Count, productListingCacheData.LastUpdatedUtc);
}
}
public void RefreshProductListings()
{
lock (refreshLock)
{
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
if ((LastDownload != null && DateTime.UtcNow - LastDownload <= TimeSpan.FromMilliseconds(minDownloadInterval)) || (productListingsTask != null && !productListingsTask.IsCompleted)) return;
LastDownload = DateTime.UtcNow;
logger.LogDebug("Refreshing listings ({0}).", LastDownload);
productListingsTask = DownloadListings();
}
}
public async Task<ProductListing> GetProductListingFromIdentifier(string identifier)
{
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
await productListingsTask;
return identifierMap[identifier];
}
private async Task<IReadOnlyDictionary<string, IList<ProductListing>>> DownloadListings()
{
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
logger.LogDebug("Beginning listing database download.");
HttpResponseMessage responseMessage = await httpClient.GetAsync("products");
parser.BuildProductListings(responseMessage.Content.ReadAsStream());
logger.LogDebug("Listing database parsed.");
Dictionary<string, IList<ProductListing>> listingNames = new Dictionary<string, IList<ProductListing>>();
identifierMap.Clear();
foreach (ProductListing product in parser.ProductListings)
{
identifierMap.TryAdd(product.Identifier, product);
IList<ProductListing> sameProducts = listingNames.GetValueOrDefault(product.Name);
if (sameProducts == null)
{
sameProducts = new List<ProductListing>();
listingNames.Add(product.Name, sameProducts);
}
sameProducts.Add(product);
}
logger.LogDebug("Downloaded listings organized.");
return listingNames;
}
public void StartUpdateTimer(int delay = 1000 * 60 * 5, int period = 1000 * 60 * 5)
{
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
if (updateTimer != null) throw new InvalidOperationException("Update timer already started.");
logger.LogInformation("Starting update timer.");
updateTimer = new Timer((state) =>
{
RefreshProductListings();
}, null, delay, period);
}
public void StopUpdateTimer()
{
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
if (updateTimer != null) throw new InvalidOperationException("Update timer not started.");
logger.LogInformation("Stopping update timer.");
updateTimer.Dispose();
updateTimer = null;
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
updateTimer?.Dispose();
updateTimer = null;
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,61 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using FuzzySharp;
using FuzzySharp.Extractor;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public class SearchManager : IDisposable
{
public float Similarity { get; set; }
public IProductListingManager ProductListingManager { get; private set; }
private bool disposedValue;
public SearchManager(IProductListingManager productListingManager, float similarity = 0.8f)
{
this.ProductListingManager = productListingManager ?? throw new ArgumentNullException("productListingManager");
this.Similarity = similarity;
}
public async IAsyncEnumerable<ProductListing> Search(string query)
{
// TODO: Implement indexed search.
if (ProductListingManager.ProductListings == null) {
ProductListingManager.RefreshProductListings();
}
IReadOnlyDictionary<string, IList<ProductListing>> productListings = await ProductListingManager.ProductListings;
if (productListings == null) throw new InvalidAsynchronousStateException("productListings can't be null");
foreach (ExtractedResult<string> listingNames in Process.ExtractAll(query, productListings.Keys, cutoff: (int)(Similarity * 100)))
{
foreach (ProductListing same in productListings[listingNames.Value])
{
yield return same;
}
}
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
ProductListingManager.Dispose();
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,17 @@
namespace Props.Shop.Adafruit.Persistence
{
public class Configuration
{
public const string FILE_NAME = "config.json";
public int MinDownloadInterval { get; set; }
public int CacheLifespan { get; set; }
public float Similarity { get; set; }
public Configuration()
{
MinDownloadInterval = 5 * 60 * 1000;
Similarity = 0.8f;
CacheLifespan = 5 * 60 * 1000;
}
}
}

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Persistence
{
public class ProductListingCacheData
{
public const string FILE_NAME = "Product-listing-cache.json";
public DateTime LastUpdatedUtc { get; set; }
public IReadOnlyDictionary<string, IList<ProductListing>> ProductListings { get; set; }
public ProductListingCacheData(IReadOnlyDictionary<string, IList<ProductListing>> productListings)
{
this.ProductListings = productListings;
LastUpdatedUtc = DateTime.UtcNow;
}
public ProductListingCacheData()
{
}
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<InvariantGlobalization>true</InvariantGlobalization>
</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>

View File

@ -0,0 +1,8 @@
namespace Props.Shop.Framework
{
public enum Currency
{
CAD,
USD
}
}

View File

@ -0,0 +1,108 @@
using System;
namespace Props.Shop.Framework
{
public class Filters
{
public Currency Currency { get; set; } = Currency.CAD;
private float minRatingNormalized = 0.8f;
public int MinRating
{
get
{
return (int)(minRatingNormalized * 100f);
}
set
{
if (value < 0 || value > 100) return;
minRatingNormalized = value / 100f;
}
}
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 KeepUnknownReviewCount { get; set; } = true;
public bool EnableMaxShipping { get; set; }
private int maxShippingFee;
public int MaxShippingFee
{
get
{
return maxShippingFee;
}
set
{
if (EnableMaxShipping) 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 &&
KeepUnknownReviewCount == other.KeepUnknownReviewCount &&
EnableMaxShipping == other.EnableMaxShipping &&
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();
}
public bool Validate(ProductListing listing)
{
if (listing.Shipping == null && !KeepUnknownShipping || (EnableMaxShipping && listing.Shipping > MaxShippingFee)) return false;
float shippingDifference = listing.Shipping != null ? listing.Shipping.Value : 0;
if (!(listing.LowerPrice + shippingDifference >= LowerPrice && (!EnableUpperPrice || listing.UpperPrice + shippingDifference <= UpperPrice))) return false;
if ((listing.Rating == null && !KeepUnrated) && MinRating > (listing.Rating == null ? 0 : listing.Rating)) return false;
if ((listing.PurchaseCount == null && !KeepUnknownPurchaseCount) || MinPurchases > (listing.PurchaseCount == null ? 0 : listing.PurchaseCount)) return false;
if ((listing.ReviewCount == null && !KeepUnknownReviewCount) || MinReviews > (listing.ReviewCount == null ? 0 : listing.ReviewCount)) return false;
return true;
}
}
}

View 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; }
}
}

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Props.Shop.Framework
{
public interface IShop : IAsyncDisposable
{
string ShopName { get; }
string ShopDescription { get; }
string ShopModuleAuthor { get; }
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters);
public Task<ProductListing> GetProductFromIdentifier(string identifier);
ValueTask Initialize(string workspaceDir, ILoggerFactory loggerFactory);
public SupportedFeatures SupportedFeatures { get; }
}
}

View File

@ -0,0 +1,55 @@
using System;
namespace Props.Shop.Framework
{
public class ProductListing
{
public float LowerPrice { get; set; }
public float UpperPrice { get; set; }
public float? Shipping { get; set; }
public string Name { get; set; }
public string URL { get; set; }
public string ImageURL { get; set; }
public float? Rating { get; set; }
public int? PurchaseCount { get; set; }
public int? ReviewCount { get; set; }
public bool ConvertedPrices { get; set; }
public bool? InStock { get; set; }
public string Identifier { get; set; }
public DateTime TimeFetchedUtc { get; set; }
public override bool Equals(object obj)
{
if (obj == null || GetType() != obj.GetType())
{
return false;
}
ProductListing other = obj as ProductListing;
return
this.LowerPrice == other.LowerPrice &&
this.UpperPrice == other.UpperPrice &&
this.Shipping == other.Shipping &&
this.Name == other.Name &&
this.URL == other.URL &&
this.ImageURL == other.ImageURL &&
this.Rating == other.Rating &&
this.PurchaseCount == other.PurchaseCount &&
this.ReviewCount == other.ReviewCount &&
this.ConvertedPrices == other.ConvertedPrices &&
this.InStock == other.InStock &&
this.Identifier == other.Identifier &&
this.TimeFetchedUtc == other.TimeFetchedUtc;
}
public override int GetHashCode()
{
return (Name, URL, UpperPrice, LowerPrice, ImageURL).GetHashCode();
}
public ProductListing Copy()
{
return MemberwiseClone() as ProductListing;
}
}
}

View File

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
</ItemGroup>
</Project>

View 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;
}
}
}

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<InvariantGlobalization>true</InvariantGlobalization>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<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="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="XunitXml.TestLogger" Version="3.0.70" />
</ItemGroup>
</Project>

12
Props.Tests/UnitTest1.cs Normal file
View File

@ -0,0 +1,12 @@
using Xunit;
namespace Props.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

6
Props/.editorconfig Normal file
View File

@ -0,0 +1,6 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true
quote_type = double

31
Props/.eslintrc.js Normal file
View File

@ -0,0 +1,31 @@
module.exports = {
"env": {
"browser": true,
"es2021": true,
"node": true,
},
"extends": "eslint:recommended",
"parser": "@babel/eslint-parser",
"rules": {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"indent": [
"error",
4
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"always"
],
"comma-dangle": ["error", "only-multiline"],
"space-before-function-paren": ["error", {
"anonymous": "always",
"named": "never",
"asyncArrow": "always"
}]
}
};

36
Props/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,36 @@
{
"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 (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/bin/Debug/net7.0/Props.dll",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
// Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach",
"processId": "${command:pickProcess}"
}
]
}

9
Props/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"todo-tree.filtering.excludeGlobs": [
"**/node_modules",
"**/wwwroot"
],
"todo-tree.regex.regex": "((//|#|<!--|;|@\\*|/\\*|^)\\s*($TAGS)|^\\s*- \\[ \\])",
"todo-tree.regex.subTagRegex": "(\\*@)",
"editor.formatOnSave": true,
}

51
Props/.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,51 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/Props.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/Props.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"${workspaceFolder}/Props.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "reset database",
"command": "py",
"type": "process",
"args": [
"${workspaceFolder}/../scripts/reset_db.py"
],
"problemMatcher": []
}
]
}

View File

@ -0,0 +1,20 @@
using System;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
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) => {
});
}
}
}

View File

@ -0,0 +1,7 @@
@page
@model ConfirmEmailModel
@{
ViewData["Title"] = "Confirm email";
}
<h1>@ViewData["Title"]</h1>

View File

@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using Props.Models;
using Props.Models.User;
namespace Props.Areas.Identity.Pages.Account
{
[AllowAnonymous]
public class ConfirmEmailModel : PageModel
{
private readonly UserManager<ApplicationUser> _userManager;
public ConfirmEmailModel(UserManager<ApplicationUser> userManager)
{
_userManager = userManager;
}
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGetAsync(string userId, string code)
{
if (userId == null || code == null)
{
return RedirectToPage("/Index");
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
return NotFound($"Unable to load user with ID '{userId}'.");
}
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
var result = await _userManager.ConfirmEmailAsync(user, code);
StatusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
return Page();
}
}
}

View File

@ -0,0 +1,83 @@
@page
@model LoginModel
@{
ViewData["Title"] = "Log in";
}
<div class="jumbotron text-center">
<img alt="Props logo" src="~/images/logo-simplified.svg" class="img-fluid" style="max-height: 150px;"
asp-append-version="true" />
<h1 class="mt-3 mb-4 display-1">@ViewData["Title"]</h1>
<p>Welcome back!</p>
</div>
<div class="jumbotron sub flex-grow-1">
<div class="py-4 row justify-content-md-center">
<div class="col-md-4">
<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="mb-3">
<label asp-for="Input.Email" class="form-label"></label>
<input asp-for="Input.Email" class="form-control" />
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Input.Password" class="form-label"></label>
<input asp-for="Input.Password" class="form-control" />
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<div class="mb-3">
<div class="form-check">
<input asp-for="Input.RememberMe" class="form-check-input" />
<label asp-for="Input.RememberMe" class="form-check-label">
@Html.DisplayNameFor(m => m.Input.RememberMe)
</label>
</div>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">Log in</button>
</div>
<div class="row">
<div class="col-lg">
<a class="link-secondary" id="forgot-password" asp-page="./ForgotPassword">Forgot your
password?</a>
</div>
<div class="col-lg">
<a class="link-secondary" asp-page="./Register" asp-route-returnUrl="@Model.ReturnUrl">Register
as a new user</a>
</div>
<div class="col-lg">
<a class="link-secondary" id="resend-confirmation" asp-page="./ResendEmailConfirmation">Resend
email confirmation</a>
</div>
</div>
</form>
</div>
@if ((Model.ExternalLogins?.Count ?? 0) != 0)
{
<div class="col-md-6 md-offset-2">
<h4>Use another service to log in.</h4>
<hr />
<form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post"
class="form-horizontal">
<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>
</div>
}
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,112 @@
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.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.Extensions.Logging;
using Props.Models;
using Props.Models.User;
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();
}
}
}

View 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>

View File

@ -0,0 +1,45 @@
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;
using Props.Models.User;
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();
}
}
}
}

View File

@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace Props.Areas.Identity.Pages.Account.Manage
{
public static class ManageNavPages
{
public static string Index => "Index";
public static string Email => "Email";
public static string ChangePassword => "ChangePassword";
public static string DownloadPersonalData => "DownloadPersonalData";
public static string DeletePersonalData => "DeletePersonalData";
public static string ExternalLogins => "ExternalLogins";
public static string PersonalData => "PersonalData";
public static string TwoFactorAuthentication => "TwoFactorAuthentication";
public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index);
public static string EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email);
public static string ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword);
public static string DownloadPersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DownloadPersonalData);
public static string DeletePersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DeletePersonalData);
public static string ExternalLoginsNavClass(ViewContext viewContext) => PageNavClass(viewContext, ExternalLogins);
public static string PersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, PersonalData);
public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication);
private static string PageNavClass(ViewContext viewContext, string page)
{
var activePage = viewContext.ViewData["ActivePage"] as string
?? System.IO.Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName);
return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null;
}
}
}

View File

@ -0,0 +1,32 @@
@{
if (ViewData.TryGetValue("ParentLayout", out var parentLayout))
{
Layout = (string)parentLayout;
}
else
{
Layout = "/Areas/Identity/Pages/_Layout.cshtml";
}
}
<div class="container">
<h1>Manage your account</h1>
<div>
<h4>Change your account settings</h4>
<hr />
<div class="row">
<div class="col-md-3">
<partial name="_ManageNav" />
</div>
<div class="col-md-9">
@RenderBody()
</div>
</div>
</div>
</div>
@section Scripts {
@RenderSection("Scripts", required: false)
}

View File

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

View File

@ -0,0 +1,10 @@
@model string
@if (!String.IsNullOrEmpty(Model))
{
var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success";
<div class="alert alert-@statusMessageClass alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
@Model
</div>
}

View File

@ -0,0 +1 @@
@using Props.Areas.Identity.Pages.Account.Manage

View File

@ -0,0 +1,59 @@
@page
@model RegisterModel
@{
ViewData["Title"] = "Register";
}
<div class="jumbotron text-center">
<img alt="Props logo" src="~/images/logo-simplified.svg" class="img-fluid" style="max-height: 150px;" asp-append-version="true" />
<h1 class="mt-3 mb-4 display-1">@ViewData["Title"]</h1>
<p>Create more projects and access them across your devices! Join the community and show off your projects to the world!</p>
</div>
<div class="jumbotron sub flex-grow-1">
<div class="py-3 row justify-content-md-center">
<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="mb-3">
<label asp-for="Input.Email" class="form-label"></label>
<input asp-for="Input.Email" class="form-control" />
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Input.Password" class="form-label"></label>
<input asp-for="Input.Password" class="form-control" />
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Input.ConfirmPassword" class="form-label"></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>
@if ((Model.ExternalLogins?.Count ?? 0) != 0)
{
<div class="col-md-6 md-offset-2">
<h4>Use another service to register.</h4>
<hr />
<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>
</div>
}
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View 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.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.Extensions.Logging;
using Props.Models.User;
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();
}
}
}

View File

@ -0,0 +1,25 @@
@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>
}
}
@* TODO: implement account confirmation. *@

View File

@ -0,0 +1,62 @@
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.AspNetCore.Identity.UI.Services;
using Props.Models.User;
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();
}
}
}

View File

@ -0,0 +1 @@
@using Props.Areas.Identity.Pages.Account

View 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>

View 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.User

View File

@ -0,0 +1,3 @@
@{
Layout = "/Pages/Shared/_Layout.cshtml";
}

View File

@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc;
namespace Props.Controllers
{
[ApiController]
[Route("api/[Controller]")]
public class ApiControllerBase : ControllerBase
{
}
}

View File

@ -0,0 +1,38 @@
using System.Collections;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Props.Models.Search;
using Props.Services.Modules;
using Props.Shop.Framework;
namespace Props.Controllers
{
public class SearchController : ApiControllerBase
{
private SearchOutline defaultOutline = new SearchOutline();
ISearchManager searchManager;
public SearchController(ISearchManager searchManager)
{
this.searchManager = searchManager;
}
[HttpGet]
[Route("AvailableShops")]
public async Task<IActionResult> GetAvailableShops()
{
return Ok(await searchManager.ShopManager.GetAllShopNames());
}
[HttpGet]
[Route("SearchShops/{search}/")]
public async Task<IActionResult> GetSearch(string searchQuery, [FromQuery] SearchOutline searchOutline)
{
if (searchQuery == null) return BadRequest();
return Ok(await searchManager.Search(searchQuery, searchOutline));
}
}
}

View File

@ -0,0 +1,211 @@
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 Props.Data;
using Props.Models.Search;
using Props.Models.User;
using Props.Shop.Framework;
namespace Props.Controllers
{
public class SearchOutlineController : ApiControllerBase
{
private ApplicationDbContext dbContext;
private UserManager<ApplicationUser> userManager;
public SearchOutlineController(UserManager<ApplicationUser> userManager, ApplicationDbContext dbContext)
{
this.userManager = userManager;
this.dbContext = dbContext;
}
[HttpDelete]
[Authorize]
[Route("{name:required}")]
public async Task<IActionResult> DeleteSearchOutline(string name)
{
if (string.IsNullOrEmpty(name))
{
return BadRequest();
}
ApplicationUser user = await userManager.GetUserAsync(User);
SearchOutlinePreferences searchOutlinePrefs = user.searchOutlinePreferences;
searchOutlinePrefs.SearchOutlines.Remove(searchOutlinePrefs.SearchOutlines.Single((outline) => name.Equals(outline.Name)));
await userManager.UpdateAsync(user);
return NoContent();
}
[HttpPost]
[Authorize]
[Route("{name:required}")]
public async Task<IActionResult> PostSearchOutline(string name)
{
if (string.IsNullOrEmpty(name)) return BadRequest();
ApplicationUser user = await userManager.GetUserAsync(User);
SearchOutline searchOutline = user.searchOutlinePreferences.SearchOutlines.SingleOrDefault((outline) => name.Equals(outline.Name));
if (searchOutline != null) return BadRequest();
searchOutline = new SearchOutline();
searchOutline.Name = name;
user.searchOutlinePreferences.SearchOutlines.Add(searchOutline);
await userManager.UpdateAsync(user);
return NoContent();
}
[HttpPut]
[Authorize]
[Route("{name:required}/Filters")]
public async Task<IActionResult> PutFilters(string name, Filters filters)
{
if (string.IsNullOrEmpty(name)) return BadRequest();
ApplicationUser user = await userManager.GetUserAsync(User);
SearchOutline searchOutline = await GetSearchOutlineByName(name);
if (searchOutline == null) return BadRequest();
searchOutline.Filters = filters;
await userManager.UpdateAsync(user);
return NoContent();
}
[HttpPut]
[Authorize]
[Route("{outlineName:required}/DisabledShops")]
public async Task<IActionResult> PutShopSelection(string outlineName, ISet<string> disabledShops)
{
if (string.IsNullOrEmpty(outlineName)) return BadRequest();
if (disabledShops == null) return BadRequest();
ApplicationUser user = await userManager.GetUserAsync(User);
SearchOutline searchOutline = await GetSearchOutlineByName(outlineName);
if (searchOutline == null) return BadRequest();
searchOutline.DisabledShops.Clear();
searchOutline.DisabledShops.UnionWith(disabledShops);
await userManager.UpdateAsync(user);
return NoContent();
}
[HttpPut]
[Authorize]
[Route("{oldName:required}/Name/{newName:required}")]
public async Task<IActionResult> PutName(string oldName, string newName)
{
if (oldName == newName) return BadRequest();
ApplicationUser user = await userManager.GetUserAsync(User);
SearchOutline outline = await GetSearchOutlineByName(oldName);
if (outline == null) return BadRequest();
if (user.searchOutlinePreferences.SearchOutlines.Any((outline) => outline.Name.Equals(newName))) return BadRequest();
outline.Name = newName;
if (user.searchOutlinePreferences.NameOfLastUsed == oldName)
{
user.searchOutlinePreferences.NameOfLastUsed = newName;
}
await userManager.UpdateAsync(user);
return NoContent();
}
[HttpPut]
[Authorize]
[Route("{name:required}/LastUsed")]
public async Task<IActionResult> PutLastUsed(string name)
{
SearchOutline outline = await GetSearchOutlineByName(name);
if (outline == null) return BadRequest();
ApplicationUser user = await userManager.GetUserAsync(User);
user.searchOutlinePreferences.NameOfLastUsed = name;
await userManager.UpdateAsync(user);
return NoContent();
}
[HttpGet]
[Authorize]
[Route("{name:required}/Filters")]
public async Task<IActionResult> GetFilters(string name)
{
Filters filters = (await GetSearchOutlineByName(name))?.Filters;
if (filters == null) return BadRequest();
return Ok(filters);
}
[HttpGet]
[Authorize]
[Route("{name:required}/DisabledShops")]
public async Task<IActionResult> GetDisabledShops(string name)
{
SearchOutline searchOutline = await GetSearchOutlineByName(name);
if (searchOutline == null)
{
return BadRequest();
}
return Ok(searchOutline.DisabledShops);
}
[HttpGet]
[Authorize]
[Route("Names")]
public async Task<IActionResult> GetSearchOutlineNames()
{
ApplicationUser user = await userManager.GetUserAsync(User);
return Ok(user.searchOutlinePreferences.SearchOutlines.Select((outline, Index) => outline.Name));
}
[HttpGet]
[Authorize]
[Route("LastUsed")]
public async Task<IActionResult> GetLastSearchOutlineName()
{
SearchOutline searchOutline = await GetLastUsedSearchOutline();
return Ok(searchOutline?.Name);
}
[HttpGet]
[Route("DefaultDisabledShops")]
public IActionResult GetDefaultDisabledShops()
{
return Ok(new SearchOutline.ShopSelector());
}
[HttpGet]
[Route("DefaultFilters")]
public IActionResult GetDefaultFilter()
{
return Ok(new Filters());
}
[HttpGet]
[Route("DefaultName")]
public async Task<IActionResult> GetDefaultName()
{
string nameTemplate = "Search Outline {0}";
if (User.Identity.IsAuthenticated)
{
ApplicationUser user = await userManager.GetUserAsync(User);
int number = user.searchOutlinePreferences.SearchOutlines.Count;
string name = null;
do
{
name = string.Format(nameTemplate, number);
number += 1;
} while (user.searchOutlinePreferences.SearchOutlines.Any((outline) => name.Equals(outline.Name)));
return Ok(name);
}
return Ok("Search Outline");
}
private async Task<SearchOutline> GetLastUsedSearchOutline()
{
if (!User.Identity.IsAuthenticated) return null;
ApplicationUser user = await userManager.GetUserAsync(User);
return user.searchOutlinePreferences.SearchOutlines.SingleOrDefault((outline) => outline.Name.Equals(user.searchOutlinePreferences.NameOfLastUsed));
}
private async Task<SearchOutline> GetSearchOutlineByName(string name)
{
if (name == null) throw new ArgumentNullException("name");
if (!User.Identity.IsAuthenticated) return null;
ApplicationUser user = await userManager.GetUserAsync(User);
return user.searchOutlinePreferences.SearchOutlines.SingleOrDefault(outline => outline.Name.Equals(name));
}
}
}

View File

@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Mvc;
namespace Props.Controllers
{
public class UserController : ApiControllerBase
{
[HttpGet]
[Route("LoggedIn")]
public IActionResult GetLoggedIn()
{
return Ok(User.Identity.IsAuthenticated);
}
}
}

View File

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.Localization;
using Props.Models;
using Props.Models.Search;
using Props.Models.User;
using Props.Shop.Framework;
namespace Props.Data
{
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public DbSet<QueryWordInfo> QueryWords { get; set; }
public DbSet<ProductListingInfo> ProductListingInfos { get; set; }
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ResultsPreferences>()
.Property(e => e.Order)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<List<ResultsPreferences.Category>>(v, (JsonSerializerOptions)null),
new ValueComparer<IList<ResultsPreferences.Category>>(
(a, b) => a.SequenceEqual(b),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => (IList<ResultsPreferences.Category>)c.ToList()
)
);
modelBuilder.Entity<SearchOutline>()
.Property(e => e.DisabledShops)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<SearchOutline.ShopSelector>(v, (JsonSerializerOptions)null),
new ValueComparer<SearchOutline.ShopSelector>(
(a, b) => a.Equals(b),
c => c.GetHashCode(),
c => new SearchOutline.ShopSelector(c)
)
);
modelBuilder.Entity<SearchOutline>()
.Property(e => e.Filters)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<Filters>(v, (JsonSerializerOptions)null),
new ValueComparer<Filters>(
(a, b) => a.Equals(b),
c => c.GetHashCode(),
c => c.Copy()
)
);
modelBuilder.Entity<ProductListingInfo>()
.Property(e => e.ProductListing)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<ProductListing>(v, (JsonSerializerOptions)null),
new ValueComparer<ProductListing>(
(a, b) => a.Equals(b),
c => c.GetHashCode(),
c => c.Copy()
)
);
}
}
}

View File

@ -0,0 +1,499 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Props.Data;
namespace Props.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20210817042955_InitialCreate")]
partial class InitialCreate
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("Props.Models.ResultsPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Order")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ProfileName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("ResultsPreferences");
});
modelBuilder.Entity("Props.Models.Search.ProductListingInfo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("Hits")
.HasColumnType("INTEGER");
b.Property<string>("ProductListing")
.HasColumnType("TEXT");
b.Property<string>("ProductListingIdentifier")
.HasColumnType("TEXT");
b.Property<string>("ShopName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("ProductListingInfos");
});
modelBuilder.Entity("Props.Models.Search.QueryWordInfo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("Hits")
.HasColumnType("INTEGER");
b.Property<string>("Word")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("QueryWords");
});
modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DisabledShops")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Filters")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SearchOutlinePreferencesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SearchOutlinePreferencesId");
b.ToTable("SearchOutline");
});
modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameOfLastUsed")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("SearchOutlinePreferences");
});
modelBuilder.Entity("Props.Shared.Models.User.ApplicationPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("DarkMode")
.HasColumnType("INTEGER");
b.Property<bool>("EnableSearchHistory")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("ApplicationPreferences");
});
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.Property<int>("FollowingId")
.HasColumnType("INTEGER");
b.Property<int>("PrecedingId")
.HasColumnType("INTEGER");
b.HasKey("FollowingId", "PrecedingId");
b.HasIndex("PrecedingId");
b.ToTable("QueryWordInfoQueryWordInfo");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Props.Models.User.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Props.Models.ResultsPreferences", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("ResultsPreferences")
.HasForeignKey("Props.Models.ResultsPreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
{
b.HasOne("Props.Models.User.SearchOutlinePreferences", "SearchOutlinePreferences")
.WithMany("SearchOutlines")
.HasForeignKey("SearchOutlinePreferencesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SearchOutlinePreferences");
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("searchOutlinePreferences")
.HasForeignKey("Props.Models.User.SearchOutlinePreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("Props.Shared.Models.User.ApplicationPreferences", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("ApplicationPreferences")
.HasForeignKey("Props.Shared.Models.User.ApplicationPreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("FollowingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("PrecedingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
{
b.Navigation("ApplicationPreferences")
.IsRequired();
b.Navigation("ResultsPreferences")
.IsRequired();
b.Navigation("searchOutlinePreferences")
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Navigation("SearchOutlines");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,404 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Props.Data.Migrations
{
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ProductListingInfos",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ShopName = table.Column<string>(type: "TEXT", nullable: true),
Hits = table.Column<uint>(type: "INTEGER", nullable: false),
ProductListing = table.Column<string>(type: "TEXT", nullable: true),
ProductListingIdentifier = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ProductListingInfos", x => x.Id);
});
migrationBuilder.CreateTable(
name: "QueryWords",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Word = table.Column<string>(type: "TEXT", nullable: false),
Hits = table.Column<uint>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_QueryWords", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoleId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ApplicationPreferences",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: false),
EnableSearchHistory = table.Column<bool>(type: "INTEGER", nullable: false),
DarkMode = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ApplicationPreferences", x => x.Id);
table.ForeignKey(
name: "FK_ApplicationPreferences_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
RoleId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ResultsPreferences",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: false),
Order = table.Column<string>(type: "TEXT", nullable: false),
ProfileName = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ResultsPreferences", x => x.Id);
table.ForeignKey(
name: "FK_ResultsPreferences_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SearchOutlinePreferences",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: false),
NameOfLastUsed = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SearchOutlinePreferences", x => x.Id);
table.ForeignKey(
name: "FK_SearchOutlinePreferences_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "QueryWordInfoQueryWordInfo",
columns: table => new
{
FollowingId = table.Column<int>(type: "INTEGER", nullable: false),
PrecedingId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_QueryWordInfoQueryWordInfo", x => new { x.FollowingId, x.PrecedingId });
table.ForeignKey(
name: "FK_QueryWordInfoQueryWordInfo_QueryWords_FollowingId",
column: x => x.FollowingId,
principalTable: "QueryWords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_QueryWordInfoQueryWordInfo_QueryWords_PrecedingId",
column: x => x.PrecedingId,
principalTable: "QueryWords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SearchOutline",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SearchOutlinePreferencesId = table.Column<int>(type: "INTEGER", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Filters = table.Column<string>(type: "TEXT", nullable: true),
DisabledShops = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SearchOutline", x => x.Id);
table.ForeignKey(
name: "FK_SearchOutline_SearchOutlinePreferences_SearchOutlinePreferencesId",
column: x => x.SearchOutlinePreferencesId,
principalTable: "SearchOutlinePreferences",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ApplicationPreferences_ApplicationUserId",
table: "ApplicationPreferences",
column: "ApplicationUserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_QueryWordInfoQueryWordInfo_PrecedingId",
table: "QueryWordInfoQueryWordInfo",
column: "PrecedingId");
migrationBuilder.CreateIndex(
name: "IX_ResultsPreferences_ApplicationUserId",
table: "ResultsPreferences",
column: "ApplicationUserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SearchOutline_SearchOutlinePreferencesId",
table: "SearchOutline",
column: "SearchOutlinePreferencesId");
migrationBuilder.CreateIndex(
name: "IX_SearchOutlinePreferences_ApplicationUserId",
table: "SearchOutlinePreferences",
column: "ApplicationUserId",
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApplicationPreferences");
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "ProductListingInfos");
migrationBuilder.DropTable(
name: "QueryWordInfoQueryWordInfo");
migrationBuilder.DropTable(
name: "ResultsPreferences");
migrationBuilder.DropTable(
name: "SearchOutline");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "QueryWords");
migrationBuilder.DropTable(
name: "SearchOutlinePreferences");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

View File

@ -0,0 +1,497 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Props.Data;
namespace Props.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("Props.Models.ResultsPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Order")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ProfileName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("ResultsPreferences");
});
modelBuilder.Entity("Props.Models.Search.ProductListingInfo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("Hits")
.HasColumnType("INTEGER");
b.Property<string>("ProductListing")
.HasColumnType("TEXT");
b.Property<string>("ProductListingIdentifier")
.HasColumnType("TEXT");
b.Property<string>("ShopName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("ProductListingInfos");
});
modelBuilder.Entity("Props.Models.Search.QueryWordInfo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("Hits")
.HasColumnType("INTEGER");
b.Property<string>("Word")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("QueryWords");
});
modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DisabledShops")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Filters")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SearchOutlinePreferencesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SearchOutlinePreferencesId");
b.ToTable("SearchOutline");
});
modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameOfLastUsed")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("SearchOutlinePreferences");
});
modelBuilder.Entity("Props.Shared.Models.User.ApplicationPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("DarkMode")
.HasColumnType("INTEGER");
b.Property<bool>("EnableSearchHistory")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("ApplicationPreferences");
});
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.Property<int>("FollowingId")
.HasColumnType("INTEGER");
b.Property<int>("PrecedingId")
.HasColumnType("INTEGER");
b.HasKey("FollowingId", "PrecedingId");
b.HasIndex("PrecedingId");
b.ToTable("QueryWordInfoQueryWordInfo");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Props.Models.User.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Props.Models.ResultsPreferences", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("ResultsPreferences")
.HasForeignKey("Props.Models.ResultsPreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
{
b.HasOne("Props.Models.User.SearchOutlinePreferences", "SearchOutlinePreferences")
.WithMany("SearchOutlines")
.HasForeignKey("SearchOutlinePreferencesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SearchOutlinePreferences");
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("searchOutlinePreferences")
.HasForeignKey("Props.Models.User.SearchOutlinePreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("Props.Shared.Models.User.ApplicationPreferences", b =>
{
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("ApplicationPreferences")
.HasForeignKey("Props.Shared.Models.User.ApplicationPreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("FollowingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("PrecedingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
{
b.Navigation("ApplicationPreferences")
.IsRequired();
b.Navigation("ResultsPreferences")
.IsRequired();
b.Navigation("searchOutlinePreferences")
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Navigation("SearchOutlines");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

@ -0,0 +1,18 @@
using System;
using Props.Shop.Framework;
namespace Props.Models.Search
{
public class ProductListingInfo
{
public int Id { get; set; }
public string ShopName { get; set; }
public uint Hits { get; set; }
public ProductListing ProductListing { get; set; }
public string ProductListingIdentifier { get; set; }
}
}

View File

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

View File

@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Props.Models.User;
using Props.Shop.Framework;
namespace Props.Models.Search
{
public class SearchOutline
{
public int Id { get; set; }
public int SearchOutlinePreferencesId { get; set; }
[Required]
public virtual SearchOutlinePreferences SearchOutlinePreferences { get; set; }
[Required]
public string Name { get; set; }
public Filters Filters { get; set; }
[Required]
public ShopSelector DisabledShops { get; set; }
public sealed class ShopSelector : HashSet<string>
{
public bool this[string name]
{
get
{
return this.Contains(name);
}
set
{
if (value)
{
this.Add(name);
}
else
{
this.Remove(name);
}
}
}
public ShopSelector()
{
}
public ShopSelector(IEnumerable<string> disabledShops) : base(disabledShops)
{
}
}
public override bool Equals(object obj)
{
if (obj == null || GetType() != obj.GetType())
{
return false;
}
SearchOutline other = (SearchOutline)obj;
return
Id == other.Id &&
Name.Equals(other.Name) &&
Filters.Equals(other.Filters) &&
DisabledShops.Equals(other.DisabledShops);
}
public override int GetHashCode()
{
return HashCode.Combine(Id, Name, Filters, DisabledShops);
}
public SearchOutline()
{
this.Filters = new Filters();
this.DisabledShops = new ShopSelector();
}
public SearchOutline(string name, Filters filters, ShopSelector disabled)
{
this.Name = name;
this.Filters = filters;
this.DisabledShops = disabled;
}
}
}

View File

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using Props.Models.User;
namespace Props.Shared.Models.User
{
public class ApplicationPreferences
{
public int Id { get; set; }
[Required]
public string ApplicationUserId { get; set; }
[Required]
public virtual ApplicationUser ApplicationUser { get; set; }
public bool EnableSearchHistory { get; set; } = true;
public bool DarkMode { get; set; } = false;
}
}

View File

@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Props.Models.Search;
using Props.Shared.Models.User;
namespace Props.Models.User
{
public class ApplicationUser : IdentityUser
{
[Required]
public virtual SearchOutlinePreferences searchOutlinePreferences { get; set; }
[Required]
public virtual ResultsPreferences ResultsPreferences { get; private set; }
[Required]
public virtual ApplicationPreferences ApplicationPreferences { get; private set; }
public ApplicationUser()
{
searchOutlinePreferences = new SearchOutlinePreferences();
ResultsPreferences = new ResultsPreferences();
ApplicationPreferences = new ApplicationPreferences();
}
public ApplicationUser(SearchOutlinePreferences searchOutlinePreferences, ResultsPreferences resultsPreferences, ApplicationPreferences applicationPreferences)
{
this.searchOutlinePreferences = searchOutlinePreferences;
this.ResultsPreferences = resultsPreferences;
this.ApplicationPreferences = applicationPreferences;
}
}
}

View File

@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text.Json;
using Props.Models.User;
namespace Props.Models
{
public class ResultsPreferences
{
public int Id { get; set; }
[Required]
public string ApplicationUserId { get; set; }
[Required]
public virtual ApplicationUser ApplicationUser { get; set; }
[Required]
public IList<Category> Order { get; set; }
public string ProfileName { get; set; }
public ResultsPreferences()
{
Order = new List<Category>(Enum.GetValues<Category>().Length);
foreach (Category category in Enum.GetValues<Category>())
{
Order.Add(category);
}
}
public enum Category
{
RatingPriceRatio,
Reviews,
Purchases,
Price,
}
}
}

View File

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

View File

@ -0,0 +1,9 @@
namespace Props.Options
{
public class MetricsOptions
{
public const string Metrics = "Metrics";
public int MaxQueryWords { get; set; }
public int MaxProductListings { get; set; }
}
}

View File

@ -0,0 +1,11 @@
namespace Props.Options
{
public class ModulesOptions
{
public const string Modules = "Modules";
public string ModulesDir { get; set; }
public string ModuleDataDir { get; set; }
public bool RecursiveLoad { get; set; }
public string ShopRegex { get; set; }
}
}

View File

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

View File

@ -0,0 +1,8 @@
namespace Props.Options
{
public class TextualOptions
{
public const string Textual = "Textual";
public string Dir { get; set; }
}
}

26
Props/Pages/Error.cshtml Normal file
View File

@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

View File

@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
namespace Props.Pages
{
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
private readonly ILogger<ErrorModel> _logger;
public ErrorModel(ILogger<ErrorModel> logger)
{
_logger = logger;
}
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}
}

106
Props/Pages/Index.cshtml Normal file
View File

@ -0,0 +1,106 @@
@page
@using Microsoft.AspNetCore.Mvc.Localization
@model IndexModel
@inject IViewLocalizer Localizer
@{
ViewData["Title"] = "Home page";
}
<section class="jumbotron d-flex flex-column align-items-center">
<div>
<img alt="Props logo" src="~/images/logo.svg" class="img-fluid" style="max-height: 540px;"
asp-append-version="true" />
</div>
<div class="text-center px-3 my-2 concise">
<h1 class="my-2 display-1">Props</h1>
<p>
@Localizer["description"]
</p>
</div>
</section>
<section class="jumbotron sub">
<div class="container d-flex flex-column align-items-center py-2 concise">
<i class="bi bi-search" style="font-size: 5rem;"></i>
<h2 class="mb-3 mt-4">@Localizer["help.title"]</h2>
<form class="concise my-4">
<div class="input-group">
<input type="text" class="form-control" placeholder="What are you looking for?" aria-label="Search"
aria-describedby="search-btn">
<button class="btn btn-outline-primary" type="button" id="search-btn">Search</button>
</div>
</form>
<p class="text-center">
@Localizer["help.searchIntroduction"]
</p>
<p class="text-center">
@Localizer["help.additionalInfo"]
</p>
</div>
</section>
<section class="container d-flex flex-column align-items-center my-3 less-concise">
<h2 class="mb-3 mt-4">Our Mission</h2>
<p class="text-center">
@Localizer["mission"]
</p>
</section>
<hr class="concise">
<section class="container d-flex flex-column align-items-center">
<div class="less-concise d-flex flex-column align-items-center">
<h2 class="mb-3 mt-4">Features</h2>
<p class="center">
@Localizer["features.description"]
</p>
</div>
<div style="width: 100%;" data-simplebar>
<div class="row px-2 py-3 flex-nowrap">
<div class="card mx-2" style="width: 32rem;">
<div class="card-body">
<h5 class="card-title">@Localizer["feature.shoppingList.title"]</h5>
<h6 class="card-subtitle mb-3 text-muted">
<slot name="subtitle">@Localizer["feature.shoppingList.subtitle"]</slot>
</h6>
<p class="card-text">
@Localizer["feature.shoppingList.text"]
</p>
</div>
</div>
<div class="card mx-2" style="width: 32rem;">
<div class="card-body">
<h5 class="card-title">@Localizer["feature.productComparison.title"]</h5>
<h6 class="card-subtitle mb-3 text-muted">
<slot name="subtitle">@Localizer["feature.productComparison.subtitle"]</slot>
</h6>
<p class="card-text">
@Localizer["feature.productComparison.text"]
</p>
</div>
</div>
<div class="card mx-2" style="width: 32rem;">
<div class="card-body">
<h5 class="card-title">@Localizer["feature.autoSearch.title"]</h5>
<h6 class="card-subtitle mb-3 text-muted">
<slot name="subtitle">@Localizer["feature.autoSearch.subtitle"]</slot>
</h6>
<p class="card-text">
@Localizer["feature.autoSearch.text"]
</p>
</div>
</div>
<div class="card mx-2" style="width: 32rem;">
<div class="card-body">
<h5 class="card-title">@Localizer["feature.sharing.title"]</h5>
<h6 class="card-subtitle mb-3 text-muted">
<slot name="subtitle">@Localizer["feature.sharing.subtitle"]</slot>
</h6>
<p class="card-text">
@Localizer["feature.sharing.text"]
</p>
</div>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
namespace Props.Pages
{
public class IndexModel : PageModel
{
private readonly ILogger<IndexModel> _logger;
public IndexModel(ILogger<IndexModel> logger)
{
_logger = logger;
}
public void OnGet()
{
}
}
}

View File

@ -0,0 +1,8 @@
@page
@model PrivacyModel
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>

View File

@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
namespace Props.Pages
{
public class PrivacyModel : PageModel
{
private readonly ILogger<PrivacyModel> _logger;
public PrivacyModel(ILogger<PrivacyModel> logger)
{
_logger = logger;
}
public void OnGet()
{
}
}
}

302
Props/Pages/Search.cshtml Normal file
View File

@ -0,0 +1,302 @@
@page
@using Microsoft.AspNetCore.Mvc.Localization
@model SearchModel
@inject IViewLocalizer Localizer
@{
ViewData["Title"] = "Search";
ViewData["Specific"] = "Search";
}
<div class="flex-grow-1 d-flex flex-column" x-data="search">
<div class="mt-4 mb-3 less-concise">
<div class="input-group">
<input type="text" class="form-control border-primary" placeholder="What are you looking for?"
aria-label="Search" aria-describedby="search-btn" id="search-bar" value="@Model.SearchQuery"
x-model="query">
<button class="btn btn-outline-secondary" type="button" id="configuration-toggle" data-bs-toggle="collapse"
data-bs-target="#configuration"><i class="bi bi-sliders"></i></button>
<button class="btn btn-primary" id="search-btn" x-on:click="submitSearch" x-on:keyup="">Search</button>
</div>
</div>
<div class="collapse tear" id="configuration">
<div class="d-flex">
<h1 class="my-3 display-2 mx-auto">
<i class="bi bi-sliders" x-show="!timeoutInProgress"></i>
<i class="bi bi-cloud-arrow-up" x-show="timeoutInProgress"></i>
Configuration
</h1>
<button class="btn align-self-start m-3" type="button" id="configuration-close" data-bs-toggle="collapse"
data-bs-target="#configuration"><i class="bi bi-x-lg"></i></button>
</div>
<div class="container">
<div class="row my-3">
<div class="col-lg-3 px-2">
<div class="row">
<div class="col">
<h3>Search Outlines</h3>
</div>
<div class="col-auto" x-show="loggedIn">
<button class="btn" x-show="!creatingSearchOutline"
x-on:click="createSearchOutlineWithGeneratedName"
x-bind:disabled="creatingSearchOutline">
<i class="bi bi-plus-lg"></i>
</button>
<div x-show="creatingSearchOutline" class="spinner-border me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<div class="row px-3">
<div style="max-height: 28em;" data-simplebar>
<template x-for="(current, i) in searchOutlines">
<div class="clean-radio d-flex">
<input type="radio" x-bind:id="`${i}-selector`" name="search-outline"
x-bind:value="i" x-model="selectedSearchOutline"
x-on:click="loadSearchOutline(current)" x-bind:disabled="!searchOutline.ready">
<label class="flex-grow-1" x-bind:for="`${i}-selector`">
<span class="me-auto" x-text="current"></span>
</label>
<button class="btn m-1" x-show="loggedIn" x-on:click="deleteSearchOutline(current)"
x-bind:disabled="deletingSearchOutline || (searchOutlines.length < 2)">
<i class="bi bi-trash"></i>
</button>
</div>
</template>
<div class="text-muted text-center my-3" x-show="!loggedIn">
<h3><i class="bi bi-box-arrow-in-right"></i></h3>
<p>This is where all your search outlines will show up. <a asp-area="Identity"
asp-page="/Account/Login">Sign in</a> to create search outlines!</p>
</div>
<template x-if="(searchOutlines.length < 2) && (loggedIn)">
<div class="text-muted text-center my-3 p-3">
<p>Add more search outlines by clicking the <i class="bi bi-plus-lg"></i> above!</p>
</div>
</template>
</div>
</div>
</div>
<div class="col-lg px-4">
<div class="row">
<input class="title-input less-concise mx-4"
x-bind:class="searchOutline.ready ? '' : 'invisible'" type="text"
x-model="searchOutlines[selectedSearchOutline]" x-on:change="SearchOutlineNameChange"
x-bind:disabled="(!loggedIn) || (changingName)">
</div>
<div class="row justify-content-md-center" x-bind:class="searchOutline.ready ? '' : 'invisible'">
<section class="col-md px-3">
<h3>Price</h3>
<div class="mb-3">
<label for="max-price" class="form-label">Maximum Price</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input mt-0" type="checkbox" id="max-price-enabled"
x-model="searchOutline.filters.enableUpperPrice"
x-on:change="searchOutlineChanged">
</div>
<span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="max-price"
x-model="searchOutline.filters.upperPrice"
x-bind:disabled="!searchOutline.filters.enableUpperPrice"
x-on:change="searchOutlineChanged">
<span class="input-group-text">.00</span>
</div>
</div>
<div class="mb-3">
<label for="min-price" class="form-label">Minimum Price</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="min-price"
x-model="searchOutline.filters.lowerPrice" x-on:change="searchOutlineChanged">
<span class="input-group-text">.00</span>
</div>
</div>
<div class="mb-3">
<label for="max-shipping" class="form-label">Maximum Shipping Fee</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input mt-0" type="checkbox" id="max-shipping-enabled"
x-model="searchOutline.filters.enableMaxShipping"
x-on:change="searchOutlineChanged">
</div>
<span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="max-shipping"
x-model="searchOutline.filters.maxShippingFee"
x-bind:disabled="!searchOutline.filters.enableMaxShipping"
x-on:change="searchOutlineChanged">
<span class="input-group-text">.00</span>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-shipping"
x-model="searchOutline.filters.keepUnknownShipping"
x-on:change="searchOutlineChanged">
<label class="form-check-label" for="keep-unknown-shipping">Keep Unknown
Shipping</label>
</div>
</div>
</section>
<section class="col-md px-3">
<h3>Metrics</h3>
<div class="mb-3">
<label for="min-purchases" class="form-label">Minimum Purchases</label>
<div class="input-group">
<input type="number" class="form-control" min="0" id="min-purchases"
x-model="searchOutline.filters.minPurchases" x-on:change="searchOutlineChanged">
<span class="input-group-text">Purchases</span>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-purchases"
x-model="searchOutline.filters.keepUnknownPurchaseCount"
x-on:change="searchOutlineChanged">
<label class="form-check-label" for="keep-unknown-purchases">Keep Unknown
Purchases</label>
</div>
</div>
<div class="mb-3">
<label for="min-reviews" class="form-label">Minimum Reviews</label>
<div class="input-group">
<input type="number" class="form-control" min="0" id="min-reviews"
x-model="searchOutline.filters.minReviews" x-on:change="searchOutlineChanged">
<span class="input-group-text">Reviews</span>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-reviews"
x-model="searchOutline.filters.keepUnknownReviewCount"
x-on:change="searchOutlineChanged">
<label class="form-check-label" for="keep-unknown-reviews">Keep Unknown Number
of
Reviews</label>
</div>
</div>
<div class="mb-1">
<label for="min-rating" class="form-label">Minimum Rating</label>
<input type="range" class="form-range" id="min-rating" min="0" max="100" step="1"
x-model="searchOutline.filters.minRating" x-on:change="searchOutlineChanged">
<div id="min-rating-display" class="form-text"
x-text="searchOutline.filters.minRating + '%'">
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unrated"
x-model="searchOutline.filters.keepUnrated" x-on:change="searchOutlineChanged">
<label class="form-check-label" for="keep-unrated">Keep Unrated Items</label>
</div>
</div>
</section>
<section class="col-md px-3">
<h3>Shops Enabled</h3>
<template x-for="shopName in Object.keys(searchOutline.shopToggles)">
<div class="form-check">
<input class="form-check-input" type="checkbox" value=""
x-bind:id="`${encodeURIComponent(shopName)}-enabled`"
x-model="searchOutline.shopToggles[shopName]"
x-on:change="searchOutlineChanged">
<label class="form-check-label"
x-bind:for="`${encodeURIComponent(shopName)}-enabled`" x-text="shopName">
</label>
</div>
</template>
</section>
</div>
<div x-show="!searchOutline.ready">
<div class="spinner-border center-overlay" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="content-pages" class="multipage mt-3 invisible">
<ul class="nav nav-pills selectors">
<li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#quick-picks-slide"><i
class="bi bi-stopwatch"></i></button>
</li>
<li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#results-slide"><i
class="bi bi-view-list"></i></button>
</li>
<li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#info-slide"><i
class="bi bi-info-lg"></i></button>
</li>
</ul>
<div class="multipage-slides tab-content">
<div class="multipage-slide tab-pane fade" id="quick-picks-slide">
<div class="multipage-title">
<h1 class="display-2"><i class="bi bi-stopwatch"></i> Quick Picks</h1>
<template x-if="hasResults()">
<p>@Localizer["quickPicks.searched"]</p>
</template>
<template x-if="!hasResults()">
<p>@Localizer["quickPicks.prompt"]</p>
</template>
<hr class="less-concise">
</div>
<div class="multipage-content">
<template x-if="hasResults()">
<template x-if="results.bestPrice">
<p>Here's the listing with the lowest price.</p>
<div>
@* TODO: Implement best price display here *@
</div>
</template>
@* TODO: Add display for top results. *@
</template>
<template x-if="!hasResults()">
<div
class="text-center less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column">
<h2>@Localizer["notSearched"]</h2>
</div>
</template>
</div>
</div>
<div class="multipage-slide tab-pane fade" id="results-slide" x-data>
<div class="multipage-title">
<h2><i class="bi bi-view-list"></i> Results</h2>
<template x-if="hasResults()">
<p>@Localizer["results.searched"]</p>
</template>
<template x-if="!hasResults()">
<p>@Localizer["results.prompt"]</p>
</template>
<hr class="less-concise">
</div>
<div class="multipage-content">
<template x-if="hasResults()">
@* TODO: Display results with UI for sorting and changing views. *@
</template>
<template x-if="!hasResults()">
<div
class="text-center less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column">
<h2>@Localizer["notSearched"]</h2>
</div>
</template>
</div>
</div>
<div class="multipage-slide tab-pane fade" id="info-slide">
<div class="multipage-content">
<div class="less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column">
<h1 class="display-3"><i class="bi bi-info-circle"></i> Get Started!</h1>
<ol>
<li>@Localizer["instructions.type"]</li>
<li>@Localizer["instructions.configure"]</li>
<li>@Localizer["instructions.search"]</li>
</ol>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,26 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Castle.Core.Internal;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Props.Data;
using Props.Extensions;
using Props.Models.Search;
using Props.Models.User;
using Props.Services.Modules;
using Props.Shop.Framework;
namespace Props.Pages
{
public class SearchModel : PageModel
{
[BindProperty(Name = "q", SupportsGet = true)]
public string SearchQuery { get; set; }
}
}

View File

@ -0,0 +1,76 @@
@using Microsoft.AspNetCore.Identity
@using Props.Models.User
@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Props</title>
<script src="~/js/site.js" asp-append-version="true"></script>
@if (!string.IsNullOrEmpty((ViewData["Specific"] as string)))
{
@* Adds page specific scripts semi-automatically. *@
<script defer src="@($"~/js/specific/{(ViewData["Specific"])}.js")" asp-append-version="true"></script>
}
</head>
<body class="theme-light">
<header>
<nav id="nav">
<div class="container-fluid">
<a class="navbar-brand" asp-area="" asp-page="/Index">Props</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<i class="bi bi-list" style="width: 100%; height: auto;"></i>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<nav-link class="nav-link" asp-area="" asp-page="/Index">Home</nav-link>
</li>
<li class="nav-item">
<nav-link class="nav-link" asp-area="" asp-page="/Search">Search</nav-link>
</li>
</ul>
<ul class="navbar-nav mb-2 mb-lg-0">
@if (SignInManager.IsSignedIn(User))
{
<li class="nav-item">
<nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Manage/Index"
title="Manage">Hello @User.Identity.Name!</nav-link>
</li>
<li class="nav-item">
<form class="form-inline" asp-area="Identity" asp-page="/Account/Logout"
asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post">
<button type="submit" class="nav-link btn btn-link">Logout</button>
</form>
</li>
}
else
{
<li class="nav-item">
<nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Register">Register
</nav-link>
</li>
<li class="nav-item">
<nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Login">Login</nav-link>
</li>
}
</ul>
</div>
</div>
</nav>
</header>
<main role="main">
@RenderBody()
</main>
<footer id="footer">
&copy; 2021 - Props - <a asp-area="" asp-page="/Privacy">Privacy</a>
</footer>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@ -0,0 +1,27 @@
@using Microsoft.AspNetCore.Identity
@using Props.Models.User
@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
<ul class="navbar-nav">
@if (SignInManager.IsSignedIn(User))
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Manage/Index" title="Manage">Hello @User.Identity.Name!</a>
</li>
<li class="nav-item">
<form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post">
<button type="submit" class="nav-link btn btn-link text-dark">Logout</button>
</form>
</li>
}
else
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Register">Register</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Login">Login</a>
</li>
}
</ul>

View File

@ -0,0 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

View File

@ -0,0 +1,6 @@
@using Microsoft.AspNetCore.Identity
@using Props
@using Props.Data
@namespace Props.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Props

View File

@ -0,0 +1,4 @@
@{
Layout = "_Layout";
ViewData["Specific"] = null;
}

23
Props/Program.cs Normal file
View File

@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using System.IO;
using System;
using System.Reflection;
namespace Props
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseWebRoot(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "wwwroot/")).UseStartup<Startup>();
});
}
}

View File

@ -0,0 +1,28 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:31014",
"sslPort": 44369
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Props": {
"commandName": "Project",
"dotnetRunMessages": "true",
"launchBrowser": true,
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

96
Props/Props.csproj Normal file
View File

@ -0,0 +1,96 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!-- https://docs.microsoft.com/en-us/dotnet/core/project-sdk/msbuild-props for more information. -->
<!-- Publish arguments: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-publish -->
<!-- Single file docs: https://docs.microsoft.com/en-us/dotnet/core/deploying/single-file/overview -->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun>true</PublishReadyToRun>
<InvariantGlobalization>true</InvariantGlobalization>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<UserSecretsId>aspnet-Props-20A2A991-EC61-4C06-91D2-953482026A7B</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<None Update="app.db" CopyToOutputDirectory="PreserveNewest" ExcludeFromSingleFile="true" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Props.Shop\Framework\Props.Shop.Framework.csproj" />
</ItemGroup>
<!-- Removing ASP.Net Core Identity static content -->
<!-- See https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity?view=aspnetcore-6.0&tabs=visual-studio#prevent-publish-of-static-identity-assets -->
<PropertyGroup>
<ResolveStaticWebAssetsInputsDependsOn>RemoveIdentityAssets</ResolveStaticWebAssetsInputsDependsOn>
</PropertyGroup>
<Target Name="RemoveIdentityAssets">
<ItemGroup>
<StaticWebAsset Remove="@(StaticWebAsset)" Condition="%(SourceId) == 'Microsoft.AspNetCore.Identity.UI'" />
</ItemGroup>
</Target>
<!-- For embedding content into single file. -->
<ItemGroup>
<Content Remove="wwwroot\**" />
<None Include="wwwroot\**" ExcludeFromSingleFile="false">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Ignore client-side asset compilation configuration. -->
<Content Remove="package.json" />
<Content Remove="package-lock.json" />
</ItemGroup>
<!-- Modules for all the shops. -->
<ItemGroup>
<None Update="shops\**\*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" ExcludeFromSingleFile="true" />
</ItemGroup>
<!-- Watch configurations. -->
<ItemGroup>
<Watch Include="assets\**\*.js;assets\**\*.scss" Exclude="wwwroot\**\*;node_modules\**\*;**\*.js.map;obj\**\*;bin\**\*" />
</ItemGroup>
<!-- Ensure Node.js is installed -->
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" !Exists('node_modules') ">
<Exec Command="node --version" ContinueOnError="false">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
</Exec>
<Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
<Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
<Exec WorkingDirectory="./" Command="npm install" />
</Target>
<!-- Build static resources -->
<Target Name="BuildWebpack" BeforeTargets="Build">
<Message Importance="high" Text="Building client-side assets..." />
<Exec Command="npm run build:dev" Condition=" '$(Configuration)' == 'Debug' " LogStandardErrorAsError="true">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
</Exec>
<Exec Command="npm run build:prod" Condition=" '$(Configuration)' == 'Release' " LogStandardErrorAsError="true">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
</Exec>
<Error Condition="'$(ErrorCode)' != '0'" Text="There was an issue attempting to build the static assets. To continue, fix client-side asset errors and try again." />
</Target>
</Project>

View 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.

View File

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

View File

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

View File

@ -0,0 +1,16 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using Props.Models.Search;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public interface IShopManager : IAsyncDisposable
{
public ValueTask<IEnumerable<string>> GetAllShopNames();
public ValueTask<IShop> GetShop(string name);
public ValueTask<IEnumerable<IShop>> GetAllShops();
}
}

View File

@ -0,0 +1,113 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Props.Data;
using Props.Models.Search;
using Props.Options;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public class LiveMetricsManager : IMetricsManager
{
private MetricsOptions metricsOptions;
private ILogger<LiveMetricsManager> logger;
private ApplicationDbContext dbContext;
private IQueryable<ProductListingInfo> leastPopularProductListings;
private IQueryable<QueryWordInfo> leastPopularQueryWords;
public LiveMetricsManager(ApplicationDbContext dbContext, ILogger<LiveMetricsManager> logger, IConfiguration configuration)
{
this.metricsOptions = configuration.GetSection(MetricsOptions.Metrics).Get<MetricsOptions>();
this.logger = logger;
this.dbContext = dbContext;
leastPopularProductListings = from listing in dbContext.ProductListingInfos orderby listing.Hits ascending select listing;
leastPopularQueryWords = from word in dbContext.QueryWords orderby word.Hits ascending select word;
}
public IEnumerable<ProductListingInfo> RetrieveTopListings(int max)
{
if (dbContext.ProductListingInfos == null) return null;
return (from l in dbContext.ProductListingInfos
orderby l.Hits descending
select l).Take(max);
}
public IEnumerable<string> RetrieveCommonKeywords(int max)
{
if (dbContext.QueryWords == null) return null;
return (from k in dbContext.QueryWords
orderby k.Hits descending
select k.Word).Take(max);
}
public void RegisterSearchQuery(string query)
{
query = query.ToLower();
string[] tokens = query.Split(' ');
QueryWordInfo[] wordInfos = new QueryWordInfo[tokens.Length];
for (int wordIndex = 0; wordIndex < tokens.Length; wordIndex++)
{
QueryWordInfo queryWordInfo = dbContext.QueryWords.Where((k) => k.Word.ToLower().Equals(tokens[wordIndex])).SingleOrDefault() ?? new QueryWordInfo();
if (queryWordInfo.Hits == 0)
{
queryWordInfo.Word = tokens[wordIndex];
dbContext.QueryWords.Add(queryWordInfo);
}
queryWordInfo.Hits += 1;
wordInfos[wordIndex] = queryWordInfo;
}
for (int wordIndex = 0; wordIndex < tokens.Length; wordIndex++)
{
for (int beforeIndex = 0; beforeIndex < wordIndex; beforeIndex++)
{
wordInfos[wordIndex].Preceding.Add(wordInfos[beforeIndex]);
}
for (int afterIndex = wordIndex; afterIndex < tokens.Length; afterIndex++)
{
wordInfos[wordIndex].Following.Add(wordInfos[afterIndex]);
}
}
CullQueryWords();
dbContext.SaveChanges();
}
public void RegisterProductListing(ProductListing productListing, string shopName)
{
ProductListingInfo productListingInfo =
(from info in dbContext.ProductListingInfos
where info.ProductListingIdentifier.Equals(productListing.Identifier)
select info).SingleOrDefault() ?? new ProductListingInfo();
if (productListingInfo.Hits == 0)
{
dbContext.Add(productListingInfo);
}
productListingInfo.ShopName = shopName;
productListingInfo.ProductListing = productListing;
productListingInfo.ProductListingIdentifier = productListing.Identifier;
productListingInfo.Hits += 1;
CullProductListings();
dbContext.SaveChanges();
}
private void CullProductListings()
{
int surplus = dbContext.ProductListingInfos.Count() - metricsOptions.MaxProductListings;
if (surplus > 0)
{
dbContext.RemoveRange(leastPopularProductListings.Take(surplus));
}
}
private void CullQueryWords()
{
int surplus = dbContext.QueryWords.Count() - metricsOptions.MaxQueryWords;
if (surplus > 0)
{
dbContext.RemoveRange(leastPopularQueryWords.Take(surplus));
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More