Compare commits

..

169 Commits

Author SHA1 Message Date
a92762b85b Merge branch 'develop'
All checks were successful
ydeng/props/pipeline/head This commit looks good
2024-07-20 14:26:54 +00:00
0abfc60fe9 Improved dev container experience.
All checks were successful
ydeng/props/pipeline/head This commit looks good
2024-07-20 05:07:59 +00:00
3e82b1d01e Added zip and tar to environment for pipeline. 2024-07-20 05:07:36 +00:00
cac2b8ace7 Added "icu" library requirement to environment.yml.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
2024-07-20 01:52:33 +00:00
7a78dcb339 Reconfigured devcontainer.
Some checks reported errors
ydeng/props/pipeline/head Something is wrong with the build of this commit
2024-07-20 01:50:47 +00:00
046b85ec4a Updated packages.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
2024-07-20 00:42:13 +00:00
79f7688980 Removed nested stages. 2024-07-20 00:42:06 +00:00
9f291dcfed Increased webpack performance limit.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
Changed entrypointSize performance hint limit.

Increased maxAssetSize webpack performance hint limit.
2024-07-20 00:04:26 +00:00
4bc74d0917 Updated Jenkins pipeline paths.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
2024-07-19 22:50:01 +00:00
60e9b06a21 Updated referenced package versions.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
2024-07-19 22:41:27 +00:00
ad1c5a3180 Made sure shop module path exists in Jenkinsfile.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
2024-07-19 22:10:22 +00:00
a2ed5c2e58 Removed unecessary step.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
2024-07-19 22:01:21 +00:00
a4adb7c582 Fixed Jenkinsfile syntax.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
2024-07-19 21:54:26 +00:00
9631cb4b6b Added for loop to iterate over all shop modules.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
2024-07-19 18:06:01 +00:00
73cc43af3c Added output folder to gitignore. 2024-07-19 18:05:28 +00:00
d0217c2166 Jenkins pipeline will now continue if tests fail.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
2024-07-19 17:50:17 +00:00
e2ffd6f976 Minor fixes applied to Jenkinsfile.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
2024-07-19 17:20:02 +00:00
a7c0e0dea7 Updating Jenkinsfile to work on common runner.
Some checks failed
ydeng/props/pipeline/head There was a failure building this commit
Began moving poject to a devcontainer.

Added webpack cache clearing script.

Updated to .NET 8.0.

testing Woodpecker CI.

Selecting conda container for all build steps.

Made conda installation quiet.

Updated NodeJS version.
2024-07-19 17:12:49 +00:00
eadd104808 Updated Microsoft Logging extension.
Some checks failed
ydeng/audioshowkit/pipeline/head There was a failure building this commit
RealYHD/props/pipeline/head There was a failure building this commit
ydeng/props/pipeline/head There was a failure building this commit
2022-12-04 08:58:15 +00:00
0c7b85351c Updated Jenkinsfile.
All "dotnet restore" commands are now part of the "install" stage.
2022-12-01 17:35:20 +00:00
4841d539fc Updated project files to .NET 7.0. 2022-12-01 17:24:04 +00:00
d4680b934e Updated npm packages. 2022-12-01 07:02:10 +00:00
dbb1a04c36 Added nodejs to environment.yml. 2022-12-01 06:29:00 +00:00
6ba4e41de9 Fixed environment.yml. 2022-12-01 06:23:33 +00:00
85f4d61ff0 Update Jenkinsfile and added Conda environment. 2022-12-01 04:48:54 +00:00
a03d3c5218 Updated Jenkinsfile. 2022-11-27 10:18:42 +00:00
7f66b73e7a Removed unused git submodule. 2022-04-26 03:56:48 -05:00
44acb94aa5 Fixed archiving paths. 2022-04-26 03:48:05 -05:00
4d49ce33c2 Changed pipeline to output to special folder. 2022-04-26 03:40:29 -05:00
e83d19b699 Removed explicit restore in "Props" stage in pipeline. 2022-04-25 12:37:17 -05:00
8801bea801 Moved cleanup step to be first. 2022-04-25 03:56:41 -05:00
a2bbd112d9 Now deletes published assets. 2022-04-25 03:51:46 -05:00
43c1bc1611 Pipeline now publishes to specific directories and archives them. 2022-04-25 03:45:54 -05:00
ab6584065b Added proper file extensions to archived files in pipeline. 2022-04-25 03:34:35 -05:00
85d8f2bf6f Added tar generation and separate fingerprinting. 2022-04-25 03:06:24 -05:00
7d85b8625f Fixed report name typo. 2022-04-25 02:44:05 -05:00
1a3a9e00e7 Removed sdk selector from pipeline. 2022-04-25 02:41:31 -05:00
2069b38dbd Added xunit logger and configured pipeline for publishing. 2022-04-25 02:40:03 -05:00
f2c297fd88 Removed SDK specification. 2022-04-25 02:19:06 -05:00
c75f1e4042 Removed default runtime from "Props.csproj" and updated in "Jenkinsfile". 2022-04-25 02:13:57 -05:00
47883bfe4a Removed calls to dotnet clean. 2022-04-25 02:07:42 -05:00
a7dd5b5f74 Allow implicit restore. 2022-04-25 02:00:15 -05:00
efc016cc05 Changed 'selfContained' to true for win-x64. 2022-04-25 01:58:19 -05:00
f09b59c500 Added win-x64 artifact. 2022-04-25 01:51:39 -05:00
f82de591c8 Trying less specific exclusion glob. 2022-04-25 01:40:10 -05:00
6c34225aab Removed typo. 2022-04-25 01:36:11 -05:00
6fc68a1fb6 Regenerated using syntax helper. 2022-04-25 01:34:47 -05:00
96b36e0650 Reconfigured pipeline artifact exclusions. 2022-04-25 01:21:56 -05:00
140f8bbf53 Pipeline now publishes windows version as well. 2022-04-25 01:13:41 -05:00
13af9e5434 Removed duplicate artifacts from pipeline. 2022-04-25 01:10:09 -05:00
2391ca1ae1 Changed deletion pattern in pipeline. 2022-04-25 01:00:02 -05:00
190ea16b02 Added cleanup step to Jenkins pipeline.
Removes all items in publish folders after pipeline complete.

Fixed publish configuration in pipeline.
2022-04-25 00:55:29 -05:00
093670385e Merge branch 'develop' of dev.sys.reslate.xyz:ydeng/props into develop 2022-04-25 00:43:59 -05:00
0488df2ed1 Switched to using ASP.Net Core localization. 2022-04-25 00:43:53 -05:00
aa5c725e50 Removed invalid parameters in 'Jenkinsfile'. 2022-04-25 00:09:40 -05:00
9192e9b0f8 Removed invalid parameters in 'Jenkinsfile'. 2022-04-25 00:05:19 -05:00
e13a14fb2e Fixed typo in 'Jenkinsfile'. 2022-04-25 00:02:37 -05:00
5f2f648eaa Attempt at making build configurations consistent. 2022-04-24 23:58:33 -05:00
19f0eeb9bf Attempt at removing duplicate artifacts. 2022-04-24 23:49:30 -05:00
e03f8867ae Changed to archive only release builds. 2022-04-24 23:34:22 -05:00
c5056eddf2 Attempt at fixing archive step and added to Props.Shop. 2022-04-24 23:26:38 -05:00
7306c0ce86 load_shop_modules.py now removes anything at the shop path. 2022-04-24 23:14:26 -05:00
cbbd67d9f6 Shop module loading script now deletes previous shop path. 2022-04-24 23:10:27 -05:00
8dc2ff21c9 Shop module loading script now manually checks if dir exists. 2022-04-24 23:03:51 -05:00
2feb46a533 Added archiving and fingerprinting.
Removed win-x64 runtime publish.
2022-04-24 22:48:46 -05:00
f8ad3b2970 wwwroot is now extracted on run. 2022-04-24 22:20:35 -05:00
8abd75506c Reconfigured project to publish as a single file. 2022-04-24 18:32:13 -05:00
1d35e8a838 Fixed a AdafruitShop test. 2022-04-24 01:26:39 -05:00
89955968bf Test failures now cause test to be unstable. 2022-04-24 01:25:30 -05:00
38a9ea605a npm install now runs independent of configuration. 2022-04-24 01:04:15 -05:00
2ae3c32ae4 Updated path to Shop Framework project. 2022-04-24 00:18:05 -05:00
bd28df53de Fixed shop module loading script. 2022-04-24 00:12:32 -05:00
b4ec5844e4 Added shops module directory to .gitignore. 2022-04-24 00:12:21 -05:00
9cc55e516d Refactored repo organization. Added Jenkinsfile. 2022-04-24 00:03:52 -05:00
44e072a723 Minor webpack configuration formatting. 2022-01-27 00:55:46 -06:00
3951fb26da Npm build script now runs with production webpack configuration. 2022-01-27 00:55:32 -06:00
07c41be42d Completed migration to .NET 6.0. 2022-01-02 16:29:20 -06:00
c1d8891c44 Updated submodule url
Due to server changing sub-domain name.
2022-01-02 04:50:37 -06:00
b78c6d2ea5 Moved number input validation to before upload. 2021-08-28 22:59:56 -04:00
c6b8ca523b Basic search outline config UI implemented. 2021-08-17 02:59:01 -05:00
8a1e5aca15 Changed Shop interface to be more asynchronous.
Implemented changes in Props.

Implemented said changes in AdafruitShop.

Fixed and improved caching in AdafruitShop.
2021-08-11 23:54:52 -05:00
ff080390f8 Updated .gitignore and added TODO in AdafruitShop. 2021-08-11 00:35:58 -05:00
0b507b90a1 Added identifier and fetch time product listings; Added persistence to AdafruitShop.
Implemented in AdafruitShop.

AdafruitShop Product listing data now persisted.

AdafruitShop configuration now persisted.
2021-08-11 00:27:40 -05:00
38ffb3c7e1 Added logging to module framework
Implemented logging to Adafruit and changed database loading behavior.
2021-08-07 17:20:46 -05:00
c94ea4a624 Added primitive search mechanism in backend.
Began implementing search mechanism for frontend.
2021-08-05 01:22:19 -05:00
f71758ca69 Changed assets structure and names. 2021-07-31 01:24:45 -05:00
5d8a4a3803 Updated styling. 2021-07-24 14:47:40 -05:00
f31293d886 Updated TODO. 2021-07-24 14:47:26 -05:00
3a079206b0 Added property for where item originated from to ProductListingInfo. 2021-07-24 02:35:44 -05:00
21cd712667 Changed Index.json description content. 2021-07-24 02:34:04 -05:00
66aba04156 Made content directory configurable. 2021-07-24 02:33:43 -05:00
4476b1b3e1 Updated Controller structuring. 2021-07-24 02:32:30 -05:00
2c90678141 Removed debug logging. 2021-07-24 01:59:25 -05:00
c7bc6ca8fa Enabled persistent caching for Webpack. 2021-07-24 01:39:26 -05:00
4de4e8dfa1 static asset generation restructured and reconfigured. 2021-07-24 00:03:33 -05:00
d91acd36f7 Added Bootstrap collapse animation to configuration. 2021-07-24 00:03:33 -05:00
22dd766db3 Split webpack configuration to development and production. 2021-07-24 00:03:33 -05:00
e22c2b3049 Performed some styling.
Switched some styling to Bootstrap classes.

Removed some classes.
2021-07-24 00:03:33 -05:00
2719142538 Implemented groundwork for search configuration. 2021-07-24 00:03:33 -05:00
3129e5e564 Moved scripts to root; Updated settings and tasks for VSCode. 2021-07-24 00:03:33 -05:00
4bafefa4dc wwwroot folder is now ignored instead of contents only. 2021-07-24 00:03:33 -05:00
b43d7bab84 Ported progress from SPA and began scaffolding Identity UI.
Laid some groundwork in backend.

Began customizing Identity UI.
2021-07-24 00:03:32 -05:00
57f67391f1 Switched to MPA powered by Razor Pages
After reconsidering where I want to take this project, I realized that a MPA is more fitting.
2021-07-24 00:03:32 -05:00
e0756e0967 Made progress on implementing some shops.
Performed some folder restructuring as well.
2021-07-24 00:03:32 -05:00
56544938ac Enabled EF Core lazy loading. 2021-07-24 00:03:31 -05:00
840b59fcba Fixed a silent login callback error and reworked project structure. 2021-07-24 00:03:31 -05:00
bad22090a3 Made foreign keys required causing cascade deletion.
Removed completed TODO comments.
2021-07-24 00:03:31 -05:00
7e8a398741 Renamed everything from MultiShop to Props. 2021-07-24 00:03:19 -05:00
cefd02f202 Worked on content for home page. 2021-07-12 20:00:17 -05:00
b62b85fccb Deleted unused file. 2021-07-12 19:56:46 -05:00
0e93992beb Changed main page vector graphic and trying new name. 2021-07-12 12:39:48 -05:00
c597d65256 Added sign up button and improved logout flow.
Also added proper link to profile management.
2021-07-12 03:10:00 -05:00
9e55b459fc Ported some code from Blazor version of project. 2021-07-12 03:10:00 -05:00
e953c52092 Updated scripts and registered tasks.
reset_db.py now has the updated path.

Added reset database to tasks.

watch_all.py has improved output.
2021-07-12 03:07:58 -05:00
8b29c6f999 Changed SCSS.
Changed color scheme.

Added comment.

Added some color to home page.

Changed home page text.
2021-07-11 02:55:12 -05:00
c1633b0b51 Changed file watching development setup.
No longer launches browser.

Added watch server task for API work.

watch_all.py only displays server output and client error output.
2021-07-11 02:38:18 -05:00
80978c652a Implemented new theme structure and began home page design. 2021-07-11 01:33:58 -05:00
fbcf6fe586 Created vector graphic and png for logo. 2021-07-11 01:33:28 -05:00
e43d1294c4 Reorganized folder structure. 2021-07-10 12:32:43 -05:00
4e12a4b7fc ASP.Net Core development mode no longer redirects to https.
Fixes hot module reloading through proxy.

Production mode still redirects to https.
2021-07-10 12:29:44 -05:00
3d3c43b944 Began transition to Vue3.
Implemented logging in and logging out.

Implemented authenticated http client.

Laid some groundwork for SCSS.
2021-07-10 12:29:34 -05:00
54b1565537 Added NodeJS package with WebPack for some assets.
Moved JS files to WebPack system.

Created python script to watch both WebPack and Dotnet files simultaneously
2021-06-06 17:46:01 -05:00
7e240bd584 Home page search transition improved. 2021-06-05 16:45:02 -05:00
21fe7845f8 Added dark mode for drag and drop list component. 2021-06-05 03:22:03 -05:00
c9d9d5bc62 Made progress on "dark mode" colours. 2021-06-05 03:05:53 -05:00
56e2b948ee Changed file name to match class name. 2021-06-05 03:03:54 -05:00
11fa15fe62 Implemented local setting persistence and fixed main page search bug. 2021-06-04 19:12:55 -05:00
04f6657ed3 Minor UI changes.
Authentication component spacing changed.

Configuration menu UI is collapsed by default now.
2021-06-02 15:14:38 -05:00
2fdcc486ce Deleted unused component. 2021-06-02 15:12:59 -05:00
3957d65370 Changed CascadingDependencies to be more modular. 2021-06-02 14:41:51 -05:00
d57a61d5ca Removed a generated todo in .gitignore. 2021-06-01 02:26:12 -05:00
78006f79d0 Improved main page. 2021-05-31 19:09:53 -05:00
065d786dd7 Updated ToggleableButton to support no additional attributes. 2021-05-31 19:09:11 -05:00
9e3de4b6dc Made SearchBar component independent of it's environment.
Also added additional attribute capability.
2021-05-31 19:08:22 -05:00
7d4be012cd Implemented application profile and dark mode.
Added a specific cascading dependencies component.

Added some content to main page.

Added configuration page and persistence for it.

Code restructured (moved some code into separate components).
2021-05-31 16:39:52 -05:00
b311206ff1 Moved implement and inject notation to partial class. 2021-05-26 17:48:22 -05:00
3c63ebc613 Moved search bar into separate component and fixed results display. 2021-05-26 17:48:17 -05:00
2d1f599bbf Decided to remove info page. 2021-05-26 16:55:40 -05:00
3611e4be34 Updated database reset script to work from any directory. 2021-05-26 16:54:16 -05:00
f459cbdfda Switched server and client logging to built in logging system. 2021-05-26 16:54:16 -05:00
862fbe15ed Reworked client assembly loading. 2021-05-26 16:54:00 -05:00
36ae3e5c99 Wrote script to completely regenerate database files. 2021-05-25 20:48:47 -05:00
235196f8e5 Added search and results profile persistence when logged in. 2021-05-25 18:09:06 -05:00
bbb2d4bd04 Moved some app settings to coded values.
Because these values shouldn't be changed for this appliction.
2021-05-25 18:08:14 -05:00
1fff881df4 Generated all remaining authentication pages.
removed a generated unused file.
2021-05-25 18:05:42 -05:00
0e3deafca0 Updated .gitignore to ignore database files. 2021-05-25 18:00:44 -05:00
c67db54eeb Updated vscode assets to launch server for hosted project. 2021-05-25 17:51:02 -05:00
3f1b7d9ac6 Added quotes in a debug message. 2021-05-24 03:32:24 -05:00
d6dbe55e46 Listing table now stays when organizing results.
Changed default results organization order.
2021-05-23 15:23:32 -05:00
549d9d7e99 Updated some UI.
Updated app loading UI.

Updated UI for various authentication states.

Updated authentication buttons in the client.
2021-05-23 14:40:28 -05:00
ac13a6352b Exposed public Web API settings, and began customizing authentication related UI.
Added controller in server for fetching public Web API settings and implemented changes in client to use said settings.

Used scaffolder to generate source code for authentication pages.

Added and customized authentication buttons in client.
2021-05-23 13:58:13 -05:00
1f519e60b1 Moved some css to component-specific css. 2021-05-22 19:04:27 -05:00
5f4429098d Removed template example classes. 2021-05-22 19:04:20 -05:00
d87025c8b4 Removed ComponentSupport.js
Unecessary for now.
2021-05-22 01:01:26 -05:00
d2084efa7d Implemented quick sort with yielding. 2021-05-22 01:00:16 -05:00
d1ea0c7337 Removed a logging call. 2021-05-22 00:20:02 -05:00
d5c89fa6ca Added Task.Yield call to category top tagging portion. 2021-05-21 22:53:56 -05:00
6c684372df Changed to hosted blazorwasm project.
Restructured project following changes.

Moved shop assembly fetching to public facing Web API.
2021-05-21 13:32:25 -05:00
e07b234eb2 Made rating price ratio use more strict.
Reorganized modules_content.json.
2021-05-18 19:51:12 -05:00
e675962c35 Added Banggood shop. 2021-05-11 01:52:57 -05:00
04d4caf2bd Added response to cancellation token to AliExpressShop. 2021-05-10 20:03:08 -05:00
3218fbf4e3 Properly added SimpleLogger as git submodule. 2021-05-10 19:59:15 -05:00
99656133c9 Changed up results configuration layout.
Removed dropdown portion.

Added buttons to change sort order.

Visual indicator for disabling changes to sort order list.

Renamed a namespace.
2021-05-10 00:31:41 -05:00
9dc8917aa5 Basic search and sorting complete. 2021-05-09 01:49:37 -05:00
5d3a74a89e Added configuration file. 2021-04-23 22:07:05 -05:00
0f3f1d866a moved assembly loading code to a different namespace. 2021-04-23 22:04:22 -05:00
86d5eceeaf Made python script to generate list of modules. 2021-04-23 22:01:26 -05:00
3057bb8dfc Completed AliExpress interfacing module.
Began work on assembly loading.
2021-04-23 15:11:49 -05:00
f2fb7bd732 Initial commit. 2021-04-23 05:12:43 -05:00
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